From 0619cfd33694765daa055eff3662a713bfefad7d Mon Sep 17 00:00:00 2001 From: Mariia Khmaruk Date: Thu, 12 Feb 2026 12:56:30 +0100 Subject: [PATCH 1/2] feat: add exchange rates integration with cnb --- 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 | 16 +- jobs/Backend/Task/Program.cs | 43 --- jobs/Backend/Task/README.md | 94 +++++++ jobs/Backend/Task/nuget.config | 7 + .../Extensions/ServiceCollectionExtensions.cs | 30 +++ .../Interfaces/IExchangeRateProvider.cs | 11 + .../Services/ExchangeRateProvider.cs | 50 ++++ .../Services/ExchangeRateProviderDecorator.cs | 77 ++++++ .../Domain/Common/CurrencyErrors.cs | 16 ++ .../Domain/Common/Error.cs | 9 + .../Domain/Common/ErrorCodes.cs | 19 ++ .../Domain/Common/ErrorType.cs | 12 + .../Domain/Common/ExchangeRateErrors.cs | 39 +++ .../Domain/Common/Result.cs | 35 +++ .../Domain/Currency.cs | 38 +++ .../Domain/ExchangeRate.cs | 68 +++++ .../ExchangeRateUpdater.Api.csproj | 18 ++ .../Extensions/ServiceCollectionExtensions.cs | 37 +++ .../Interfaces/IExternalExchangeRateClient.cs | 11 + .../Models/ExternalExchangeRate.cs | 14 + .../Providers/Cnb/CnbApiClient.cs | 62 +++++ .../Providers/Cnb/CnbApiOptions.cs | 17 ++ .../Providers/Cnb/Interfaces/ICnbApiClient.cs | 11 + .../Providers/Cnb/Models/CnbExRateDaily.cs | 10 + .../Cnb/Models/CnbExRateDailyResponse.cs | 3 + .../Requests/GetExchangeRateByCodeRequest.cs | 24 ++ .../Requests/GetExchangeRatesRequest.cs | 21 ++ .../Contracts/Responses/ApiResponse.cs | 28 ++ .../Responses/ExchangeRateResponse.cs | 8 + .../Controllers/ApiControllerBase.cs | 25 ++ .../Controllers/ExchangeRatesController.cs | 113 ++++++++ .../Extensions/OpenApiExtensions.cs | 32 +++ .../Extensions/ServiceCollectionExtensions.cs | 47 ++++ .../Presentation/Options/OpenApiOptions.cs | 17 ++ .../Validators/DateFormatAttribute.cs | 22 ++ .../src/ExchangeRateUpdater.Api/Program.cs | 23 ++ .../Properties/launchSettings.json | 23 ++ .../appsettings.Development.json | 11 + .../ExchangeRateUpdater.Api/appsettings.json | 21 ++ .../CachedExchangeRateProviderTests.cs | 119 ++++++++ .../Services/ExchangeRateProviderTests.cs | 128 +++++++++ .../Domain/Common/ResultTests.cs | 54 ++++ .../Domain/CurrencyTests.cs | 118 ++++++++ .../Domain/ExchangeRateTests.cs | 76 ++++++ .../ExchangeRateUpdater.Api.Tests.csproj | 28 ++ .../Helpers/FakeHttpMessageHandler.cs | 16 ++ .../CnbApi/CnbApiClientTests.cs | 122 +++++++++ .../ExchangeRatesControllerTests.cs | 254 ++++++++++++++++++ .../GetExchangeRatesRequestValidatorTests.cs | 82 ++++++ 53 files changed, 2111 insertions(+), 118 deletions(-) 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/Program.cs create mode 100644 jobs/Backend/Task/README.md create mode 100644 jobs/Backend/Task/nuget.config create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Options/OpenApiOptions.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Validators/DateFormatAttribute.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.Development.json create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/CachedExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/Common/ResultTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/CurrencyTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/ExchangeRateTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/ExchangeRateUpdater.Api.Tests.csproj create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Helpers/FakeHttpMessageHandler.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Infrastructure/CnbApi/CnbApiClientTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Controllers/ExchangeRatesControllerTests.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Validators/GetExchangeRatesRequestValidatorTests.cs diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12b..0000000000 --- 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 index 89be84daff..e83f998f88 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,9 @@ 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}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "src\ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{24FD0841-D796-4953-A583-773A6FD11A01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api.Tests", "tests\ExchangeRateUpdater.Api.Tests\ExchangeRateUpdater.Api.Tests.csproj", "{128355E8-210A-4942-92F9-7AC7EEE07118}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +13,14 @@ Global 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 + {24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.Build.0 = Release|Any CPU + {128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- 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(); - } - } -} diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 0000000000..45fad3e10f --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,94 @@ +# Exchange Rate API + +A REST API service that provides daily currency exchange rates from the Czech National Bank. Built with clean architecture principles, the service fetches, caches, and serves exchange rate data with support for filtering by currency and date. + +**Purpose:** Simplify currency conversion by providing a reliable, fast, and well-documented API for accessing real-time and historical exchange rates against the Czech Koruna (CZK). + +## Tech Stack + +- **.NET 10.0** - Latest cross-platform framework +- **ASP.NET Core** - Web API +- **FluentValidation** - Request validation +- **Microsoft.Extensions.Http.Resilience** - HTTP resilience (retry, circuit breaker, timeout) +- **Scalar** - Interactive API documentation +- **xUnit v3** - Unit testing +- **NSubstitute** - Mocking + +## Quick Start + +### Prerequisites + +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download) + +### Run the API + +```bash +cd jobs/Backend/Task +dotnet run --project src/ExchangeRateUpdater.Api +``` + +Access the API at: +- **API Docs**: http://localhost:5213/scalar/v1 +- **Swagger**: http://localhost:5213/openapi/v1.json + +### Run Tests + +```bash +dotnet test +``` + +All tests should pass. + +### Response Format + +**Success (200 OK):** +```json +{ + "data": [ + { + "sourceCurrency": "USD", + "targetCurrency": "CZK", + "rate": 23.45 + } + ] +} +``` + +**Error (400/404/502):** +```json +{ + "errorCode": "ExchangeRate.CurrencyNotFound", + "errorMessage": "Exchange rate for currency 'XYZ' was not found in the source data." +} +``` + +## Architecture + +Clean architecture with 4 layers: + +``` +Domain/ # Entities, value objects (Currency, ExchangeRate) +Application/ # Business logic, caching decorator +Infrastructure/ # External APIs, HTTP clients, CNB provider +Presentation/ # Controllers, validation, API responses +``` + +## Adding More Providers + +To add a new exchange rate provider (e.g., ECB, Bank of England): + +1. **Create provider folder:** `Infrastructure/Providers/Ecb/` +2. **Implement HTTP client:** Create `EcbApiClient` with provider-specific logic +3. **Create adapter:** Implement `IExternalExchangeRateClient` to map to common format +4. **Register in DI:** Add to `Infrastructure/Extensions/ServiceCollectionExtensions.cs` +5. **Configure:** Add provider settings to `appsettings.json` + +**Run with hot reload:** +```bash +dotnet watch --project src/ExchangeRateUpdater.Api +``` + +**Test with coverage:** +```bash +dotnet test --collect:"XPlat Code Coverage" +``` diff --git a/jobs/Backend/Task/nuget.config b/jobs/Backend/Task/nuget.config new file mode 100644 index 0000000000..4d736c19ec --- /dev/null +++ b/jobs/Backend/Task/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..71f076b010 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Application.Services; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Api.Application.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + + services.AddScoped(sp => + { + var inner = sp.GetRequiredService(); + var cache = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var options = sp.GetRequiredService>().Value; + + return new ExchangeRateProviderDecorator( + inner, cache, logger, + TimeSpan.FromMinutes(options.CacheDurationMinutes), + TimeSpan.FromMinutes(options.HistoricalCacheDurationMinutes)); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..95a47593f2 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,11 @@ +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; + +namespace ExchangeRateUpdater.Api.Application.Interfaces; + +public interface IExchangeRateProvider +{ + Task>> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..a44eb5a073 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProvider.cs @@ -0,0 +1,50 @@ +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Infrastructure.Interfaces; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +namespace ExchangeRateUpdater.Api.Application.Services; + +public sealed class ExchangeRateProvider( + IExternalExchangeRateClient externalClient, + ILogger logger) : IExchangeRateProvider +{ + private const string TargetCurrencyCode = "CZK"; + + public async Task>> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default) + { + var externalResult = await externalClient.GetDailyRatesAsync(date, cancellationToken); + + if (externalResult.IsFailure) + return Result>.Failure(externalResult.Error); + + var domainRates = new List(); + + foreach (var external in externalResult.Value.Rates) + { + var result = MapToDomainModel(external); + if (result.IsFailure) + return Result>.Failure(result.Error); + + domainRates.Add(result.Value); + } + + logger.LogInformation( + "Mapped {Count} exchange rates for date {Date}", + domainRates.Count, + date?.ToString("yyyy-MM-dd") ?? "latest"); + + return Result>.Success(domainRates.ToArray()); + } + + private static Result MapToDomainModel(CnbExRateDaily external) => + ExchangeRate.Create( + external.CurrencyCode, + TargetCurrencyCode, + external.Rate, + external.Amount, + DateOnly.Parse(external.ValidFor)); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs new file mode 100644 index 0000000000..56fa0797ad --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Application/Services/ExchangeRateProviderDecorator.cs @@ -0,0 +1,77 @@ +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; +using Microsoft.Extensions.Caching.Memory; + +namespace ExchangeRateUpdater.Api.Application.Services; + +public sealed class ExchangeRateProviderDecorator( + IExchangeRateProvider inner, + IMemoryCache cache, + ILogger logger, + TimeSpan cacheDuration, + TimeSpan historicalCacheDuration) : IExchangeRateProvider +{ + private const string CacheKeyPrefix = "exchange-rates"; + private static readonly string LatestCacheKey = BuildCacheKey(null); + + private DateOnly _lastCachedUtcDate = DateOnly.FromDateTime(DateTime.UtcNow); + private readonly object _dateLock = new(); + + public async Task>> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default) + { + EvictLatestOnDayRollover(); + + var cacheKey = BuildCacheKey(date); + + if (cache.TryGetValue(cacheKey, out Result>? cached) && cached is not null) + { + logger.LogDebug("Cache hit for exchange rates with key {CacheKey}", cacheKey); + return cached; + } + + logger.LogDebug("Cache miss for exchange rates with key {CacheKey}", cacheKey); + + var result = await inner.GetDailyRatesAsync(date, cancellationToken); + + if (result.IsSuccess) + { + var ttl = IsHistoricalDate(date) ? historicalCacheDuration : cacheDuration; + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }; + + cache.Set(cacheKey, result, cacheOptions); + logger.LogDebug("Cached exchange rates with key {CacheKey} for {Duration}", cacheKey, ttl); + } + + return result; + } + + private static bool IsHistoricalDate(DateOnly? date) => + date.HasValue && date.Value < DateOnly.FromDateTime(DateTime.UtcNow); + + private void EvictLatestOnDayRollover() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + lock (_dateLock) + { + if (today <= _lastCachedUtcDate) + return; + + _lastCachedUtcDate = today; + } + + cache.Remove(LatestCacheKey); + + logger.LogInformation( + "New UTC day {Today} detected — evicted latest exchange rates cache", today); + } + + private static string BuildCacheKey(DateOnly? date) => + $"{CacheKeyPrefix}-{date?.ToString("yyyy-MM-dd") ?? "latest"}"; +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs new file mode 100644 index 0000000000..134f910a93 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/CurrencyErrors.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +public static class CurrencyErrors +{ + public static Error NullOrWhitespace() => + new(ErrorCodes.ValidationCurrencyCodeNullOrWhitespace, + "Currency code is missing."); + + public static Error InvalidLength(string code) => + new(ErrorCodes.ValidationCurrencyCodeInvalidLength, + $"Currency code must be exactly 3 characters. Got '{code}' with length {code.Length}."); + + public static Error InvalidCharacters(string code) => + new(ErrorCodes.ValidationCurrencyCodeInvalidCharacters, + $"Currency code must contain only letters. Got '{code}'."); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs new file mode 100644 index 0000000000..524bdf8fa0 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Error.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +/// +/// Represents a domain error with areadable error code and message. +/// +public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure) +{ + public static readonly Error None = new(string.Empty, string.Empty); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs new file mode 100644 index 0000000000..f44b7f3a8a --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorCodes.cs @@ -0,0 +1,19 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +public static class ErrorCodes +{ + // Validation Errors + public const string ValidationFailed = "Validation.Failed"; + public const string ValidationInvalidCurrencyCode = "Validation.InvalidCurrencyCode"; + public const string ValidationInvalidDate = "Validation.InvalidDate"; + public const string ValidationInvalidRate = "Validation.InvalidRate"; + public const string ValidationInvalidAmount = "Validation.InvalidAmount"; + public const string ValidationCurrencyCodeNullOrWhitespace = "Validation.CurrencyCodeNullOrWhitespace"; + public const string ValidationCurrencyCodeInvalidLength = "Validation.CurrencyCodeInvalidLength"; + public const string ValidationCurrencyCodeInvalidCharacters = "Validation.CurrencyCodeInvalidCharacters"; + + // Exchange Rate Errors + public const string ExchangeRateCurrencyNotFound = "ExchangeRate.CurrencyNotFound"; + public const string ExchangeRateSourceUnavailable = "ExchangeRate.SourceUnavailable"; + public const string ExchangeRateNoRatesAvailable = "ExchangeRate.NoRatesAvailable"; +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs new file mode 100644 index 0000000000..d4149b5b08 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ErrorType.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +/// +/// Classifies the kind of error for proper HTTP status code mapping. +/// +public enum ErrorType +{ + Failure, + Validation, + NotFound, + Unavailable +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs new file mode 100644 index 0000000000..cd4d4d6a85 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/ExchangeRateErrors.cs @@ -0,0 +1,39 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +public static class ExchangeRateErrors +{ + public static Error CurrencyNotFound(string currencyCode) => + new(ErrorCodes.ExchangeRateCurrencyNotFound, + $"Exchange rate for currency '{currencyCode}' was not found in the source data.", + ErrorType.NotFound); + + public static readonly Error SourceUnavailable = + new(ErrorCodes.ExchangeRateSourceUnavailable, + "The exchange rate data source is currently unavailable. Please try again later.", + ErrorType.Unavailable); + + public static Error InvalidCurrencyCode(string code) => + new(ErrorCodes.ValidationInvalidCurrencyCode, + $"'{code}' is not a valid ISO 4217 currency code. Currency codes must be exactly 3 letters.", + ErrorType.Validation); + + public static Error InvalidDate(string date) => + new(ErrorCodes.ValidationInvalidDate, + $"'{date}' is not a valid date. Expected format: yyyy-MM-dd.", + ErrorType.Validation); + + public static Error InvalidRate(decimal rate) => + new(ErrorCodes.ValidationInvalidRate, + $"Exchange rate '{rate}' is invalid. Rate must be non-negative.", + ErrorType.Validation); + + public static Error InvalidAmount(int amount) => + new(ErrorCodes.ValidationInvalidAmount, + $"Amount '{amount}' is invalid. Amount must be a positive integer.", + ErrorType.Validation); + + public static readonly Error NoRatesAvailable = + new(ErrorCodes.ExchangeRateNoRatesAvailable, + "No exchange rates are available for the specified criteria.", + ErrorType.NotFound); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs new file mode 100644 index 0000000000..d38125b1d0 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Common/Result.cs @@ -0,0 +1,35 @@ +namespace ExchangeRateUpdater.Api.Domain.Common; + +public sealed class Result +{ + private readonly TValue? _value; + private readonly Error? _error; + + private Result(TValue value) + { + _value = value; + IsSuccess = true; + } + + private Result(Error error) + { + _error = error; + IsSuccess = false; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Cannot access Value on a failed result. Check IsSuccess first."); + + public Error Error => IsFailure + ? _error! + : throw new InvalidOperationException("Cannot access Error on a successful result."); + + public static Result Success(TValue value) => new(value); + + public static Result Failure(Error error) => new(error); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs new file mode 100644 index 0000000000..006f8d8598 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/Currency.cs @@ -0,0 +1,38 @@ +namespace ExchangeRateUpdater.Api.Domain; + +using Common; + +/// +/// Represents a currency identified by its ISO 4217 three-letter code. +/// +public sealed record Currency +{ + private Currency(string code) + { + Code = code.ToUpperInvariant(); + } + + /// + /// Three-letter ISO 4217 currency code. + /// + public string Code { get; } + + /// + /// Creates a new Currency instance with validation. + /// + public static Result Create(string code) + { + if (string.IsNullOrWhiteSpace(code)) + return Result.Failure(CurrencyErrors.NullOrWhitespace()); + + if (code.Length != 3) + return Result.Failure(CurrencyErrors.InvalidLength(code)); + + if (!code.All(char.IsLetter)) + return Result.Failure(CurrencyErrors.InvalidCharacters(code)); + + return Result.Success(new Currency(code)); + } + + public override string ToString() => Code; +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs new file mode 100644 index 0000000000..a33c201ba9 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Domain/ExchangeRate.cs @@ -0,0 +1,68 @@ +using ExchangeRateUpdater.Api.Domain.Common; + +namespace ExchangeRateUpdater.Api.Domain; + +/// +/// Represents an exchange rate between two currencies. +/// The rate expresses how many units of the target currency equal one unit of the source currency. +/// +public sealed record ExchangeRate +{ + private ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal rate, int amount, DateOnly validFor) + { + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Rate = rate; + Amount = amount; + ValidFor = validFor; + } + + /// + /// The currency being converted from. + /// + public Currency SourceCurrency { get; } + + /// + /// The currency being converted to (always CZK for CNB rates). + /// + public Currency TargetCurrency { get; } + + /// + /// The exchange rate value: 1 unit of SourceCurrency equals this many units of TargetCurrency. + /// + public decimal Rate { get; } + + /// + /// The number of units of the source currency the rate applies to. + /// + public int Amount { get; } + + /// + /// The date for which this exchange rate is valid. + /// + public DateOnly ValidFor { get; } + + /// + /// Creates a new exchange rate with validation. + /// + public static Result Create(string sourceCurrencyCode, string targetCurrencyCode, decimal rate, int amount, DateOnly validFor) + { + var sourceResult = Currency.Create(sourceCurrencyCode); + if (!sourceResult.IsSuccess) + return Result.Failure(sourceResult.Error); + + var targetResult = Currency.Create(targetCurrencyCode); + if (!targetResult.IsSuccess) + return Result.Failure(targetResult.Error); + + if (rate < 0) + return Result.Failure(ExchangeRateErrors.InvalidRate(rate)); + + if (amount <= 0) + return Result.Failure(ExchangeRateErrors.InvalidAmount(amount)); + + return Result.Success(new ExchangeRate(sourceResult.Value, targetResult.Value, rate, amount, validFor)); + } + + public override string ToString() => FormattableString.Invariant($"{SourceCurrency}/{TargetCurrency}={Rate}"); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..261c5b3572 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..8372326a69 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using System.Net.Mime; +using ExchangeRateUpdater.Api.Infrastructure.Interfaces; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Api.Infrastructure.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure( + configuration.GetSection(CnbApiOptions.SectionName)); + + services.AddMemoryCache(); + services.AddCnbExchangeRateProvider(); + + return services; + } + + private static IServiceCollection AddCnbExchangeRateProvider(this IServiceCollection services) + { + services + .AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + }) + .AddStandardResilienceHandler(); + + return services; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs new file mode 100644 index 0000000000..411cbfa195 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Interfaces/IExternalExchangeRateClient.cs @@ -0,0 +1,11 @@ +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +namespace ExchangeRateUpdater.Api.Infrastructure.Interfaces; + +public interface IExternalExchangeRateClient +{ + Task> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs new file mode 100644 index 0000000000..79c5f46153 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Models/ExternalExchangeRate.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Api.Infrastructure.Models; + +/// +/// Represents a raw exchange rate record from an external data source. +/// +/// ISO 4217 currency code of the source currency. +/// ISO 4217 currency code of the target currency. +/// Exchange rate value. +/// The amount of source currency units the rate applies to (for normalization). +public sealed record ExternalExchangeRate( + string SourceCurrencyCode, + string TargetCurrencyCode, + decimal Rate, + int Amount = 1); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs new file mode 100644 index 0000000000..76e9c957c0 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiClient.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Infrastructure.Interfaces; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb; + +public sealed class CnbApiClient(HttpClient httpClient, ILogger logger) : IExternalExchangeRateClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default) + { + try + { + var url = BuildUrl(date); + logger.LogInformation("Fetching daily exchange rates from CNB API: {Url}", url); + + var response = await httpClient.GetFromJsonAsync( + url, JsonOptions, cancellationToken); + + if (response is null or { Rates.Length: 0 }) + { + logger.LogWarning("CNB API returned empty rates for date {Date}", date); + return Result.Failure(ExchangeRateErrors.NoRatesAvailable); + } + + logger.LogInformation("Successfully fetched {Count} raw rates from CNB API", response.Rates.Length); + return Result.Success(response); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "HTTP error while fetching exchange rates from CNB API"); + return Result.Failure(ExchangeRateErrors.SourceUnavailable); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, "Timeout while fetching exchange rates from CNB API"); + return Result.Failure(ExchangeRateErrors.SourceUnavailable); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to deserialize CNB API response"); + return Result.Failure(ExchangeRateErrors.SourceUnavailable); + } + catch (Exception ex) + { + logger.LogError(ex, "Something went wrong during CNB API call"); + return Result.Failure(ExchangeRateErrors.SourceUnavailable); + } + } + + private static string BuildUrl(DateOnly? date) + { + var url = "/cnbapi/exrates/daily?lang=EN"; + if (date.HasValue) + url += $"&date={date.Value:yyyy-MM-dd}"; + return url; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs new file mode 100644 index 0000000000..79d4394b36 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/CnbApiOptions.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb; + +public sealed class CnbApiOptions +{ + public const string SectionName = "CnbApi"; + + [Required] + public required string BaseUrl { get; set; } + + public string Language { get; set; } = "EN"; + + public int CacheDurationMinutes { get; set; } = 60; + + public int HistoricalCacheDurationMinutes { get; set; } = 1440; +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs new file mode 100644 index 0000000000..f8711d9661 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Interfaces/ICnbApiClient.cs @@ -0,0 +1,11 @@ +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Interfaces; + +public interface ICnbApiClient +{ + Task> GetDailyRatesAsync( + DateOnly? date = null, + CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs new file mode 100644 index 0000000000..b65ab06e94 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDaily.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +public sealed record CnbExRateDaily( + string ValidFor, + int Order, + string Country, + string Currency, + int Amount, + string CurrencyCode, + decimal Rate); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs new file mode 100644 index 0000000000..4cd07d4127 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Infrastructure/Providers/Cnb/Models/CnbExRateDailyResponse.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; + +public sealed record CnbExRateDailyResponse(CnbExRateDaily[] Rates); \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs new file mode 100644 index 0000000000..854ae2a7f4 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRateByCodeRequest.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using ExchangeRateUpdater.Api.Presentation.Validators; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Api.Presentation.Contracts.Requests; + +public sealed class GetExchangeRateByCodeRequest +{ + /// + /// ISO 4217 three-letter currency code (e.g. USD, EUR, GBP). + /// + [FromRoute(Name = "currencyCode")] + [Required(ErrorMessage = "Currency code is required.")] + [RegularExpression(@"^[a-zA-Z]{3}$", + ErrorMessage = "Currency code must be exactly 3 letters.")] + public string CurrencyCode { get; init; } = string.Empty; + + /// + /// Optional date in yyyy-MM-dd format. Defaults to the latest available rates. + /// + [FromQuery] + [DateFormat] + public string? Date { get; init; } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs new file mode 100644 index 0000000000..0a84ea3ea4 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Requests/GetExchangeRatesRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using ExchangeRateUpdater.Api.Presentation.Validators; + +namespace ExchangeRateUpdater.Api.Presentation.Contracts.Requests; + +public sealed class GetExchangeRatesRequest +{ + /// + /// Optional comma-separated list of ISO 4217 three-letter currency codes to filter by. + /// Example: USD,EUR,GBP + /// + [RegularExpression(@"^[a-zA-Z]{3}(,[a-zA-Z]{3})*$", + ErrorMessage = "Currencies must be a comma-separated list of 3-letter ISO 4217 codes (e.g. USD,EUR,GBP).")] + public string? Currencies { get; init; } + + /// + /// Optional date in yyyy-MM-dd format. Defaults to the latest available rates. + /// + [DateFormat] + public string? Date { get; init; } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs new file mode 100644 index 0000000000..7189ced3dc --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ApiResponse.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Api.Presentation.Contracts.Responses; + +/// +/// Standard API response wrapper that contains either data (success) or error information (failure). +/// +public sealed class ApiResponse +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public T? Data { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorCode { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorMessage { get; init; } + + [JsonIgnore] + public bool IsSuccess => ErrorCode is null; + + private ApiResponse() { } + + public static ApiResponse Success(T data) => new() { Data = data }; + + public static ApiResponse Failure(string errorCode, string errorMessage) => + new() { ErrorCode = errorCode, ErrorMessage = errorMessage }; +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs new file mode 100644 index 0000000000..20e978d19b --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Contracts/Responses/ExchangeRateResponse.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Api.Presentation.Contracts.Responses; + +public sealed record ExchangeRateResponse( + string SourceCurrency, + string TargetCurrency, + decimal Rate, + int Amount, + string ValidFor); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000000..60c046f19b --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ApiControllerBase.cs @@ -0,0 +1,25 @@ +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Presentation.Contracts.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Api.Presentation.Controllers; + +/// +/// Base controller providing shared error-to-action-result mapping using the ApiResponse pattern. +/// +[ApiController] +public abstract class ApiControllerBase : ControllerBase +{ + protected IActionResult ToErrorResponse(Error error) + { + var response = ApiResponse.Failure(error.Code, error.Message); + + return error.Type switch + { + ErrorType.Validation => BadRequest(response), + ErrorType.NotFound => NotFound(response), + ErrorType.Unavailable => StatusCode(StatusCodes.Status502BadGateway, response), + _ => StatusCode(StatusCodes.Status500InternalServerError, response) + }; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..169fcc6b01 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Controllers/ExchangeRatesController.cs @@ -0,0 +1,113 @@ +using System.Net.Mime; +using Asp.Versioning; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Presentation.Contracts.Requests; +using ExchangeRateUpdater.Api.Presentation.Contracts.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Api.Presentation.Controllers; + +/// +/// API controller for retrieving currency exchange rates from external data providers. +/// +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/exchange-rates")] +[Produces(MediaTypeNames.Application.Json)] +public class ExchangeRatesController(IExchangeRateProvider provider) : ApiControllerBase +{ + /// + /// Get exchange rates + /// + /// + /// Returns daily exchange rates from the configured data source. + /// Optionally filter by comma-separated currency codes and/or a specific date. + /// + [HttpGet(Name = "GetExchangeRates")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status502BadGateway)] + public async Task GetExchangeRates( + [FromQuery] GetExchangeRatesRequest request, + CancellationToken cancellationToken) + { + var dateResult = ParseDate(request.Date); + var currencyCodes = ParseCurrencyCodes(request.Currencies); + + var result = await provider.GetDailyRatesAsync(dateResult, cancellationToken); + + if (result.IsFailure) + return ToErrorResponse>(result.Error); + + var rates = result.Value; + var filtered = currencyCodes.Length > 0 + ? rates.Where(r => currencyCodes.Contains(r.SourceCurrency.Code, StringComparer.OrdinalIgnoreCase)) + : rates; + + var response = filtered.Select(r => new ExchangeRateResponse( + r.SourceCurrency.Code, + r.TargetCurrency.Code, + r.Rate, + r.Amount, + r.ValidFor.ToString("yyyy-MM-dd"))).ToArray(); + + if (currencyCodes.Length > 0 && response.Length == 0) + { + var requestedCurrencies = string.Join(", ", currencyCodes); + + return ToErrorResponse>( + ExchangeRateErrors.CurrencyNotFound(requestedCurrencies)); + } + + return Ok(ApiResponse>.Success(response)); + } + + /// + /// Get exchange rate by currency code + /// + /// + /// Returns the daily exchange rate for a specific ISO 4217 currency codefrom the configured data source. + /// + [HttpGet("{currencyCode}", Name = "GetExchangeRateByCode")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status502BadGateway)] + public async Task GetExchangeRate( + GetExchangeRateByCodeRequest request, + CancellationToken cancellationToken) + { + var dateResult = ParseDate(request.Date); + + var result = await provider.GetDailyRatesAsync(dateResult, cancellationToken); + + if (result.IsFailure) + return ToErrorResponse(result.Error); + + var rate = result.Value.FirstOrDefault(r => + r.SourceCurrency.Code.Equals(request.CurrencyCode, StringComparison.OrdinalIgnoreCase)); + + if (rate is null) + return ToErrorResponse(ExchangeRateErrors.CurrencyNotFound(request.CurrencyCode)); + + var response = new ExchangeRateResponse( + rate.SourceCurrency.Code, + rate.TargetCurrency.Code, + rate.Rate, + rate.Amount, + rate.ValidFor.ToString("yyyy-MM-dd")); + + return Ok(ApiResponse.Success(response)); + } + + private static string[] ParseCurrencyCodes(string? currencies) => + string.IsNullOrWhiteSpace(currencies) + ? [] + : currencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static DateOnly? ParseDate(string? date) => + string.IsNullOrWhiteSpace(date) + ? null + : DateOnly.ParseExact(date, "yyyy-MM-dd"); +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs new file mode 100644 index 0000000000..f3e39659ae --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/OpenApiExtensions.cs @@ -0,0 +1,32 @@ +using ExchangeRateUpdater.Api.Presentation.Options; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Api.Presentation.Extensions; + +public static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApiDocumentation( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure( + configuration.GetSection(OpenApiOptions.SectionName)); + + services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, context, _) => + { + var openApiOptions = context.ApplicationServices + .GetRequiredService>().Value; + + document.Info.Title = openApiOptions.Title; + document.Info.Version = openApiOptions.Version; + document.Info.Description = openApiOptions.Description; + + return Task.CompletedTask; + }); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..c2c0803a64 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Asp.Versioning; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Presentation.Contracts.Responses; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Api.Presentation.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddPresentation(this IServiceCollection services) + { + services + .AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + services + .AddControllers() + .ConfigureApiBehaviorOptions(options => + { + options.InvalidModelStateResponseFactory = context => + { + var errors = context.ModelState + .Where(e => e.Value?.Errors.Count > 0) + .SelectMany(e => e.Value!.Errors.Select(err => err.ErrorMessage)) + .ToArray(); + + var response = ApiResponse.Failure( + ErrorCodes.ValidationFailed, + string.Join("; ", errors)); + + return new BadRequestObjectResult(response); + }; + }); + + return services; + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Options/OpenApiOptions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Options/OpenApiOptions.cs new file mode 100644 index 0000000000..1c697c2e9f --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Options/OpenApiOptions.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Api.Presentation.Options; + +public sealed class OpenApiOptions +{ + public const string SectionName = "OpenApi"; + + [Required] + public required string Title { get; set; } + + [Required] + public required string Version { get; set; } + + [Required] + public required string Description { get; set; } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Validators/DateFormatAttribute.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Validators/DateFormatAttribute.cs new file mode 100644 index 0000000000..2a28087061 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Presentation/Validators/DateFormatAttribute.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Api.Presentation.Validators; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class DateFormatAttribute : ValidationAttribute +{ + private const string ExpectedFormat = "yyyy-MM-dd"; + + public DateFormatAttribute() + : base("Date must be in yyyy-MM-dd format and represent a valid date.") + { + } + + public override bool IsValid(object? value) + { + if (value is not string dateString || string.IsNullOrWhiteSpace(dateString)) + return true; + + return DateOnly.TryParseExact(dateString, ExpectedFormat, out _); + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..67e37eb2e3 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,23 @@ +using ExchangeRateUpdater.Api.Application.Extensions; +using ExchangeRateUpdater.Api.Infrastructure.Extensions; +using ExchangeRateUpdater.Api.Presentation.Extensions; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApiDocumentation(builder.Configuration); +builder.Services.AddPresentation(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +var app = builder.Build(); + +app.MapOpenApi(); +app.MapScalarApiReference(); + +app.MapGet("/", () => Results.Redirect("/scalar/v1")) + .ExcludeFromDescription(); + +app.MapControllers(); + +app.Run(); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..a3edc8e090 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7074;http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..aaf498947d --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "CnbApi": { + "CacheDurationMinutes": 5 + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..00929cf384 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,21 @@ +{ + "AllowedHosts": "*", + "CnbApi": { + "BaseUrl": "https://api.cnb.cz", + "Language": "EN", + "CacheDurationMinutes": 10, + "HistoricalCacheDurationMinutes": 1440 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http": "Warning" + } + }, + "OpenApi": { + "Title": "Exchange Rate API", + "Version": "v1", + "Description": "API for retrieving daily exchange rates published by the Czech National Bank (CNB). Rates express how many CZK equal a given amount of a foreign currency." + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/CachedExchangeRateProviderTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/CachedExchangeRateProviderTests.cs new file mode 100644 index 0000000000..84e17b91c3 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/CachedExchangeRateProviderTests.cs @@ -0,0 +1,119 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Application.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Api.Tests.Application.Services; + +public class CachedExchangeRateProviderTests +{ + private readonly IExchangeRateProvider _inner; + private readonly ExchangeRateProviderDecorator _cachedExchangeRateProvider; + + public CachedExchangeRateProviderTests() + { + var fixture = new Fixture().Customize(new AutoNSubstituteCustomization()); + _inner = fixture.Freeze(); + IMemoryCache cache = new MemoryCache(new MemoryCacheOptions()); + var logger = fixture.Freeze>(); + _cachedExchangeRateProvider = new ExchangeRateProviderDecorator(_inner, cache, logger, TimeSpan.FromMinutes(10), TimeSpan.FromDays(1)); + } + + public class GetDailyRatesAsync : CachedExchangeRateProviderTests + { + [Fact] + public async Task GetDailyRatesAsync_OnCacheMiss_CallsInnerProvider() + { + // Arrange + var rates = CreateSuccessResult(); + _inner + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + // Act + var result = await _cachedExchangeRateProvider.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + await _inner.Received(1).GetDailyRatesAsync(null, Arg.Any()); + } + + [Fact] + public async Task GetDailyRatesAsync_OnCacheHit_ReturnsFromCacheWithoutCallingInner() + { + // Arrange + var rates = CreateSuccessResult(); + _inner + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + // Act — first call to populate cache + await _cachedExchangeRateProvider.GetDailyRatesAsync(null, CancellationToken.None); + // Second call should come from cache + var result = await _cachedExchangeRateProvider.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + await _inner.Received(1).GetDailyRatesAsync(null, Arg.Any()); + } + + [Fact] + public async Task GetDailyRatesAsync_WithDifferentDates_UsesSeparateCacheKeys() + { + // Arrange + var rates = CreateSuccessResult(); + _inner + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var date1 = new DateOnly(2025, 6, 1); + var date2 = new DateOnly(2025, 6, 2); + + // Act + await _cachedExchangeRateProvider.GetDailyRatesAsync(date1, CancellationToken.None); + await _cachedExchangeRateProvider.GetDailyRatesAsync(date2, CancellationToken.None); + + // Assert — inner called twice, once for each date + await _inner.Received(1).GetDailyRatesAsync(date1, Arg.Any()); + await _inner.Received(1).GetDailyRatesAsync(date2, Arg.Any()); + } + + [Fact] + public async Task GetDailyRatesAsync_WhenInnerReturnsFailure_DoesNotCacheResult() + { + // Arrange + var error = ExchangeRateErrors.SourceUnavailable; + var failureResult = Result>.Failure(error); + var successResult = CreateSuccessResult(); + + _inner + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(failureResult, successResult); + + // Act + var first = await _cachedExchangeRateProvider.GetDailyRatesAsync(null, CancellationToken.None); + var second = await _cachedExchangeRateProvider.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(first.IsFailure); + Assert.True(second.IsSuccess); + await _inner.Received(2).GetDailyRatesAsync(null, Arg.Any()); + } + + private static Result> CreateSuccessResult() + { + IReadOnlyCollection rates = + [ + ExchangeRate.Create("USD", "CZK", 23.45m, 1, new DateOnly(2025, 6, 15)).Value, + ExchangeRate.Create("EUR", "CZK", 25.10m, 1, new DateOnly(2025, 6, 15)).Value + ]; + + return Result>.Success(rates); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..5a7691fa0e --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Application/Services/ExchangeRateProviderTests.cs @@ -0,0 +1,128 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Application.Services; +using ExchangeRateUpdater.Api.Infrastructure.Interfaces; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Api.Tests.Application.Services; + +public class ExchangeRateProviderTests +{ + private readonly IFixture _fixture; + private readonly IExternalExchangeRateClient _externalClient; + private readonly ExchangeRateProvider _sut; + + public ExchangeRateProviderTests() + { + _fixture = new Fixture().Customize(new AutoNSubstituteCustomization()); + _externalClient = _fixture.Freeze(); + var logger = _fixture.Freeze>(); + _sut = new ExchangeRateProvider(_externalClient, logger); + } + + public class GetDailyRatesAsync : ExchangeRateProviderTests + { + [Fact] + public async Task GetDailyRatesAsync_WithExternalRates_ReturnsMappedDomainModels() + { + // Arrange + var response = new CnbExRateDailyResponse( + [ + new CnbExRateDaily("2025-06-15", 115, "USA", "dollar", 1, "USD", 23.45m), + new CnbExRateDaily("2025-06-15", 115, "EMU", "euro", 1, "EUR", 25.10m) + ]); + + _externalClient + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success(response)); + + // Act + var result = await _sut.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + var rates = result.Value; + Assert.Equal(2, rates.Count); + Assert.All(rates, r => Assert.Equal("CZK", r.TargetCurrency.Code)); + } + + [Fact] + public async Task GetDailyRatesAsync_MapsValidForFromExternalResponse() + { + // Arrange + var response = new CnbExRateDailyResponse( + [ + new CnbExRateDaily("2025-06-15", 115, "USA", "dollar", 1, "USD", 23.45m) + ]); + + _externalClient + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success(response)); + + // Act + var result = await _sut.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + var rate = Assert.Single(result.Value); + Assert.Equal(new DateOnly(2025, 6, 15), rate.ValidFor); + } + + [Fact] + public async Task GetDailyRatesAsync_WithSpecificDate_PassesDateToExternalClient() + { + // Arrange + var date = new DateOnly(2025, 6, 15); + var response = new CnbExRateDailyResponse([]); + + _externalClient + .GetDailyRatesAsync(date, Arg.Any()) + .Returns(Result.Success(response)); + + // Act + await _sut.GetDailyRatesAsync(date, CancellationToken.None); + + // Assert + await _externalClient.Received(1).GetDailyRatesAsync(date, Arg.Any()); + } + + [Fact] + public async Task GetDailyRatesAsync_WithEmptyExternalResponse_ReturnsEmptyCollection() + { + // Arrange + var response = new CnbExRateDailyResponse([]); + + _externalClient + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success(response)); + + // Act + var result = await _sut.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(result.Value); + } + + [Fact] + public async Task GetDailyRatesAsync_WhenExternalClientFails_PropagatesError() + { + // Arrange + var error = ExchangeRateErrors.SourceUnavailable; + + _externalClient + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Failure(error)); + + // Act + var result = await _sut.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(error.Code, result.Error.Code); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/Common/ResultTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/Common/ResultTests.cs new file mode 100644 index 0000000000..997f62d581 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/Common/ResultTests.cs @@ -0,0 +1,54 @@ +using ExchangeRateUpdater.Api.Domain.Common; + +namespace ExchangeRateUpdater.Api.Tests.Domain.Common; + +public class ResultTests +{ + public class SuccessMethod + { + [Fact] + public void Success_CreatesSuccessResult() + { + var result = Result.Success(42); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(42, result.Value); + } + } + + public class FailureMethod + { + [Fact] + public void Failure_CreatesFailureResult() + { + var error = new Error("Test.Error", "Something went wrong", ErrorType.Failure); + + var result = Result.Failure(error); + + Assert.False(result.IsSuccess); + Assert.True(result.IsFailure); + Assert.Equal(error, result.Error); + } + + [Fact] + public void Failure_AccessingValue_ThrowsInvalidOperationException() + { + var error = new Error("Test.Error", "fail", ErrorType.Failure); + var result = Result.Failure(error); + + Assert.Throws(() => result.Value); + } + } + + public class ErrorAccess + { + [Fact] + public void Error_OnSuccessResult_ThrowsInvalidOperationException() + { + var result = Result.Success(1); + + Assert.Throws(() => result.Error); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/CurrencyTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/CurrencyTests.cs new file mode 100644 index 0000000000..8dfbe4e77b --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/CurrencyTests.cs @@ -0,0 +1,118 @@ +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; + +namespace ExchangeRateUpdater.Api.Tests.Domain; + +public class CurrencyTests +{ + public class Create + { + [Fact] + public void Create_WithValidCode_ReturnsSuccessWithCurrency() + { + var result = Currency.Create("USD"); + + Assert.True(result.IsSuccess); + Assert.Equal("USD", result.Value.Code); + } + + [Theory] + [InlineData("usd", "USD")] + [InlineData("eUr", "EUR")] + [InlineData("GbP", "GBP")] + public void Create_WithAnyCase_NormalizesToUppercase(string input, string expected) + { + var result = Currency.Create(input); + + Assert.True(result.IsSuccess); + Assert.Equal(expected, result.Value.Code); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Create_WithEmptyOrWhitespace_ReturnsFailure(string code) + { + var result = Currency.Create(code); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationCurrencyCodeNullOrWhitespace, result.Error.Code); + } + + [Theory] + [InlineData("US")] + [InlineData("USDX")] + [InlineData("A")] + public void Create_WithInvalidLength_ReturnsFailure(string code) + { + var result = Currency.Create(code); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationCurrencyCodeInvalidLength, result.Error.Code); + } + + [Theory] + [InlineData("12A")] + [InlineData("U$D")] + [InlineData("US1")] + public void Create_WithNonAlphaCharacters_ReturnsFailure(string code) + { + var result = Currency.Create(code); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationCurrencyCodeInvalidCharacters, result.Error.Code); + } + } + + public class Equality + { + [Fact] + public void Equals_WithSameCode_ReturnsTrue() + { + var a = Currency.Create("USD").Value; + var b = Currency.Create("USD").Value; + + Assert.Equal(a, b); + Assert.True(a == b); + } + + [Fact] + public void Equals_WithDifferentCase_ReturnsTrue() + { + var a = Currency.Create("usd").Value; + var b = Currency.Create("USD").Value; + + Assert.Equal(a, b); + } + + [Fact] + public void Equals_WithDifferentCode_ReturnsFalse() + { + var a = Currency.Create("USD").Value; + var b = Currency.Create("EUR").Value; + + Assert.NotEqual(a, b); + Assert.True(a != b); + } + + [Fact] + public void GetHashCode_WithEqualCurrencies_ReturnsSameHashCode() + { + var a = Currency.Create("usd").Value; + var b = Currency.Create("USD").Value; + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + } + + public class ToStringMethod + { + [Fact] + public void ReturnsCode() + { + var currency = Currency.Create("CZK").Value; + + Assert.Equal("CZK", currency.ToString()); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/ExchangeRateTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/ExchangeRateTests.cs new file mode 100644 index 0000000000..7d8d66ae96 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Domain/ExchangeRateTests.cs @@ -0,0 +1,76 @@ +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; + +namespace ExchangeRateUpdater.Api.Tests.Domain; + +public class ExchangeRateTests +{ + public class Create + { + private static readonly DateOnly DefaultDate = new(2025, 6, 15); + + [Fact] + public void Create_WithValidArguments_ReturnsSuccessWithExchangeRate() + { + var result = ExchangeRate.Create("USD", "CZK", 23.45m, 1, DefaultDate); + + Assert.True(result.IsSuccess); + Assert.Equal("USD", result.Value.SourceCurrency.Code); + Assert.Equal("CZK", result.Value.TargetCurrency.Code); + Assert.Equal(23.45m, result.Value.Rate); + Assert.Equal(1, result.Value.Amount); + Assert.Equal(DefaultDate, result.Value.ValidFor); + } + + [Fact] + public void Create_WithZeroRate_ReturnsSuccess() + { + var result = ExchangeRate.Create("USD", "CZK", 0m, 1, DefaultDate); + + Assert.True(result.IsSuccess); + Assert.Equal(0m, result.Value.Rate); + } + + [Fact] + public void Create_WithNegativeRate_ReturnsFailure() + { + var result = ExchangeRate.Create("USD", "CZK", -1m, 1, DefaultDate); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationInvalidRate, result.Error.Code); + } + + [Fact] + public void Create_WithInvalidSourceCurrency_ReturnsFailure() + { + var result = ExchangeRate.Create("US", "CZK", 23.45m, 1, DefaultDate); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationCurrencyCodeInvalidLength, result.Error.Code); + } + + [Fact] + public void Create_WithInvalidTargetCurrency_ReturnsFailure() + { + var result = ExchangeRate.Create("USD", "12A", 23.45m, 1, DefaultDate); + + Assert.True(result.IsFailure); + Assert.Equal(ErrorCodes.ValidationCurrencyCodeInvalidCharacters, result.Error.Code); + } + } + + public class ToStringMethod + { + [Fact] + public void ToString_ReturnsFormattedString() + { + var result = ExchangeRate.Create("USD", "CZK", 23.45m, 1, new DateOnly(2025, 6, 15)); + + var str = result.Value.ToString(); + + Assert.Contains("USD", str); + Assert.Contains("CZK", str); + Assert.Contains("23.45", str, StringComparison.InvariantCultureIgnoreCase); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/ExchangeRateUpdater.Api.Tests.csproj b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/ExchangeRateUpdater.Api.Tests.csproj new file mode 100644 index 0000000000..4da6d82417 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/ExchangeRateUpdater.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Helpers/FakeHttpMessageHandler.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Helpers/FakeHttpMessageHandler.cs new file mode 100644 index 0000000000..2ee39553b9 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Helpers/FakeHttpMessageHandler.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace ExchangeRateUpdater.Api.Tests.Helpers; + +public sealed class FakeHttpMessageHandler(HttpStatusCode statusCode, HttpContent? content = null) : HttpMessageHandler +{ + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + var response = new HttpResponseMessage(statusCode) { Content = content }; + return Task.FromResult(response); + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Infrastructure/CnbApi/CnbApiClientTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Infrastructure/CnbApi/CnbApiClientTests.cs new file mode 100644 index 0000000000..973180ac55 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Infrastructure/CnbApi/CnbApiClientTests.cs @@ -0,0 +1,122 @@ +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb; +using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models; +using ExchangeRateUpdater.Api.Tests.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Api.Tests.Infrastructure.CnbApi; + +public class CnbApiClientTests +{ + private readonly ILogger _logger = Substitute.For>(); + + private CnbApiClient CreateClient(HttpStatusCode statusCode, object? responseBody = null) + { + HttpContent? content = responseBody is not null + ? new StringContent(JsonSerializer.Serialize(responseBody), Encoding.UTF8, MediaTypeNames.Application.Json) + : null; + + var handler = new FakeHttpMessageHandler(statusCode, content); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; + + return new CnbApiClient(httpClient, _logger); + } + + private static FakeHttpMessageHandler CreateHandler(HttpStatusCode statusCode, object? responseBody = null) + { + HttpContent? content = responseBody is not null + ? new StringContent(JsonSerializer.Serialize(responseBody), Encoding.UTF8, MediaTypeNames.Application.Json) + : null; + + return new FakeHttpMessageHandler(statusCode, content); + } + + public class GetDailyRatesAsync : CnbApiClientTests + { + [Theory] + [InlineData(null, false)] + [InlineData("2025-03-15", true)] + public async Task GetDailyRatesAsync_BuildsCorrectUrl(string? dateString, bool shouldContainDate) + { + // Arrange + var date = dateString is not null ? DateOnly.Parse(dateString) : (DateOnly?)null; + var responseBody = new CnbExRateDailyResponse([]); + var handler = CreateHandler(HttpStatusCode.OK, responseBody); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; + var client = new CnbApiClient(httpClient, _logger); + + // Act + await client.GetDailyRatesAsync(date, CancellationToken.None); + + // Assert + Assert.NotNull(handler.LastRequest); + var url = handler.LastRequest!.RequestUri!.ToString(); + Assert.Contains("/cnbapi/exrates/daily", url); + Assert.Contains("lang=EN", url); + + if (shouldContainDate) + Assert.Contains($"date={dateString}", url); + else + Assert.DoesNotContain("date=", url); + } + + [Fact] + public async Task GetDailyRatesAsync_WithSuccessResponse_DeserializesRates() + { + // Arrange + var responseBody = new CnbExRateDailyResponse( + [ + new CnbExRateDaily("2025-06-15", 115, "USA", "dollar", 1, "USD", 23.45m) + ]); + + var client = CreateClient(HttpStatusCode.OK, responseBody); + + // Act + var result = await client.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + var rates = result.Value.Rates; + var rate = Assert.Single(rates); + Assert.Equal("USD", rate.CurrencyCode); + Assert.Equal(23.45m, rate.Rate); + Assert.Equal(1, rate.Amount); + } + + [Fact] + public async Task GetDailyRatesAsync_WhenHttpRequestFails_ReturnsUnavailableError() + { + // Arrange + var client = CreateClient(HttpStatusCode.InternalServerError); + + // Act + var result = await client.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal("ExchangeRate.SourceUnavailable", result.Error.Code); + } + + [Fact] + public async Task GetDailyRatesAsync_WithInvalidJson_ReturnsUnavailableError() + { + // Arrange + var handler = new FakeHttpMessageHandler( + HttpStatusCode.OK, + new StringContent("not json", Encoding.UTF8, MediaTypeNames.Application.Json)); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; + var client = new CnbApiClient(httpClient, _logger); + + // Act + var result = await client.GetDailyRatesAsync(null, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal("ExchangeRate.SourceUnavailable", result.Error.Code); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Controllers/ExchangeRatesControllerTests.cs new file mode 100644 index 0000000000..e802add4af --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Controllers/ExchangeRatesControllerTests.cs @@ -0,0 +1,254 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using ExchangeRateUpdater.Api.Domain; +using ExchangeRateUpdater.Api.Domain.Common; +using ExchangeRateUpdater.Api.Application.Interfaces; +using ExchangeRateUpdater.Api.Presentation.Contracts.Requests; +using ExchangeRateUpdater.Api.Presentation.Contracts.Responses; +using ExchangeRateUpdater.Api.Presentation.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Api.Tests.Presentation.Controllers; + +public class ExchangeRatesControllerTests +{ + private readonly IFixture _fixture; + private readonly IExchangeRateProvider _provider; + private readonly ExchangeRatesController _sut; + + public ExchangeRatesControllerTests() + { + _fixture = new Fixture().Customize(new AutoNSubstituteCustomization()); + _provider = _fixture.Freeze(); + + _sut = new ExchangeRatesController(_provider) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + private static Result> SuccessRates( + params ExchangeRate[] rates) => + Result>.Success(rates); + + private static ExchangeRate CreateRate(string source, string target, decimal rate) => + ExchangeRate.Create(source, target, rate, 1, new DateOnly(2025, 6, 15)).Value; + + public class GetExchangeRates : ExchangeRatesControllerTests + { + [Fact] + public async Task GetExchangeRates_WithMultipleCurrencies_ReturnsAllRatesWrappedInApiResponse() + { + // Arrange + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m), + CreateRate("EUR", "CZK", 25.10m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRatesRequest(); + + // Act + var result = await _sut.GetExchangeRates(request, CancellationToken.None); + + // Assert + var okResult = Assert.IsType(result); + var apiResponse = Assert.IsType>>(okResult.Value); + Assert.NotNull(apiResponse.Data); + var list = apiResponse.Data.ToArray(); + Assert.Equal(2, list.Length); + Assert.Contains(list, r => r.SourceCurrency == "USD" && r.Rate == 23.45m); + Assert.Contains(list, r => r.SourceCurrency == "EUR" && r.Rate == 25.10m); + } + + [Fact] + public async Task GetExchangeRates_WithCurrencyFilter_ReturnsOnlyMatchingCurrencies() + { + // Arrange + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m), + CreateRate("EUR", "CZK", 25.10m), + CreateRate("GBP", "CZK", 29.80m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRatesRequest { Currencies = "USD,GBP" }; + + // Act + var result = await _sut.GetExchangeRates(request, CancellationToken.None); + + // Assert + var okResult = Assert.IsType(result); + var apiResponse = Assert.IsType>>(okResult.Value); + Assert.NotNull(apiResponse.Data); + var list = apiResponse.Data.ToArray(); + Assert.Equal(2, list.Length); + Assert.Contains(list, r => r.SourceCurrency == "USD"); + Assert.Contains(list, r => r.SourceCurrency == "GBP"); + Assert.DoesNotContain(list, r => r.SourceCurrency == "EUR"); + } + + [Fact] + public async Task GetExchangeRates_WithSpecificDate_PassesDateToProvider() + { + // Arrange + var rates = SuccessRates(); + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRatesRequest { Date = "2025-06-15" }; + + // Act + await _sut.GetExchangeRates(request, CancellationToken.None); + + // Assert + await _provider.Received(1).GetDailyRatesAsync( + new DateOnly(2025, 6, 15), Arg.Any()); + } + + [Fact] + public async Task GetExchangeRates_WhenProviderUnavailable_Returns502() + { + // Arrange + var failure = Result>.Failure( + ExchangeRateErrors.SourceUnavailable); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(failure); + + var request = new GetExchangeRatesRequest(); + + // Act + var result = await _sut.GetExchangeRates(request, CancellationToken.None); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + } + } + + public class GetExchangeRate : ExchangeRatesControllerTests + { + [Fact] + public async Task GetExchangeRate_WithExistingCurrency_ReturnsRateWrappedInApiResponse() + { + // Arrange + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRateByCodeRequest { CurrencyCode = "USD" }; + + // Act + var result = await _sut.GetExchangeRate(request, CancellationToken.None); + + // Assert + var okResult = Assert.IsType(result); + var apiResponse = Assert.IsType>(okResult.Value); + Assert.NotNull(apiResponse.Data); + Assert.Equal("USD", apiResponse.Data.SourceCurrency); + Assert.Equal("CZK", apiResponse.Data.TargetCurrency); + Assert.Equal(23.45m, apiResponse.Data.Rate); + } + + [Fact] + public async Task GetExchangeRate_WithCaseInsensitiveCurrency_ReturnsRate() + { + // Arrange + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRateByCodeRequest { CurrencyCode = "usd" }; + + // Act + var result = await _sut.GetExchangeRate(request, CancellationToken.None); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetExchangeRate_WithNonExistentCurrency_Returns404() + { + // Arrange - Currency not in the provider's response + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRateByCodeRequest { CurrencyCode = "XYZ" }; + + // Act + var result = await _sut.GetExchangeRate(request, CancellationToken.None); + + // Assert + var notFound = Assert.IsType(result); + var apiResponse = Assert.IsType>(notFound.Value); + Assert.Equal(ErrorCodes.ExchangeRateCurrencyNotFound, apiResponse.ErrorCode); + Assert.Null(apiResponse.Data); + } + + [Fact] + public async Task GetExchangeRate_WhenProviderFails_Returns502() + { + // Arrange + var failure = Result>.Failure( + ExchangeRateErrors.SourceUnavailable); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(failure); + + var request = new GetExchangeRateByCodeRequest { CurrencyCode = "USD" }; + + // Act + var result = await _sut.GetExchangeRate(request, CancellationToken.None); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode); + } + + [Fact] + public async Task GetExchangeRate_WithSpecificDate_PassesDateToProvider() + { + // Arrange + var rates = SuccessRates( + CreateRate("USD", "CZK", 23.45m)); + + _provider + .GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(rates); + + var request = new GetExchangeRateByCodeRequest { CurrencyCode = "USD", Date = "2025-06-15" }; + + // Act + await _sut.GetExchangeRate(request, CancellationToken.None); + + // Assert + await _provider.Received(1).GetDailyRatesAsync( + new DateOnly(2025, 6, 15), Arg.Any()); + } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Validators/GetExchangeRatesRequestValidatorTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Validators/GetExchangeRatesRequestValidatorTests.cs new file mode 100644 index 0000000000..0d7403d10c --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.Api.Tests/Presentation/Validators/GetExchangeRatesRequestValidatorTests.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; +using ExchangeRateUpdater.Api.Presentation.Contracts.Requests; + +namespace ExchangeRateUpdater.Api.Tests.Presentation.Validators; + +public class GetExchangeRatesRequestValidatorTests +{ + private static IList ValidateModel(GetExchangeRatesRequest request) + { + var context = new ValidationContext(request); + var results = new List(); + Validator.TryValidateObject(request, context, results, validateAllProperties: true); + return results; + } + + [Fact] + public void Validate_WithValidRequest_PassesValidation() + { + // Arrange + var request = new GetExchangeRatesRequest + { + Currencies = "USD,EUR,GBP", + Date = "2025-06-15" + }; + + // Act + var results = ValidateModel(request); + + // Assert + Assert.Empty(results); + } + + [Fact] + public void Validate_WithNullValues_PassesValidation() + { + // Arrange + var request = new GetExchangeRatesRequest(); + + // Act + var results = ValidateModel(request); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("US")] + [InlineData("USDA")] + [InlineData("U$D")] + [InlineData("123")] + [InlineData("USD,E")] + public void Validate_WithInvalidCurrencyCodes_FailsValidation(string currencies) + { + // Arrange + var request = new GetExchangeRatesRequest { Currencies = currencies }; + + // Act + var results = ValidateModel(request); + + // Assert + Assert.NotEmpty(results); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(GetExchangeRatesRequest.Currencies))); + } + + [Theory] + [InlineData("not-a-date")] + [InlineData("2025-13-01")] + [InlineData("2025-06-32")] + [InlineData("25-06-15")] + public void Validate_WithInvalidDate_FailsValidation(string date) + { + // Arrange + var request = new GetExchangeRatesRequest { Date = date }; + + // Act + var results = ValidateModel(request); + + // Assert + Assert.NotEmpty(results); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(GetExchangeRatesRequest.Date))); + } +} From 3b521c184e28cd850157cc3ac18edade67f773c8 Mon Sep 17 00:00:00 2001 From: Mariia Khmaruk Date: Thu, 12 Feb 2026 13:04:02 +0100 Subject: [PATCH 2/2] feat: update readme file --- jobs/Backend/Task/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 45fad3e10f..71c73edee7 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -8,7 +8,7 @@ A REST API service that provides daily currency exchange rates from the Czech Na - **.NET 10.0** - Latest cross-platform framework - **ASP.NET Core** - Web API -- **FluentValidation** - Request validation +- **DataAnnotation** - Request validation - **Microsoft.Extensions.Http.Resilience** - HTTP resilience (retry, circuit breaker, timeout) - **Scalar** - Interactive API documentation - **xUnit v3** - Unit testing