diff --git a/.gitignore b/.gitignore index fd35865456..8c95ed1693 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,60 @@ +## .NET gitignore for Mews Backend Task + +# Keep these files !.gitkeep !.gitignore !*.dll -[Oo]bj -[Bb]in + +# Build results +[Dd]ebug/ +[Rr]elease/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio +.vs/ *.user *.suo +*.userosscache *.[Cc]ache *.bak *.ncb -*.DS_Store -*.userprefs -*.iml +[Tt]humbs.db + +# NuGet +*.nupkg +*.nuget.props +*.nuget.targets + +# Test Results +TestResults/ +*.trx + +# NCrunch *.ncrunch* .*crunch*.local.xml -.idea -[Tt]humbs.db -*.tgz + +# VS Code +.vscode/ +*.code-workspace *.sublime-* -node_modules -bower_components +# JetBrains Rider +.idea/ +*.sln.iml +*.iml +*.userprefs + +# macOS +.DS_Store + +# Logs +*.log + +# Node.js (if used for tooling) +node_modules/ +bower_components/ npm-debug.log + +# Archives +*.tgz diff --git a/jobs/Backend/IntegrationTests/ApiEndpointTests.cs b/jobs/Backend/IntegrationTests/ApiEndpointTests.cs new file mode 100644 index 0000000000..98edcf8f5a --- /dev/null +++ b/jobs/Backend/IntegrationTests/ApiEndpointTests.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using ExchangeRateUpdater.Api; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace ExchangeRateUpdater.IntegrationTests; + +public class ApiEndpointTests : IClassFixture> +{ + private readonly HttpClient _client; + private readonly WebApplicationFactory _factory; + + public ApiEndpointTests(WebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetExchangeRates_WithValidCurrencies_ReturnsOkWithRates() + { + var response = await _client.GetAsync("/api/exchange-rates?currencies=USD,EUR,GBP"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var rates = JsonSerializer.Deserialize>(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + rates.Should().NotBeNull(); + rates.Should().HaveCount(3); + rates.Should().OnlyContain(r => r.TargetCurrency == "CZK"); + rates.Should().OnlyContain(r => r.Rate > 0); + rates.Should().Contain(r => r.SourceCurrency == "USD"); + rates.Should().Contain(r => r.SourceCurrency == "EUR"); + rates.Should().Contain(r => r.SourceCurrency == "GBP"); + } + + [Fact] + public async Task GetExchangeRates_WithSingleCurrency_ReturnsOkWithSingleRate() + { + var response = await _client.GetAsync("/api/exchange-rates?currencies=EUR"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var rates = await response.Content.ReadFromJsonAsync>( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + rates.Should().NotBeNull(); + rates.Should().HaveCount(1); + rates![0].SourceCurrency.Should().Be("EUR"); + rates[0].TargetCurrency.Should().Be("CZK"); + rates[0].Rate.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetExchangeRates_WithMissingCurrencies_ReturnsBadRequest() + { + var response = await _client.GetAsync("/api/exchange-rates"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Currency codes are required"); + } + + [Fact] + public async Task GetExchangeRates_WithEmptyCurrencies_ReturnsBadRequest() + { + var response = await _client.GetAsync("/api/exchange-rates?currencies="); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetExchangeRates_WithInvalidCurrency_ReturnsOkWithEmptyList() + { + var response = await _client.GetAsync("/api/exchange-rates?currencies=INVALID,XXX"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var rates = await response.Content.ReadFromJsonAsync>( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + rates.Should().NotBeNull(); + rates.Should().BeEmpty(); + } + + [Fact] + public async Task PostExchangeRates_WithValidCurrencies_ReturnsOkWithRates() + { + var request = new ExchangeRateRequest + { + CurrencyCodes = new[] { "USD", "EUR" } + }; + + var response = await _client.PostAsJsonAsync("/api/exchange-rates", request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var rates = await response.Content.ReadFromJsonAsync>( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + rates.Should().NotBeNull(); + rates.Should().HaveCount(2); + rates.Should().OnlyContain(r => r.TargetCurrency == "CZK"); + rates.Should().OnlyContain(r => r.Rate > 0); + } + + [Fact] + public async Task PostExchangeRates_WithEmptyCurrencies_ReturnsBadRequest() + { + var request = new ExchangeRateRequest + { + CurrencyCodes = Array.Empty() + }; + + var response = await _client.PostAsJsonAsync("/api/exchange-rates", request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostExchangeRates_WithNullCurrencies_ReturnsBadRequest() + { + var request = new { CurrencyCodes = (string[]?)null }; + + var response = await _client.PostAsJsonAsync("/api/exchange-rates", request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetSupportedCurrencies_ReturnsOkWithCurrencyList() + { + var response = await _client.GetAsync("/api/exchange-rates/supported"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content); + + result.GetProperty("baseCurrency").GetString().Should().Be("CZK"); + result.GetProperty("supportedCurrencies").EnumerateArray().Should().NotBeEmpty(); + result.GetProperty("count").GetInt32().Should().BeGreaterThan(0); + + var currencies = result.GetProperty("supportedCurrencies").EnumerateArray() + .Select(c => c.GetString()).ToList(); + + currencies.Should().Contain("USD"); + currencies.Should().Contain("EUR"); + currencies.Should().OnlyHaveUniqueItems(); + currencies.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task HealthCheck_ReturnsOkWithHealthyStatus() + { + var response = await _client.GetAsync("/health"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + result.GetProperty("status").GetString().Should().Be("Healthy"); + result.GetProperty("service").GetString().Should().Be("Exchange Rate API"); + result.TryGetProperty("timestamp", out _).Should().BeTrue(); + } +} diff --git a/jobs/Backend/IntegrationTests/CachingE2ETests.cs b/jobs/Backend/IntegrationTests/CachingE2ETests.cs new file mode 100644 index 0000000000..ece23f5a50 --- /dev/null +++ b/jobs/Backend/IntegrationTests/CachingE2ETests.cs @@ -0,0 +1,186 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.IntegrationTests; + +/// +/// End-to-end tests specifically focused on caching behavior +/// with real MemoryCache and real CNB API integration +/// +public class CachingE2ETests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly ExchangeRateProvider _provider; + private readonly IExchangeRateCache _cache; + + public CachingE2ETests() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); + services.AddExchangeRateProvider(configuration); + + _serviceProvider = services.BuildServiceProvider(); + _provider = _serviceProvider.GetRequiredService(); + _cache = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task CacheMissFollowedByCacheHit_WorksCorrectly() + { + // Arrange + _cache.Clear(); // Ensure clean state + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + + // Act - First call should be cache miss (fetch from API) + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var firstCallRates = await _provider.GetExchangeRatesAsync(currencies); + var firstCallTime = stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + // Second call should be cache hit (much faster) + var secondCallRates = await _provider.GetExchangeRatesAsync(currencies); + var secondCallTime = stopwatch.ElapsedMilliseconds; + stopwatch.Stop(); + + var firstList = firstCallRates.ToList(); + var secondList = secondCallRates.ToList(); + + // Assert + firstList.Should().HaveCount(2); + secondList.Should().HaveCount(2); + + // Results should be identical + for (int i = 0; i < firstList.Count; i++) + { + firstList[i].SourceCurrency.Code.Should().Be(secondList[i].SourceCurrency.Code); + firstList[i].Value.Should().Be(secondList[i].Value); + } + + // Second call should be significantly faster (cache hit) + secondCallTime.Should().BeLessThan(firstCallTime / 2, + "cached call should be at least 2x faster than API call"); + } + + [Fact] + public async Task DifferentCurrencySets_HaveSeparateCacheEntries() + { + // Arrange + _cache.Clear(); + var usdOnly = new[] { new Currency("USD") }; + var eurOnly = new[] { new Currency("EUR") }; + var both = new[] { new Currency("USD"), new Currency("EUR") }; + + // Act + var usdRates = await _provider.GetExchangeRatesAsync(usdOnly); + var eurRates = await _provider.GetExchangeRatesAsync(eurOnly); + var bothRates = await _provider.GetExchangeRatesAsync(both); + + // Assert + usdRates.Should().HaveCount(1).And.Contain(r => r.SourceCurrency.Code == "USD"); + eurRates.Should().HaveCount(1).And.Contain(r => r.SourceCurrency.Code == "EUR"); + bothRates.Should().HaveCount(2); + } + + [Fact] + public async Task CacheOrderIndependence_SameSetDifferentOrder_HitsCache() + { + // Arrange + _cache.Clear(); + var order1 = new[] { new Currency("USD"), new Currency("EUR"), new Currency("GBP") }; + var order2 = new[] { new Currency("GBP"), new Currency("USD"), new Currency("EUR") }; + + // Act + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var firstCall = await _provider.GetExchangeRatesAsync(order1); + var firstCallTime = stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + var secondCall = await _provider.GetExchangeRatesAsync(order2); + var secondCallTime = stopwatch.ElapsedMilliseconds; + + // Assert + secondCallTime.Should().BeLessThan(firstCallTime / 2, + "second call with different order should hit cache"); + + var firstCodes = firstCall.Select(r => r.SourceCurrency.Code).OrderBy(c => c).ToList(); + var secondCodes = secondCall.Select(r => r.SourceCurrency.Code).OrderBy(c => c).ToList(); + firstCodes.Should().BeEquivalentTo(secondCodes); + } + + [Fact] + public async Task CacheClear_RemovesAllCachedData() + { + // Arrange + _cache.Clear(); + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + + // Populate cache + await _provider.GetExchangeRatesAsync(currencies); + + // Verify cache hit + var cachedData = _cache.GetCachedRates(currencies.Select(c => c.Code)); + cachedData.Should().NotBeNull("data should be cached"); + + // Act + _cache.Clear(); + + // Assert + var afterClear = _cache.GetCachedRates(currencies.Select(c => c.Code)); + afterClear.Should().BeNull("cache should be empty after clear"); + } + + [Fact] + public async Task ConcurrentRequests_BothHitCache() + { + // Arrange + _cache.Clear(); + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + + // First call to populate cache + await _provider.GetExchangeRatesAsync(currencies); + + // Act - Make concurrent requests + var tasks = Enumerable.Range(0, 5) + .Select(_ => _provider.GetExchangeRatesAsync(currencies)) + .ToList(); + + var results = await Task.WhenAll(tasks); + + // Assert + results.Should().HaveCount(5); + foreach (var result in results) + { + result.Should().HaveCount(2); + result.Should().OnlyContain(r => r.Value > 0); + } + + // All results should be identical (from cache) + var firstResult = results[0].ToList(); + foreach (var result in results.Skip(1)) + { + var resultList = result.ToList(); + for (int i = 0; i < firstResult.Count; i++) + { + resultList[i].Value.Should().Be(firstResult[i].Value); + } + } + } + + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} diff --git a/jobs/Backend/IntegrationTests/ErrorScenarioE2ETests.cs b/jobs/Backend/IntegrationTests/ErrorScenarioE2ETests.cs new file mode 100644 index 0000000000..5f3c727e1a --- /dev/null +++ b/jobs/Backend/IntegrationTests/ErrorScenarioE2ETests.cs @@ -0,0 +1,61 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.IntegrationTests; + +/// +/// End-to-end tests for error scenarios and edge cases +/// Tests resilience features like retry, timeout, and error handling +/// +public class ErrorScenarioE2ETests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly ExchangeRateProvider _provider; + + public ErrorScenarioE2ETests() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); // More verbose for error scenarios + }); + services.AddExchangeRateProvider(configuration); + + _serviceProvider = services.BuildServiceProvider(); + _provider = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithCancellation_RespectsCancellationToken() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + // Act & Assert + // The cancellation is wrapped in ExchangeRateProviderException + var exception = await _provider.Invoking(p => p.GetExchangeRatesAsync(currencies, cts.Token)) + .Should().ThrowAsync(); + + exception.And.InnerException.Should().BeOfType(); + } + + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} diff --git a/jobs/Backend/IntegrationTests/ExchangeRateProviderE2ETests.cs b/jobs/Backend/IntegrationTests/ExchangeRateProviderE2ETests.cs new file mode 100644 index 0000000000..9f627b07f2 --- /dev/null +++ b/jobs/Backend/IntegrationTests/ExchangeRateProviderE2ETests.cs @@ -0,0 +1,149 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.IntegrationTests; + +/// +/// End-to-end tests that verify the entire exchange rate provider workflow +/// with real dependencies (actual CNB API calls, real caching, etc.) +/// +public class ExchangeRateProviderE2ETests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderE2ETests() + { + // Build configuration from test settings + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + // Build real DI container with all services + var services = new ServiceCollection(); + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + services.AddExchangeRateProvider(configuration); + + _serviceProvider = services.BuildServiceProvider(); + _provider = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithRealCnbApi_ReturnsValidRates() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("GBP") + }; + + // Act + var rates = await _provider.GetExchangeRatesAsync(currencies); + var ratesList = rates.ToList(); + + // Assert + ratesList.Should().HaveCount(3, "all three currencies should be returned"); + ratesList.Should().OnlyContain(r => r.TargetCurrency.Code == "CZK", "all rates should be to CZK"); + ratesList.Should().OnlyContain(r => r.Value > 0, "all exchange rates should be positive"); + + // Verify specific currencies + ratesList.Should().Contain(r => r.SourceCurrency.Code == "USD"); + ratesList.Should().Contain(r => r.SourceCurrency.Code == "EUR"); + ratesList.Should().Contain(r => r.SourceCurrency.Code == "GBP"); + + // Verify realistic rate values (sanity check) + var usdRate = ratesList.First(r => r.SourceCurrency.Code == "USD"); + usdRate.Value.Should().BeInRange(15m, 35m, "USD/CZK rate should be realistic"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithMultipleCalls_ReturnsConsistentResults() + { + // Arrange + var currencies = new[] { new Currency("EUR") }; + + // Act - Call twice in succession + var firstCall = await _provider.GetExchangeRatesAsync(currencies); + var secondCall = await _provider.GetExchangeRatesAsync(currencies); + + var firstRate = firstCall.First(); + var secondRate = secondCall.First(); + + // Assert - Results should be identical (from cache) + firstRate.SourceCurrency.Code.Should().Be(secondRate.SourceCurrency.Code); + firstRate.TargetCurrency.Code.Should().Be(secondRate.TargetCurrency.Code); + firstRate.Value.Should().Be(secondRate.Value); + } + + [Fact] + public void GetExchangeRates_Synchronous_WithRealApi_ReturnsValidRates() + { + // Arrange + var currencies = new[] { new Currency("EUR"), new Currency("USD") }; + + // Act + var rates = _provider.GetExchangeRates(currencies); + var ratesList = rates.ToList(); + + // Assert + ratesList.Should().HaveCount(2); + ratesList.Should().OnlyContain(r => r.Value > 0); + ratesList.Should().OnlyContain(r => r.TargetCurrency.Code == "CZK"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithInvalidCurrency_ReturnsEmpty() + { + // Arrange - Use a currency code that CNB definitely doesn't support + var currencies = new[] { new Currency("XXX"), new Currency("INVALID") }; + + // Act + var rates = await _provider.GetExchangeRatesAsync(currencies); + var ratesList = rates.ToList(); + + // Assert + ratesList.Should().BeEmpty("invalid currency codes should return no results"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithMixedValidAndInvalid_ReturnsOnlyValid() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), // Valid + new Currency("XXX"), // Invalid + new Currency("EUR"), // Valid + new Currency("FAKE") // Invalid + }; + + // Act + var rates = await _provider.GetExchangeRatesAsync(currencies); + var ratesList = rates.ToList(); + + // Assert + ratesList.Should().HaveCount(2, "only valid currencies should be returned"); + ratesList.Should().Contain(r => r.SourceCurrency.Code == "USD"); + ratesList.Should().Contain(r => r.SourceCurrency.Code == "EUR"); + ratesList.Should().NotContain(r => r.SourceCurrency.Code == "XXX"); + ratesList.Should().NotContain(r => r.SourceCurrency.Code == "FAKE"); + } + + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} diff --git a/jobs/Backend/IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj b/jobs/Backend/IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj new file mode 100644 index 0000000000..6feb0be028 --- /dev/null +++ b/jobs/Backend/IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/jobs/Backend/IntegrationTests/appsettings.test.json b/jobs/Backend/IntegrationTests/appsettings.test.json new file mode 100644 index 0000000000..9b55276470 --- /dev/null +++ b/jobs/Backend/IntegrationTests/appsettings.test.json @@ -0,0 +1,17 @@ +{ + "CnbExchangeRate": { + "ApiUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30, + "RetryCount": 3, + "RetryDelayMilliseconds": 1000, + "EnableCache": true, + "CacheDurationMinutes": 1 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + } +} diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore new file mode 100644 index 0000000000..e7b690f114 --- /dev/null +++ b/jobs/Backend/Task/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/jobs/Backend/Task/.gitignore b/jobs/Backend/Task/.gitignore new file mode 100644 index 0000000000..a255f99347 --- /dev/null +++ b/jobs/Backend/Task/.gitignore @@ -0,0 +1,399 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ diff --git a/jobs/Backend/Task/Api/ErrorResponse.cs b/jobs/Backend/Task/Api/ErrorResponse.cs new file mode 100644 index 0000000000..c739768a3e --- /dev/null +++ b/jobs/Backend/Task/Api/ErrorResponse.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Api; + +/// +/// Error response model for API errors +/// +public record ErrorResponse +{ + /// + /// Error message + /// + public required string Error { get; init; } + + /// + /// Optional detailed error information + /// + public string? Details { get; init; } +} diff --git a/jobs/Backend/Task/Api/ExchangeRateEndpoints.cs b/jobs/Backend/Task/Api/ExchangeRateEndpoints.cs new file mode 100644 index 0000000000..287555f08b --- /dev/null +++ b/jobs/Backend/Task/Api/ExchangeRateEndpoints.cs @@ -0,0 +1,187 @@ +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Api; + +public static class ExchangeRateEndpoints +{ + public static void MapExchangeRateEndpoints(this WebApplication app) + { + var api = app.MapGroup("/api/exchange-rates") + .WithTags("Exchange Rates") + .WithOpenApi(); + + api.MapGet("/", GetExchangeRates) + .WithName("GetExchangeRates") + .WithSummary("Get exchange rates for specified currencies") + .WithDescription("Fetches current exchange rates from Czech National Bank for the specified currency codes. Results are cached for 60 minutes.") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status503ServiceUnavailable) + .Produces(StatusCodes.Status500InternalServerError); + + api.MapPost("/", PostExchangeRates) + .WithName("PostExchangeRates") + .WithSummary("Get exchange rates for specified currencies (POST)") + .WithDescription("Fetches current exchange rates from Czech National Bank for the specified currency codes. Use this endpoint for large lists of currencies.") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status503ServiceUnavailable) + .Produces(StatusCodes.Status500InternalServerError); + + api.MapGet("/supported", GetSupportedCurrencies) + .WithName("GetSupportedCurrencies") + .WithSummary("Get list of supported currency codes") + .WithDescription("Returns a list of commonly supported currency codes that can be fetched from Czech National Bank") + .Produces(StatusCodes.Status200OK); + + app.MapGet("/health", HealthCheck) + .WithName("HealthCheck") + .WithTags("Health") + .WithSummary("Health check endpoint") + .Produces(StatusCodes.Status200OK); + } + + private static async Task GetExchangeRates( + [FromQuery] string? currencies, + [FromServices] ExchangeRateProvider provider, + [FromServices] ILogger logger, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(currencies)) + { + return Results.BadRequest(new ErrorResponse + { + Error = ApiMessages.Validation.CurrencyCodesRequired, + Details = ApiMessages.Validation.CurrencyCodesRequiredDetails + }); + } + + var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (currencyCodes.Length == 0) + { + return Results.BadRequest(new ErrorResponse + { + Error = ApiMessages.Validation.AtLeastOneCurrencyRequired + }); + } + + return await FetchExchangeRatesAsync(currencyCodes, provider, logger, cancellationToken); + } + + private static async Task PostExchangeRates( + [FromBody] ExchangeRateRequest request, + [FromServices] ExchangeRateProvider provider, + [FromServices] ILogger logger, + CancellationToken cancellationToken) + { + if (request.CurrencyCodes == null || request.CurrencyCodes.Length == 0) + { + return Results.BadRequest(new ErrorResponse + { + Error = ApiMessages.Validation.CurrencyCodesRequired, + Details = ApiMessages.Validation.CurrencyCodesRequiredBodyDetails + }); + } + + return await FetchExchangeRatesAsync(request.CurrencyCodes, provider, logger, cancellationToken); + } + + private static async Task FetchExchangeRatesAsync( + string[] currencyCodes, + ExchangeRateProvider provider, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var currencyList = currencyCodes.Select(code => new Currency(code)); + + logger.LogInformation("Fetching exchange rates for currencies: {Currencies}", string.Join(", ", currencyCodes)); + + var rates = await provider.GetExchangeRatesAsync(currencyList, cancellationToken); + + var response = rates.Select(r => new ExchangeRateResponse + { + SourceCurrency = r.SourceCurrency.Code, + TargetCurrency = r.TargetCurrency.Code, + Rate = r.Value + }).ToList(); + + logger.LogInformation("Successfully retrieved {Count} exchange rates", response.Count); + + return Results.Ok(response); + } + catch (ExchangeRateProviderException ex) + { + logger.LogError(ex, "Failed to retrieve exchange rates"); + return Results.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status503ServiceUnavailable, + title: ApiMessages.Error.ServiceUnavailable + ); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error occurred"); + return Results.Problem( + detail: ApiMessages.Error.UnexpectedErrorFetchingRates, + statusCode: StatusCodes.Status500InternalServerError, + title: ApiMessages.Error.InternalServerError + ); + } + } + + private static async Task GetSupportedCurrencies( + [FromServices] ExchangeRateProvider provider, + [FromServices] ILogger logger, + CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Fetching list of supported currencies"); + + var supportedCurrencies = await provider.GetSupportedCurrenciesAsync(cancellationToken); + + return Results.Ok(new + { + BaseCurrency = ApiMessages.Response.BaseCurrency, + SupportedCurrencies = supportedCurrencies.ToArray(), + Count = supportedCurrencies.Count(), + Note = ApiMessages.Response.SupportedCurrenciesNote + }); + } + catch (ExchangeRateProviderException ex) + { + logger.LogError(ex, "Failed to retrieve supported currencies"); + return Results.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status503ServiceUnavailable, + title: ApiMessages.Error.ServiceUnavailable + ); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error occurred"); + return Results.Problem( + detail: ApiMessages.Error.UnexpectedErrorFetchingSupportedCurrencies, + statusCode: StatusCodes.Status500InternalServerError, + title: ApiMessages.Error.InternalServerError + ); + } + } + + private static IResult HealthCheck() + { + return Results.Ok(new + { + Status = ApiMessages.Response.HealthStatus, + Timestamp = DateTime.UtcNow, + Service = ApiMessages.Response.ServiceName + }); + } +} diff --git a/jobs/Backend/Task/Api/ExchangeRateRequest.cs b/jobs/Backend/Task/Api/ExchangeRateRequest.cs new file mode 100644 index 0000000000..c9aa13a7c4 --- /dev/null +++ b/jobs/Backend/Task/Api/ExchangeRateRequest.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Api; + +/// +/// Request model for fetching exchange rates +/// +public record ExchangeRateRequest +{ + /// + /// List of currency codes to fetch rates for (e.g., ["USD", "EUR", "GBP"]) + /// + public required string[] CurrencyCodes { get; init; } +} diff --git a/jobs/Backend/Task/Api/ExchangeRateResponse.cs b/jobs/Backend/Task/Api/ExchangeRateResponse.cs new file mode 100644 index 0000000000..c3a0f4ad1f --- /dev/null +++ b/jobs/Backend/Task/Api/ExchangeRateResponse.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateUpdater.Api; + +/// +/// Response model for exchange rate API endpoints +/// +public record ExchangeRateResponse +{ + /// + /// Source currency code (e.g., "USD", "EUR") + /// + public required string SourceCurrency { get; init; } + + /// + /// Target currency code (e.g., "CZK") + /// + public required string TargetCurrency { get; init; } + + /// + /// Exchange rate value (how many target currency units per 1 source currency unit) + /// + public required decimal Rate { get; init; } +} diff --git a/jobs/Backend/Task/Configuration/CnbExchangeRateConfiguration.cs b/jobs/Backend/Task/Configuration/CnbExchangeRateConfiguration.cs new file mode 100644 index 0000000000..9984d074d1 --- /dev/null +++ b/jobs/Backend/Task/Configuration/CnbExchangeRateConfiguration.cs @@ -0,0 +1,40 @@ +namespace ExchangeRateUpdater.Configuration; + +/// +/// Configuration settings for the Czech National Bank exchange rate provider. +/// +public class CnbExchangeRateConfiguration +{ + public const string SectionName = "CnbExchangeRate"; + + /// + /// The base URL for CNB daily exchange rates. + /// + public string ApiUrl { get; init; } = "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; + + /// + /// Timeout for HTTP requests in seconds. + /// + public int TimeoutSeconds { get; init; } = 30; + + /// + /// Number of retry attempts for failed requests. + /// + public int RetryCount { get; init; } = 3; + + /// + /// Delay between retry attempts in milliseconds. + /// + public int RetryDelayMilliseconds { get; init; } = 1000; + + /// + /// Enable caching of exchange rates. + /// + public bool EnableCache { get; init; } = true; + + /// + /// Cache duration in minutes. Default is 60 minutes (1 hour). + /// CNB updates rates once per day, so caching for 1 hour is reasonable. + /// + public int CacheDurationMinutes { get; init; } = 60; +} diff --git a/jobs/Backend/Task/Constants/LogMessages.cs b/jobs/Backend/Task/Constants/LogMessages.cs new file mode 100644 index 0000000000..bcc528929e --- /dev/null +++ b/jobs/Backend/Task/Constants/LogMessages.cs @@ -0,0 +1,107 @@ +namespace ExchangeRateUpdater.Constants; + +/// +/// Contains all log message templates used throughout the application. +/// +public static class LogMessages +{ + public static class CnbApiClient + { + public const string FetchingExchangeRates = "Fetching exchange rates from CNB API: {ApiUrl}"; + public const string FetchSuccessful = "Successfully fetched exchange rates from CNB API"; + public const string HttpRequestFailed = "HTTP request failed while fetching exchange rates from CNB"; + public const string RequestTimedOut = "Request to CNB API timed out"; + public const string UnexpectedError = "Unexpected error while fetching exchange rates from CNB"; + } + + public static class CnbDataParser + { + public const string EmptyOrNullData = "Received empty or null data to parse"; + public const string InsufficientLines = "Data contains fewer than expected lines (header + column names + data)"; + public const string FailedToParseLine = "Failed to parse line: {Line}"; + public const string ParseSuccessful = "Successfully parsed {Count} exchange rates"; + public const string UnexpectedColumnCount = "Line has unexpected number of columns. Expected: {Expected}, Actual: {Actual}, Line: {Line}"; + public const string FailedToParseAmount = "Failed to parse amount: {Amount}"; + public const string FailedToParseRate = "Failed to parse rate: {Rate}"; + } + + public static class ExchangeRateCache + { + public const string CacheHit = "Cache hit for key: {CacheKey}, {Count} rates retrieved"; + public const string CacheMiss = "Cache miss for key: {CacheKey}"; + public const string AttemptedCacheEmpty = "Attempted to cache empty rate list"; + public const string CachedRates = "Cached {Count} exchange rates for key: {CacheKey}, TTL: {TTL} minutes"; + public const string ClearedEntries = "Cleared {Count} cache entries"; + } + + public static class SupportedCurrenciesCache + { + public const string CacheHit = "Cache hit for supported currencies: {Count} currencies"; + public const string CacheMiss = "Cache miss for supported currencies"; + public const string AttemptedCacheEmpty = "Attempted to cache empty currency list"; + public const string CachedCurrencies = "Cached {Count} supported currencies, TTL: {TTL} minutes"; + public const string ClearedCache = "Cleared supported currencies cache"; + } + + public static class ExchangeRateProvider + { + public const string NoCurrenciesRequested = "No currencies requested"; + public const string FetchingExchangeRates = "Fetching exchange rates for {Count} currencies"; + public const string ReturningFromCache = "Returning {Count} exchange rates from cache"; + public const string RetrievalSuccessful = "Successfully retrieved {Retrieved} exchange rates out of {Requested} requested currencies"; + public const string UnexpectedError = "Unexpected error while getting exchange rates"; + public const string ReturningCachedSupportedCurrencies = "Returning supported currencies from cache"; + public const string FetchingSupportedCurrencies = "Fetching supported currencies from CNB"; + public const string FoundSupportedCurrencies = "Found {Count} supported currencies"; + public const string FailedToFetchSupportedCurrencies = "Failed to fetch supported currencies from CNB"; + } +} + +/// +/// Contains all exception messages thrown throughout the application. +/// +public static class ExceptionMessages +{ + public static class CnbApiClient + { + public const string NetworkError = "Failed to fetch exchange rates from CNB API due to network error"; + public const string Timeout = "Request to CNB API timed out"; + public const string UnexpectedError = "Unexpected error occurred while fetching exchange rates"; + } + + public static class ExchangeRateProvider + { + public const string FailedToRetrieveRates = "Failed to retrieve exchange rates"; + public const string FailedToRetrieveSupportedCurrencies = "Failed to retrieve supported currencies"; + } +} + +/// +/// Contains all API response messages and descriptions. +/// +public static class ApiMessages +{ + public static class Validation + { + public const string CurrencyCodesRequired = "Currency codes are required"; + public const string CurrencyCodesRequiredDetails = "Provide currency codes as a comma-separated query parameter (e.g., ?currencies=USD,EUR,GBP)"; + public const string AtLeastOneCurrencyRequired = "At least one currency code is required"; + public const string CurrencyCodesRequiredBodyDetails = "Provide at least one currency code in the request body"; + } + + public static class Error + { + public const string ServiceUnavailable = "Service Unavailable"; + public const string InternalServerError = "Internal Server Error"; + public const string UnexpectedErrorFetchingRates = "An unexpected error occurred while fetching exchange rates"; + public const string UnexpectedErrorFetchingSupportedCurrencies = "An unexpected error occurred while fetching supported currencies"; + } + + public static class Response + { + public const string BaseCurrency = "CZK"; + public const string SupportedCurrenciesNote = "This list is dynamically fetched from Czech National Bank and includes all currently available currencies."; + public const string HealthStatus = "Healthy"; + public const string ServiceName = "Exchange Rate API"; + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/Dockerfile b/jobs/Backend/Task/Dockerfile new file mode 100644 index 0000000000..f38b197c07 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile @@ -0,0 +1,48 @@ +# Use the official .NET 8 SDK image for building +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy csproj and restore dependencies +COPY ExchangeRateUpdater.csproj ./ +RUN dotnet restore + +# Copy everything else and build +COPY . ./ +RUN dotnet build ExchangeRateUpdater.csproj -c Release -o /app/build + +# Publish the application +RUN dotnet publish ExchangeRateUpdater.csproj -c Release -o /app/publish /p:UseAppHost=false + +# Use the official .NET 8 ASP.NET runtime image for running web apps +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app + +# Expose ports +EXPOSE 8080 +EXPOSE 8081 + +# Install curl for health checks (as root before switching to non-root user) +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Create a non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Copy the published application +COPY --from=build /app/publish . + +# Set environment variables +ENV DOTNET_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_HTTP_PORTS=8080 +ENV CnbExchangeRate__TimeoutSeconds=30 +ENV CnbExchangeRate__RetryCount=3 +ENV CnbExchangeRate__EnableCache=true +ENV CnbExchangeRate__CacheDurationMinutes=60 + +# Health check - ping the /health endpoint +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl --fail http://localhost:8080/health || exit 1 + +# Run the application +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"] diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..07a652b20f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,32 @@ - + Exe - net6.0 + net8.0 + enable + latest + ExchangeRateUpdater.IntegrationTests + + + + + + + + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..a01018c203 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,16 +5,56 @@ VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "..\UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{02111FE1-FE32-4888-BE63-D2E06F23705A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.IntegrationTests", "..\IntegrationTests\ExchangeRateUpdater.IntegrationTests.csproj", "{130B9CEF-CCF3-4F06-A81C-9C589196FBB2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|x64.ActiveCfg = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|x64.Build.0 = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|x86.ActiveCfg = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Debug|x86.Build.0 = Debug|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|Any CPU.Build.0 = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|x64.ActiveCfg = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|x64.Build.0 = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|x86.ActiveCfg = Release|Any CPU + {02111FE1-FE32-4888-BE63-D2E06F23705A}.Release|x86.Build.0 = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|x64.Build.0 = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Debug|x86.Build.0 = Debug|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|Any CPU.Build.0 = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|x64.ActiveCfg = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|x64.Build.0 = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|x86.ActiveCfg = Release|Any CPU + {130B9CEF-CCF3-4F06-A81C-9C589196FBB2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/GlobalUsings.cs b/jobs/Backend/Task/GlobalUsings.cs new file mode 100644 index 0000000000..b645ae9eb3 --- /dev/null +++ b/jobs/Backend/Task/GlobalUsings.cs @@ -0,0 +1,11 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; diff --git a/jobs/Backend/Task/Infrastructure/CnbApiClient.cs b/jobs/Backend/Task/Infrastructure/CnbApiClient.cs new file mode 100644 index 0000000000..ffe239cd37 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CnbApiClient.cs @@ -0,0 +1,50 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Constants; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// HTTP client for interacting with the Czech National Bank API. +/// +public class CnbApiClient( + HttpClient httpClient, + ILogger logger, + IOptions configuration) : ICnbApiClient +{ + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly CnbExchangeRateConfiguration _configuration = (configuration?.Value ?? throw new ArgumentNullException(nameof(configuration))); + + public async Task FetchExchangeRatesAsync(CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation(LogMessages.CnbApiClient.FetchingExchangeRates, _configuration.ApiUrl); + + var response = await _httpClient.GetAsync(_configuration.ApiUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogInformation(LogMessages.CnbApiClient.FetchSuccessful); + + return content; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, LogMessages.CnbApiClient.HttpRequestFailed); + throw new ExchangeRateProviderException(ExceptionMessages.CnbApiClient.NetworkError, ex); + } + catch (TaskCanceledException ex) + { + _logger.LogError(ex, LogMessages.CnbApiClient.RequestTimedOut); + throw new ExchangeRateProviderException(ExceptionMessages.CnbApiClient.Timeout, ex); + } + catch (Exception ex) + { + _logger.LogError(ex, LogMessages.CnbApiClient.UnexpectedError); + throw new ExchangeRateProviderException(ExceptionMessages.CnbApiClient.UnexpectedError, ex); + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CnbDataParser.cs b/jobs/Backend/Task/Infrastructure/CnbDataParser.cs new file mode 100644 index 0000000000..fc5f1d0d58 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CnbDataParser.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Parser for CNB exchange rate data format. +/// CNB format: Country|Currency|Amount|Code|Rate +/// Example: USA|dollar|1|USD|22.950 +/// +public class CnbDataParser(ILogger logger) : ICnbDataParser +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private const char Delimiter = '|'; + private const int ExpectedColumnCount = 5; + + public IEnumerable Parse(string rawData) + { + if (string.IsNullOrWhiteSpace(rawData)) + { + _logger.LogWarning(LogMessages.CnbDataParser.EmptyOrNullData); + return Enumerable.Empty(); + } + + var lines = rawData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + if (lines.Length < 3) + { + _logger.LogWarning(LogMessages.CnbDataParser.InsufficientLines); + return Enumerable.Empty(); + } + + // Skip first two lines (date header and column names) + var dataLines = lines.Skip(2); + var results = new List(); + + foreach (var line in dataLines) + { + try + { + var dto = ParseLine(line); + if (dto != null) + { + results.Add(dto); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, LogMessages.CnbDataParser.FailedToParseLine, line); + // Continue parsing other lines even if one fails + } + } + + _logger.LogInformation(LogMessages.CnbDataParser.ParseSuccessful, results.Count); + return results; + } + + private CnbExchangeRateDto? ParseLine(string line) + { + var parts = line.Split(Delimiter); + + if (parts.Length != ExpectedColumnCount) + { + _logger.LogWarning(LogMessages.CnbDataParser.UnexpectedColumnCount, + ExpectedColumnCount, parts.Length, line); + return null; + } + + if (!int.TryParse(parts[2], out var amount)) + { + _logger.LogWarning(LogMessages.CnbDataParser.FailedToParseAmount, parts[2]); + return null; + } + + if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + { + _logger.LogWarning(LogMessages.CnbDataParser.FailedToParseRate, parts[4]); + return null; + } + + return new CnbExchangeRateDto + { + Country = parts[0].Trim(), + CurrencyName = parts[1].Trim(), + Amount = amount, + Code = parts[3].Trim(), + Rate = rate + }; + } +} diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateCache.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateCache.cs new file mode 100644 index 0000000000..74a59250e5 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateCache.cs @@ -0,0 +1,100 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// In-memory cache implementation for exchange rates. +/// +public class ExchangeRateCache( + IMemoryCache cache, + ILogger logger, + IOptions configuration) : IExchangeRateCache +{ + private readonly IMemoryCache _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly CnbExchangeRateConfiguration _configuration = (configuration?.Value ?? throw new ArgumentNullException(nameof(configuration))); + private readonly HashSet _cacheKeys = new(); + private readonly object _lock = new(); + private const string CacheKeyPrefix = "ExchangeRates_"; + + public IEnumerable? GetCachedRates(IEnumerable currencyCodes) + { + var cacheKey = GenerateCacheKey(currencyCodes); + + if (_cache.TryGetValue>(cacheKey, out var cachedRates) && cachedRates != null) + { + _logger.LogInformation(LogMessages.ExchangeRateCache.CacheHit, cacheKey, cachedRates.Count); + return cachedRates; + } + + _logger.LogInformation(LogMessages.ExchangeRateCache.CacheMiss, cacheKey); + return null; + } + + public void SetCachedRates(IEnumerable rates) + { + if (rates == null) + { + throw new ArgumentNullException(nameof(rates)); + } + + var ratesList = rates.ToList(); + if (!ratesList.Any()) + { + _logger.LogWarning(LogMessages.ExchangeRateCache.AttemptedCacheEmpty); + return; + } + + var currencyCodes = ratesList.Select(r => r.SourceCurrency.Code); + var cacheKey = GenerateCacheKey(currencyCodes); + + var cacheDuration = TimeSpan.FromMinutes(_configuration.CacheDurationMinutes); + var slidingExpiration = TimeSpan.FromMinutes(Math.Max(1, _configuration.CacheDurationMinutes / 2.0)); + + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = cacheDuration, + SlidingExpiration = slidingExpiration + }; + + _cache.Set(cacheKey, ratesList, cacheOptions); + + // Track cache key for clearing + lock (_lock) + { + _cacheKeys.Add(cacheKey); + } + + _logger.LogInformation( + LogMessages.ExchangeRateCache.CachedRates, + ratesList.Count, + cacheKey, + _configuration.CacheDurationMinutes); + } + + public void Clear() + { + lock (_lock) + { + foreach (var key in _cacheKeys) + { + _cache.Remove(key); + } + + var count = _cacheKeys.Count; + _cacheKeys.Clear(); + + _logger.LogInformation(LogMessages.ExchangeRateCache.ClearedEntries, count); + } + } + + private static string GenerateCacheKey(IEnumerable currencyCodes) + { + var sortedCodes = string.Join("_", currencyCodes.OrderBy(c => c)); + return $"{CacheKeyPrefix}{sortedCodes}"; + } +} diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProviderException.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProviderException.cs new file mode 100644 index 0000000000..0d31c35b0a --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProviderException.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Exception thrown when exchange rate provider operations fail. +/// +public class ExchangeRateProviderException : Exception +{ + public ExchangeRateProviderException(string message) : base(message) + { + } + + public ExchangeRateProviderException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/jobs/Backend/Task/Infrastructure/ICnbApiClient.cs b/jobs/Backend/Task/Infrastructure/ICnbApiClient.cs new file mode 100644 index 0000000000..2fe9dbe0ff --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ICnbApiClient.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Interface for Czech National Bank API client. +/// +public interface ICnbApiClient +{ + /// + /// Fetches the raw exchange rate data from CNB. + /// + /// Cancellation token. + /// Raw text data from CNB API. + Task FetchExchangeRatesAsync(CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/Infrastructure/ICnbDataParser.cs b/jobs/Backend/Task/Infrastructure/ICnbDataParser.cs new file mode 100644 index 0000000000..a059b144da --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ICnbDataParser.cs @@ -0,0 +1,16 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Interface for parsing CNB exchange rate data. +/// +public interface ICnbDataParser +{ + /// + /// Parses raw CNB data into exchange rate DTOs. + /// + /// Raw text data from CNB API. + /// Collection of parsed exchange rate data. + IEnumerable Parse(string rawData); +} diff --git a/jobs/Backend/Task/Infrastructure/IExchangeRateCache.cs b/jobs/Backend/Task/Infrastructure/IExchangeRateCache.cs new file mode 100644 index 0000000000..3db0446095 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/IExchangeRateCache.cs @@ -0,0 +1,27 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Interface for caching exchange rates. +/// +public interface IExchangeRateCache +{ + /// + /// Gets cached exchange rates for the specified currencies. + /// + /// Currency codes to retrieve. + /// Cached exchange rates, or null if not in cache or expired. + IEnumerable? GetCachedRates(IEnumerable currencyCodes); + + /// + /// Caches exchange rates with configured TTL. + /// + /// Exchange rates to cache. + void SetCachedRates(IEnumerable rates); + + /// + /// Clears all cached exchange rates. + /// + void Clear(); +} diff --git a/jobs/Backend/Task/Infrastructure/ISupportedCurrenciesCache.cs b/jobs/Backend/Task/Infrastructure/ISupportedCurrenciesCache.cs new file mode 100644 index 0000000000..05973d1c2e --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ISupportedCurrenciesCache.cs @@ -0,0 +1,24 @@ +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Interface for caching supported currency codes. +/// +public interface ISupportedCurrenciesCache +{ + /// + /// Gets cached list of supported currency codes. + /// + /// Cached currency codes, or null if not cached. + IEnumerable? GetCachedCurrencies(); + + /// + /// Caches the list of supported currency codes. + /// + /// Currency codes to cache. + void SetCachedCurrencies(IEnumerable currencyCodes); + + /// + /// Clears the cached currency codes. + /// + void Clear(); +} diff --git a/jobs/Backend/Task/Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..01a7d78297 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// Extension methods for configuring services in the DI container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Exchange Rate Provider and its dependencies to the service collection. + /// + public static IServiceCollection AddExchangeRateProvider( + this IServiceCollection services, + IConfiguration configuration) + { + // Bind configuration + services.Configure( + configuration.GetSection(CnbExchangeRateConfiguration.SectionName)); + + // Get configuration for HttpClient setup + var config = configuration + .GetSection(CnbExchangeRateConfiguration.SectionName) + .Get() ?? new CnbExchangeRateConfiguration(); + + // Register caching + services.AddMemoryCache(); + services.AddSingleton(); + services.AddSingleton(); + + // Register services + services.AddSingleton(); + services.AddScoped(); + + // Register HttpClient with resilience (retry, timeout, circuit breaker) + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(5)) + .AddStandardResilienceHandler(options => + { + // Configure retry + options.Retry.MaxRetryAttempts = config.RetryCount; + options.Retry.Delay = TimeSpan.FromMilliseconds(config.RetryDelayMilliseconds); + options.Retry.BackoffType = Polly.DelayBackoffType.Exponential; + + // Configure timeout + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds); + + // Configure circuit breaker (sampling duration must be at least 2x timeout) + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(config.TimeoutSeconds * 2 + 10); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/Infrastructure/SupportedCurrenciesCache.cs b/jobs/Backend/Task/Infrastructure/SupportedCurrenciesCache.cs new file mode 100644 index 0000000000..92352807f4 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/SupportedCurrenciesCache.cs @@ -0,0 +1,69 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Constants; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure; + +/// +/// In-memory cache implementation for supported currency codes. +/// +public class SupportedCurrenciesCache( + IMemoryCache cache, + ILogger logger, + IOptions configuration) : ISupportedCurrenciesCache +{ + private readonly IMemoryCache _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly CnbExchangeRateConfiguration _configuration = (configuration?.Value ?? throw new ArgumentNullException(nameof(configuration))); + private const string CacheKey = "SupportedCurrencies_All"; + + public IEnumerable? GetCachedCurrencies() + { + if (_cache.TryGetValue>(CacheKey, out var cachedCurrencies) && cachedCurrencies != null) + { + _logger.LogInformation(LogMessages.SupportedCurrenciesCache.CacheHit, cachedCurrencies.Count); + return cachedCurrencies; + } + + _logger.LogInformation(LogMessages.SupportedCurrenciesCache.CacheMiss); + return null; + } + + public void SetCachedCurrencies(IEnumerable currencyCodes) + { + if (currencyCodes == null) + { + throw new ArgumentNullException(nameof(currencyCodes)); + } + + var currencyList = currencyCodes.ToList(); + if (!currencyList.Any()) + { + _logger.LogWarning(LogMessages.SupportedCurrenciesCache.AttemptedCacheEmpty); + return; + } + + var cacheDuration = TimeSpan.FromMinutes(_configuration.CacheDurationMinutes); + var slidingExpiration = TimeSpan.FromMinutes(Math.Max(1, _configuration.CacheDurationMinutes / 2.0)); + + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = cacheDuration, + SlidingExpiration = slidingExpiration + }; + + _cache.Set(CacheKey, currencyList, cacheOptions); + + _logger.LogInformation( + LogMessages.SupportedCurrenciesCache.CachedCurrencies, + currencyList.Count, + _configuration.CacheDurationMinutes); + } + + public void Clear() + { + _cache.Remove(CacheKey); + _logger.LogInformation(LogMessages.SupportedCurrenciesCache.ClearedCache); + } +} diff --git a/jobs/Backend/Task/Models/CnbExchangeRateDto.cs b/jobs/Backend/Task/Models/CnbExchangeRateDto.cs new file mode 100644 index 0000000000..b3ab0d447c --- /dev/null +++ b/jobs/Backend/Task/Models/CnbExchangeRateDto.cs @@ -0,0 +1,13 @@ +namespace ExchangeRateUpdater.Models; + +/// +/// Data transfer object representing a single exchange rate entry from CNB. +/// +public record CnbExchangeRateDto +{ + public required string Country { get; init; } + public required string CurrencyName { get; init; } + public required int Amount { get; init; } + public required string Code { get; init; } + public required decimal Rate { get; init; } +} diff --git a/jobs/Backend/Task/Models/Currency.cs b/jobs/Backend/Task/Models/Currency.cs new file mode 100644 index 0000000000..2cf385d5c3 --- /dev/null +++ b/jobs/Backend/Task/Models/Currency.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Models; + +/// +/// Represents a currency with its ISO 4217 code. +/// +/// Three-letter ISO 4217 code of the currency. +public record Currency(string Code); diff --git a/jobs/Backend/Task/Models/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs new file mode 100644 index 0000000000..5a824ea0e3 --- /dev/null +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater.Models; + +/// +/// Represents an exchange rate between two currencies. +/// +/// The source currency. +/// The target currency. +/// The exchange rate value (how many target currency units per 1 source currency unit). +public record ExchangeRate( + Currency SourceCurrency, + Currency TargetCurrency, + decimal Value) +{ + public override string ToString() + { + return $"{SourceCurrency.Code}/{TargetCurrency.Code}={Value}"; + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..733d35710c 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,43 +1,48 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using ExchangeRateUpdater.Api; +using ExchangeRateUpdater.Infrastructure; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater; + +public class Program { - public static class Program + public static void Main(string[] args) { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => { - try + c.SwaggerDoc("v1", new() { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) + Title = "Exchange Rate API", + Version = "v1", + Description = "API for fetching exchange rates from Czech National Bank", + Contact = new() { - Console.WriteLine(rate.ToString()); + Name = "Exchange Rate Updater", + Url = new Uri("https://github.com/MewsSystems/developers") } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } + }); + }); + + builder.Services.AddExchangeRateProvider(builder.Configuration); - Console.ReadLine(); + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Exchange Rate API v1"); + c.RoutePrefix = string.Empty; + }); } + + app.UseHttpsRedirection(); + + app.MapExchangeRateEndpoints(); + + app.Run(); } } diff --git a/jobs/Backend/Task/Properties/launchSettings.json b/jobs/Backend/Task/Properties/launchSettings.json new file mode 100644 index 0000000000..a38170163e --- /dev/null +++ b/jobs/Backend/Task/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ExchangeRateUpdater": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:63739;http://localhost:63740" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 0000000000..e6170c207b --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,782 @@ +# Exchange Rate API + +A production-ready REST API for fetching real-time exchange rates from the Czech National Bank (CNB). Built with .NET 8.0, featuring caching, resilience patterns, comprehensive API documentation, and Docker support. + +## 📋 Table of Contents + +- [Features](#-features) +- [Quick Start](#-quick-start) +- [API Endpoints](#-api-endpoints) +- [Running the Application](#-running-the-application) +- [Docker Deployment](#-docker-deployment) +- [Configuration](#️-configuration) +- [API Examples](#-api-examples) +- [Architecture](#️-architecture) +- [Testing](#-testing) +- [Production Deployment](#-production-deployment) +- [Troubleshooting](#-troubleshooting) + +--- + +## ✨ Features + +- **RESTful API** with 4 endpoints for exchange rate operations +- **Swagger/OpenAPI** documentation with interactive UI +- **Smart Caching** - 60-minute cache to reduce API calls (configurable) +- **Resilience Patterns** - Retry logic, timeouts, and circuit breaker +- **Health Checks** - Monitoring endpoint for orchestration +- **Docker Support** - Multi-stage builds with security best practices +- **Comprehensive Logging** - Structured logging with correlation +- **Input Validation** - Proper error handling and HTTP status codes +- **Production Ready** - Non-root user, health checks, configurable settings +- **43 Tests** - 22 unit tests + 21 integration tests, all passing ✅ + +### Technical Stack + +- **.NET 8.0** (LTS) +- **ASP.NET Core** Minimal APIs +- **Polly** for resilience +- **Swashbuckle** for API documentation +- **xUnit** for testing +- **FluentAssertions** for readable test assertions +- **Docker** for containerization + +--- + +## 🚀 Quick Start + +### Prerequisites + +- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) +- [Docker](https://www.docker.com/get-started) (optional) + +### Run Locally (Fastest) + +```bash +cd jobs\Backend\Task +dotnet run +``` + +The API will start at: +- **HTTP**: http://localhost:5000 +- **HTTPS**: https://localhost:5001 + +Open **http://localhost:5000** in your browser to access the **Swagger UI** 🎉 + +--- + +## 📡 API Endpoints + +### 1. Get Exchange Rates (GET) + +Fetch exchange rates for specific currencies using query parameters. + +**Endpoint:** `GET /api/exchange-rates?currencies=USD,EUR,GBP` + +**Example:** +```bash +curl "http://localhost:5000/api/exchange-rates?currencies=USD,EUR,GBP" +``` + +**Response:** +```json +[ + { + "sourceCurrency": "USD", + "targetCurrency": "CZK", + "rate": 21.216 + }, + { + "sourceCurrency": "EUR", + "targetCurrency": "CZK", + "rate": 24.375 + }, + { + "sourceCurrency": "GBP", + "targetCurrency": "CZK", + "rate": 28.123 + } +] +``` + +--- + +### 2. Get Exchange Rates (POST) + +Fetch exchange rates using a JSON body (useful for large currency lists). + +**Endpoint:** `POST /api/exchange-rates` + +**Request Body:** +```json +{ + "currencyCodes": ["USD", "EUR", "GBP", "JPY", "CHF"] +} +``` + +**Example:** +```bash +curl -X POST "http://localhost:5000/api/exchange-rates" \ + -H "Content-Type: application/json" \ + -d '{"currencyCodes": ["USD", "EUR", "GBP"]}' +``` + +**Response:** Same as GET endpoint + +--- + +### 3. Get Supported Currencies + +Get a dynamically fetched list of all currencies currently supported by CNB. + +**Endpoint:** `GET /api/exchange-rates/supported` + +**Example:** +```bash +curl "http://localhost:5000/api/exchange-rates/supported" +``` + +**Response:** +```json +{ + "baseCurrency": "CZK", + "supportedCurrencies": [ + "AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "DKK", "EUR", + "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", + "KRW", "MXN", "NOK", "NZD", "PHP", "PLN", "RON", "RUB", + "SEK", "SGD", "THB", "TRY", "USD", "ZAR" + ], + "count": 30, + "note": "This list is dynamically fetched from CNB and cached for performance." +} +``` + +--- + +### 4. Health Check + +Check if the API is running and healthy. + +**Endpoint:** `GET /health` + +**Example:** +```bash +curl "http://localhost:5000/health" +``` + +**Response:** +```json +{ + "status": "Healthy", + "timestamp": "2025-11-06T10:00:00Z", + "service": "Exchange Rate API" +} +``` + +--- + +## 💻 Running the Application + +### Option 1: Development Mode with Auto-Reload + +```bash +cd C:\mews-task\mews-backend-task\jobs\Backend\Task +dotnet watch run +``` + +The app will automatically reload when you make code changes. + +--- + +### Option 2: Build and Run + +```bash +# Build +dotnet build -c Release + +# Run +dotnet run -c Release +``` + +--- + +### Option 3: Using the Published DLL + +```bash +# Publish +dotnet publish -c Release -o ./publish + +# Run +cd publish +dotnet ExchangeRateUpdater.dll +``` + +--- + +## 🐳 Docker Deployment + +### Quick Start with Docker Compose (Recommended) + +```bash +cd C:\mews-task\mews-backend-task\jobs\Backend\Task + +# Start the API +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the API +docker-compose down +``` + +The API will be available at **http://localhost:5000** + +--- + +### Manual Docker Commands + +**Build the image:** +```bash +docker build -t exchange-rate-api . +``` + +**Run the container:** +```bash +docker run -d \ + --name exchange-rate-api \ + -p 5000:8080 \ + -e CnbExchangeRate__CacheDurationMinutes=60 \ + exchange-rate-api +``` + +**View logs:** +```bash +docker logs -f exchange-rate-api +``` + +**Stop and remove:** +```bash +docker stop exchange-rate-api +docker rm exchange-rate-api +``` + +--- + +### Docker Image Details + +- **Base Image (Build):** `mcr.microsoft.com/dotnet/sdk:8.0` +- **Base Image (Runtime):** `mcr.microsoft.com/dotnet/aspnet:8.0` +- **Exposed Ports:** 8080 (HTTP) +- **Security:** Runs as non-root user (`appuser`) +- **Health Check:** Pings `/health` endpoint every 30 seconds +- **Multi-stage Build:** Optimized for smaller image size + +--- + +## ⚙️ Configuration + +### appsettings.json + +```json +{ + "CnbExchangeRate": { + "ApiUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30, + "RetryCount": 3, + "RetryDelayMilliseconds": 1000, + "EnableCache": true, + "CacheDurationMinutes": 60 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} +``` + +### Environment Variables + +Override settings using environment variables: + +```bash +# Enable/disable cache +export CnbExchangeRate__EnableCache=true + +# Cache duration (minutes) +export CnbExchangeRate__CacheDurationMinutes=120 + +# Timeout for CNB API calls (seconds) +export CnbExchangeRate__TimeoutSeconds=30 + +# Number of retry attempts +export CnbExchangeRate__RetryCount=5 + +# Run the app +dotnet run +``` + +### Docker Environment Variables + +```bash +docker run -d \ + -p 5000:8080 \ + -e CnbExchangeRate__EnableCache=true \ + -e CnbExchangeRate__CacheDurationMinutes=120 \ + -e CnbExchangeRate__TimeoutSeconds=30 \ + -e CnbExchangeRate__RetryCount=5 \ + exchange-rate-api +``` + +--- + +## 🔍 API Examples + +### PowerShell Examples + +```powershell +# GET request +Invoke-RestMethod -Uri "http://localhost:5000/api/exchange-rates?currencies=USD,EUR,GBP" + +# POST request +$body = @{ + currencyCodes = @("USD", "EUR", "GBP", "JPY") +} | ConvertTo-Json + +Invoke-RestMethod -Uri "http://localhost:5000/api/exchange-rates" ` + -Method Post ` + -Body $body ` + -ContentType "application/json" + +# Get supported currencies +Invoke-RestMethod -Uri "http://localhost:5000/api/exchange-rates/supported" + +# Health check +Invoke-RestMethod -Uri "http://localhost:5000/health" +``` + +### cURL Examples + +```bash +# GET request +curl "http://localhost:5000/api/exchange-rates?currencies=USD,EUR" + +# POST request +curl -X POST "http://localhost:5000/api/exchange-rates" \ + -H "Content-Type: application/json" \ + -d '{ + "currencyCodes": ["USD", "EUR", "GBP", "JPY", "CHF"] + }' + +# Get supported currencies +curl "http://localhost:5000/api/exchange-rates/supported" + +# Health check +curl "http://localhost:5000/health" +``` + +### JavaScript/Fetch Example + +```javascript +// GET request +const response = await fetch( + 'http://localhost:5000/api/exchange-rates?currencies=USD,EUR,GBP' +); +const rates = await response.json(); +console.log(rates); + +// POST request +const response = await fetch('http://localhost:5000/api/exchange-rates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + currencyCodes: ['USD', 'EUR', 'GBP', 'JPY'] + }) +}); +const rates = await response.json(); +console.log(rates); +``` + +### Python Example + +```python +import requests + +# GET request +response = requests.get( + 'http://localhost:5000/api/exchange-rates', + params={'currencies': 'USD,EUR,GBP'} +) +rates = response.json() +print(rates) + +# POST request +response = requests.post( + 'http://localhost:5000/api/exchange-rates', + json={'currencyCodes': ['USD', 'EUR', 'GBP', 'JPY']} +) +rates = response.json() +print(rates) +``` + +--- + +## 🏗️ Architecture + +### Project Structure + +``` +Task/ +├── Api/ # API models and endpoints +│ ├── ExchangeRateEndpoints.cs # API endpoint definitions +│ ├── ExchangeRateRequest.cs # Request DTOs +│ ├── ExchangeRateResponse.cs # Response DTOs +│ └── ErrorResponse.cs # Error DTOs +├── Configuration/ # Configuration classes +│ └── CnbExchangeRateConfiguration.cs +├── Constants/ # Application constants +│ └── LogMessages.cs # Log message templates +├── Infrastructure/ # Infrastructure layer +│ ├── CnbApiClient.cs # HTTP client for CNB API +│ ├── CnbDataParser.cs # Parse CNB text format +│ ├── ExchangeRateCache.cs # In-memory caching for rates +│ ├── SupportedCurrenciesCache.cs # Cache for supported currencies +│ ├── ExchangeRateProviderException.cs +│ ├── ServiceCollectionExtensions.cs +│ ├── ICnbApiClient.cs # Interface +│ ├── ICnbDataParser.cs # Interface +│ ├── IExchangeRateCache.cs # Interface +│ └── ISupportedCurrenciesCache.cs # Interface +├── Models/ # Domain models +│ ├── Currency.cs # Currency value object (record) +│ ├── ExchangeRate.cs # Exchange rate value object (record) +│ └── CnbExchangeRateDto.cs # CNB data transfer object +├── Services/ # Business logic +│ └── ExchangeRateProvider.cs # Main service +├── Program.cs # Application entry point +├── GlobalUsings.cs # Global using statements +├── appsettings.json # Configuration +├── Dockerfile # Docker image definition +├── docker-compose.yml # Docker Compose configuration +└── README.md # This file +``` + +### Design Principles + +- **SOLID Principles** - Single responsibility, dependency inversion +- **Clean Architecture** - Separation of concerns (Models, Services, Infrastructure) +- **Dependency Injection** - All dependencies injected via DI container +- **Resilience Patterns** - Retry, timeout, circuit breaker using Polly +- **Caching Strategy** - In-memory cache with configurable TTL +- **Error Handling** - Comprehensive exception handling with proper HTTP codes + +### Key Components + +1. **ExchangeRateProvider** - Main service orchestrating the workflow +2. **CnbApiClient** - HTTP client with resilience patterns (retry, timeout, circuit breaker) +3. **CnbDataParser** - Parses CNB's pipe-delimited text format +4. **ExchangeRateCache** - In-memory cache for exchange rates with TTL +5. **SupportedCurrenciesCache** - Dedicated cache for supported currency codes +6. **ExchangeRateEndpoints** - API endpoint definitions separated from Program.cs +7. **LogMessages** - Centralized log message templates for consistency + +### Data Flow + +``` +Client Request + ↓ +API Endpoint (Program.cs) + ↓ +ExchangeRateProvider (check cache) + ↓ +[Cache Hit] → Return cached data + ↓ +[Cache Miss] → CnbApiClient (with retry/timeout) + ↓ +CNB API (https://www.cnb.cz/...) + ↓ +CnbDataParser (parse text format) + ↓ +ExchangeRateCache (store in cache) + ↓ +Return to client +``` + +--- + +## 🧪 Testing + +### Run All Tests + +```bash +# Unit tests +cd C:\mews-task\mews-backend-task\jobs\Backend\UnitTests +dotnet test + +# Integration tests (requires internet connection) +cd C:\mews-task\mews-backend-task\jobs\Backend\IntegrationTests +dotnet test + +# Run all tests from solution +cd C:\mews-task\mews-backend-task\jobs\Backend\Task +dotnet build +dotnet test +``` + +### Test Coverage + +**Unit Tests (22 tests):** +- `CnbDataParserTests` - 7 tests for parsing logic and edge cases +- `ExchangeRateProviderTests` - 10 tests for provider logic, caching, and supported currencies +- `ExchangeRateCacheTests` - 5 tests for caching behavior + +**Integration Tests (21 tests):** +- `ExchangeRateProviderE2ETests` - 6 tests with real CNB API +- `CachingE2ETests` - 6 tests for end-to-end cache behavior +- `ErrorScenarioE2ETests` - 1 test for cancellation token handling +- `ApiEndpointTests` - 8 tests for HTTP API endpoints (GET, POST, validation) + +**Total: 43 tests, all passing ✅** + +### Run Tests with Coverage + +```bash +dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover +``` + +### Run Specific Tests + +```bash +# Run only unit tests +dotnet test --filter "FullyQualifiedName~ExchangeRateUpdater.UnitTests" + +# Run only integration tests +dotnet test --filter "FullyQualifiedName~ExchangeRateUpdater.IntegrationTests" + +# Run tests matching a pattern +dotnet test --filter "FullyQualifiedName~Cache" + +# Run specific test class +dotnet test --filter "FullyQualifiedName~ApiEndpointTests" +``` + +--- + +## 🚀 Production Deployment + +### Recommended Settings + +1. **Use HTTPS only** with valid SSL certificates +2. **Implement authentication** (API keys, OAuth, JWT) +3. **Add rate limiting** to prevent abuse +4. **Configure CORS** for web clients +5. **Use distributed cache** (Redis) for multi-instance deployments +6. **Enable application monitoring** (Application Insights, Prometheus) +7. **Set up logging aggregation** (ELK, Seq, Azure Monitor) +8. **Configure health checks** for load balancers + +### Production docker-compose.yml + +```yaml +version: '3.8' + +services: + exchange-rate-api: + image: exchange-rate-api:1.0.0 + container_name: exchange-rate-api-prod + ports: + - "5000:8080" + environment: + - DOTNET_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - CnbExchangeRate__EnableCache=true + - CnbExchangeRate__CacheDurationMinutes=60 + - CnbExchangeRate__TimeoutSeconds=30 + - CnbExchangeRate__RetryCount=3 + restart: always + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.5' + memory: 256M + networks: + - app-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + app-network: + driver: bridge +``` + +### Cloud Deployment Options + +**Azure:** +- Azure App Service +- Azure Container Instances +- Azure Kubernetes Service (AKS) + +**AWS:** +- AWS Elastic Beanstalk +- AWS ECS/Fargate +- AWS EKS + +**Google Cloud:** +- Google App Engine +- Google Cloud Run +- Google Kubernetes Engine (GKE) + +--- + +## 🐛 Troubleshooting + +### API Won't Start + +**Check port availability:** +```bash +# Windows +netstat -ano | findstr :5000 + +# Linux/Mac +lsof -i :5000 +``` + +**Solution:** Kill the process or use a different port + +```bash +# Use different port +dotnet run --urls "http://localhost:5001" +``` + +--- + +### "Service Unavailable" Errors + +**Possible causes:** +1. CNB API is down +2. Network connectivity issues +3. Firewall blocking outbound requests + +**Test CNB API manually:** +```bash +curl https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt +``` + +**Solution:** Check network settings, verify firewall rules + +--- + +### Empty Response / No Exchange Rates + +**Causes:** +1. Invalid currency codes +2. CNB doesn't support the requested currency +3. Rates not yet published (CNB updates after 2:30 PM CET) + +**Solution:** +```bash +# Check supported currencies +curl "http://localhost:5000/api/exchange-rates/supported" + +# Verify currency codes are valid (3-letter ISO codes) +``` + +--- + +### Docker Container Won't Start + +**Check logs:** +```bash +docker logs exchange-rate-api +``` + +**Common issues:** +- Port conflict → Change port mapping: `-p 5001:8080` +- Image build failed → Rebuild: `docker-compose build --no-cache` +- Health check failing → Wait 10-15 seconds for startup + +**Verify health:** +```bash +docker inspect --format='{{.State.Health.Status}}' exchange-rate-api +``` + +--- + +### Cache Not Working + +**Verify cache is enabled:** +```bash +# Check configuration +cat appsettings.json | grep EnableCache +``` + +**Test cache behavior:** +```bash +# First call (cache miss - slower) +time curl "http://localhost:5000/api/exchange-rates?currencies=USD" + +# Second call (cache hit - faster) +time curl "http://localhost:5000/api/exchange-rates?currencies=USD" +``` + +The second call should be significantly faster (< 10ms vs 200ms+) + +--- + +## 📚 Common Currency Codes + +| Code | Currency | Country/Region | +|------|----------|----------------| +| USD | US Dollar | United States | +| EUR | Euro | Eurozone | +| GBP | British Pound | United Kingdom | +| JPY | Japanese Yen | Japan | +| CHF | Swiss Franc | Switzerland | +| AUD | Australian Dollar | Australia | +| CAD | Canadian Dollar | Canada | +| CNY | Chinese Yuan | China | +| INR | Indian Rupee | India | +| RUB | Russian Ruble | Russia | +| SEK | Swedish Krona | Sweden | +| NOK | Norwegian Krone | Norway | +| DKK | Danish Krone | Denmark | +| PLN | Polish Zloty | Poland | +| THB | Thai Baht | Thailand | +| SGD | Singapore Dollar | Singapore | +| HKD | Hong Kong Dollar | Hong Kong | +| KRW | South Korean Won | South Korea | +| ZAR | South African Rand | South Africa | +| MXN | Mexican Peso | Mexico | + +--- + +## 📝 Notes + +- **Base Currency:** All rates are in CZK (Czech Koruna) +- **Cache Duration:** Default 60 minutes (configurable) +- **Invalid Currencies:** Silently omitted from response (no error thrown) +- **Rate Normalization:** Rates provided per 1 unit of source currency (CNB sometimes provides rates per 100 units, which are automatically normalized) + +--- + +## 🤝 Contributing + +This is a task implementation for Mews. For the original task, see: +https://github.com/MewsSystems/developers/blob/master/jobs/Backend/DotNet.md + +--- + diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..2e4ffb9241 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,160 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Services; + +/// +/// Provides exchange rates from the Czech National Bank with optional caching. +/// +public class ExchangeRateProvider( + ICnbApiClient apiClient, + ICnbDataParser dataParser, + ILogger logger, + IOptions configuration, + IExchangeRateCache? cache = null, + ISupportedCurrenciesCache? supportedCurrenciesCache = null) +{ + private readonly ICnbApiClient _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + private readonly ICnbDataParser _dataParser = dataParser ?? throw new ArgumentNullException(nameof(dataParser)); + private readonly IExchangeRateCache? _cache = cache; + private readonly ISupportedCurrenciesCache? _supportedCurrenciesCache = supportedCurrenciesCache; + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly CnbExchangeRateConfiguration _configuration = (configuration?.Value ?? throw new ArgumentNullException(nameof(configuration))); + + private static readonly Currency CzkCurrency = new("CZK"); + + /// + /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined + /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", + /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide + /// some of the currencies, ignore them. + /// + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + return GetExchangeRatesAsync(currencies, CancellationToken.None) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + } + + /// + /// Asynchronously retrieves exchange rates for the specified currencies. + /// + /// Currencies to get rates for. + /// Cancellation token. + /// Exchange rates as defined by CNB (foreign currency to CZK). + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(currencies); + + var currencyCodes = currencies.Select(c => c.Code).ToList(); + if (currencyCodes.Count == 0) + { + _logger.LogInformation(LogMessages.ExchangeRateProvider.NoCurrenciesRequested); + return Enumerable.Empty(); + } + + _logger.LogInformation(LogMessages.ExchangeRateProvider.FetchingExchangeRates, currencyCodes.Count); + + if (_configuration.EnableCache && _cache != null) + { + var cachedRates = _cache.GetCachedRates(currencyCodes); + if (cachedRates != null) + { + _logger.LogInformation(LogMessages.ExchangeRateProvider.ReturningFromCache, cachedRates.Count()); + return cachedRates; + } + } + + try + { + var rawData = await _apiClient.FetchExchangeRatesAsync(cancellationToken); + + var cnbRates = _dataParser.Parse(rawData); + + var requestedCurrencyCodes = new HashSet( + currencyCodes, + StringComparer.OrdinalIgnoreCase); + + var exchangeRates = cnbRates + .Where(dto => requestedCurrencyCodes.Contains(dto.Code)) + .Select(dto => ConvertToExchangeRate(dto)) + .ToList(); + + _logger.LogInformation( + LogMessages.ExchangeRateProvider.RetrievalSuccessful, + exchangeRates.Count, + currencyCodes.Count); + + if (_configuration.EnableCache && _cache != null && exchangeRates.Any()) + { + _cache.SetCachedRates(exchangeRates); + } + + return exchangeRates; + } + catch (ExchangeRateProviderException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, LogMessages.ExchangeRateProvider.UnexpectedError); + throw new ExchangeRateProviderException(ExceptionMessages.ExchangeRateProvider.FailedToRetrieveRates, ex); + } + } + + /// + /// Gets all currency codes currently supported by CNB. + /// + /// Cancellation token. + /// List of supported currency codes. + public async Task> GetSupportedCurrenciesAsync(CancellationToken cancellationToken = default) + { + if (_configuration.EnableCache && _supportedCurrenciesCache != null) + { + var cached = _supportedCurrenciesCache.GetCachedCurrencies(); + if (cached != null) + { + _logger.LogInformation(LogMessages.ExchangeRateProvider.ReturningCachedSupportedCurrencies); + return cached; + } + } + + try + { + _logger.LogInformation(LogMessages.ExchangeRateProvider.FetchingSupportedCurrencies); + + var rawData = await _apiClient.FetchExchangeRatesAsync(cancellationToken); + var cnbRates = _dataParser.Parse(rawData); + + var currencyCodes = cnbRates.Select(dto => dto.Code).OrderBy(c => c).ToList(); + + if (_configuration.EnableCache && _supportedCurrenciesCache != null && currencyCodes.Any()) + { + _supportedCurrenciesCache.SetCachedCurrencies(currencyCodes); + } + + _logger.LogInformation(LogMessages.ExchangeRateProvider.FoundSupportedCurrencies, currencyCodes.Count); + return currencyCodes; + } + catch (Exception ex) + { + _logger.LogError(ex, LogMessages.ExchangeRateProvider.FailedToFetchSupportedCurrencies); + throw new ExchangeRateProviderException(ExceptionMessages.ExchangeRateProvider.FailedToRetrieveSupportedCurrencies, ex); + } + } + + private ExchangeRate ConvertToExchangeRate(CnbExchangeRateDto dto) + { + var sourceCurrency = new Currency(dto.Code); + var rate = dto.Rate / dto.Amount; + + return new ExchangeRate(sourceCurrency, CzkCurrency, rate); + } +} diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..b748393ef9 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,17 @@ +{ + "CnbExchangeRate": { + "ApiUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30, + "RetryCount": 3, + "RetryDelayMilliseconds": 1000, + "EnableCache": true, + "CacheDurationMinutes": 60 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + } +} diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 0000000000..67363d5009 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + exchange-rate-api: + build: + context: . + dockerfile: Dockerfile + container_name: exchange-rate-api + ports: + - "5000:8080" # Map host port 5000 to container port 8080 + environment: + - DOTNET_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - CnbExchangeRate__TimeoutSeconds=30 + - CnbExchangeRate__RetryCount=3 + - CnbExchangeRate__EnableCache=true + - CnbExchangeRate__CacheDurationMinutes=60 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - exchange-rate-network + +networks: + exchange-rate-network: + driver: bridge diff --git a/jobs/Backend/UnitTests/CnbDataParserTests.cs b/jobs/Backend/UnitTests/CnbDataParserTests.cs new file mode 100644 index 0000000000..240b56ad9d --- /dev/null +++ b/jobs/Backend/UnitTests/CnbDataParserTests.cs @@ -0,0 +1,115 @@ +using ExchangeRateUpdater.Infrastructure; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdater.UnitTests; + +public class CnbDataParserTests +{ + private readonly Mock> _loggerMock; + private readonly CnbDataParser _parser; + + public CnbDataParserTests() + { + _loggerMock = new Mock>(); + _parser = new CnbDataParser(_loggerMock.Object); + } + + [Fact] + public void Parse_ValidData_ReturnsExchangeRates() + { + // Arrange + var rawData = @"05 Nov 2025 #215 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|14.567 +USA|dollar|1|USD|22.950 +EMU|euro|1|EUR|24.375"; + + // Act + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(3); + + result[0].Code.Should().Be("AUD"); + result[0].Amount.Should().Be(1); + result[0].Rate.Should().Be(14.567m); + + result[1].Code.Should().Be("USD"); + result[2].Code.Should().Be("EUR"); + } + + [Fact] + public void Parse_EmptyData_ReturnsEmptyCollection() + { + // Act + var result = _parser.Parse(string.Empty); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Parse_NullData_ReturnsEmptyCollection() + { + // Act + var result = _parser.Parse(null!); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Parse_DataWithDifferentAmounts_NormalizesCorrectly() + { + // Arrange + var rawData = @"05 Nov 2025 #215 +Country|Currency|Amount|Code|Rate +Japan|yen|100|JPY|15.234 +Thailand|baht|1|THB|0.652"; + + // Act + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(2); + result[0].Amount.Should().Be(100); + result[1].Amount.Should().Be(1); + } + + [Fact] + public void Parse_MalformedLine_SkipsInvalidLine() + { + // Arrange + var rawData = @"05 Nov 2025 #215 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|14.567 +Invalid|Line|Data +USA|dollar|1|USD|22.950"; + + // Act + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(2); + result[0].Code.Should().Be("AUD"); + result[1].Code.Should().Be("USD"); + } + + [Fact] + public void Parse_InvalidNumericValues_SkipsInvalidLine() + { + // Arrange + var rawData = @"05 Nov 2025 #215 +Country|Currency|Amount|Code|Rate +Australia|dollar|INVALID|AUD|14.567 +USA|dollar|1|USD|NOTANUMBER"; + + // Act + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().BeEmpty(); + } +} diff --git a/jobs/Backend/UnitTests/ExchangeRateCacheTests.cs b/jobs/Backend/UnitTests/ExchangeRateCacheTests.cs new file mode 100644 index 0000000000..e1e501ba8c --- /dev/null +++ b/jobs/Backend/UnitTests/ExchangeRateCacheTests.cs @@ -0,0 +1,133 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateUpdater.UnitTests; + +public class ExchangeRateCacheTests +{ + private readonly IMemoryCache _memoryCache; + private readonly Mock> _loggerMock; + private readonly IOptions _configuration; + private readonly ExchangeRateCache _cache; + + public ExchangeRateCacheTests() + { + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _loggerMock = new Mock>(); + _configuration = Options.Create(new CnbExchangeRateConfiguration + { + CacheDurationMinutes = 60 + }); + _cache = new ExchangeRateCache(_memoryCache, _loggerMock.Object, _configuration); + } + + [Fact] + public void GetCachedRates_WhenEmpty_ReturnsNull() + { + // Arrange + var currencyCodes = new[] { "USD", "EUR" }; + + // Act + var result = _cache.GetCachedRates(currencyCodes); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void SetCachedRates_ThenGet_ReturnsRates() + { + // Arrange + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 22.95m), + new(new Currency("EUR"), new Currency("CZK"), 24.375m) + }; + var currencyCodes = new[] { "USD", "EUR" }; + + // Act + _cache.SetCachedRates(rates); + var result = _cache.GetCachedRates(currencyCodes); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().Contain(r => r.SourceCurrency.Code == "USD"); + result.Should().Contain(r => r.SourceCurrency.Code == "EUR"); + } + + [Fact] + public void GetCachedRates_OrderIndependent() + { + // Arrange + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 22.95m), + new(new Currency("EUR"), new Currency("CZK"), 24.375m) + }; + + _cache.SetCachedRates(rates); + + // Act - Try different order + var result1 = _cache.GetCachedRates(new[] { "USD", "EUR" }); + var result2 = _cache.GetCachedRates(new[] { "EUR", "USD" }); + + // Assert - Both should return cached data + result1.Should().NotBeNull(); + result2.Should().NotBeNull(); + result1.Should().HaveCount(2); + result2.Should().HaveCount(2); + } + + [Fact] + public void SetCachedRates_WithEmptyList_DoesNotCache() + { + // Arrange + var emptyRates = new List(); + + // Act + _cache.SetCachedRates(emptyRates); + var result = _cache.GetCachedRates(new[] { "USD" }); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void SetCachedRates_WithNull_ThrowsArgumentNullException() + { + // Act & Assert + _cache.Invoking(c => c.SetCachedRates(null!)) + .Should().Throw(); + } + + [Fact] + public void GetCachedRates_DifferentCurrencies_ReturnsDifferentCache() + { + // Arrange + var usdRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 22.95m) + }; + var eurRates = new List + { + new(new Currency("EUR"), new Currency("CZK"), 24.375m) + }; + + _cache.SetCachedRates(usdRates); + + // Act + var usdResult = _cache.GetCachedRates(new[] { "USD" }); + var eurResult = _cache.GetCachedRates(new[] { "EUR" }); + + // Assert + usdResult.Should().NotBeNull().And.HaveCount(1); + eurResult.Should().BeNull(); // EUR not cached + } +} diff --git a/jobs/Backend/UnitTests/ExchangeRateProviderTests.cs b/jobs/Backend/UnitTests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..a77505f3c9 --- /dev/null +++ b/jobs/Backend/UnitTests/ExchangeRateProviderTests.cs @@ -0,0 +1,285 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateUpdater.UnitTests; + +public class ExchangeRateProviderTests +{ + private readonly Mock _apiClientMock; + private readonly Mock _dataParserMock; + private readonly Mock> _loggerMock; + private readonly Mock _cacheMock; + private readonly IOptions _configuration; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _apiClientMock = new Mock(); + _dataParserMock = new Mock(); + _loggerMock = new Mock>(); + _cacheMock = new Mock(); + _configuration = Options.Create(new CnbExchangeRateConfiguration + { + EnableCache = false // Disable cache for tests by default + }); + + _provider = new ExchangeRateProvider( + _apiClientMock.Object, + _dataParserMock.Object, + _loggerMock.Object, + _configuration, + _cacheMock.Object); + } + + [Fact] + public void GetExchangeRates_WithValidCurrencies_ReturnsExchangeRates() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + var rawData = "sample data"; + + var cnbRates = new List + { + new() { Code = "USD", Amount = 1, Rate = 22.950m, Country = "USA", CurrencyName = "dollar" }, + new() { Code = "EUR", Amount = 1, Rate = 24.375m, Country = "EMU", CurrencyName = "euro" }, + new() { Code = "GBP", Amount = 1, Rate = 28.123m, Country = "UK", CurrencyName = "pound" } + }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + _dataParserMock.Setup(x => x.Parse(rawData)) + .Returns(cnbRates); + + // Act + var result = _provider.GetExchangeRates(currencies).ToList(); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(r => r.SourceCurrency.Code == "USD"); + result.Should().Contain(r => r.SourceCurrency.Code == "EUR"); + result.Should().NotContain(r => r.SourceCurrency.Code == "GBP"); + + result.ForEach(r => r.TargetCurrency.Code.Should().Be("CZK")); + } + + [Fact] + public void GetExchangeRates_WithNoCurrencies_ReturnsEmpty() + { + // Arrange + var currencies = Array.Empty(); + + // Act + var result = _provider.GetExchangeRates(currencies); + + // Assert + result.Should().BeEmpty(); + _apiClientMock.Verify(x => x.FetchExchangeRatesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public void GetExchangeRates_WithNullCurrencies_ThrowsArgumentNullException() + { + // Act & Assert + _provider.Invoking(p => p.GetExchangeRates(null!)) + .Should().Throw(); + } + + [Fact] + public void GetExchangeRates_WhenApiClientThrows_ThrowsExchangeRateProviderException() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ThrowsAsync(new ExchangeRateProviderException("API error")); + + // Act & Assert + _provider.Invoking(p => p.GetExchangeRates(currencies)) + .Should().Throw() + .WithMessage("API error"); + } + + [Fact] + public async Task GetExchangeRatesAsync_NormalizesRatesPerUnit() + { + // Arrange - CNB provides some rates for multiple units (e.g., 100 JPY) + var currencies = new[] { new Currency("JPY"), new Currency("USD") }; + var rawData = "sample data"; + + var cnbRates = new List + { + new() { Code = "JPY", Amount = 100, Rate = 1523.4m, Country = "Japan", CurrencyName = "yen" }, + new() { Code = "USD", Amount = 1, Rate = 22.950m, Country = "USA", CurrencyName = "dollar" } + }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + _dataParserMock.Setup(x => x.Parse(rawData)) + .Returns(cnbRates); + + // Act + var result = await _provider.GetExchangeRatesAsync(currencies); + var resultList = result.ToList(); + + // Assert + resultList.Should().HaveCount(2); + + var jpyRate = resultList.First(r => r.SourceCurrency.Code == "JPY"); + jpyRate.Value.Should().Be(15.234m); // 1523.4 / 100 + + var usdRate = resultList.First(r => r.SourceCurrency.Code == "USD"); + usdRate.Value.Should().Be(22.950m); // 22.950 / 1 + } + + [Fact] + public void GetExchangeRates_CaseInsensitiveCurrencyMatching() + { + // Arrange + var currencies = new[] { new Currency("usd"), new Currency("EUR") }; + var rawData = "sample data"; + + var cnbRates = new List + { + new() { Code = "USD", Amount = 1, Rate = 22.950m, Country = "USA", CurrencyName = "dollar" }, + new() { Code = "eur", Amount = 1, Rate = 24.375m, Country = "EMU", CurrencyName = "euro" } + }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + _dataParserMock.Setup(x => x.Parse(rawData)) + .Returns(cnbRates); + + // Act + var result = _provider.GetExchangeRates(currencies).ToList(); + + // Assert + result.Should().HaveCount(2); + } + + [Fact] + public async Task GetSupportedCurrenciesAsync_ReturnsAllCurrencyCodes() + { + // Arrange + var rawData = "sample data"; + var cnbRates = new List + { + new() { Code = "USD", Amount = 1, Rate = 22.950m, Country = "USA", CurrencyName = "dollar" }, + new() { Code = "EUR", Amount = 1, Rate = 24.375m, Country = "EMU", CurrencyName = "euro" }, + new() { Code = "GBP", Amount = 1, Rate = 28.123m, Country = "UK", CurrencyName = "pound" } + }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + _dataParserMock.Setup(x => x.Parse(rawData)) + .Returns(cnbRates); + + // Act + var result = await _provider.GetSupportedCurrenciesAsync(); + var resultList = result.ToList(); + + // Assert + resultList.Should().HaveCount(3); + resultList.Should().Contain("EUR"); + resultList.Should().Contain("GBP"); + resultList.Should().Contain("USD"); + resultList.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task GetSupportedCurrenciesAsync_WhenApiClientThrows_ThrowsExchangeRateProviderException() + { + // Arrange + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + await _provider.Invoking(p => p.GetSupportedCurrenciesAsync()) + .Should().ThrowAsync() + .WithMessage("Failed to retrieve supported currencies"); + } + + [Fact] + public async Task GetSupportedCurrenciesAsync_WithCachingEnabled_UsesCachedData() + { + // Arrange + var configuration = Options.Create(new CnbExchangeRateConfiguration + { + EnableCache = true + }); + + var supportedCurrenciesCacheMock = new Mock(); + var cachedCurrencies = new List { "EUR", "USD" }; + + supportedCurrenciesCacheMock.Setup(x => x.GetCachedCurrencies()) + .Returns(cachedCurrencies); + + var provider = new ExchangeRateProvider( + _apiClientMock.Object, + _dataParserMock.Object, + _loggerMock.Object, + configuration, + _cacheMock.Object, + supportedCurrenciesCacheMock.Object); + + // Act + var result = await provider.GetSupportedCurrenciesAsync(); + var resultList = result.ToList(); + + // Assert + resultList.Should().HaveCount(2); + resultList.Should().Contain("EUR"); + resultList.Should().Contain("USD"); + _apiClientMock.Verify(x => x.FetchExchangeRatesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSupportedCurrenciesAsync_WithCachingEnabled_CachesResults() + { + // Arrange + var configuration = Options.Create(new CnbExchangeRateConfiguration + { + EnableCache = true + }); + + var supportedCurrenciesCacheMock = new Mock(); + + supportedCurrenciesCacheMock.Setup(x => x.GetCachedCurrencies()) + .Returns((IEnumerable?)null); + + var provider = new ExchangeRateProvider( + _apiClientMock.Object, + _dataParserMock.Object, + _loggerMock.Object, + configuration, + _cacheMock.Object, + supportedCurrenciesCacheMock.Object); + + var rawData = "sample data"; + var cnbRates = new List + { + new() { Code = "USD", Amount = 1, Rate = 22.950m, Country = "USA", CurrencyName = "dollar" } + }; + + _apiClientMock.Setup(x => x.FetchExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + _dataParserMock.Setup(x => x.Parse(rawData)) + .Returns(cnbRates); + + // Act + await provider.GetSupportedCurrenciesAsync(); + + // Assert + supportedCurrenciesCacheMock.Verify(x => x.SetCachedCurrencies(It.IsAny>()), Times.Once); + } +} diff --git a/jobs/Backend/UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..c3da7731a9 --- /dev/null +++ b/jobs/Backend/UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + +