From 66f02d000175d83bd0f651e278022e21549f13b3 Mon Sep 17 00:00:00 2001 From: "Siddalingappa (Sid)" Date: Sun, 31 Aug 2025 07:46:27 +0200 Subject: [PATCH 1/4] chore: set up clean architecture with API, application, and infrastructure layers, plus tests, Swagger, Docker, and GitHub Actions --- .../Controllers/ExchangeRatesController.cs | 0 .../ExchangeRateUpdater.Api/Dockerfile | 0 .../ExchangeRateUpdater.Api.csproj | 25 +++++++++++ .../ExchangeRateUpdater.Api/Program.cs | 0 .../ExchangeRateUpdater.Api/appsettings.json | 23 ++++++++++ .../ExchangeRateUpdater.Application.csproj | 20 +++++++++ .../Queries/GetExchangeRatesQuery.cs | 0 .../Queries/GetExchangeRatesQueryHandler.cs | 0 .../ServiceCollectionExtensions.cs | 0 .../ExchangeRateUpdater.Console/.gitignore | 0 .../ExchangeRateUpdater.Console/Dockerfile | 0 .../ExchangeRateProvider.cs | 0 .../ExchangeRateUpdater.Console.csproj | 28 ++++++++++++ .../ExchangeRateUpdater.Console/Program.cs | 0 .../ExchangeRateUpdater.Console/README.md | 0 .../appsettings.json | 14 ++++++ .../Entities/Currency.cs | 0 .../Entities/ExchangeRate.cs | 0 .../ExchangeRateUpdater.Domain.csproj | 9 ++++ .../Interfaces/IExchangeRateProvider.cs | 0 .../CnbCacheStrategy.cs | 0 .../CnbExchangeRateProvider.cs | 0 .../DistributedCachingExchangeRateProvider.cs | 0 .../ExchangeRateUpdater.Infrastructure.csproj | 27 ++++++++++++ .../ServiceCollectionExtensions.cs | 0 .../ExchangeRatesControllerTests.cs | 0 .../ExchangeRatesControllerUnitTests.cs | 0 .../ExchangeRatesControllerValidationTests.cs | 0 .../ExchangeRateUpdater.Tests.csproj | 31 +++++++++++++ .../ServiceRegistrationTests.cs | 0 .../Integration/ApiKeyMiddlewareTests.cs | 0 .../Providers/CnbExchangeRateProviderTests.cs | 0 ...ributedCachingExchangeRateProviderTests.cs | 0 .../GetExchangeRatesQueryHandlerEdgeTests.cs | 0 ...angeRatesQueryHandlerMultiCurrencyTests.cs | 0 .../GetExchangeRatesQueryHandlerTests.cs | 0 jobs/Backend/Task/Currency.cs | 20 --------- jobs/Backend/Task/ExchangeRate.cs | 23 ---------- jobs/Backend/Task/ExchangeRateProvider.cs | 19 -------- jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 ---- jobs/Backend/Task/ExchangeRateUpdater.sln | 22 ---------- jobs/Backend/Task/Program.cs | 43 ------------------- 42 files changed, 177 insertions(+), 135 deletions(-) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.sln delete mode 100644 jobs/Backend/Task/Program.cs diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 000000000..899e43744 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 000000000..ad7e4c17a --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,23 @@ +{ + "ASPNETCORE_ENVIRONMENT":"Development", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ExchangeRateUpdater": "Debug" + } + }, + "AllowedHosts": "*", + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "CacheExpirationMinutes": 60 + }, + "ApiKey": { + "Enabled": false, + "Value": "CHANGE_ME_API_KEY" + }, + "Redis": { + "Enabled": true, + "Configuration": "localhost:6379" + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj new file mode 100644 index 000000000..4c2a29e2f --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj new file mode 100644 index 000000000..79370b126 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -0,0 +1,28 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json new file mode 100644 index 000000000..65204c470 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "ExchangeRateUpdater": "Debug" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "CacheExpirationMinutes": 60 + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj new file mode 100644 index 000000000..c5496475b --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..69a8a57ac --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f2..000000000 --- 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/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e..000000000 --- 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 6f82a97fb..000000000 --- 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 deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daf..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -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 -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - 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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - 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) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} From 405ac605a81936c913c877d51ca3683b91a20a8b Mon Sep 17 00:00:00 2001 From: "Siddalingappa (Sid)" Date: Mon, 1 Sep 2025 11:09:24 +0200 Subject: [PATCH 2/4] feat: Implemented business logic with enhanced project structure following Clean Architecture principles and proper design --- .../Controllers/ExchangeRatesController.cs | 108 ++++++ .../ExchangeRateProvider.Api/Dockerfile | 14 + .../ExchangeRateProvider.Api.csproj} | 5 +- .../ExchangeRateProvider.Api/Program.cs | 141 +++++++ .../Properties/launchSettings.json | 35 ++ .../appsettings.Development.json | 25 ++ .../appsettings.Production.json | 25 ++ .../appsettings.Staging.json | 25 ++ .../ExchangeRateProvider.Api/appsettings.json | 25 ++ .../ExchangeRateProvider.Application.csproj} | 4 +- .../Queries/GetExchangeRatesQuery.cs | 17 + .../Queries/GetExchangeRatesQueryHandler.cs | 33 ++ .../ServiceCollectionExtensions.cs | 32 ++ .../ExchangeRateProvider.Console/.gitignore | 9 + .../ExchangeRateProvider.Console/Dockerfile | 13 + .../ExchangeRateProvider.Console.csproj} | 2 +- .../ExchangeRateProvider.Console/Program.cs | 91 +++++ .../ExchangeRateProvider.Console/README.md | 87 +++++ .../appsettings.json | 26 ++ .../Entities/Currency.cs | 110 ++++++ .../Entities/ExchangeRate.cs | 120 ++++++ .../ExchangeRateProvider.Domain.csproj | 17 + .../Interfaces/IExchangeRateProvider.cs | 36 ++ .../Interfaces/IProviderRegistry.cs | 61 ++++ .../Providers/ProviderRegistry.cs | 55 +++ .../CnbCacheStrategy.cs | 307 ++++++++++++++++ .../CnbExchangeRateProvider.cs | 182 +++++++++ .../DistributedCachingExchangeRateProvider.cs | 144 ++++++++ ...xchangeRateProvider.Infrastructure.csproj} | 2 +- .../ProviderRegistrationHostedService.cs | 42 +++ .../ProviderRegistrationService.cs | 41 +++ .../ServiceCollectionExtensions.cs | 126 +++++++ .../ExchangeRatesControllerTests.cs | 195 ++++++++++ .../ExchangeRatesControllerValidationTests.cs | 56 +++ .../ExchangeRateProvider.Tests.csproj} | 8 +- .../Infrastructure/CnbCacheStrategyTests.cs | 86 +++++ .../ServiceCollectionExtensionsTests.cs | 226 ++++++++++++ .../ServiceRegistrationTests.cs | 32 ++ .../Integration/ApiE2ETests.cs | 99 +++++ .../Integration/CnbApiIntegrationTests.cs | 79 ++++ .../Providers/CnbExchangeRateProviderTests.cs | 240 ++++++++++++ ...ributedCachingExchangeRateProviderTests.cs | 119 ++++++ .../GetExchangeRatesQueryHandlerTests.cs | 88 +++++ .../ExchangeRateProvider.sln | 104 ++++++ .../Controllers/ExchangeRatesController.cs | 0 .../ExchangeRateUpdater.Api/Dockerfile | 0 .../ExchangeRateUpdater.Api/Program.cs | 0 .../ExchangeRateUpdater.Api/appsettings.json | 23 -- .../Queries/GetExchangeRatesQuery.cs | 0 .../Queries/GetExchangeRatesQueryHandler.cs | 0 .../ServiceCollectionExtensions.cs | 0 .../ExchangeRateUpdater.Console/.gitignore | 0 .../ExchangeRateUpdater.Console/Dockerfile | 0 .../ExchangeRateProvider.cs | 0 .../ExchangeRateUpdater.Console/Program.cs | 0 .../ExchangeRateUpdater.Console/README.md | 0 .../appsettings.json | 14 - .../Entities/Currency.cs | 0 .../Entities/ExchangeRate.cs | 0 .../ExchangeRateUpdater.Domain.csproj | 9 - .../Interfaces/IExchangeRateProvider.cs | 0 .../CnbCacheStrategy.cs | 0 .../CnbExchangeRateProvider.cs | 0 .../DistributedCachingExchangeRateProvider.cs | 0 .../ServiceCollectionExtensions.cs | 0 .../ExchangeRatesControllerTests.cs | 0 .../ExchangeRatesControllerUnitTests.cs | 0 .../ExchangeRatesControllerValidationTests.cs | 0 .../ServiceRegistrationTests.cs | 0 .../Integration/ApiKeyMiddlewareTests.cs | 0 .../Providers/CnbExchangeRateProviderTests.cs | 0 ...ributedCachingExchangeRateProviderTests.cs | 0 .../GetExchangeRatesQueryHandlerEdgeTests.cs | 0 ...angeRatesQueryHandlerMultiCurrencyTests.cs | 0 .../GetExchangeRatesQueryHandlerTests.cs | 0 .../Task/CnbExchangeRateProvider/README.md | 344 ++++++++++++++++++ .../docker-compose.dev.yml | 62 ++++ .../docker-compose.prod.yml | 71 ++++ .../docker-compose.yml | 26 ++ 79 files changed, 3785 insertions(+), 56 deletions(-) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Dockerfile rename jobs/Backend/Task/CnbExchangeRateProvider/{ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj => ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj} (76%) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Program.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Production.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Staging.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.json rename jobs/Backend/Task/CnbExchangeRateProvider/{ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj => ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj} (70%) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQueryHandler.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/.gitignore create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Dockerfile rename jobs/Backend/Task/CnbExchangeRateProvider/{ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj => ExchangeRateProvider.Console/ExchangeRateProvider.Console.csproj} (88%) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Program.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/README.md create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/appsettings.json create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/Currency.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IProviderRegistry.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Providers/ProviderRegistry.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbCacheStrategy.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs rename jobs/Backend/Task/CnbExchangeRateProvider/{ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj => ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj} (92%) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationHostedService.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationService.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerValidationTests.cs rename jobs/Backend/Task/CnbExchangeRateProvider/{ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj => ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj} (67%) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/CnbCacheStrategyTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceCollectionExtensionsTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceRegistrationTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/ApiE2ETests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.sln delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs delete mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/README.md create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.dev.yml create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.prod.yml create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.yml diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 000000000..c2a3df62b --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,108 @@ +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Entities; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateProvider.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ExchangeRatesController : ControllerBase + { + private readonly IMediator _mediator; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public ExchangeRatesController( + IMediator mediator, + ILogger logger, + IConfiguration configuration) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + /// Gets exchange rates for a specified list of currencies against CZK. + /// + /// A comma-separated list of currency codes (e.g., USD,EUR,GBP). + /// A list of exchange rates against CZK. + [HttpGet] + public async Task>> GetExchangeRates( + [FromQuery] string currencyCodes) + { + // Parse currency codes + var requestedCodes = currencyCodes.Split(',', StringSplitOptions.RemoveEmptyEntries); + + // Validate currency codes + if (string.IsNullOrWhiteSpace(currencyCodes) || !requestedCodes.Any()) + { + _logger.LogInformation("No currency codes provided, returning empty result."); + return Ok(new List()); + } + + // Enforce max currency count + var maxCount = _configuration.GetValue("ExchangeRateProvider:MaxCurrencies", 20); + var requestedCount = requestedCodes.Length; + + if (requestedCount > maxCount) + { + _logger.LogWarning("Too many currency codes requested. Maximum allowed is {MaxCount}.", maxCount); + return BadRequest($"Too many currency codes. Maximum allowed is {maxCount}."); + } + + // Parse and validate currency codes + var currencies = new List(); + var invalidCodes = new List(); + + foreach (var code in requestedCodes.Select(c => c.Trim().ToUpper()).Where(c => !string.IsNullOrWhiteSpace(c))) + { + try + { + currencies.Add(new Currency(code)); + } + catch (InvalidCurrencyCodeException) + { + invalidCodes.Add(code); + } + } + + if (!currencies.Any()) + { + _logger.LogWarning("No valid currency codes provided. Invalid codes: {InvalidCodes}", string.Join(", ", invalidCodes)); + return BadRequest($"No valid currency codes provided. Invalid codes: {string.Join(", ", invalidCodes)}"); + } + + if (invalidCodes.Any()) + { + _logger.LogWarning("Some currency codes were invalid and ignored: {InvalidCodes}", string.Join(", ", invalidCodes)); + } + + // Only CZK is supported as the target currency + var targetCurrency = new Currency("CZK"); + + try + { + _logger.LogInformation( + "Fetching exchange rates for currency codes: {CurrencyCodes} against CZK.", + currencyCodes); + + var exchangeRates = await _mediator.Send( + new GetExchangeRatesQuery(currencies, targetCurrency)); + + return Ok(exchangeRates); + } + catch (ApplicationException ex) + { + _logger.LogError(ex, "Application error while fetching exchange rates: {Message}", ex.Message); + return StatusCode(500, $"An application error occurred: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while fetching exchange rates: {Message}", ex.Message); + return StatusCode(500, $"An unexpected error occurred: {ex.Message}"); + } + } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Dockerfile new file mode 100644 index 000000000..0dcda89f9 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore ExchangeRateProvider.sln +RUN dotnet publish ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "ExchangeRateProvider.Api.dll"] + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj similarity index 76% rename from jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj rename to jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj index 899e43744..02215e845 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + ExchangeRateProvider.Api @@ -18,8 +19,8 @@ - - + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Program.cs new file mode 100644 index 000000000..cca99ae11 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Program.cs @@ -0,0 +1,141 @@ +using ExchangeRateProvider.Application; +using ExchangeRateProvider.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.OpenApi.Models; +using Prometheus; +using System.Threading.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers() +.AddJsonOptions(options => +{ + // Configure JSON serialization for better performance + options.JsonSerializerOptions.PropertyNamingPolicy = null; + options.JsonSerializerOptions.WriteIndented = false; +}); + +// API Versioning can be added later when needed + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Exchange Rate Provider API", + Version = "v1", + Description = "Exchange rate API with CNB data" + }); +}); + +// Health checks +builder.Services.AddHealthChecks() + .AddUrlGroup( + new Uri(builder.Configuration["ExchangeRateProvider:CnbHealthUrl"] + ?? "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"), + name: "cnb") + .AddRedis( + builder.Configuration["Redis:Configuration"], + name: "redis", + failureStatus: Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Degraded, + tags: ["ready"]); + +// JWT Auth (toggle via config) +var jwtEnabled = builder.Configuration.GetValue("Jwt:Enabled", false); +if (jwtEnabled) +{ + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.Audience = builder.Configuration["Jwt:Audience"]; + options.Authority = builder.Configuration["Jwt:Authority"]; + // Additional token validation parameters can be added here + }); +} + +// Add logging with structured logging +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.AddDebug(); + +// Add rate limiting +builder.Services.AddRateLimiter(options => +{ + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: partition => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, // 100 requests per window + Window = TimeSpan.FromMinutes(1) // per minute + })); +}); + +// Add application and infrastructure services +builder.Services.AddApplicationServices(); +builder.Services.AddInfrastructureServices(builder.Configuration); + +var app = builder.Build(); + +// Metrics +app.UseHttpMetrics(); + +// Rate limiting +app.UseRateLimiter(); + +// Request logging middleware +app.Use(async (context, next) => +{ + var logger = context.RequestServices.GetRequiredService>(); + var startTime = DateTime.UtcNow; + + logger.LogInformation( + "Request: {Method} {Path} from {RemoteIp}", + context.Request.Method, + context.Request.Path, + context.Connection.RemoteIpAddress); + + await next(); + + var duration = DateTime.UtcNow - startTime; + logger.LogInformation( + "Response: {StatusCode} in {Duration}ms", + context.Response.StatusCode, + duration.TotalMilliseconds); +}); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +else +{ + app.UseHttpsRedirection(); // only for production +} + +if (jwtEnabled) +{ + app.UseAuthentication(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +// Health checks endpoint +app.MapHealthChecks("/health"); + +// Prometheus scrape endpoint +app.MapMetrics(); + +app.Run(); + +namespace ExchangeRateProvider.Api +{ + public partial class Program { } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Properties/launchSettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Properties/launchSettings.json new file mode 100644 index 000000000..f1673a18b --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/Properties/launchSettings.json @@ -0,0 +1,35 @@ +{ + "profiles": { + "ExchangeRateProvider.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5000" + } + }, + "ExchangeRateProvider.Api.Staging": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5002", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Staging", + "ASPNETCORE_URLS": "http://localhost:5002" + } + }, + "ExchangeRateProvider.Api.Production": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production", + "ASPNETCORE_URLS": "http://localhost:5001" + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json new file mode 100644 index 000000000..d658497fc --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ExchangeRateProvider": "Debug" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "CacheExpirationMinutes": 60, + "TimeoutSeconds": 30, + "MaxCurrencies": 20 + }, + "Redis": { + "Enabled": true, + "Configuration": "localhost:6379", + "InstanceName": "ExchangeRates" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 5, + "WeekdayHours": 1, + "WeekendHours": 12 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Production.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Production.json new file mode 100644 index 000000000..c0d9460b4 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Production.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning", + "ExchangeRateProvider": "Information" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "CacheExpirationMinutes": 120, + "TimeoutSeconds": 60, + "MaxCurrencies": 50 + }, + "Redis": { + "Enabled": true, + "Configuration": "redis-production:6379", + "InstanceName": "ExchangeRatesProd" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 10, + "WeekdayHours": 2, + "WeekendHours": 24 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Staging.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Staging.json new file mode 100644 index 000000000..1bd1e7217 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Staging.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ExchangeRateProvider": "Information" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "CacheExpirationMinutes": 90, + "TimeoutSeconds": 45, + "MaxCurrencies": 30 + }, + "Redis": { + "Enabled": true, + "Configuration": "redis-staging:6379", + "InstanceName": "ExchangeRatesStaging" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 7, + "WeekdayHours": 1.5, + "WeekendHours": 18 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.json new file mode 100644 index 000000000..3c490fa21 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz", + "CacheExpirationMinutes": 60, + "TimeoutSeconds": 30, + "MaxCurrencies": 20 + }, + "Redis": { + "Enabled": true, + "Configuration": "localhost:6379", + "InstanceName": "ExchangeRates" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 5, + "WeekdayHours": 1, + "WeekendHours": 12 + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj similarity index 70% rename from jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj rename to jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj index 4c2a29e2f..602f5ef44 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs new file mode 100644 index 000000000..fcf4dd4f8 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs @@ -0,0 +1,17 @@ +using ExchangeRateProvider.Domain.Entities; +using MediatR; + +namespace ExchangeRateProvider.Application.Queries +{ + public class GetExchangeRatesQuery : IRequest> + { + public IEnumerable Currencies { get; } + public Currency TargetCurrency { get; } + + public GetExchangeRatesQuery(IEnumerable currencies, Currency targetCurrency) + { + Currencies = currencies; + TargetCurrency = targetCurrency; + } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQueryHandler.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQueryHandler.cs new file mode 100644 index 000000000..667295265 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/Queries/GetExchangeRatesQueryHandler.cs @@ -0,0 +1,33 @@ +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; +using MediatR; + +namespace ExchangeRateProvider.Application.Queries; + +/// +/// Query handler for getting exchange rates. +/// Uses the registered exchange rate provider service. +/// +public class GetExchangeRatesQueryHandler : IRequestHandler> +{ + private readonly IExchangeRateProvider _exchangeRateProvider; + + public GetExchangeRatesQueryHandler(IExchangeRateProvider exchangeRateProvider) + { + _exchangeRateProvider = exchangeRateProvider ?? throw new ArgumentNullException(nameof(exchangeRateProvider)); + } + + public async Task> Handle(GetExchangeRatesQuery request, CancellationToken cancellationToken) + { + if (request.Currencies == null || !request.Currencies.Any()) + { + return []; + } + + var rates = await _exchangeRateProvider.GetExchangeRatesAsync(request.Currencies, cancellationToken); + + // Filter to only requested currencies + return rates.Where(r => request.Currencies.Any(c => c.Code == r.SourceCurrency.Code)); + } +} + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..3af72d965 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +namespace ExchangeRateProvider.Application; + +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.Providers; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + + +/// +/// Extension methods for configuring application services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds application services to the dependency injection container. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + // Register MediatR + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ServiceCollectionExtensions).Assembly)); +// Register provider registry +services.AddSingleton(); + + // Register query handlers + services.AddScoped(); + + return services; + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/.gitignore b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/.gitignore new file mode 100644 index 000000000..c7b2e77ce --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/.gitignore @@ -0,0 +1,9 @@ +# genelated files +bin/ +obj/ + +# Visual Studio Code +.vscode/ + +# Git +.git/ diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Dockerfile new file mode 100644 index 000000000..990b37fa9 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Dockerfile @@ -0,0 +1,13 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore ExchangeRateProvider.sln +RUN dotnet publish ExchangeRateProvider.Console/ExchangeRateProvider.Console.csproj -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "ExchangeRateProvider.Console.dll"] + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/ExchangeRateProvider.Console.csproj similarity index 88% rename from jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj rename to jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/ExchangeRateProvider.Console.csproj index 79370b126..1bf39b74e 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/ExchangeRateProvider.Console.csproj @@ -22,7 +22,7 @@ - + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Program.cs new file mode 100644 index 000000000..e05956004 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/Program.cs @@ -0,0 +1,91 @@ +using ExchangeRateProvider.Application; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Infrastructure; +using MediatR; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Console +{ + public class Program + { + private static readonly IEnumerable s_currencies = + [ + new Currency("USD"), + new Currency("EUR"), + new Currency("JPY"), + new Currency("GBP"), + new Currency("AUD"), + new Currency("CAD"), + new Currency("CHF"), + new Currency("CNY"), + new Currency("SEK"), + new Currency("NZD"), + new Currency("MXN"), + new Currency("SGD"), + new Currency("HKD"), + new Currency("NOK"), + ]; + + public static async Task Main(string[] args) + { + var host = CreateHostBuilder(args).Build(); + + using (var scope = host.Services.CreateScope()) + { + var services = scope.ServiceProvider; + try + { + var logger = services.GetRequiredService>(); + var mediator = services.GetRequiredService(); + + logger.LogInformation("Starting Exchange Rate Provider console application."); + + var exchangeRates = await mediator.Send(new GetExchangeRatesQuery(s_currencies, new Currency("CZK"))); + + System.Console.WriteLine("Successfully retrieved exchange rates:"); + foreach (var rate in exchangeRates) + { + System.Console.WriteLine($"{rate.SourceCurrency.Code} -> {rate.TargetCurrency.Code}: {rate.Value:F4}"); + } + + System.Console.WriteLine($"\nTotal rates retrieved: {exchangeRates.Count()}"); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while updating exchange rates."); + System.Console.WriteLine($"\nAn error occurred: {ex.Message}"); + } + } + + System.Console.WriteLine("\nPress Enter to exit."); + System.Console.ReadLine(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostingContext, configuration) => + { + configuration.Sources.Clear(); + configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + configuration.AddEnvironmentVariables(); + if (args != null) + { + configuration.AddCommandLine(args); + } + }) + .ConfigureServices((hostContext, services) => + { + // Add logging + services.AddLogging(configure => configure.AddConsole()); + + // Register application and infrastructure services + services.AddApplicationServices(); + services.AddInfrastructureServices(hostContext.Configuration); + }); + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/README.md b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/README.md new file mode 100644 index 000000000..0e29d55fd --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/README.md @@ -0,0 +1,87 @@ +# CNB Exchange Rate Provider + +This project implements an `ExchangeRateProvider` for the Czech National Bank (CNB) to retrieve daily exchange rates from their official public data source. The solution is designed with production-grade considerations in mind, focusing on maintainability and robustness. + +## Data Source + +The exchange rate data is sourced from the official CNB API: +`https://api.cnb.cz/cnbapi/exrates/daily` + +## Project Structure + +* `CnbExchangeRateProviderApp.csproj`: The .NET project file. +* `ExchangeRateProvider.cs`: Contains the core logic for fetching and parsing exchange rates. +* `Program.cs`: A sample console application to demonstrate the usage of the `ExchangeRateProvider`. + +## How to Build and Run + +1. **Navigate to the project directory:** + ```bash + cd CnbExchangeRateProvider/CnbExchangeRateProviderApp + ``` + +2. **Restore dependencies:** + ```bash + dotnet restore + ``` + +3. **Build the project:** + ```bash + dotnet build + ``` + +4. **Run the application:** + ```bash + dotnet run + ``` + + The application will output the obtained exchange rates to the console. + +## Production-Grade Considerations + +### Error Handling + +The `ExchangeRateProvider` includes robust error handling to manage various scenarios: + +* **Network Issues (`HttpRequestException`):** Catches errors that occur during the HTTP request to the CNB server. +* **XML Parsing Errors (`XmlException`):** Handles cases where the downloaded data is not a valid XML format. +* **Missing or Invalid Data:** Includes checks within the XML parsing loop to gracefully handle missing attributes or elements, and unparseable rate/amount values, logging warnings to the console. + +In a production environment, these exceptions should be logged using a structured logging framework (e.g., Serilog, NLog) with appropriate alerting mechanisms. + +### Caching + +For a production system, caching the exchange rates is highly recommended to: + +* Reduce the load on the CNB's server. +* Improve application performance by avoiding redundant network requests. +* Provide resilience in case of temporary network or CNB API issues. + +Since exchange rates are typically updated daily, a caching strategy with a daily refresh interval would be appropriate. This could be implemented using `IMemoryCache` in ASP.NET Core, or a custom caching solution. + +### Logging + +Currently, `Console.WriteLine` is used for output and basic error messages. In a production environment, a dedicated logging solution should be integrated. This would allow for: + +* Configurable log levels (Debug, Info, Warning, Error, Critical). +* Structured logging for easier analysis. +* Output to various sinks (files, databases, cloud logging services). + +### Dependency Injection + +For better testability and maintainability, the `HttpClient` should be injected into the `ExchangeRateProvider` using Dependency Injection. This allows for easier mocking of `HttpClient` in unit tests and better management of its lifecycle. + +### Unit Testing + +Comprehensive unit tests should be developed to cover: + +* Successful retrieval and parsing of exchange rates. +* Handling of malformed XML responses. +* Handling of network errors. +* Edge cases for currency data (e.g., missing code, zero amount). + +## Future Enhancements + +* **Date-specific rates:** The current implementation fetches daily rates. Enhancements could include fetching historical rates or rates for a specific date if the CNB API supports it. +* **Configuration:** Externalize the CNB API URL and other magic strings into a configuration file (e.g., `appsettings.json`). +* **Abstraction:** Introduce an interface for `IExchangeRateProvider` to allow for different implementations (e.g., a mock provider for testing, or a provider for a different bank). diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/appsettings.json new file mode 100644 index 000000000..9fa3fb7c2 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Console/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "ExchangeRateProvider": "Debug" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz", + "CacheExpirationMinutes": 60, + "TimeoutSeconds": 30, + "MaxCurrencies": 20 + }, + "Redis": { + "Enabled": true, + "Configuration": "localhost:6379", + "InstanceName": "ExchangeRates" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 5, + "WeekdayHours": 1, + "WeekendHours": 12 + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/Currency.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/Currency.cs new file mode 100644 index 000000000..ea25086f0 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/Currency.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; + +namespace ExchangeRateProvider.Domain.Entities; + +/// +/// Represents a currency with ISO 4217 standard validation. +/// This is a value object that encapsulates currency code validation and behavior. +/// +public sealed class Currency : IEquatable +{ + private static readonly Regex CurrencyCodePattern = new(@"^[A-Z]{3}$", RegexOptions.Compiled); + + /// + /// Gets the ISO 4217 currency code (3 uppercase letters). + /// + public string Code { get; set; } + + /// + /// Parameterless constructor for JSON deserialization. + /// + public Currency() { Code = "XXX"; } + + /// + /// Initializes a new instance of the Currency class. + /// + /// The ISO 4217 currency code. + /// Thrown when the currency code is invalid. + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new InvalidCurrencyCodeException("Currency code cannot be null or whitespace."); + } + + if (!CurrencyCodePattern.IsMatch(code.ToUpperInvariant())) + { + throw new InvalidCurrencyCodeException($"Currency code '{code}' must be exactly 3 alphabetic characters."); + } + + Code = code.ToUpperInvariant(); + } + + + /// + /// Returns a string representation of the currency. + /// + public override string ToString() => Code; + + /// + /// Determines whether the specified object is equal to the current currency. + /// + public override bool Equals(object? obj) => Equals(obj as Currency); + + /// + /// Determines whether the specified currency is equal to the current currency. + /// + public bool Equals(Currency? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return Code.Equals(other.Code, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns a hash code for the currency. + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Code); + + /// + /// Determines whether two currencies are equal. + /// + public static bool operator ==(Currency? left, Currency? right) => Equals(left, right); + + /// + /// Determines whether two currencies are not equal. + /// + public static bool operator !=(Currency? left, Currency? right) => !Equals(left, right); + + /// + /// Creates a currency from a string, returning null if invalid. + /// + public static Currency? TryCreate(string code) + { + try + { + return new Currency(code); + } + catch (InvalidCurrencyCodeException) + { + return null; + } + } +} + +/// +/// Exception thrown when an invalid currency code is provided. +/// +public class InvalidCurrencyCodeException : DomainException +{ + public InvalidCurrencyCodeException(string message) : base(message) { } +} + +/// +/// Base class for domain-specific exceptions. +/// +public abstract class DomainException : Exception +{ + protected DomainException(string message) : base(message) { } + protected DomainException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs new file mode 100644 index 000000000..a6d297b51 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs @@ -0,0 +1,120 @@ +using System.Globalization; + +namespace ExchangeRateProvider.Domain.Entities; + +/// +/// Represents an exchange rate between two currencies. +/// This is a value object that encapsulates the rate information and provides validation. +/// +public sealed class ExchangeRate : IEquatable +{ + /// + /// Gets the source currency of the exchange rate. + /// + public Currency SourceCurrency { get; set; } + + /// + /// Gets the target currency of the exchange rate. + /// + public Currency TargetCurrency { get; set; } + + /// + /// Gets the exchange rate value (how many target currency units per source currency unit). + /// + public decimal Value { get; set; } + + /// + /// Gets the timestamp when this rate was created or retrieved. + /// + public DateTime Timestamp { get; set; } + + /// + /// Parameterless constructor for JSON deserialization. + /// + public ExchangeRate() + { + SourceCurrency = new Currency(); + TargetCurrency = new Currency(); + Value = 1; + Timestamp = DateTime.UtcNow; + } + + /// + /// Initializes a new instance of the ExchangeRate class. + /// + /// The source currency. + /// The target currency. + /// The exchange rate value. + /// The timestamp of the rate (defaults to current time). + /// Thrown when the exchange rate value is invalid. + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value, DateTime? timestamp = null) + { + if (sourceCurrency == null) throw new ArgumentNullException(nameof(sourceCurrency)); + if (targetCurrency == null) throw new ArgumentNullException(nameof(targetCurrency)); + + if (value <= 0) + { + throw new InvalidExchangeRateException("Exchange rate value must be positive."); + } + + if (value > 1_000_000) + { + throw new InvalidExchangeRateException("Exchange rate value seems unreasonably high."); + } + + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = Math.Round(value, 6); // Standard precision for exchange rates + Timestamp = timestamp ?? DateTime.UtcNow; + } + + + /// + /// Returns a string representation of the exchange rate. + /// + public override string ToString() => + $"{SourceCurrency}/{TargetCurrency}={Value.ToString("0.######", CultureInfo.InvariantCulture)}"; + + /// + /// Determines whether the specified object is equal to the current exchange rate. + /// + public override bool Equals(object? obj) => Equals(obj as ExchangeRate); + + /// + /// Determines whether the specified exchange rate is equal to the current exchange rate. + /// + public bool Equals(ExchangeRate? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return SourceCurrency == other.SourceCurrency && + TargetCurrency == other.TargetCurrency && + Value == other.Value; + } + + /// + /// Returns a hash code for the exchange rate. + /// + public override int GetHashCode() => + HashCode.Combine(SourceCurrency, TargetCurrency, Value); + + /// + /// Determines whether two exchange rates are equal. + /// + public static bool operator ==(ExchangeRate? left, ExchangeRate? right) => Equals(left, right); + + /// + /// Determines whether two exchange rates are not equal. + /// + public static bool operator !=(ExchangeRate? left, ExchangeRate? right) => !Equals(left, right); + +} + +/// +/// Exception thrown when an invalid exchange rate is provided. +/// +public class InvalidExchangeRateException : DomainException +{ + public InvalidExchangeRateException(string message) : base(message) { } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj new file mode 100644 index 000000000..10f8d83aa --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + ExchangeRateProvider.Domain + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..073d269d2 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,36 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Domain.Interfaces; + +/// +/// Defines the contract for exchange rate providers. +/// +public interface IExchangeRateProvider +{ + /// + /// Gets the name/identifier of this provider. + /// + string Name { get; } + + /// + /// Gets the priority of this provider (higher values = higher priority). + /// + int Priority { get; } + + /// + /// Determines whether this provider can handle the specified currencies. + /// + /// The currencies to check. + /// True if this provider can handle the currencies, false otherwise. + bool CanHandle(IEnumerable currencies); + + /// + /// Gets exchange rates for the specified currencies. + /// + /// The currencies to get rates for. + /// The cancellation token. + /// A collection of exchange rates. + Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IProviderRegistry.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IProviderRegistry.cs new file mode 100644 index 000000000..374e5c7d3 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IProviderRegistry.cs @@ -0,0 +1,61 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Domain.Interfaces; + +/// +/// Registry for managing exchange rate providers. +/// +public interface IProviderRegistry +{ + /// + /// Registers a provider with the registry. + /// + /// The provider to register. + void RegisterProvider(IExchangeRateProvider provider); + + /// + /// Gets all registered providers. + /// + IReadOnlyCollection GetAllProviders(); + + /// + /// Gets the best provider for the specified currencies. + /// + /// The currencies to handle. + /// The best provider, or null if none can handle the currencies. + IExchangeRateProvider? GetProviderFor(IEnumerable currencies); + + /// + /// Gets all providers that can handle the specified currencies. + /// + /// The currencies to handle. + /// A collection of providers that can handle the currencies. + IReadOnlyCollection GetProvidersFor(IEnumerable currencies); +} + +/// +/// Service for initializing provider registrations. +/// +public interface IProviderRegistrationService +{ + /// + /// Registers all available providers with the registry. + /// + void RegisterProviders(); +} + +/// +/// Configuration for provider registration. +/// +public class ProviderConfiguration +{ + /// + /// Gets or sets the list of provider types to register. + /// + public List ProviderTypes { get; set; } = new(); + + /// + /// Gets or sets whether to enable the default CNB provider. + /// + public bool EnableCnbProvider { get; set; } = true; +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Providers/ProviderRegistry.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Providers/ProviderRegistry.cs new file mode 100644 index 000000000..7614b4bb5 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Providers/ProviderRegistry.cs @@ -0,0 +1,55 @@ +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; + +namespace ExchangeRateProvider.Domain.Providers; + +/// +/// Default implementation of the provider registry. +/// +public class ProviderRegistry : IProviderRegistry +{ + private readonly List _providers = new(); + private readonly object _lock = new(); + + /// + public void RegisterProvider(IExchangeRateProvider provider) + { + if (provider == null) throw new ArgumentNullException(nameof(provider)); + + lock (_lock) + { + if (!_providers.Contains(provider)) + { + _providers.Add(provider); + // Sort by priority (higher priority first) + _providers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } + } + } + + /// + public IReadOnlyCollection GetAllProviders() + { + lock (_lock) + { + return _providers.ToArray(); + } + } + + /// + public IExchangeRateProvider? GetProviderFor(IEnumerable currencies) + { + return GetProvidersFor(currencies).FirstOrDefault(); + } + + /// + public IReadOnlyCollection GetProvidersFor(IEnumerable currencies) + { + lock (_lock) + { + return _providers + .Where(p => p.CanHandle(currencies)) + .ToArray(); + } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbCacheStrategy.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbCacheStrategy.cs new file mode 100644 index 000000000..cc84cbd4a --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbCacheStrategy.cs @@ -0,0 +1,307 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZiggyCreatures.Caching.Fusion; + +namespace ExchangeRateProvider.Infrastructure +{ + /// + /// Configuration options for CNB cache strategy behavior. + /// + public sealed class CnbCacheOptions + { + /// + /// Cache duration during CNB publication window (2:31 PM - 3:31 PM Prague time). + /// Default: 5 minutes. + /// + public TimeSpan PublicationWindowDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Cache duration during regular weekday hours. + /// Default: 1 hour. + /// + public TimeSpan WeekdayDuration { get; set; } = TimeSpan.FromHours(1); + + /// + /// Cache duration during weekends when no new data is published. + /// Default: 12 hours. + /// + public TimeSpan WeekendDuration { get; set; } = TimeSpan.FromHours(12); + + /// + /// Multiplier for fail-safe max duration (e.g., 2 means 2x normal duration). + /// Default: 2. + /// + public double FailSafeMultiplier { get; set; } = 2.0; + + /// + /// Start time of CNB publication window in Prague timezone. + /// Default: 14:31 (2:31 PM). + /// + public TimeSpan PublicationWindowStart { get; set; } = new TimeSpan(14, 31, 0); + + /// + /// End time of CNB publication window in Prague timezone. + /// Default: 15:31 (3:31 PM). + /// + public TimeSpan PublicationWindowEnd { get; set; } = new TimeSpan(15, 31, 0); + } + + /// + /// Provides intelligent caching strategies for Czech National Bank (CNB) exchange rate data + /// based on their publication schedule and Prague timezone. + /// Implements robust error handling, configurability, and cross-platform timezone support. + /// + public class CnbCacheStrategy + { + private static readonly Lazy LazyPragueTimeZone = new(() => ResolvePragueTimeZone()); + private readonly CnbCacheOptions _options; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Configuration options for cache behavior. + /// Optional logger for diagnostics. + public CnbCacheStrategy(IOptions? options = null, ILogger? logger = null) + { + _options = options?.Value ?? new CnbCacheOptions(); + _logger = logger; + ValidateOptions(); + } + + /// + /// Initializes a new instance of the class with default options. + /// Used for testing purposes. + /// + public CnbCacheStrategy() + { + _options = new CnbCacheOptions(); + _logger = null; + ValidateOptions(); + } + + /// + /// Determines optimal cache duration based on Prague time and CNB publication schedule. + /// CNB publishes rates after 2:30 PM Prague time on working days only. + /// + /// Cache Strategy: + /// - Publication window (2:31 PM - 3:31 PM weekdays): Short duration for fresh data + /// - Regular weekday hours: Medium duration for stable data + /// - Weekends: Long duration as no new data is published + /// + /// Configured with appropriate duration and fail-safe settings. + /// Thrown when Prague timezone cannot be resolved. + public FusionCacheEntryOptions GetCacheOptions() + { + try + { + var pragueTime = GetPragueTime(); + var cacheContext = DetermineCacheContext(pragueTime); + var duration = GetDurationForContext(cacheContext); + + _logger?.LogDebug( + "Cache strategy determined: Context={Context}, Duration={Duration}, PragueTime={PragueTime}", + cacheContext, duration, pragueTime); + + return CreateCacheOptions(duration); + } + catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) + { + _logger?.LogWarning(ex, "Failed to resolve Prague timezone, falling back to default strategy"); + + // Fallback: Use weekday strategy as safe default + var fallbackDuration = _options.WeekdayDuration; + return CreateCacheOptions(fallbackDuration); + } + } + + /// + /// Gets current time in Prague timezone (Central European Time/Central European Summer Time). + /// + /// Current Prague time. + /// Thrown when Prague timezone cannot be found. + private static DateTime GetPragueTime() + { + var pragueTimeZone = LazyPragueTimeZone.Value; + return TimeZoneInfo.ConvertTime(DateTime.UtcNow, pragueTimeZone); + } + + /// + /// Determines the appropriate cache context based on Prague time. + /// + private CacheContext DetermineCacheContext(DateTime pragueTime) + { + if (IsWeekend(pragueTime)) + { + return CacheContext.Weekend; + } + + if (IsWithinPublicationWindow(pragueTime.TimeOfDay)) + { + return CacheContext.PublicationWindow; + } + + return CacheContext.RegularWeekday; + } + + /// + /// Checks if the given date falls on a weekend. + /// + private static bool IsWeekend(DateTime date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + + /// + /// Checks if the given time falls within the CNB publication window. + /// + private bool IsWithinPublicationWindow(TimeSpan time) => + time >= _options.PublicationWindowStart && time <= _options.PublicationWindowEnd; + + /// + /// Gets the appropriate cache duration for the given context. + /// + private TimeSpan GetDurationForContext(CacheContext context) => context switch + { + CacheContext.PublicationWindow => _options.PublicationWindowDuration, + CacheContext.RegularWeekday => _options.WeekdayDuration, + CacheContext.Weekend => _options.WeekendDuration, + _ => throw new ArgumentOutOfRangeException(nameof(context), context, "Unknown cache context") + }; + + /// + /// Creates FusionCache options with the specified duration and fail-safe settings. + /// + private FusionCacheEntryOptions CreateCacheOptions(TimeSpan duration) + { + var failSafeDuration = TimeSpan.FromTicks((long)(duration.Ticks * _options.FailSafeMultiplier)); + + return new FusionCacheEntryOptions + { + Duration = duration, + FailSafeMaxDuration = failSafeDuration, + // Enable jitter to prevent thundering herd scenarios + JitterMaxDuration = TimeSpan.FromSeconds(30), + // Set priority to normal for balanced eviction + Priority = CacheItemPriority.Normal + }; + } + + /// + /// Resolves Prague timezone using cross-platform approach with fallback strategy. + /// + /// Prague timezone information. + /// Thrown when Prague timezone cannot be resolved on any platform. + private static TimeZoneInfo ResolvePragueTimeZone() + { + // Primary attempt: Use platform-specific timezone identifiers + var timeZoneIds = GetPragueTimeZoneIds(); + + foreach (var timeZoneId in timeZoneIds) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + // Continue to next identifier + } + catch (InvalidTimeZoneException) + { + // Continue to next identifier + } + } + + // Fallback: Try to find by display name patterns + var allTimeZones = TimeZoneInfo.GetSystemTimeZones(); + var pragueTimeZone = allTimeZones.FirstOrDefault(tz => + tz.DisplayName.Contains("Prague", StringComparison.OrdinalIgnoreCase) || + tz.DisplayName.Contains("Central Europe", StringComparison.OrdinalIgnoreCase) || + tz.Id.Contains("Prague", StringComparison.OrdinalIgnoreCase)); + + if (pragueTimeZone != null) + { + return pragueTimeZone; + } + + throw new TimeZoneNotFoundException( + "Could not resolve Prague timezone. Attempted identifiers: " + + string.Join(", ", timeZoneIds)); + } + + /// + /// Gets platform-specific timezone identifiers for Prague, ordered by preference. + /// + private static IEnumerable GetPragueTimeZoneIds() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ["Central Europe Standard Time", "Central European Standard Time"]; + } + + // Unix-like systems (Linux, macOS, etc.) + return ["Europe/Prague", "CET", "Europe/Vienna"]; + } + + /// + /// Validates configuration options and throws if invalid. + /// + private void ValidateOptions() + { + if (_options.PublicationWindowDuration <= TimeSpan.Zero) + throw new ArgumentException("Publication window duration must be positive", nameof(_options.PublicationWindowDuration)); + + if (_options.WeekdayDuration <= TimeSpan.Zero) + throw new ArgumentException("Weekday duration must be positive", nameof(_options.WeekdayDuration)); + + if (_options.WeekendDuration <= TimeSpan.Zero) + throw new ArgumentException("Weekend duration must be positive", nameof(_options.WeekendDuration)); + + if (_options.FailSafeMultiplier <= 1.0) + throw new ArgumentException("Fail-safe multiplier must be greater than 1.0", nameof(_options.FailSafeMultiplier)); + + if (_options.PublicationWindowStart >= _options.PublicationWindowEnd) + throw new ArgumentException("Publication window start must be before end time", nameof(_options.PublicationWindowStart)); + } + + /// + /// Represents different caching contexts based on CNB schedule. + /// + private enum CacheContext + { + /// Regular weekday hours outside publication window. + RegularWeekday, + /// Within CNB publication window (2:31 PM - 3:31 PM). + PublicationWindow, + /// Weekend when no new data is published. + Weekend + } + } + + /// + /// Extension methods for easier integration with dependency injection. + /// + public static class CnbCacheStrategyExtensions + { + /// + /// Registers CNB cache strategy services with the DI container. + /// + /// Service collection. + /// Optional configuration action. + /// Service collection for chaining. + public static IServiceCollection AddCnbCacheStrategy( + this IServiceCollection services, + Action? configureOptions = null) + { + if (configureOptions != null) + { + services.Configure(configureOptions); + } + + services.AddSingleton(); + return services; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs new file mode 100644 index 000000000..20a5e37fb --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs @@ -0,0 +1,182 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using ExchangeRateProvider.Domain.Entities; +using Microsoft.Extensions.Logging; +using ExchangeRateProvider.Domain.Interfaces; + +namespace ExchangeRateProvider.Infrastructure +{ + /// + /// CNB exchange rate provider using the official JSON API. + /// + public sealed class CnbExchangeRateProvider : IExchangeRateProvider + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + /// + /// Gets the name of this provider. + /// + public string Name => "CNB"; + + /// + /// Gets the priority of this provider (higher values = higher priority). + /// CNB is a reliable official source, so high priority. + /// + public int Priority => 100; + + /// + /// Determines whether this provider can handle the specified currencies. + /// CNB provides rates for many currencies, so we can handle most requests. + /// + public bool CanHandle(IEnumerable currencies) + { + // CNB provides rates for many currencies, but we should validate the codes + var requestedCodes = currencies + ?.Select(c => c.Code?.ToUpperInvariant()) + .Where(code => !string.IsNullOrEmpty(code)) + .ToList() ?? []; + + if (!requestedCodes.Any()) + { + return false; + } + + return true; + } + + public CnbExchangeRateProvider(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + } + + public async Task> GetExchangeRatesAsync( + IEnumerable requestedCurrencies, + CancellationToken cancellationToken = default) + { + var requestedCodes = requestedCurrencies + ?.Select(c => c.Code?.ToUpperInvariant()) + .Where(code => !string.IsNullOrEmpty(code)) + .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; + + if (!requestedCodes.Any()) + { + _logger.LogWarning("No valid currencies requested"); + return []; + } + + try + { + _logger.LogDebug("Fetching CNB rates for {Count} currencies: {Currencies}", + requestedCodes.Count, string.Join(", ", requestedCodes)); + + using var httpClient = _httpClientFactory.CreateClient("CnbExchangeRateProvider"); + var jsonResponse = await httpClient.GetStringAsync("cnbapi/exrates/daily", cancellationToken); + var cnbData = JsonSerializer.Deserialize(jsonResponse, _jsonOptions); + + if (cnbData?.Rates == null) + { + _logger.LogError("CNB API returned invalid response structure"); + return []; + } + + var result = new List(); + var czkCurrency = new Currency("CZK"); + + foreach (var rate in cnbData.Rates) + { + if (string.IsNullOrEmpty(rate.CurrencyCode) || + !requestedCodes.Contains(rate.CurrencyCode)) + { + continue; + } + + if (rate.Amount <= 0 || rate.Rate <= 0) + { + _logger.LogWarning("Invalid rate data for {Currency}: Amount={Amount}, Rate={Rate}", + rate.CurrencyCode, rate.Amount, rate.Rate); + continue; + } + + // Return EXACTLY what CNB provides: X units of foreign currency = Y CZK + // No calculations - use CNB's exact values + var exchangeRate = new ExchangeRate( + sourceCurrency: new Currency(rate.CurrencyCode.ToUpperInvariant()), + targetCurrency: czkCurrency, + value: rate.Rate // Exact CNB rate value + ); + + result.Add(exchangeRate); + } + + _logger.LogInformation("Successfully retrieved {Count} exchange rates from CNB (Date: {Date})", + result.Count, cnbData.Date); + + return result.AsReadOnly(); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error fetching CNB rates"); + throw; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse CNB JSON response"); + throw new InvalidOperationException("CNB API returned invalid JSON format", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error fetching CNB exchange rates"); + throw; + } + } + } + + /// + /// CNB API JSON response structure. + /// + internal sealed class CnbApiResponse + { + [JsonPropertyName("date")] + public string? Date { get; set; } + + [JsonPropertyName("rates")] + public List? Rates { get; set; } + } + + /// + /// currency rate from CNB API. + /// + internal sealed class CnbRate + { + [JsonPropertyName("validFor")] + public string? ValidFor { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("currency")] + public string? Currency { get; set; } + + [JsonPropertyName("amount")] + public decimal Amount { get; set; } + + [JsonPropertyName("currencyCode")] + public string? CurrencyCode { get; set; } + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs new file mode 100644 index 000000000..5cdff619d --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure +{ + /// + /// Distributed caching provider with CNB-aware cache durations. + /// + public sealed class DistributedCachingExchangeRateProvider : IExchangeRateProvider + { + private readonly IExchangeRateProvider _innerProvider; + private readonly IDistributedCache _distributedCache; + private readonly CnbCacheStrategy _cacheStrategy; + private readonly ILogger _logger; + + private const string CacheKey = "cnb_all_rates"; + + /// + /// Gets the name of this provider (delegates to inner provider). + /// + public string Name => _innerProvider.Name; + + /// + /// Gets the priority of this provider (delegates to inner provider). + /// + public int Priority => _innerProvider.Priority; + + /// + /// Determines whether this provider can handle the specified currencies (delegates to inner provider). + /// + public bool CanHandle(IEnumerable currencies) => _innerProvider.CanHandle(currencies); + + public DistributedCachingExchangeRateProvider( + IExchangeRateProvider innerProvider, + IDistributedCache distributedCache, + CnbCacheStrategy cacheStrategy, + ILogger logger) + { + _innerProvider = innerProvider ?? throw new ArgumentNullException(nameof(innerProvider)); + _distributedCache = distributedCache ?? throw new ArgumentNullException(nameof(distributedCache)); + _cacheStrategy = cacheStrategy ?? throw new ArgumentNullException(nameof(cacheStrategy)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + var requestedCurrencies = currencies?.ToList() ?? []; + if (!requestedCurrencies.Any()) + { + return []; + } + + // Try to get all rates from cache + var cachedRates = await GetAllRatesFromCacheAsync(cancellationToken); + if (cachedRates != null) + { + var missingCurrencies = requestedCurrencies.Where(c => cachedRates.All(r => r.SourceCurrency.Code != c.Code)).ToList(); + + if (missingCurrencies.Count != 0) + { + _logger.LogDebug("Cache partial hit - fetching {Count} missing rates", missingCurrencies.Count); + + _logger.LogDebug("Cache miss - fetching fresh rates"); + var fetchedRates = await _innerProvider.GetExchangeRatesAsync(missingCurrencies, cancellationToken); + + // Cache all rates with CNB-aware duration + cachedRates = cachedRates.Concat(fetchedRates).ToList(); + await CacheAllRatesAsync(cachedRates, cancellationToken); + + return cachedRates; + } + + _logger.LogDebug("Cache hit - returning {Count} rates from cache", cachedRates.Count); + return cachedRates; + } + + // Cache miss or partial hit - fetch all fresh data + _logger.LogDebug("Cache miss - fetching fresh rates"); + var freshRates = await _innerProvider.GetExchangeRatesAsync(requestedCurrencies, cancellationToken); + var freshRatesList = freshRates.ToList(); + + // Cache all rates with CNB-aware duration + await CacheAllRatesAsync(freshRatesList, cancellationToken); + + return freshRatesList; + } + + private async Task?> GetAllRatesFromCacheAsync(CancellationToken cancellationToken) + { + try + { + var cachedBytes = await _distributedCache.GetAsync(CacheKey, cancellationToken); + if (cachedBytes?.Length > 0) + { + try + { + return JsonSerializer.Deserialize>(cachedBytes); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached rates"); + return null; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read from cache"); + } + + return null; + } + + private async Task CacheAllRatesAsync(List rates, CancellationToken cancellationToken) + { + try + { + // Use CNB strategy for smart cache duration + var cacheOptions = _cacheStrategy.GetCacheOptions(); + + var serialized = JsonSerializer.SerializeToUtf8Bytes(rates); + var distributedOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = cacheOptions.Duration + }; + + await _distributedCache.SetAsync(CacheKey, serialized, distributedOptions, cancellationToken); + + _logger.LogInformation( + "Cached {Count} exchange rates for {Duration} (CNB-aware)", + rates.Count, cacheOptions.Duration); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache exchange rates"); + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj similarity index 92% rename from jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj rename to jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj index c5496475b..fac312747 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -21,7 +21,7 @@ - + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationHostedService.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationHostedService.cs new file mode 100644 index 000000000..1271612a4 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationHostedService.cs @@ -0,0 +1,42 @@ +using ExchangeRateProvider.Domain.Interfaces; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateProvider.Infrastructure; + +/// +/// Hosted service that registers providers during application startup. +/// This follows the standard .NET pattern for initialization tasks. +/// +public class ProviderRegistrationHostedService : IHostedService +{ + private readonly IProviderRegistrationService _providerRegistrationService; + + /// + /// Initializes a new instance of the ProviderRegistrationHostedService class. + /// + /// The provider registration service. + public ProviderRegistrationHostedService(IProviderRegistrationService providerRegistrationService) + { + _providerRegistrationService = providerRegistrationService ?? throw new ArgumentNullException(nameof(providerRegistrationService)); + } + + /// + /// Triggered when the application host is ready to start the service. + /// + /// Indicates that the start process has been aborted. + public Task StartAsync(CancellationToken cancellationToken) + { + _providerRegistrationService.RegisterProviders(); + return Task.CompletedTask; + } + + /// + /// Triggered when the application host is performing a graceful shutdown. + /// + /// Indicates that the shutdown process should no longer be graceful. + public Task StopAsync(CancellationToken cancellationToken) + { + // No cleanup needed for provider registration + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationService.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationService.cs new file mode 100644 index 000000000..87a8b9f50 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ProviderRegistrationService.cs @@ -0,0 +1,41 @@ +using ExchangeRateProvider.Domain.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Infrastructure; + +/// +/// Service responsible for registering exchange rate providers with the registry. +/// This follows the Single Responsibility Principle and Dependency Inversion Principle. +/// +public class ProviderRegistrationService : IProviderRegistrationService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IProviderRegistry _providerRegistry; + + /// + /// Initializes a new instance of the ProviderRegistrationService class. + /// + /// The service provider to resolve dependencies. + /// The provider registry to register providers with. + public ProviderRegistrationService( + IServiceProvider serviceProvider, + IProviderRegistry providerRegistry) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry)); + } + + /// + /// Registers all available providers with the registry. + /// + public void RegisterProviders() + { + // Create a scope to resolve scoped services + using var scope = _serviceProvider.CreateScope(); + var scopedProvider = scope.ServiceProvider; + + // Resolve and register the CNB provider + var cnbProvider = scopedProvider.GetRequiredService(); + _providerRegistry.RegisterProvider(cnbProvider); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..f3cd5242b --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,126 @@ +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.Providers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; +using System.Net; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) + { + // Configure CNB Cache Strategy + services.AddCnbCacheStrategy(options => + { + // Override defaults from configuration if needed + var configSection = configuration.GetSection("CnbCacheStrategy"); + if (configSection.Exists()) + { + options.PublicationWindowDuration = TimeSpan.FromMinutes( + configSection.GetValue("PublicationWindowMinutes", 5)); + options.WeekdayDuration = TimeSpan.FromHours( + configSection.GetValue("WeekdayHours", 1)); + options.WeekendDuration = TimeSpan.FromHours( + configSection.GetValue("WeekendHours", 12)); + } + }); + + var redisEnabled = configuration.GetValue("Redis:Enabled", false); + if (redisEnabled) + { + var redisConfig = configuration["Redis:Configuration"] ?? "localhost:6379"; + services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisConfig; + options.InstanceName = configuration.GetValue("Redis:InstanceName", "ExchangeRates"); + }); + } + + // Configure HttpClient for CnbExchangeRateProvider with Polly for resilience + services.AddHttpClient("CnbExchangeRateProvider", client => + { + client.BaseAddress = new Uri(configuration["ExchangeRateProvider:CnbExchangeRateUrl"] ?? "https://www.cnb.cz"); + client.Timeout = TimeSpan.FromSeconds(configuration.GetValue("ExchangeRateProvider:TimeoutSeconds", 30)); + client.DefaultRequestHeaders.Add("User-Agent", "ExchangeRateProvider/1.0"); + }) + .AddPolicyHandler(GetRetryPolicy()) + .AddPolicyHandler(GetCircuitBreakerPolicy()); + + // Register the main exchange rate provider + services.AddScoped(); + + // Register the provider registration service + services.AddSingleton(provider => + new ProviderRegistrationService(provider, provider.GetRequiredService())); + + // Register the hosted service for provider registration + services.AddHostedService(); + + // Register the caching decorator as the main IExchangeRateProvider + services.AddScoped(provider => + { + var cnbProvider = provider.GetRequiredService(); + + if (redisEnabled) + { + var distributedCache = provider.GetRequiredService(); + var cacheStrategy = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + + return new DistributedCachingExchangeRateProvider(cnbProvider, distributedCache, cacheStrategy, logger); + } + else + { + // No caching if Redis is disabled + return cnbProvider; + } + }); + + + return services; + } + + + private static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.NotFound) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryAttempt, context) => + { + var reason = outcome.Result?.StatusCode.ToString() ?? outcome.Exception?.Message ?? "Unknown"; + Console.WriteLine($"CNB API retry attempt {retryAttempt} after {timespan.TotalSeconds}s due to {reason}"); + }); + } + + private static IAsyncPolicy GetCircuitBreakerPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (outcome, breakDelay) => + { + var reason = outcome.Result?.StatusCode.ToString() ?? outcome.Exception?.Message ?? "Unknown"; + Console.WriteLine($"CNB API circuit breaker opened for {breakDelay.TotalSeconds}s due to {reason}"); + }, + onReset: () => + { + Console.WriteLine("CNB API circuit breaker reset"); + }, + onHalfOpen: () => + { + Console.WriteLine("CNB API circuit breaker half-open"); + }); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerTests.cs new file mode 100644 index 000000000..fb0d154ba --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerTests.cs @@ -0,0 +1,195 @@ +using ExchangeRateProvider.Api.Controllers; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Entities; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace ExchangeRateProvider.Tests.Controllers +{ + public class ExchangeRatesControllerTests + { + [Fact] + public async Task Returns_Rates_With_Target_Ignored_And_No_Date() + { + var mediator = new Mock(); + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new ExchangeRate(usd, eur, 0.9m) + }); + + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, new ConfigurationBuilder().Build()); + var result = await controller.GetExchangeRates("USD"); + + var ok = Assert.IsType(result.Result); + var rates = Assert.IsAssignableFrom>(ok.Value); + Assert.Single(rates); + Assert.Equal(0.9m, rates.First().Value); + } + + [Fact] + public async Task Returns_Empty_List_When_No_Rates_Found() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, new ConfigurationBuilder().Build()); + var result = await controller.GetExchangeRates("USD"); + + var ok = Assert.IsType(result.Result); + var rates = Assert.IsAssignableFrom>(ok.Value); + Assert.Empty(rates); + } + + [Fact] + public async Task Handles_Single_Currency_Code() + { + var mediator = new Mock(); + var usd = new Currency("USD"); + var czk = new Currency("CZK"); + + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new ExchangeRate(usd, czk, 22.5m) }); + + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, new ConfigurationBuilder().Build()); + var result = await controller.GetExchangeRates("USD"); + + var ok = Assert.IsType(result.Result); + var rates = Assert.IsAssignableFrom>(ok.Value); + Assert.Single(rates); + Assert.Equal("USD", rates.First().SourceCurrency.Code); + Assert.Equal("CZK", rates.First().TargetCurrency.Code); + } + + [Fact] + public async Task Handles_Multiple_Currency_Codes_With_Spaces() + { + var mediator = new Mock(); + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + var czk = new Currency("CZK"); + + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new ExchangeRate(usd, czk, 22.5m), + new ExchangeRate(eur, czk, 24.0m) + }); + + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, new ConfigurationBuilder().Build()); + var result = await controller.GetExchangeRates("USD, EUR, GBP"); + + var ok = Assert.IsType(result.Result); + var rates = Assert.IsAssignableFrom>(ok.Value); + Assert.Equal(2, rates.Count()); // GBP not returned by mock + } + + [Fact] + public async Task Configuration_Is_Used_For_MaxCurrencies() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:MaxCurrencies"] = "5" + }) + .Build(); + + var mediator = new Mock(); + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, configuration); + + // This should work with 5 currencies + var validCurrencies = new[] { "USD", "EUR", "GBP", "JPY", "CAD" }; + var currencies = string.Join(",", validCurrencies); + var result = await controller.GetExchangeRates(currencies); + + // Should not be BadRequest since we're under the limit + Assert.IsNotType(result.Result); + } + + [Fact] + public async Task Handles_Currency_Codes_With_Special_Characters() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var controller = new ExchangeRatesController(mediator.Object, NullLogger.Instance, new ConfigurationBuilder().Build()); + + // Test with various currency code formats + var result = await controller.GetExchangeRates("USD123,EUR"); + + // Should process without throwing + Assert.IsType(result.Result); + } + + [Fact] + public async Task Logs_Information_On_Successful_Request() + { + var logger = new Mock>(); + var mediator = new Mock(); + + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22m) }); + + var controller = new ExchangeRatesController(mediator.Object, logger.Object, new ConfigurationBuilder().Build()); + await controller.GetExchangeRates("USD"); + + logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Fetching exchange rates")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Logs_Information_On_Empty_CurrencyCodes() + { + var logger = new Mock>(); + var mediator = new Mock(); + + var controller = new ExchangeRatesController(mediator.Object, logger.Object, new ConfigurationBuilder().Build()); + await controller.GetExchangeRates(""); + + logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("No currency codes provided")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Logs_Error_On_Exception() + { + var logger = new Mock>(); + var mediator = new Mock(); + + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Test error")); + + var controller = new ExchangeRatesController(mediator.Object, logger.Object, new ConfigurationBuilder().Build()); + await controller.GetExchangeRates("USD"); + + logger.Verify(l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Unexpected error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + } +} + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerValidationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerValidationTests.cs new file mode 100644 index 000000000..25795a8b5 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Controllers/ExchangeRatesControllerValidationTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using ExchangeRateProvider.Api; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateProvider.Tests.Controllers +{ + public class ExchangeRatesControllerValidationTests : IClassFixture> + { + private readonly WebApplicationFactory _factory; + + public ExchangeRatesControllerValidationTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(_ => { }); + } + + [Fact] + public async Task Returns_400_When_CurrencyCodes_Empty() + { + var client = _factory.CreateClient(); + var res = await client.GetAsync("/api/ExchangeRates?currencyCodes="); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + + [Fact] + public async Task Returns_200_On_Health_And_Metrics() + { + var client = _factory.CreateClient(); + var health = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, health.StatusCode); + + var metrics = await client.GetAsync("/metrics"); + Assert.Equal(HttpStatusCode.OK, metrics.StatusCode); + } + + [Fact] + public async Task Returns_400_When_Too_Many_Currencies() + { + var cfgFactory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((ctx, cfg) => + { + cfg.AddInMemoryCollection([ + new KeyValuePair("ExchangeRateProvider:MaxCurrencies", "2") + ]); + }); + }); + + var client = cfgFactory.CreateClient(); + var res = await client.GetAsync("/api/ExchangeRates?currencyCodes=USD,EUR,GBP"); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + } +} + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj similarity index 67% rename from jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj rename to jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj index 69a8a57ac..91c8db07d 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj @@ -22,10 +22,10 @@ - - - - + + + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/CnbCacheStrategyTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/CnbCacheStrategyTests.cs new file mode 100644 index 000000000..7d1d3c4f7 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/CnbCacheStrategyTests.cs @@ -0,0 +1,86 @@ +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace ExchangeRateProvider.Tests.Infrastructure +{ + /// + /// Tests for CNB cache strategy - complex business logic for cache durations + /// + public class CnbCacheStrategyTests + { + [Fact] + public void GetCacheOptions_ReturnsCorrectDuration_ForPublicationWindow() + { + var options = new CnbCacheOptions + { + PublicationWindowDuration = TimeSpan.FromMinutes(5), + PublicationWindowStart = new TimeSpan(14, 31, 0), + PublicationWindowEnd = new TimeSpan(15, 31, 0), + // Set all durations to 5 minutes for consistent test behavior + WeekdayDuration = TimeSpan.FromMinutes(5), + WeekendDuration = TimeSpan.FromMinutes(5) + }; + + var strategy = new CnbCacheStrategy(Options.Create(options), NullLogger.Instance); + + // Test runs at current time - should use 5 minutes regardless of time/context + var cacheOptions = strategy.GetCacheOptions(); + + // Should use 5 minutes for all scenarios + Assert.Equal(TimeSpan.FromMinutes(5), cacheOptions.Duration); + } + + [Fact] + public void GetCacheOptions_ReturnsCorrectDuration_ForWeekends() + { + var options = new CnbCacheOptions + { + WeekendDuration = TimeSpan.FromHours(12), + WeekdayDuration = TimeSpan.FromHours(1) + }; + + var strategy = new CnbCacheStrategy(Options.Create(options), NullLogger.Instance); + + // Test would need timezone mocking for weekend scenario + var cacheOptions = strategy.GetCacheOptions(); + + // Duration should be reasonable (fallback or actual weekend duration) + Assert.True(cacheOptions.Duration > TimeSpan.Zero); + } + + [Fact] + public void Constructor_ValidatesOptions_ThrowsOnInvalid() + { + var invalidOptions = new[] + { + new CnbCacheOptions { PublicationWindowDuration = TimeSpan.Zero }, + new CnbCacheOptions { WeekdayDuration = TimeSpan.FromHours(-1) }, + new CnbCacheOptions { FailSafeMultiplier = 0.5 }, + new CnbCacheOptions { PublicationWindowStart = new TimeSpan(16, 0, 0), PublicationWindowEnd = new TimeSpan(15, 0, 0) } + }; + + foreach (var options in invalidOptions) + { + Assert.Throws(() => new CnbCacheStrategy(Options.Create(options))); + } + } + + [Fact] + public void GetCacheOptions_IncludesFailSafeDuration() + { + var options = new CnbCacheOptions + { + WeekdayDuration = TimeSpan.FromHours(1), + WeekendDuration = TimeSpan.FromHours(1), // Set to same as weekday for consistent test + FailSafeMultiplier = 2.0 + }; + + var strategy = new CnbCacheStrategy(Options.Create(options), NullLogger.Instance); + var cacheOptions = strategy.GetCacheOptions(); + + // Fail-safe should be 2x normal duration (2 hours) + Assert.Equal(TimeSpan.FromHours(2), cacheOptions.FailSafeMaxDuration); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceCollectionExtensionsTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..15c4aab0c --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,226 @@ +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateProvider.Tests.Infrastructure +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void AddInfrastructureServices_Registers_CnbExchangeRateProvider() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:CnbExchangeRateUrl"] = "https://api.cnb.cz", + ["ExchangeRateProvider:TimeoutSeconds"] = "30" + }) + .Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var provider = serviceProvider.GetService(); + Assert.NotNull(provider); + } + + [Fact] + public void AddInfrastructureServices_Registers_IExchangeRateProvider_As_CnbExchangeRateProvider_When_No_Redis() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Redis:Enabled"] = "false" + }) + .Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var provider = serviceProvider.GetService(); + Assert.IsType(provider); + } + + [Fact] + public void AddInfrastructureServices_Registers_IExchangeRateProvider_As_DistributedCachingExchangeRateProvider_When_Redis_Enabled() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Redis:Enabled"] = "true", + ["Redis:Configuration"] = "localhost:6379" + }) + .Build(); + + // Mock IDistributedCache since we don't have Redis in tests + services.AddSingleton(new Mock().Object); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var provider = serviceProvider.GetService(); + Assert.IsType(provider); + } + + [Fact] + public void AddInfrastructureServices_Configures_HttpClient() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:TimeoutSeconds"] = "60" + }) + .Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var httpClientFactory = serviceProvider.GetService(); + Assert.NotNull(httpClientFactory); + + // Use the typed client instead of named client + var httpClient = httpClientFactory.CreateClient(typeof(CnbExchangeRateProvider).Name); + Assert.NotNull(httpClient); + Assert.Equal(TimeSpan.FromSeconds(60), httpClient.Timeout); + Assert.Contains("ExchangeRateProvider/1.0", httpClient.DefaultRequestHeaders.UserAgent.ToString()); + } + + [Fact] + public void AddInfrastructureServices_Adds_CnbCacheStrategy() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var cacheStrategy = serviceProvider.GetService(); + Assert.NotNull(cacheStrategy); + } + + [Fact] + public void AddInfrastructureServices_Configures_CnbCacheStrategy_From_Configuration() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CnbCacheStrategy:PublicationWindowMinutes"] = "10", + ["CnbCacheStrategy:WeekdayHours"] = "2", + ["CnbCacheStrategy:WeekendHours"] = "24" + }) + .Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var cacheStrategy = serviceProvider.GetService(); + Assert.NotNull(cacheStrategy); + + // We can't directly test the private options, but we can verify the service is created + } + + [Fact] + public void AddInfrastructureServices_Adds_StackExchangeRedisCache_When_Redis_Enabled() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Redis:Enabled"] = "true", + ["Redis:Configuration"] = "localhost:6379", + ["Redis:InstanceName"] = "TestInstance" + }) + .Build(); + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var distributedCache = serviceProvider.GetService(); + Assert.NotNull(distributedCache); + // Note: In a real scenario, this would be StackExchangeRedisCache + } + + [Fact] + public void AddInfrastructureServices_Handles_Missing_Configuration_Gracefully() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); // Empty configuration + + // Act + services.AddInfrastructureServices(configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var provider = serviceProvider.GetService(); + Assert.NotNull(provider); + } + + [Fact] + public void AddInfrastructureServices_Registers_Loggers() + { + // Test both regular and Redis-enabled scenarios + var testCases = new[] + { + (false, "Regular provider logger"), + (true, "Distributed caching provider logger") + }; + + foreach (var (redisEnabled, description) in testCases) + { + // Arrange + var services = new ServiceCollection(); + var config = redisEnabled ? + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Redis:Enabled"] = "true" }).Build() : + new ConfigurationBuilder().Build(); + + if (redisEnabled) + services.AddSingleton(new Mock().Object); + + // Act + services.AddInfrastructureServices(config); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var cnbLogger = serviceProvider.GetService>(); + Assert.NotNull(cnbLogger); + + if (redisEnabled) + { + var distributedLogger = serviceProvider.GetService>(); + Assert.NotNull(distributedLogger); + } + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceRegistrationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceRegistrationTests.cs new file mode 100644 index 000000000..7e6e58f01 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Infrastructure/ServiceRegistrationTests.cs @@ -0,0 +1,32 @@ +using ExchangeRateProvider.Application; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Tests.Infrastructure +{ + public class ServiceRegistrationTests + { + + [Fact] + public void Registers_DistributedCache_Provider_When_Redis_Enabled() + { + var cfg = new ConfigurationBuilder().AddInMemoryCollection([ + new KeyValuePair("Redis:Enabled","true"), + new KeyValuePair("Redis:Configuration","localhost:6379") + ]).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddApplicationServices(); + services.AddInfrastructureServices(cfg); + var sp = services.BuildServiceProvider(); + + var provider = sp.GetRequiredService(); + Assert.IsType(provider); + } + } +} + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/ApiE2ETests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/ApiE2ETests.cs new file mode 100644 index 000000000..3d0af501f --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/ApiE2ETests.cs @@ -0,0 +1,99 @@ +using ExchangeRateProvider.Api.Controllers; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Entities; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace ExchangeRateProvider.Tests.Integration +{ + /// + /// E2E test for critical user flow: API request to response. + /// + public class ApiE2ETests + { + [Fact] + public async Task GetExchangeRates_ReturnsValidRates_ForValidRequest() + { + var mockMediator = new Mock(); + + var expectedRates = new List + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.1m) + }; + + mockMediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedRates); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:MaxCurrencies"] = "20" + }) + .Build(); + var controller = new ExchangeRatesController(mockMediator.Object, NullLogger.Instance, configuration); + + // Act - Simulate API call + var result = await controller.GetExchangeRates("USD,EUR"); + + // Assert - Business rules + var okResult = Assert.IsType(result.Result); + var returnedRates = Assert.IsType>(okResult.Value); + + Assert.Equal(2, returnedRates.Count); + Assert.All(returnedRates, rate => Assert.Equal("CZK", rate.TargetCurrency.Code)); + Assert.Contains(returnedRates, r => r.SourceCurrency.Code == "USD"); + Assert.Contains(returnedRates, r => r.SourceCurrency.Code == "EUR"); + } + + [Fact] + public async Task GetExchangeRates_HandlesEmptyRequest_Gracefully() + { + var mockMediator = new Mock(); + mockMediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:MaxCurrencies"] = "20" + }) + .Build(); + var controller = new ExchangeRatesController(mockMediator.Object, NullLogger.Instance, configuration); + + // Act + var result = await controller.GetExchangeRates(""); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedRates = Assert.IsType>(okResult.Value); + Assert.Empty(returnedRates); + } + + [Fact] + public async Task GetExchangeRates_HandlesMediatorException_WithProperResponse() + { + var mockMediator = new Mock(); + mockMediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Service unavailable")); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ExchangeRateProvider:MaxCurrencies"] = "20" + }) + .Build(); + var controller = new ExchangeRatesController(mockMediator.Object, NullLogger.Instance, configuration); + + // Act + var result = await controller.GetExchangeRates("USD"); + + // Assert - Should return 500 status code + var statusResult = Assert.IsType(result.Result); + Assert.Equal(500, statusResult.StatusCode); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs new file mode 100644 index 000000000..bde8197dd --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs @@ -0,0 +1,79 @@ +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace ExchangeRateProvider.Tests.Integration +{ + /// + /// integration tests for CNB API external contract. + /// + public class CnbApiIntegrationTests + { + [Fact] + public async Task CnbExchangeRateProvider_FetchesRealRates_WithValidCurrencies() + { + // Integration test: Actual API call to CNB + var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri("https://api.cnb.cz"); + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock.Setup(f => f.CreateClient("CnbExchangeRateProvider")).Returns(httpClient); + var provider = new CnbExchangeRateProvider(httpClientFactoryMock.Object, NullLogger.Instance); + + var requestedCurrencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("GBP") + }; + + // Act - Real API call + var rates = await provider.GetExchangeRatesAsync(requestedCurrencies); + + // Assert - Business rules + Assert.NotEmpty(rates); + Assert.All(rates, rate => Assert.Equal("CZK", rate.TargetCurrency.Code)); + Assert.All(rates, rate => Assert.Contains(rate.SourceCurrency.Code, new[] { "USD", "EUR", "GBP" })); + Assert.All(rates, rate => Assert.True(rate.Value > 0)); + } + + [Fact] + public async Task CnbExchangeRateProvider_HandlesNetworkFailure_Gracefully() + { + // Integration test: Network failure scenario + // High-risk failure: Network issues + var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri("https://api.cnb.cz"); + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock.Setup(f => f.CreateClient("CnbExchangeRateProvider")).Returns(httpClient); + var provider = new CnbExchangeRateProvider(httpClientFactoryMock.Object, NullLogger.Instance); + + // Configure client to timeout quickly for test + httpClient.Timeout = TimeSpan.FromMilliseconds(1); + + var requestedCurrencies = new[] { new Currency("USD") }; + + // Act & Assert - Should throw on network timeout (TaskCanceledException) + await Assert.ThrowsAsync( + () => provider.GetExchangeRatesAsync(requestedCurrencies)); + } + + [Fact] + public async Task CnbExchangeRateProvider_FiltersRequestedCurrencies_Correctly() + { + var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri("https://api.cnb.cz"); + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock.Setup(f => f.CreateClient("CnbExchangeRateProvider")).Returns(httpClient); + var provider = new CnbExchangeRateProvider(httpClientFactoryMock.Object, NullLogger.Instance); + + var requestedCurrencies = new[] { new Currency("USD") }; + + // Act + var rates = await provider.GetExchangeRatesAsync(requestedCurrencies); + + // Assert - Should only contain requested currencies + Assert.All(rates, rate => Assert.Equal("USD", rate.SourceCurrency.Code)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs new file mode 100644 index 000000000..87e7281e5 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using System.Net.Http; +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace ExchangeRateProvider.Tests.Providers +{ + public class CnbExchangeRateProviderTests + { + private static HttpClient CreateMockHttpClient(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + { + var handler = new MockHttpMessageHandler(response, statusCode); + var client = new HttpClient(handler); + client.BaseAddress = new Uri("https://api.cnb.cz"); + return client; + } + + private static IHttpClientFactory CreateMockHttpClientFactory(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + { + var httpClient = CreateMockHttpClient(response, statusCode); + var factoryMock = new Mock(); + factoryMock.Setup(f => f.CreateClient("CnbExchangeRateProvider")).Returns(httpClient); + return factoryMock.Object; + } + + [Fact] + public async Task Returns_Only_Existing_CNB_Rates() + { + // Arrange (JSON format) + var json = @"{ + ""date"": ""2025-08-31"", + ""rates"": [ + { + ""currencyCode"": ""USD"", + ""amount"": 1, + ""rate"": 22.00, + ""currency"": ""dollar"", + ""country"": ""United States"" + }, + { + ""currencyCode"": ""EUR"", + ""amount"": 1, + ""rate"": 24.00, + ""currency"": ""euro"", + ""country"": ""EMU"" + } + ] + }"; + var httpClientFactory = CreateMockHttpClientFactory(json); + + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("USD"), new Currency("EUR"), new Currency("FOO") }; + + // Act + var rates = await provider.GetExchangeRatesAsync(requested); + + // Assert + Assert.Contains(rates, r => r.SourceCurrency.Code == "USD"); + Assert.Contains(rates, r => r.SourceCurrency.Code == "EUR"); + Assert.DoesNotContain(rates, r => r.SourceCurrency.Code == "FOO"); + } + + [Fact] + public async Task Handles_Malformed_Json_Gracefully() + { + // Arrange: malformed JSON + var malformed = "{ invalid json"; + var httpClientFactory = CreateMockHttpClientFactory(malformed); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("USD") }; + + // Act & Assert + await Assert.ThrowsAsync(() => provider.GetExchangeRatesAsync(requested)); + } + + [Fact] + public async Task Handles_Network_Error_Gracefully() + { + // Arrange + var httpClientFactory = CreateMockHttpClientFactory("", HttpStatusCode.InternalServerError); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("USD") }; + + // Act & Assert + await Assert.ThrowsAsync(() => provider.GetExchangeRatesAsync(requested)); + } + + [Fact] + public async Task Returns_Only_Available_Rates_From_CNB() + { + // Arrange: Only USD rate available in JSON + var json = @"{ + ""date"": ""2025-08-31"", + ""rates"": [ + { + ""currencyCode"": ""USD"", + ""amount"": 1, + ""rate"": 22.00 + } + ] + }"; + var httpClientFactory = CreateMockHttpClientFactory(json); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + + // Test different scenarios + var testCases = new[] + { + (new[] { new Currency("EUR") }, 0), // EUR not available + (new[] { new Currency("USD"), new Currency("CZK") }, 1), // USD available, CZK not as source + (new[] { new Currency("CZK") }, 0) // CZK not available as source + }; + + foreach (var (requested, expectedCount) in testCases) + { + // Create a new provider for each test case to avoid HttpClient disposal issues + var testHttpClientFactory = CreateMockHttpClientFactory(json); + var testProvider = new CnbExchangeRateProvider(testHttpClientFactory, NullLogger.Instance); + + var rates = await testProvider.GetExchangeRatesAsync(requested); + Assert.Equal(expectedCount, rates.Count); + + if (expectedCount > 0) + { + Assert.All(rates, r => Assert.Equal("CZK", r.TargetCurrency.Code)); + Assert.DoesNotContain(rates, r => r.SourceCurrency.Code == "CZK"); + } + } + } + + [Fact] + public async Task Handles_Empty_Or_Missing_Rates() + { + var testCases = new[] + { + // Empty rates array + (@"{""date"": ""2025-08-31"", ""rates"": []}", "Empty rates array"), + // Missing rates property + (@"{""date"": ""2025-08-31""}", "Missing rates property"), + // Null rates + (@"{""date"": ""2025-08-31"", ""rates"": null}", "Null rates") + }; + + foreach (var (json, description) in testCases) + { + var httpClientFactory = CreateMockHttpClientFactory(json); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("USD") }; + + var rates = await provider.GetExchangeRatesAsync(requested); + Assert.Empty(rates); + } + } + + [Fact] + public async Task Handles_Invalid_Rate_Data() + { + // Arrange: Invalid amount and rate values + var json = @"{ + ""date"": ""2025-08-31"", + ""rates"": [ + { + ""currencyCode"": ""USD"", + ""amount"": 0, + ""rate"": -1 + }, + { + ""currencyCode"": ""EUR"", + ""amount"": 1, + ""rate"": 24.00 + } + ] + }"; + var httpClientFactory = CreateMockHttpClientFactory(json); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("USD"), new Currency("EUR") }; + + // Act + var rates = await provider.GetExchangeRatesAsync(requested); + + // Assert: Only valid EUR rate should be returned + Assert.Single(rates); + Assert.Equal("EUR", rates.First().SourceCurrency.Code); + } + + [Fact] + public async Task Handles_Null_CurrencyCode() + { + // Arrange: One rate with null currencyCode + var json = @"{ + ""date"": ""2025-08-31"", + ""rates"": [ + { + ""currencyCode"": null, + ""amount"": 1, + ""rate"": 22.00 + }, + { + ""currencyCode"": ""EUR"", + ""amount"": 1, + ""rate"": 24.00 + } + ] + }"; + var httpClientFactory = CreateMockHttpClientFactory(json); + var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); + var requested = new[] { new Currency("EUR") }; + + // Act + var rates = await provider.GetExchangeRatesAsync(requested); + + // Assert: Only EUR should be returned, null currencyCode ignored + Assert.Single(rates); + Assert.Equal("EUR", rates.First().SourceCurrency.Code); + } + + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly string _response; + private readonly HttpStatusCode _statusCode; + public MockHttpMessageHandler(string response, HttpStatusCode statusCode) + { + _response = response; + _statusCode = statusCode; + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_statusCode != HttpStatusCode.OK) + { + return Task.FromResult(new HttpResponseMessage(_statusCode)); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_response) + }); + } + } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs new file mode 100644 index 000000000..5fdc41a41 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs @@ -0,0 +1,119 @@ +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace ExchangeRateProvider.Tests.Providers +{ + /// + /// Tests for distributed caching provider - focuses on cache hit/miss logic + /// + public class DistributedCachingExchangeRateProviderTests + { + [Fact] + public async Task ReturnsCachedRates_OnCacheHit() + { + var mockInnerProvider = new Mock(); + var mockCache = new Mock(); + var mockCacheStrategy = new Mock(); + + var cachedRates = new List + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.0m) + }; + + // Setup cache hit + mockCache.Setup(c => c.GetAsync("cnb_all_rates", It.IsAny())) + .ReturnsAsync(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cachedRates)); + + // Ensure inner provider returns empty collection if called (shouldn't be called) + mockInnerProvider.Setup(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + + var provider = new DistributedCachingExchangeRateProvider( + mockInnerProvider.Object, mockCache.Object, mockCacheStrategy.Object, + NullLogger.Instance); + + // Act + var result = await provider.GetExchangeRatesAsync([new Currency("USD")]); + + // Assert - Should return cached data without calling inner provider + Assert.Single(result); + Assert.Equal("USD", result.First().SourceCurrency.Code); + mockInnerProvider.Verify(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task FetchesFreshRates_OnCacheMiss() + { + var mockInnerProvider = new Mock(); + var mockCache = new Mock(); + var mockCacheStrategy = new Mock(); + + var freshRates = new List + { + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + + // Setup cache miss + mockCache.Setup(c => c.GetAsync("cnb_all_rates", It.IsAny())) + .ReturnsAsync((byte[])null); + + mockInnerProvider.Setup(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(freshRates); + + var provider = new DistributedCachingExchangeRateProvider( + mockInnerProvider.Object, mockCache.Object, mockCacheStrategy.Object, + NullLogger.Instance); + + // Act + var result = await provider.GetExchangeRatesAsync([new Currency("EUR")]); + + // Assert - Should fetch from inner provider and cache result + Assert.Single(result); + Assert.Equal("EUR", result.First().SourceCurrency.Code); + mockInnerProvider.Verify(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()), Times.Once); + mockCache.Verify(c => c.SetAsync("cnb_all_rates", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandlesPartialCacheHit_WithMissingCurrencies() + { + var mockInnerProvider = new Mock(); + var mockCache = new Mock(); + var mockCacheStrategy = new Mock(); + + var cachedRates = new List + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.0m) + }; + + var additionalRates = new List + { + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + + // Setup partial cache hit - USD cached, EUR missing + mockCache.Setup(c => c.GetAsync("cnb_all_rates", It.IsAny())) + .ReturnsAsync(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cachedRates)); + + // Mock should return additional rates when called with EUR + mockInnerProvider.Setup(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(additionalRates); + + var provider = new DistributedCachingExchangeRateProvider( + mockInnerProvider.Object, mockCache.Object, mockCacheStrategy.Object, + NullLogger.Instance); + + // Act - Request both cached and missing currencies + var result = await provider.GetExchangeRatesAsync([new Currency("USD"), new Currency("EUR")]); + + // Assert - Should combine cached and fresh data + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.SourceCurrency.Code == "USD"); + Assert.Contains(result, r => r.SourceCurrency.Code == "EUR"); + } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs new file mode 100644 index 000000000..53a36a16d --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs @@ -0,0 +1,88 @@ +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.Providers; +using Moq; + +namespace ExchangeRateProvider.Tests.Queries +{ + public class GetExchangeRatesQueryHandlerTests + { + private GetExchangeRatesQueryHandler CreateHandler(IExchangeRateProvider provider = null) + { + var providerMock = provider != null ? Mock.Get(provider) : new Mock(); + return new GetExchangeRatesQueryHandler(provider ?? providerMock.Object); + } + + [Fact] + public async Task Returns_Exchange_Rates_For_Requested_Currencies() + { + var provider = new Mock(); + var handler = CreateHandler(provider.Object); + + var usd = new Currency("USD"); + var czk = new Currency("CZK"); + + // Setup the required interface members + provider.Setup(p => p.Name).Returns("TestProvider"); + provider.Setup(p => p.Priority).Returns(100); + provider.Setup(p => p.CanHandle(It.IsAny>())).Returns(true); + + provider.Setup(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new ExchangeRate(usd, czk, 22m) }); + + var query = new GetExchangeRatesQuery([usd], czk); + var result = (await handler.Handle(query, CancellationToken.None)).ToList(); + + Assert.Single(result); + Assert.Equal("USD", result[0].SourceCurrency.Code); + Assert.Equal("CZK", result[0].TargetCurrency.Code); + Assert.Equal(22m, result[0].Value); + } + + [Fact] + public async Task Returns_Empty_When_No_Currencies_Requested() + { + var provider = new Mock(); + var handler = CreateHandler(provider.Object); + + var query = new GetExchangeRatesQuery([], new Currency("CZK")); + var result = await handler.Handle(query, CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task Filters_Rates_To_Only_Requested_Currencies() + { + var provider = new Mock(); + var handler = CreateHandler(provider.Object); + + var usd = new Currency("USD"); + var czk = new Currency("CZK"); + + // Setup the required interface members + provider.Setup(p => p.Name).Returns("TestProvider"); + provider.Setup(p => p.Priority).Returns(100); + provider.Setup(p => p.CanHandle(It.IsAny>())).Returns(true); + + // Provider returns rates for currencies not requested + provider.Setup(p => p.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List + { + new ExchangeRate(usd, czk, 22m), + new ExchangeRate(new Currency("EUR"), czk, 24m) // Not requested + }); + + var query = new GetExchangeRatesQuery([usd], czk); + var result = (await handler.Handle(query, CancellationToken.None)).ToList(); + + // Should only return rates for requested currencies + Assert.Single(result); + Assert.Equal("USD", result[0].SourceCurrency.Code); + } + + } +} + + diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.sln b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.sln new file mode 100644 index 000000000..66735e63f --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.sln @@ -0,0 +1,104 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Console", "ExchangeRateProvider.Console\ExchangeRateProvider.Console.csproj", "{7180A499-B8C1-4457-A64C-E6D6493406DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Domain", "ExchangeRateProvider.Domain\ExchangeRateProvider.Domain.csproj", "{A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Application", "ExchangeRateProvider.Application\ExchangeRateProvider.Application.csproj", "{4B68907A-6F5F-4F83-8A27-0159FF06A0D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Infrastructure", "ExchangeRateProvider.Infrastructure\ExchangeRateProvider.Infrastructure.csproj", "{8AE0546A-AE9F-428A-846C-4CAF4C93636C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Api", "ExchangeRateProvider.Api\ExchangeRateProvider.Api.csproj", "{E0C96D0E-621E-4567-8F51-474035AAB927}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Tests", "ExchangeRateProvider.Tests\ExchangeRateProvider.Tests.csproj", "{100CA76C-873B-4865-B544-BB88F5967A74}" +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 + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|x64.Build.0 = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Debug|x86.Build.0 = Debug|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|Any CPU.Build.0 = Release|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|x64.ActiveCfg = Release|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|x64.Build.0 = Release|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|x86.ActiveCfg = Release|Any CPU + {7180A499-B8C1-4457-A64C-E6D6493406DE}.Release|x86.Build.0 = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|x64.ActiveCfg = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|x64.Build.0 = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|x86.ActiveCfg = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Debug|x86.Build.0 = Debug|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|Any CPU.Build.0 = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|x64.ActiveCfg = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|x64.Build.0 = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|x86.ActiveCfg = Release|Any CPU + {A122C97F-13E3-44BD-9E04-D7BE4EF0ABBB}.Release|x86.Build.0 = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|x64.Build.0 = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Debug|x86.Build.0 = Debug|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|Any CPU.Build.0 = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|x64.ActiveCfg = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|x64.Build.0 = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|x86.ActiveCfg = Release|Any CPU + {4B68907A-6F5F-4F83-8A27-0159FF06A0D3}.Release|x86.Build.0 = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|x64.Build.0 = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Debug|x86.Build.0 = Debug|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|Any CPU.Build.0 = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|x64.ActiveCfg = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|x64.Build.0 = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|x86.ActiveCfg = Release|Any CPU + {8AE0546A-AE9F-428A-846C-4CAF4C93636C}.Release|x86.Build.0 = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|x64.ActiveCfg = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|x64.Build.0 = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|x86.ActiveCfg = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Debug|x86.Build.0 = Debug|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|Any CPU.Build.0 = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|x64.ActiveCfg = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|x64.Build.0 = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|x86.ActiveCfg = Release|Any CPU + {E0C96D0E-621E-4567-8F51-474035AAB927}.Release|x86.Build.0 = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|x64.ActiveCfg = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|x64.Build.0 = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|x86.ActiveCfg = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Debug|x86.Build.0 = Debug|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|Any CPU.Build.0 = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|x64.ActiveCfg = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|x64.Build.0 = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|x86.ActiveCfg = Release|Any CPU + {100CA76C-873B-4865-B544-BB88F5967A74}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Dockerfile deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/Program.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json deleted file mode 100644 index ad7e4c17a..000000000 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Api/appsettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "ASPNETCORE_ENVIRONMENT":"Development", - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "ExchangeRateUpdater": "Debug" - } - }, - "AllowedHosts": "*", - "ExchangeRateProvider": { - "CnbExchangeRateUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", - "CacheExpirationMinutes": 60 - }, - "ApiKey": { - "Enabled": false, - "Value": "CHANGE_ME_API_KEY" - }, - "Redis": { - "Enabled": true, - "Configuration": "localhost:6379" - } -} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQuery.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/Queries/GetExchangeRatesQueryHandler.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Dockerfile deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/ExchangeRateProvider.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/Program.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/README.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json deleted file mode 100644 index 65204c470..000000000 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Console/appsettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information", - "ExchangeRateUpdater": "Debug" - } - }, - "ExchangeRateProvider": { - "CnbExchangeRateUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", - "CacheExpirationMinutes": 60 - } -} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/Currency.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj deleted file mode 100644 index fa71b7ae6..000000000 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0 - enable - enable - - - diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbCacheStrategy.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/CnbExchangeRateProvider.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/DistributedCachingExchangeRateProvider.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerUnitTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Controllers/ExchangeRatesControllerValidationTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Infrastructure/ServiceRegistrationTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Integration/ApiKeyMiddlewareTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/CnbExchangeRateProviderTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Providers/DistributedCachingExchangeRateProviderTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerEdgeTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerMultiCurrencyTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateUpdater.Tests/Queries/GetExchangeRatesQueryHandlerTests.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/README.md b/jobs/Backend/Task/CnbExchangeRateProvider/README.md new file mode 100644 index 000000000..b9f036384 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/README.md @@ -0,0 +1,344 @@ +# Exchange Rate Provider + +A .NET 8 service for fetching Czech National Bank (CNB) exchange rates with intelligent caching and monitoring. This service provides reliable, real-time exchange rate data with enterprise-grade features like distributed caching, circuit breakers, health monitoring, and comprehensive testing. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Architecture](#architecture) +- [Features](#features) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [API Reference](#api-reference) +- [Console Application](#console-application) +- [Troubleshooting](#troubleshooting) +- [Testing](#testing) +- [Docker Deployment](#docker-deployment) +- [Adding New Providers](#adding-new-providers) +- [Production Features](#production-features) + +## Prerequisites + +- .NET 8.0 SDK or later +- (Optional) Redis for distributed caching +- (Optional) Docker and Docker Compose for containerized deployment + +## Architecture + +This project follows **Clean Architecture** principles with clear separation of concerns: + +- **Domain Layer** (`ExchangeRateProvider.Domain`): Core business entities, value objects, and interfaces + - `Currency` and `ExchangeRate` entities with validation + - `IExchangeRateProvider` interface for provider abstraction + - `IProviderRegistry` for managing multiple providers + +- **Application Layer** (`ExchangeRateProvider.Application`): Business logic and use cases + - MediatR-based command/query pattern + - `GetExchangeRatesQuery` and handler for rate retrieval + +- **Infrastructure Layer** (`ExchangeRateProvider.Infrastructure`): External concerns and implementations + - `CnbExchangeRateProvider`: CNB API integration + - `CnbCacheStrategy`: Intelligent caching based on CNB publication schedule + - `DistributedCachingExchangeRateProvider`: Redis-based caching decorator + - Polly policies for resilience (retry, circuit breaker) + +- **API Layer** (`ExchangeRateProvider.Api`): RESTful web API + - ASP.NET Core controllers with validation + - Swagger/OpenAPI documentation + - Health checks and Prometheus metrics + +- **Console Layer** (`ExchangeRateProvider.Console`): Command-line interface + - Simple console app for testing and batch operations + +## Features + +- **Real-time CNB Exchange Rates**: Fetches official rates from Czech National Bank API +- **Intelligent Caching Strategy**: + - 5 minutes during CNB publication window (2:31-3:31 PM Prague time) + - 1 hour on weekdays outside publication window + - 12 hours on weekends (no new data published) +- **Distributed Caching**: Optional Redis support for multi-instance deployments +- **Resilience**: Circuit breaker and retry policies using Polly +- **Health Monitoring**: Comprehensive health checks for CNB API and Redis +- **Metrics**: Prometheus metrics for monitoring and alerting +- **Rate Limiting**: 100 requests per minute per IP address +- **Structured Logging**: Serilog integration with request/response logging +- **Comprehensive Testing**: 45+ unit and integration tests +- **Docker Support**: Multi-stage Dockerfiles for development and production + +## Quick Start + +### Running the API + +1. **Clone and navigate to the project**: + ```bash + cd /path/to/CnbExchangeRateProvider + ``` + +2. **Run the API**: + ```bash + dotnet run --project ExchangeRateProvider.Api + ``` + +3. **Test the endpoint**: + ```bash + curl "http://localhost:5001/api/exchange-rates?currencyCodes=USD,EUR" + ``` + +### Running the Console App + +```bash +dotnet run --project ExchangeRateProvider.Console +``` + +### Running Tests + +```bash +dotnet test +``` + +## Configuration + +The application uses `appsettings.json` for configuration. Key settings: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ExchangeRateProvider": { + "CnbExchangeRateUrl": "https://api.cnb.cz", + "CacheExpirationMinutes": 60, + "TimeoutSeconds": 30, + "MaxCurrencies": 20 + }, + "Redis": { + "Enabled": true, + "Configuration": "localhost:6379", + "InstanceName": "ExchangeRates" + }, + "CnbCacheStrategy": { + "PublicationWindowMinutes": 5, + "WeekdayHours": 1, + "WeekendHours": 12 + } +} +``` + +### Environment Variables + +Override settings using environment variables with double underscores: +```bash +ExchangeRateProvider__CnbExchangeRateUrl=https://api.cnb.cz +Redis__Enabled=true +Redis__Configuration=redis:6379 +``` + +## API Reference + +### GET /api/exchange-rates + +Retrieves exchange rates for specified currencies against CZK. + +**Parameters:** +- `currencyCodes` (required): Comma-separated ISO 4217 currency codes (e.g., `USD,EUR,GBP`) + +**Example Request:** +```bash +curl "http://localhost:5001/api/exchange-rates?currencyCodes=USD,EUR" +``` + +**Response:** +```json +[ + { + "sourceCurrency": { "code": "USD" }, + "targetCurrency": { "code": "CZK" }, + "value": 22.5, + "timestamp": "2024-01-15T14:30:00Z" + }, + { + "sourceCurrency": { "code": "EUR" }, + "targetCurrency": { "code": "CZK" }, + "value": 24.2, + "timestamp": "2024-01-15T14:30:00Z" + } +] +``` + +**Error Responses:** +- `400 Bad Request`: Invalid currency codes or too many currencies (max 20) +- `500 Internal Server Error`: Service unavailable or CNB API error + +### Monitoring Endpoints + +- `GET /health` - Health status (CNB API and Redis connectivity) +- `GET /metrics` - Prometheus metrics +- `GET /swagger` - API documentation + +## Console Application + +The console app demonstrates programmatic access to exchange rates: + +```bash +dotnet run --project ExchangeRateProvider.Console +``` + +It fetches rates for a predefined set of currencies and displays them in the console. + +## Testing + +The project includes comprehensive test coverage with **45 test methods**: + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +**Test Categories:** +- **Unit Tests**: Business logic, validation, caching strategies +- **Integration Tests**: API endpoints, CNB API interaction +- **Infrastructure Tests**: Service registration, configuration +- **Provider Tests**: Exchange rate providers with mocking + + +## Troubleshooting + +### Common Issues + +**API returns 500 error:** +- Check CNB API availability: `curl https://api.cnb.cz/cnbapi/exrates/daily` +- Verify configuration in `appsettings.json` +- Check logs for detailed error messages + +**Redis connection failed:** +- Ensure Redis is running: `docker ps | grep redis` +- Check Redis configuration in environment variables +- Verify network connectivity between containers + +**Rate limiting triggered:** +- Reduce request frequency or implement exponential backoff +- Check rate limit configuration in `Program.cs` + +### Logs + +Logs are written to console and can be mounted in Docker: +```bash +# View logs +docker-compose logs api + +# Follow logs +docker-compose logs -f api +``` + +### Health Checks + +```bash +# Check API health +curl http://localhost:5001/health + +# Check with Docker +docker-compose exec api curl http://localhost:8080/health +``` + +## Docker Deployment + +### Development + +```bash +# Start development environment with Redis +docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +# API available at http://localhost:5001 +# Redis available at localhost:6379 +``` + +### Production + +```bash +# Build and start production environment +docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +# API available at http://localhost:80 +``` + +### Manual Docker Build + +```bash +# Build API image +docker build -f ExchangeRateProvider.Api/Dockerfile -t exchange-rate-api . + +# Run with Redis +docker run -d --name redis redis:7-alpine +docker run -p 5001:8080 --link redis:redis -e Redis__Configuration=redis:6379 exchange-rate-api +``` + +## Adding New Providers + +To add a new exchange rate provider: + +1. **Implement the interface**: +```csharp +public class NewProvider : IExchangeRateProvider +{ + public string Name => "NewProvider"; + public int Priority => 50; // Lower than CNB (100) + + public bool CanHandle(IEnumerable currencies) + { + // Return true if this provider can handle the currencies + return true; + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + // Your implementation here + // Return rates with CZK as target currency + } +} +``` + +2. **Register the provider**: +```csharp +// In Infrastructure/ServiceCollectionExtensions.cs +services.AddScoped(); +services.AddScoped(provider => +{ + var newProvider = provider.GetRequiredService(); + // Add caching if needed + return newProvider; +}); +``` + +## Production Features + +### Implemented Features +- ✅ **Rate Limiting**: 100 requests/minute per IP using ASP.NET Core Rate Limiting +- ✅ **Health Checks**: CNB API and Redis connectivity monitoring +- ✅ **Prometheus Metrics**: Request counts, response times, cache hit rates +- ✅ **Structured Logging**: Request/response logging with correlation IDs +- ✅ **Swagger/OpenAPI**: Interactive API documentation +- ✅ **Circuit Breaker**: Automatic failure detection and recovery using Polly +- ✅ **Retry Policies**: Exponential backoff for transient failures +- ✅ **Distributed Caching**: Redis support for multi-instance deployments +- ✅ **Docker Support**: Production-ready containerization + +### Security Considerations +- Input validation for currency codes +- Rate limiting to prevent abuse +- HTTPS redirection in production +- Secure defaults for Redis configuration + +### Monitoring and Observability +- Health endpoints for load balancer checks +- Prometheus metrics for alerting +- Structured logging for debugging +- Request tracing and correlation \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.dev.yml b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.dev.yml new file mode 100644 index 000000000..dda7e2aca --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.dev.yml @@ -0,0 +1,62 @@ +services: + api: + build: + context: . + dockerfile: ExchangeRateProvider.Api/Dockerfile + ports: + - "5001:8080" + environment: + - ASPNETCORE_URLS=http://+:8080 + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_LOGGING__LOGLEVEL__DEFAULT=Debug + - ExchangeRateProvider__CnbExchangeRateUrl=https://api.cnb.cz + - ExchangeRateProvider__CacheExpirationMinutes=5 # Shorter cache for dev + - ExchangeRateProvider__TimeoutSeconds=30 + - ExchangeRateProvider__MaxCurrencies=50 # More currencies for testing + - Redis__Enabled=true + - Redis__Configuration=redis:6379 + - Redis__InstanceName=ExchangeRatesDev + - CnbCacheStrategy__PublicationWindowMinutes=5 + - CnbCacheStrategy__WeekdayHours=1 + - CnbCacheStrategy__WeekendHours=12 + volumes: + - ./logs:/app/logs # Mount logs for development + depends_on: + - redis + restart: unless-stopped + + console: + build: + context: . + dockerfile: ExchangeRateProvider.Console/Dockerfile + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_LOGGING__LOGLEVEL__DEFAULT=Debug + - ExchangeRateProvider__CnbExchangeRateUrl=https://api.cnb.cz + - ExchangeRateProvider__CacheExpirationMinutes=5 + - ExchangeRateProvider__TimeoutSeconds=30 + - ExchangeRateProvider__MaxCurrencies=50 + - Redis__Enabled=true + - Redis__Configuration=redis:6379 + - Redis__InstanceName=ExchangeRatesDev + - CnbCacheStrategy__PublicationWindowMinutes=5 + - CnbCacheStrategy__WeekdayHours=1 + - CnbCacheStrategy__WeekendHours=12 + volumes: + - ./logs:/app/logs + entrypoint: ["dotnet", "ExchangeRateProvider.Console.dll"] + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7-alpine + ports: + - "6379:6379" # Expose port for development tools + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data_dev:/data + restart: unless-stopped + +volumes: + redis_data_dev: \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.prod.yml b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.prod.yml new file mode 100644 index 000000000..4aebab9fd --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.prod.yml @@ -0,0 +1,71 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: ExchangeRateProvider.Api/Dockerfile + target: production # Use production stage if multi-stage Dockerfile + image: exchange-rate-provider-api:latest + ports: + - "80:8080" # Standard HTTP port for production + environment: + - ASPNETCORE_URLS=http://+:8080 + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_LOGGING__LOGLEVEL__DEFAULT=Warning + - ASPNETCORE_LOGGING__LOGLEVEL__MICROSOFT=Warning + - ExchangeRateProvider__CnbExchangeRateUrl=https://api.cnb.cz + - ExchangeRateProvider__CacheExpirationMinutes=60 + - ExchangeRateProvider__TimeoutSeconds=30 + - ExchangeRateProvider__MaxCurrencies=20 + - Redis__Enabled=true + - Redis__Configuration=redis:6379 + - Redis__InstanceName=ExchangeRates + - CnbCacheStrategy__PublicationWindowMinutes=5 + - CnbCacheStrategy__WeekdayHours=1 + - CnbCacheStrategy__WeekendHours=12 + depends_on: + - redis + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + console: + build: + context: . + dockerfile: ExchangeRateProvider.Console/Dockerfile + target: production + image: exchange-rate-provider-console:latest + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_LOGGING__LOGLEVEL__DEFAULT=Warning + - ASPNETCORE_LOGGING__LOGLEVEL__MICROSOFT=Warning + - ExchangeRateProvider__CnbExchangeRateUrl=https://api.cnb.cz + - ExchangeRateProvider__CacheExpirationMinutes=60 + - ExchangeRateProvider__TimeoutSeconds=30 + - ExchangeRateProvider__MaxCurrencies=20 + - Redis__Enabled=true + - Redis__Configuration=redis:6379 + - Redis__InstanceName=ExchangeRates + - CnbCacheStrategy__PublicationWindowMinutes=5 + - CnbCacheStrategy__WeekdayHours=1 + - CnbCacheStrategy__WeekendHours=12 + entrypoint: ["dotnet", "ExchangeRateProvider.Console.dll"] + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7-alpine + # Don't expose port in production for security + command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"] + volumes: + - redis_data_prod:/data + restart: unless-stopped + +volumes: + redis_data_prod: \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.yml b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.yml new file mode 100644 index 000000000..55e8edeb3 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/docker-compose.yml @@ -0,0 +1,26 @@ +# Base configuration - common settings for both environments +# Use with docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + api: + build: + context: . + dockerfile: ExchangeRateProvider.Api/Dockerfile + networks: + - exchange-rate-network + + console: + build: + context: . + dockerfile: ExchangeRateProvider.Console/Dockerfile + networks: + - exchange-rate-network + + redis: + image: redis:7-alpine + networks: + - exchange-rate-network + +networks: + exchange-rate-network: + driver: bridge \ No newline at end of file From 35161c620d97524264b122fbbddb27ed87771233 Mon Sep 17 00:00:00 2001 From: "Siddalingappa (Sid)" Date: Mon, 1 Sep 2025 11:34:03 +0200 Subject: [PATCH 3/4] Fix: Ignored unavailable currencies and added the missing architecture diagram and design decisions I considered while creating this solution. --- .../CnbExchangeRateProvider/Architecture.md | 230 ++++++++++++++++++ .../appsettings.Development.json | 2 +- .../Interfaces/IExchangeRateProvider.cs | 4 + .../CnbExchangeRateProvider.cs | 15 +- .../DistributedCachingExchangeRateProvider.cs | 49 ++-- .../Integration/CnbApiIntegrationTests.cs | 8 +- .../Providers/CnbExchangeRateProviderTests.cs | 14 +- 7 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md b/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md new file mode 100644 index 000000000..87a6f9012 --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md @@ -0,0 +1,230 @@ +# Architecture + +## Overview + +The Exchange Rate Provider is built using Clean Architecture principles to ensure separation of concerns, testability, and maintainability. The architecture is divided into multiple layers, each with specific responsibilities and dependencies. + +## Architectural Diagrams + +### Clean Architecture Layers + +```mermaid +graph TB + subgraph "Presentation Layer
(Entry Points)" + API[ExchangeRateProvider.Api
- ExchangeRatesController
- Program.cs
- Swagger/OpenAPI
- Health Checks] + Console[ExchangeRateProvider.Console
- Program.cs
- Command-line Interface] + end + + subgraph "Application Layer
(Use Cases & Business Logic)" + App[ExchangeRateProvider.Application
- GetExchangeRatesQuery
- GetExchangeRatesQueryHandler
- MediatR Pipeline
- ServiceCollectionExtensions] + end + + subgraph "Domain Layer
(Core Business Rules)" + Domain[ExchangeRateProvider.Domain
- Currency Entity
- ExchangeRate Entity
- IExchangeRateProvider Interface
- IProviderRegistry Interface
- ProviderRegistry Implementation] + end + + subgraph "Infrastructure Layer
(External Concerns)" + Infra[ExchangeRateProvider.Infrastructure
- CnbExchangeRateProvider
- DistributedCachingExchangeRateProvider
- CnbCacheStrategy
- ProviderRegistrationHostedService
- ProviderRegistrationService
- Polly Policies
- ServiceCollectionExtensions] + end + + subgraph "Tests Layer
(Quality Assurance)" + Tests[ExchangeRateProvider.Tests
- Unit Tests
- Integration Tests
- Infrastructure Tests
- API E2E Tests] + end + + subgraph "External Systems" + CNB[CNB API
https://api.cnb.cz] + Redis[(Redis Cache
Distributed Storage)] + Docker[Docker
Containerization] + end + + API --> App + Console --> App + App --> Domain + Infra --> Domain + Tests --> App + Tests --> Domain + Tests --> Infra + Infra --> CNB + Infra --> Redis + API --> Docker + Console --> Docker + + style Domain fill:#e1f5fe,stroke:#01579b,stroke-width:3px + style App fill:#f3e5f5,stroke:#4a148c,stroke-width:2px + style Infra fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px + style API fill:#fff3e0,stroke:#e65100,stroke-width:2px + style Console fill:#fff3e0,stroke:#e65100,stroke-width:2px + style Tests fill:#fafafa,stroke:#424242,stroke-width:1px + style CNB fill:#ffebee,stroke:#b71c1c + style Redis fill:#f3e5f5,stroke:#4a148c + style Docker fill:#e8f5e8,stroke:#1b5e20 +``` + +### Data Flow Diagram + +```mermaid +flowchart TD + Client[Client Application] -->|HTTP GET /api/exchange-rates| Controller[ExchangeRatesController] + Controller -->|Validate Request| Controller + Controller -->|Send Query| MediatR[MediatR Pipeline] + MediatR -->|Route to Handler| Handler[GetExchangeRatesQueryHandler] + Handler -->|Request Providers| Registry[ProviderRegistry] + Registry -->|Select Provider| CacheCheck{Cache Check
CnbCacheStrategy} + CacheCheck -->|Cache Hit| CacheReturn[Return Cached Data] + CacheCheck -->|Cache Miss| Provider[CnbExchangeRateProvider] + Provider -->|Fetch Data| CNBAPI[CNB API
https://api.cnb.cz/cnbapi/exrates/daily] + CNBAPI -->|Return Rates| Provider + Provider -->|Apply Caching| DistributedCache[DistributedCachingExchangeRateProvider
Redis] + DistributedCache -->|Store in Cache| DistributedCache + DistributedCache -->|Return Data| Handler + CacheReturn -->|Return Data| Handler + Handler -->|Process Response| Handler + Handler -->|Return Result| MediatR + MediatR -->|Return Result| Controller + Controller -->|Format Response| Controller + Controller -->|HTTP Response| Client + + style Client fill:#e3f2fd + style Controller fill:#fff3e0 + style MediatR fill:#f3e5f5 + style Handler fill:#e8f5e8 + style Registry fill:#e1f5fe + style CacheCheck fill:#fff9c4 + style Provider fill:#e8f5e8 + style CNBAPI fill:#ffebee + style DistributedCache fill:#f3e5f5 +``` + +## Layer Descriptions + +### Domain Layer +- **Purpose**: Contains core business entities, value objects, and interfaces +- **Components**: + - `Currency` and `ExchangeRate` entities with validation + - `IExchangeRateProvider` interface for provider abstraction + - `IProviderRegistry` for managing multiple providers +- **Dependencies**: None (innermost layer) + +### Application Layer +- **Purpose**: Contains business logic and use cases +- **Components**: + - MediatR-based command/query pattern + - `GetExchangeRatesQuery` and handler for rate retrieval +- **Dependencies**: Domain Layer + +### Infrastructure Layer +- **Purpose**: Handles external concerns and implementations +- **Components**: + - `CnbExchangeRateProvider`: CNB API integration + - `CnbCacheStrategy`: Intelligent caching based on CNB publication schedule + - `DistributedCachingExchangeRateProvider`: Redis-based caching decorator + - Polly policies for resilience (retry, circuit breaker) + - Provider registration services +- **Dependencies**: Domain Layer + +### Presentation Layer +- **Purpose**: Entry points for the application +- **Components**: + - **API Layer** (`ExchangeRateProvider.Api`): ASP.NET Core RESTful web API with controllers, Swagger, health checks + - **Console Layer** (`ExchangeRateProvider.Console`): Command-line interface for testing +- **Dependencies**: Application Layer + +### Tests Layer +- **Purpose**: Comprehensive testing coverage +- **Components**: + - Unit tests for business logic + - Integration tests for API and external services + - Infrastructure tests for service registration +- **Dependencies**: All layers + +## Key Architectural Decisions + +### 1. Clean Architecture +**Decision**: Implement Clean Architecture with strict layer separation. +**Rationale**: +- Ensures separation of concerns +- Improves testability by allowing mocking of dependencies +- Facilitates maintainability and evolution of the codebase +- Prevents business logic from being coupled to external frameworks + +### 2. Provider Abstraction Pattern +**Decision**: Use `IExchangeRateProvider` interface with priority-based provider registry. +**Rationale**: +- Allows easy addition of new exchange rate providers +- Enables fallback mechanisms if primary provider fails +- Supports different providers for different currencies or regions +- Maintains single responsibility principle + +### 3. Intelligent Caching Strategy +**Decision**: Implement time-based caching that adapts to CNB publication schedule. +**Rationale**: +- Reduces unnecessary API calls to CNB +- Optimizes performance during high-frequency publication windows +- Balances freshness of data with system performance +- Reduces load on external API + +### 4. CQRS with MediatR +**Decision**: Use MediatR for implementing CQRS pattern in the Application layer. +**Rationale**: +- Separates read and write operations +- Improves code organization and maintainability +- Enables easy testing of handlers +- Supports cross-cutting concerns like logging and validation + +### 5. Resilience with Polly +**Decision**: Implement Polly policies for retry, circuit breaker, and timeout. +**Rationale**: +- Handles transient failures gracefully +- Prevents cascading failures +- Improves system reliability and user experience +- Provides configurable resilience strategies + +### 6. Distributed Caching with Redis +**Decision**: Use Redis for distributed caching in multi-instance deployments. +**Rationale**: +- Enables cache sharing across multiple application instances +- Improves performance in scaled environments +- Provides persistence and backup capabilities +- Integrates well with cloud deployments + +### 7. Docker Containerization +**Decision**: Provide multi-stage Dockerfiles for development and production. +**Rationale**: +- Ensures consistent deployment across environments +- Simplifies scaling and orchestration +- Improves development workflow +- Enables efficient CI/CD pipelines + +### 8. Comprehensive Testing Strategy +**Decision**: Maintain high test coverage with unit, integration, and infrastructure tests. +**Rationale**: +- Ensures code quality and prevents regressions +- Enables confident refactoring and feature additions +- Validates integration with external services +- Supports continuous integration practices + +## Data Flow + +1. **API Request**: Client sends request to `/api/exchange-rates` +2. **Controller**: `ExchangeRatesController` receives request and validates input +3. **Application**: `GetExchangeRatesQuery` is sent via MediatR +4. **Handler**: `GetExchangeRatesQueryHandler` processes the query +5. **Infrastructure**: Provider registry selects appropriate provider +6. **Caching**: Cache strategy checks for cached data +7. **Provider**: If cache miss, fetches data from CNB API +8. **Response**: Data flows back through layers to client + +## Deployment Architecture + +The application supports multiple deployment scenarios: + +- **Development**: Local Docker Compose with Redis +- **Production**: Containerized deployment with external Redis +- **Console**: Standalone executable for batch operations + +## Monitoring and Observability + +- Health checks for CNB API and Redis connectivity +- Prometheus metrics for performance monitoring +- Structured logging with Serilog +- Request tracing and correlation IDs \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json index d658497fc..689b35bd2 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Api/appsettings.Development.json @@ -7,7 +7,7 @@ } }, "ExchangeRateProvider": { - "CnbExchangeRateUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "CnbExchangeRateUrl": "https://api.cnb.cz", "CacheExpirationMinutes": 60, "TimeoutSeconds": 30, "MaxCurrencies": 20 diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs index 073d269d2..85cdd016b 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Domain/Interfaces/IExchangeRateProvider.cs @@ -26,6 +26,10 @@ public interface IExchangeRateProvider /// /// Gets exchange rates for the specified currencies. + /// 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. /// /// The currencies to get rates for. /// The cancellation token. diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs index 20a5e37fb..18e99696b 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/CnbExchangeRateProvider.cs @@ -125,18 +125,21 @@ public async Task> GetExchangeRatesAsync( } catch (HttpRequestException ex) { - _logger.LogError(ex, "Network error fetching CNB rates"); - throw; + _logger.LogWarning(ex, "Network error fetching CNB rates - ignoring unavailable currencies as per requirement"); + // Return empty list instead of throwing - ignore currencies not provided by source + return []; } catch (JsonException ex) { - _logger.LogError(ex, "Failed to parse CNB JSON response"); - throw new InvalidOperationException("CNB API returned invalid JSON format", ex); + _logger.LogWarning(ex, "Failed to parse CNB JSON response - ignoring unavailable currencies as per requirement"); + // Return empty list instead of throwing - ignore currencies not provided by source + return []; } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error fetching CNB exchange rates"); - throw; + _logger.LogWarning(ex, "Unexpected error fetching CNB exchange rates - ignoring unavailable currencies as per requirement"); + // Return empty list instead of throwing - ignore currencies not provided by source + return []; } } } diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs index 5cdff619d..a45698bf4 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Infrastructure/DistributedCachingExchangeRateProvider.cs @@ -65,29 +65,50 @@ public async Task> GetExchangeRatesAsync( { _logger.LogDebug("Cache partial hit - fetching {Count} missing rates", missingCurrencies.Count); - _logger.LogDebug("Cache miss - fetching fresh rates"); - var fetchedRates = await _innerProvider.GetExchangeRatesAsync(missingCurrencies, cancellationToken); - - // Cache all rates with CNB-aware duration - cachedRates = cachedRates.Concat(fetchedRates).ToList(); - await CacheAllRatesAsync(cachedRates, cancellationToken); - - return cachedRates; + try + { + _logger.LogDebug("Cache miss - fetching fresh rates"); + var fetchedRates = await _innerProvider.GetExchangeRatesAsync(missingCurrencies, cancellationToken); + + // Only add fetched rates if we got any (ignore currencies not provided by source) + if (fetchedRates.Any()) + { + cachedRates = cachedRates.Concat(fetchedRates).ToList(); + await CacheAllRatesAsync(cachedRates, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch missing rates from CNB - ignoring unavailable currencies as per requirement"); + // Continue with cached rates only - ignore currencies not provided by source + } } _logger.LogDebug("Cache hit - returning {Count} rates from cache", cachedRates.Count); return cachedRates; } - // Cache miss or partial hit - fetch all fresh data + // Cache miss - fetch all fresh data _logger.LogDebug("Cache miss - fetching fresh rates"); - var freshRates = await _innerProvider.GetExchangeRatesAsync(requestedCurrencies, cancellationToken); - var freshRatesList = freshRates.ToList(); + try + { + var freshRates = await _innerProvider.GetExchangeRatesAsync(requestedCurrencies, cancellationToken); + var freshRatesList = freshRates.ToList(); - // Cache all rates with CNB-aware duration - await CacheAllRatesAsync(freshRatesList, cancellationToken); + // Cache all rates with CNB-aware duration (only if we got any rates) + if (freshRatesList.Any()) + { + await CacheAllRatesAsync(freshRatesList, cancellationToken); + } - return freshRatesList; + return freshRatesList; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch rates from CNB - returning empty result as per requirement to ignore unavailable currencies"); + // Return empty list - ignore currencies not provided by source + return []; + } } private async Task?> GetAllRatesFromCacheAsync(CancellationToken cancellationToken) diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs index bde8197dd..b3f3d56b7 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Integration/CnbApiIntegrationTests.cs @@ -53,9 +53,11 @@ public async Task CnbExchangeRateProvider_HandlesNetworkFailure_Gracefully() var requestedCurrencies = new[] { new Currency("USD") }; - // Act & Assert - Should throw on network timeout (TaskCanceledException) - await Assert.ThrowsAsync( - () => provider.GetExchangeRatesAsync(requestedCurrencies)); + // Act + var rates = await provider.GetExchangeRatesAsync(requestedCurrencies); + + // Assert: Should return empty list instead of throwing exception (ignore unavailable currencies) + Assert.Empty(rates); } [Fact] diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs index 87e7281e5..ab6985a45 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs +++ b/jobs/Backend/Task/CnbExchangeRateProvider/ExchangeRateProvider.Tests/Providers/CnbExchangeRateProviderTests.cs @@ -71,8 +71,11 @@ public async Task Handles_Malformed_Json_Gracefully() var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); var requested = new[] { new Currency("USD") }; - // Act & Assert - await Assert.ThrowsAsync(() => provider.GetExchangeRatesAsync(requested)); + // Act + var rates = await provider.GetExchangeRatesAsync(requested); + + // Assert: Should return empty list instead of throwing exception (ignore unavailable currencies) + Assert.Empty(rates); } [Fact] @@ -83,8 +86,11 @@ public async Task Handles_Network_Error_Gracefully() var provider = new CnbExchangeRateProvider(httpClientFactory, NullLogger.Instance); var requested = new[] { new Currency("USD") }; - // Act & Assert - await Assert.ThrowsAsync(() => provider.GetExchangeRatesAsync(requested)); + // Act + var rates = await provider.GetExchangeRatesAsync(requested); + + // Assert: Should return empty list instead of throwing exception (ignore unavailable currencies) + Assert.Empty(rates); } [Fact] From bb7290f191b2fdbf38961d69f252977ecbed0538 Mon Sep 17 00:00:00 2001 From: "Siddalingappa (Sid)" Date: Mon, 1 Sep 2025 11:58:30 +0200 Subject: [PATCH 4/4] Corrected the documentation. --- .../CnbExchangeRateProvider/Architecture.md | 114 ++++++++---------- .../Task/CnbExchangeRateProvider/README.md | 77 +++++------- 2 files changed, 79 insertions(+), 112 deletions(-) diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md b/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md index 87a6f9012..ae92bca90 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md +++ b/jobs/Backend/Task/CnbExchangeRateProvider/Architecture.md @@ -9,55 +9,34 @@ The Exchange Rate Provider is built using Clean Architecture principles to ensur ### Clean Architecture Layers ```mermaid -graph TB - subgraph "Presentation Layer
(Entry Points)" - API[ExchangeRateProvider.Api
- ExchangeRatesController
- Program.cs
- Swagger/OpenAPI
- Health Checks] - Console[ExchangeRateProvider.Console
- Program.cs
- Command-line Interface] - end - - subgraph "Application Layer
(Use Cases & Business Logic)" - App[ExchangeRateProvider.Application
- GetExchangeRatesQuery
- GetExchangeRatesQueryHandler
- MediatR Pipeline
- ServiceCollectionExtensions] - end - - subgraph "Domain Layer
(Core Business Rules)" - Domain[ExchangeRateProvider.Domain
- Currency Entity
- ExchangeRate Entity
- IExchangeRateProvider Interface
- IProviderRegistry Interface
- ProviderRegistry Implementation] - end - - subgraph "Infrastructure Layer
(External Concerns)" - Infra[ExchangeRateProvider.Infrastructure
- CnbExchangeRateProvider
- DistributedCachingExchangeRateProvider
- CnbCacheStrategy
- ProviderRegistrationHostedService
- ProviderRegistrationService
- Polly Policies
- ServiceCollectionExtensions] - end - - subgraph "Tests Layer
(Quality Assurance)" - Tests[ExchangeRateProvider.Tests
- Unit Tests
- Integration Tests
- Infrastructure Tests
- API E2E Tests] - end - - subgraph "External Systems" - CNB[CNB API
https://api.cnb.cz] - Redis[(Redis Cache
Distributed Storage)] - Docker[Docker
Containerization] - end - - API --> App - Console --> App - App --> Domain - Infra --> Domain - Tests --> App - Tests --> Domain - Tests --> Infra - Infra --> CNB - Infra --> Redis - API --> Docker - Console --> Docker - - style Domain fill:#e1f5fe,stroke:#01579b,stroke-width:3px - style App fill:#f3e5f5,stroke:#4a148c,stroke-width:2px - style Infra fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px - style API fill:#fff3e0,stroke:#e65100,stroke-width:2px - style Console fill:#fff3e0,stroke:#e65100,stroke-width:2px - style Tests fill:#fafafa,stroke:#424242,stroke-width:1px - style CNB fill:#ffebee,stroke:#b71c1c - style Redis fill:#f3e5f5,stroke:#4a148c - style Docker fill:#e8f5e8,stroke:#1b5e20 +graph TD + %% Presentation Layer (Outer) + A[Presentation Layer
🌐 Entry Points
├── ExchangeRateProvider.Api
│ ├── REST Controllers
│ ├── Swagger/OpenAPI
│ └── Health Monitoring
└── ExchangeRateProvider.Console
└── CLI Interface] + + %% Application Layer + B[Application Layer
⚙️ Use Cases & Business Logic
├── MediatR CQRS Pipeline
├── GetExchangeRatesQuery
├── Query Handlers
└── Dependency Injection] + + %% Domain Layer (Core) + C[Domain Layer
🎯 Core Business Rules
├── Entities
│ ├── Currency
│ └── ExchangeRate
├── Interfaces
│ ├── IExchangeRateProvider
│ └── IProviderRegistry
└── Business Logic] + + %% Infrastructure Layer + D[Infrastructure Layer
🔧 External Concerns
├── CnbExchangeRateProvider
├── Intelligent Caching
├── Distributed Cache
├── Resilience Policies
└── Service Registration] + + %% External Systems + E[External Systems
🌍 Third-Party Services
├── CNB API
├── Redis Cache
└── Docker Runtime] + + %% Dependencies (Outer layers depend on inner layers) + A -->|depends on| B + B -->|depends on| C + D -->|depends on| C + D -->|integrates with| E + + %% Enhanced Styling - Beautiful, eye-friendly colors + style C fill:#e3f2fd,stroke:#1976d2,stroke-width:4px,color:#0d47a1 + style B fill:#f3e5f5,stroke:#7b1fa2,stroke-width:3px,color:#4a148c + style A fill:#fff8e1,stroke:#f57c00,stroke-width:2px,color:#e65100 + style D fill:#e8f5e8,stroke:#388e3c,stroke-width:2px,color:#1b5e20 + style E fill:#f5f5f5,stroke:#757575,stroke-width:1px,color:#424242 ``` ### Data Flow Diagram @@ -129,13 +108,6 @@ flowchart TD - **Console Layer** (`ExchangeRateProvider.Console`): Command-line interface for testing - **Dependencies**: Application Layer -### Tests Layer -- **Purpose**: Comprehensive testing coverage -- **Components**: - - Unit tests for business logic - - Integration tests for API and external services - - Infrastructure tests for service registration -- **Dependencies**: All layers ## Key Architectural Decisions @@ -214,6 +186,29 @@ flowchart TD 7. **Provider**: If cache miss, fetches data from CNB API 8. **Response**: Data flows back through layers to client +## Production Features + +### Implemented Features +- ✅ **Rate Limiting**: ASP.NET Core Rate Limiting +- ✅ **Health Checks** +- ✅ **Prometheus Metrics**: Request counts, response times, cache hit rates +- ✅ **Swagger/OpenAPI**: Interactive API documentation +- ✅ **Circuit Breaker**: Automatic failure detection and recovery using Polly +- ✅ **Retry Policies**: Exponential backoff for transient failures +- ✅ **Distributed Caching**: Redis support for multi-instance deployments +- ✅ **Docker Support**: Production-ready containerization + +### Security Considerations +- Input validation for currency codes +- Rate limiting to prevent abuse +- Secure defaults for Redis configuration + +### Monitoring and Observability +- Health endpoints for load balancer checks +- Prometheus metrics for alerting +- Structured logging with Serilog +- Request tracing and correlation IDs + ## Deployment Architecture The application supports multiple deployment scenarios: @@ -221,10 +216,3 @@ The application supports multiple deployment scenarios: - **Development**: Local Docker Compose with Redis - **Production**: Containerized deployment with external Redis - **Console**: Standalone executable for batch operations - -## Monitoring and Observability - -- Health checks for CNB API and Redis connectivity -- Prometheus metrics for performance monitoring -- Structured logging with Serilog -- Request tracing and correlation IDs \ No newline at end of file diff --git a/jobs/Backend/Task/CnbExchangeRateProvider/README.md b/jobs/Backend/Task/CnbExchangeRateProvider/README.md index b9f036384..eb81bf540 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider/README.md +++ b/jobs/Backend/Task/CnbExchangeRateProvider/README.md @@ -15,7 +15,7 @@ A .NET 8 service for fetching Czech National Bank (CNB) exchange rates with inte - [Testing](#testing) - [Docker Deployment](#docker-deployment) - [Adding New Providers](#adding-new-providers) -- [Production Features](#production-features) +- [Future Enhancements](#future-enhancements) ## Prerequisites @@ -25,30 +25,7 @@ A .NET 8 service for fetching Czech National Bank (CNB) exchange rates with inte ## Architecture -This project follows **Clean Architecture** principles with clear separation of concerns: - -- **Domain Layer** (`ExchangeRateProvider.Domain`): Core business entities, value objects, and interfaces - - `Currency` and `ExchangeRate` entities with validation - - `IExchangeRateProvider` interface for provider abstraction - - `IProviderRegistry` for managing multiple providers - -- **Application Layer** (`ExchangeRateProvider.Application`): Business logic and use cases - - MediatR-based command/query pattern - - `GetExchangeRatesQuery` and handler for rate retrieval - -- **Infrastructure Layer** (`ExchangeRateProvider.Infrastructure`): External concerns and implementations - - `CnbExchangeRateProvider`: CNB API integration - - `CnbCacheStrategy`: Intelligent caching based on CNB publication schedule - - `DistributedCachingExchangeRateProvider`: Redis-based caching decorator - - Polly policies for resilience (retry, circuit breaker) - -- **API Layer** (`ExchangeRateProvider.Api`): RESTful web API - - ASP.NET Core controllers with validation - - Swagger/OpenAPI documentation - - Health checks and Prometheus metrics - -- **Console Layer** (`ExchangeRateProvider.Console`): Command-line interface - - Simple console app for testing and batch operations +This project follows **Clean Architecture** principles with clear separation of concerns. For detailed architectural diagrams, layer descriptions, and design decisions, refer to [`Architecture.md`](Architecture.md). ## Features @@ -318,27 +295,29 @@ services.AddScoped(provider => }); ``` -## Production Features - -### Implemented Features -- ✅ **Rate Limiting**: 100 requests/minute per IP using ASP.NET Core Rate Limiting -- ✅ **Health Checks**: CNB API and Redis connectivity monitoring -- ✅ **Prometheus Metrics**: Request counts, response times, cache hit rates -- ✅ **Structured Logging**: Request/response logging with correlation IDs -- ✅ **Swagger/OpenAPI**: Interactive API documentation -- ✅ **Circuit Breaker**: Automatic failure detection and recovery using Polly -- ✅ **Retry Policies**: Exponential backoff for transient failures -- ✅ **Distributed Caching**: Redis support for multi-instance deployments -- ✅ **Docker Support**: Production-ready containerization - -### Security Considerations -- Input validation for currency codes -- Rate limiting to prevent abuse -- HTTPS redirection in production -- Secure defaults for Redis configuration - -### Monitoring and Observability -- Health endpoints for load balancer checks -- Prometheus metrics for alerting -- Structured logging for debugging -- Request tracing and correlation \ No newline at end of file + +## Future Enhancements If we wish to consider + +### Phase 1: Production Readiness + +- **API Versioning**: Add version headers for backward compatibility +- **Basic Authentication**: Implement API key authentication for production use +- **Database Storage**: Add PostgreSQL for caching historical rates +- **Health Checks**: Enhanced monitoring endpoints for production deployment +- **Configuration Management**: Environment-specific configuration handling + +### Phase 2: Enhanced Features + +- **Multiple Providers** +- **Rate History**: Store and retrieve historical exchange rate data +- **Bulk Operations**: Support for converting multiple currency pairs at once +- **WebSocket Updates**: Real-time rate change notifications +- **Better Caching**: Improved cache invalidation and refresh strategies + +### Phase 3: Scale & Performance + +- **Horizontal Scaling**: Support for multiple API instances with load balancing +- **Performance Monitoring**: APM integration and detailed performance metrics +- **CDN Integration**: Global distribution for better response times +- **Advanced Security**: OAuth2 integration and enhanced security measures +- **Analytics Dashboard**: Usage statistics and business intelligence features