From bb3f1e88f66d43d9df4faa8e562ac14b6f5937a7 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Sun, 7 Dec 2025 19:10:55 +0000 Subject: [PATCH 01/18] Add solution with initial happy path flow of data from source through to API layer --- jobs/Backend/Task/Currency.cs | 20 --------- jobs/Backend/Task/ExchangeRate.cs | 23 ---------- jobs/Backend/Task/ExchangeRateProvider.cs | 19 -------- .../ExchangeRateProvider.slnx | 12 ++++++ .../OpenApiDocumentTransformer.cs | 26 +++++++++++ .../Constants/ApiEndpoints.cs | 13 ++++++ .../Controllers/ExchangeRateController.cs | 35 +++++++++++++++ .../ExchangeRateProvider.Api.csproj | 19 ++++++++ .../src/ExchangeRateProvider.Api/Program.cs | 40 +++++++++++++++++ .../appsettings.Development.json | 8 ++++ .../ExchangeRateProvider.Api/appsettings.json | 9 ++++ .../Abstractions/QueryHandler.cs | 6 +++ .../DTOs/ExchangeRateDto.cs | 9 ++++ .../ExchangeRateProvider.Application.csproj | 17 ++++++++ .../Handlers/GetExchangeRateQueryHandler.cs | 23 ++++++++++ .../Queries/GetExchangeRatesQuery.cs | 8 ++++ .../ServiceRegistration.cs | 17 ++++++++ .../ExchangeRateProvider.Domain.csproj | 9 ++++ .../Interfaces/IExchangeRateService.cs | 8 ++++ .../ValueObjects/Currency.cs | 11 +++++ .../ValueObjects/ExchangeRate.cs | 17 ++++++++ ...ExchangeRateProvider.Infrastructure.csproj | 18 ++++++++ .../ExternalServices/ExchangeRateResponse.cs | 31 +++++++++++++ .../ExternalServices/ExchangeRateService.cs | 25 +++++++++++ .../ServiceRegistration.cs | 19 ++++++++ ...ExchangeRateProvider.Api.Tests.Unit.csproj | 9 ++++ ...changeRateProvider.Application.Unit.csproj | 9 ++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 ---- jobs/Backend/Task/ExchangeRateUpdater.sln | 22 ---------- jobs/Backend/Task/Program.cs | 43 ------------------- 30 files changed, 398 insertions(+), 135 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 create mode 100644 jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.Development.json create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.json create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Abstractions/QueryHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateService.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj 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/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/ExchangeRateProvider/ExchangeRateProvider.slnx b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx new file mode 100644 index 0000000000..bb775d4a4e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs new file mode 100644 index 0000000000..b110a5707f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace ExchangeRateProvider.Api.Configuration +{ + public class OpenApiDocumentTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info = new OpenApiInfo + { + Title = "Exchange Rate Provider API", + Version = "v1", + Description = "API for retrieving exchange rates for the given currency." + + "Provides current exchange rates and supports filtering by quote currencies.", + Contact = new OpenApiContact + { + Name = "API Support", + Email = "ashleighadams.contact@gmail.com" + } + }; + + return Task.CompletedTask; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs new file mode 100644 index 0000000000..444eaa7015 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs @@ -0,0 +1,13 @@ +namespace ExchangeRateProvider.Api.Constants; + +public static class ApiEndpoints +{ + public const string ApiVersion = "v1"; + private const string ApiBase = $"{ApiVersion}/api"; + + public static class ExchangeRates + { + public const string Base = $"{ApiBase}/exchange-rates"; + public const string GetByCurrency = Base; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs new file mode 100644 index 0000000000..a97b9e9b7b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs @@ -0,0 +1,35 @@ +using ExchangeRateProvider.Api.Constants; +using ExchangeRateProvider.Application.Abstractions; +using ExchangeRateProvider.Application.DTOs; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.ValueObjects; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateProvider.Api.Controllers +{ + [ApiController] + [Produces("application/json")] + [Tags("Exchange Rates")] + public class ExchangeRateController(IQueryHandler> handler, ILogger logger) : ControllerBase + { + [HttpGet(ApiEndpoints.ExchangeRates.GetByCurrency)] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [EndpointSummary("Get exchange rates")] + [EndpointDescription("Retrieves current exchange rates for the specified base currency, with optional filtering by quote currencies.")] + public async Task> GetByCurrency( + [FromQuery] string baseCurrency, + [FromQuery] List quoteCurrencies, + CancellationToken cancellationToken = default) + { + var quotes = quoteCurrencies? + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(code => new Currency(code.ToUpperInvariant())) + .ToList() ?? new List(); + + var rates = await handler.HandleAsync(new GetExchangeRatesQuery(new Currency(baseCurrency), quotes), CancellationToken.None); + return rates; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj new file mode 100644 index 0000000000..9a50050dd4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs new file mode 100644 index 0000000000..10bbb3c1f6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs @@ -0,0 +1,40 @@ +using ExchangeRateProvider.Api.Configuration; +using ExchangeRateProvider.Application; +using ExchangeRateProvider.Infrastructure; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Logging.AddConsole(); + +// Add services to the container. +builder.Services.AddApplicationServices(); +builder.Services.AddInfrastructureServices(); + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer(); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +var logger = app.Services.GetRequiredService>(); +logger.LogInformation("Exchange Rate API started successfully"); + +app.Run(); + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Abstractions/QueryHandler.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Abstractions/QueryHandler.cs new file mode 100644 index 0000000000..ea887f800a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Abstractions/QueryHandler.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateProvider.Application.Abstractions; + +public interface IQueryHandler where TQuery : class +{ + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs new file mode 100644 index 0000000000..947e008f28 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs @@ -0,0 +1,9 @@ +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Application.DTOs; + +public record ExchangeRateDto( + Currency BaseCurrency, + Currency QuoteCurrency, + decimal Rate +); diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj new file mode 100644 index 0000000000..0e588987e3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs new file mode 100644 index 0000000000..5ae7ce40f4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs @@ -0,0 +1,23 @@ +using ExchangeRateProvider.Application.Abstractions; +using ExchangeRateProvider.Application.DTOs; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Application.Handlers; + +public class GetExchangeRateQueryHandler(IExchangeRateService exchangeRateService) : IQueryHandler> +{ + public async Task> HandleAsync(GetExchangeRatesQuery query, CancellationToken cancellationToken = default) + { + var rates = await exchangeRateService.GetExchangeRatesAsync( + query.BaseCurrency, + cancellationToken); + + IEnumerable filteredRates = query.QuoteCurrencies.Count > 0 + ? rates.Where(r => query.QuoteCurrencies.Any(qc => qc.Code == r.QuoteCurrency.Code)) + : rates; + + return filteredRates.Select(r => new ExchangeRateDto(r.BaseCurrency, r.QuoteCurrency, r.Rate)).ToList(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs new file mode 100644 index 0000000000..8b5fc5619b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs @@ -0,0 +1,8 @@ +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Application.Queries; + +public record GetExchangeRatesQuery( + Currency BaseCurrency, + List QuoteCurrencies +); diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs new file mode 100644 index 0000000000..d52ccb0c44 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs @@ -0,0 +1,17 @@ +using ExchangeRateProvider.Application.Abstractions; +using ExchangeRateProvider.Application.DTOs; +using ExchangeRateProvider.Application.Handlers; +using ExchangeRateProvider.Application.Queries; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Application; + +public static class ServiceRegistration +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddScoped>, GetExchangeRateQueryHandler>(); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj new file mode 100644 index 0000000000..b760144708 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateService.cs new file mode 100644 index 0000000000..f0cd8d9a71 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateService.cs @@ -0,0 +1,8 @@ +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Domain.Interfaces; + +public interface IExchangeRateService +{ + Task>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs new file mode 100644 index 0000000000..97399b9c62 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateProvider.Domain.ValueObjects; + +public record Currency +{ + public Currency(string code) + { + Code = code; + } + + public string Code { get; } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs new file mode 100644 index 0000000000..81a14c35ee --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateProvider.Domain.ValueObjects; + +public record ExchangeRate +{ + public ExchangeRate(Currency baseCurrency, Currency quoteCurrency, decimal rate) + { + BaseCurrency = baseCurrency; + QuoteCurrency = quoteCurrency; + Rate = rate; + } + + public Currency BaseCurrency { get; } + + public Currency QuoteCurrency { get; } + + public decimal Rate { get; } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj new file mode 100644 index 0000000000..daef521dd3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs new file mode 100644 index 0000000000..b86dce442a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices; + +internal sealed record ExchangeRateResponse( + List Rates +); + +internal sealed record CnbRate +{ + [JsonPropertyName("amount")] + public required long Amount { get; init; } + + [JsonPropertyName("country")] + public required string Country { get; init; } + + [JsonPropertyName("currency")] + public required string Currency { get; init; } + + [JsonPropertyName("currencyCode")] + public required string CurrencyCode { get; init; } + + [JsonPropertyName("order")] + public required int Order { get; init; } + + [JsonPropertyName("rate")] + public required decimal Rate { get; init; } + + [JsonPropertyName("validFor")] + public required DateOnly ValidFor { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs new file mode 100644 index 0000000000..676f73ef7e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs @@ -0,0 +1,25 @@ +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; +using Microsoft.Extensions.Logging; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices; + +public class ExchangeRateService(HttpClient httpClient, ILogger logger) : IExchangeRateService +{ + public async Task>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) + { + var response = await httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"), cancellationToken); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); + var list = root?.Rates ?? new List(); + + return list.Select(r => new ExchangeRate( + baseCurrency, + new Currency(r.CurrencyCode), + r.Amount / r.Rate + )).ToList(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs new file mode 100644 index 0000000000..510b253f37 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -0,0 +1,19 @@ +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure.ExternalServices; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Infrastructure; + +public static class ServiceRegistration +{ + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.cnb.cz"); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj new file mode 100644 index 0000000000..b760144708 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj new file mode 100644 index 0000000000..b760144708 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + 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 deleted file mode 100644 index 89be84daff..0000000000 --- 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 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(); - } - } -} From 886897d24603c87af7c0aa112785b4ecf2df8892 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Sun, 7 Dec 2025 21:56:59 +0000 Subject: [PATCH 02/18] - Add validation to API including unit tests - Refactored service implementation use factory pattern and KeyedSerivces to return appropriate service for BaseCurrency - Made QuoteCurrencies explicitly optional - Added remaining unit test projects --- .../ExchangeRateProvider.slnx | 7 +- .../Controllers/ExchangeRateController.cs | 62 ++- .../ExchangeRateProvider.Api.csproj | 1 + .../src/ExchangeRateProvider.Api/Program.cs | 3 + .../Validators/QuoteCurrenciesValidator.cs | 27 ++ .../Handlers/GetExchangeRateQueryHandler.cs | 18 +- .../Queries/GetExchangeRatesQuery.cs | 2 +- .../Constants/CurrencyServiceKeys.cs | 23 ++ .../Interfaces/IExchangeRateServiceFactory.cs | 8 + .../CzkExchangeRateResponse.cs} | 8 +- .../CzkExchangeRateService.cs} | 10 +- .../Factories/ExchangeRateServiceFactory.cs | 18 + .../ServiceRegistration.cs | 12 +- .../ExchangeRateControllerTests.cs | 365 ++++++++++++++++++ ...ExchangeRateProvider.Api.Tests.Unit.csproj | 21 +- .../QuoteCurrenciesValidatorTests.cs | 278 +++++++++++++ ...RateProvider.Application.Tests.Unit.csproj | 21 + ...changeRateProvider.Application.Unit.csproj | 9 - ...eProvider.Infrastructure.Tests.Unit.csproj | 21 + 19 files changed, 873 insertions(+), 41 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Validators/QuoteCurrenciesValidator.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Constants/CurrencyServiceKeys.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateServiceFactory.cs rename jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/{ExchangeRateResponse.cs => CZK/CzkExchangeRateResponse.cs} (79%) rename jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/{ExchangeRateService.cs => CZK/CzkExchangeRateService.cs} (59%) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Factories/ExchangeRateServiceFactory.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Validators/QuoteCurrenciesValidatorTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj delete mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj diff --git a/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx index bb775d4a4e..9ad4da2e68 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx +++ b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx @@ -1,12 +1,13 @@ + - - - + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs index a97b9e9b7b..0736257290 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs @@ -1,7 +1,9 @@ using ExchangeRateProvider.Api.Constants; +using ExchangeRateProvider.Api.Validators; using ExchangeRateProvider.Application.Abstractions; using ExchangeRateProvider.Application.DTOs; using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Constants; using ExchangeRateProvider.Domain.ValueObjects; using Microsoft.AspNetCore.Mvc; @@ -10,7 +12,11 @@ namespace ExchangeRateProvider.Api.Controllers [ApiController] [Produces("application/json")] [Tags("Exchange Rates")] - public class ExchangeRateController(IQueryHandler> handler, ILogger logger) : ControllerBase + public class ExchangeRateController( + IQueryHandler> handler, + IQuoteCurrenciesValidator quoteCurrenciesValidator, + ILogger logger) : ControllerBase { [HttpGet(ApiEndpoints.ExchangeRates.GetByCurrency)] [ProducesResponseType>(StatusCodes.Status200OK)] @@ -18,18 +24,60 @@ public class ExchangeRateController(IQueryHandler(StatusCodes.Status500InternalServerError)] [EndpointSummary("Get exchange rates")] [EndpointDescription("Retrieves current exchange rates for the specified base currency, with optional filtering by quote currencies.")] - public async Task> GetByCurrency( - [FromQuery] string baseCurrency, - [FromQuery] List quoteCurrencies, + public async Task>> GetByCurrency( + [FromQuery] string? baseCurrency, + [FromQuery] List? quoteCurrencies, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(baseCurrency)) + { + return BadRequest(new ProblemDetails + { + Title = "Invalid base currency", + Detail = "Base currency is required.", + Status = StatusCodes.Status400BadRequest + }); + } + + if (!CurrencyServiceKeys.IsSupported(baseCurrency)) + { + var supportedCurrencies = string.Join(", ", CurrencyServiceKeys.GetSupportedCurrencies()); + return BadRequest(new ProblemDetails + { + Title = "Unsupported base currency", + Detail = $"The currency '{baseCurrency}' is not supported. Supported currencies: {supportedCurrencies}", + Status = StatusCodes.Status400BadRequest + }); + } + + if(quoteCurrencies is not null) + { + var validationResult = await quoteCurrenciesValidator.ValidateAsync(quoteCurrencies, cancellationToken); + + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new ProblemDetails + { + Title = "Invalid quote currencies", + Detail = errors, + Status = StatusCodes.Status400BadRequest + }); + } + } + var quotes = quoteCurrencies? .Where(c => !string.IsNullOrWhiteSpace(c)) .Select(code => new Currency(code.ToUpperInvariant())) - .ToList() ?? new List(); + .ToList(); + + var query = new GetExchangeRatesQuery( + new Currency(baseCurrency), + quotes?.Count > 0 ? quotes : null + ); - var rates = await handler.HandleAsync(new GetExchangeRatesQuery(new Currency(baseCurrency), quotes), CancellationToken.None); - return rates; + var rates = await handler.HandleAsync(query, cancellationToken); + return Ok(rates); } } } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj index 9a50050dd4..ccf1d9cb4a 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs index 10bbb3c1f6..46618a42b5 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs @@ -1,4 +1,5 @@ using ExchangeRateProvider.Api.Configuration; +using ExchangeRateProvider.Api.Validators; using ExchangeRateProvider.Application; using ExchangeRateProvider.Infrastructure; using Scalar.AspNetCore; @@ -11,6 +12,8 @@ builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(); +builder.Services.AddScoped(); + builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(options => diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Validators/QuoteCurrenciesValidator.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Validators/QuoteCurrenciesValidator.cs new file mode 100644 index 0000000000..5db65b3d8c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Validators/QuoteCurrenciesValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace ExchangeRateProvider.Api.Validators; + +public interface IQuoteCurrenciesValidator : IValidator> +{ +} +public class QuoteCurrenciesValidator : AbstractValidator>, IQuoteCurrenciesValidator +{ + public QuoteCurrenciesValidator() + { + RuleForEach(quoteCurrencies => quoteCurrencies) + .Must(BeValidIso4217Code) + .WithMessage((list, currency) => $"The quote currency '{currency}' is not a valid ISO 4217 code (must be exactly 3 letters)."); + } + + private static bool BeValidIso4217Code(string? currencyCode) + { + if (string.IsNullOrWhiteSpace(currencyCode)) + { + return true; + } + + var upperCode = currencyCode.ToUpperInvariant(); + return upperCode.Length == 3 && upperCode.All(char.IsLetter); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs index 5ae7ce40f4..b3eead1389 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs @@ -6,17 +6,21 @@ namespace ExchangeRateProvider.Application.Handlers; -public class GetExchangeRateQueryHandler(IExchangeRateService exchangeRateService) : IQueryHandler> +public class GetExchangeRateQueryHandler(IExchangeRateServiceFactory serviceFactory) : IQueryHandler> { public async Task> HandleAsync(GetExchangeRatesQuery query, CancellationToken cancellationToken = default) { - var rates = await exchangeRateService.GetExchangeRatesAsync( - query.BaseCurrency, - cancellationToken); + var exchangeRateService = serviceFactory.GetService(query.BaseCurrency); - IEnumerable filteredRates = query.QuoteCurrencies.Count > 0 - ? rates.Where(r => query.QuoteCurrencies.Any(qc => qc.Code == r.QuoteCurrency.Code)) - : rates; + var rates = await exchangeRateService.GetExchangeRatesAsync(query.BaseCurrency, cancellationToken); + + IEnumerable filteredRates = rates; + + if (query.QuoteCurrencies is not null && query.QuoteCurrencies.Count > 0) + { + var quoteCodes = query.QuoteCurrencies.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + filteredRates = rates.Where(r => quoteCodes.Contains(r.QuoteCurrency.Code)); + } return filteredRates.Select(r => new ExchangeRateDto(r.BaseCurrency, r.QuoteCurrency, r.Rate)).ToList(); } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs index 8b5fc5619b..8ff4a98d70 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs @@ -4,5 +4,5 @@ namespace ExchangeRateProvider.Application.Queries; public record GetExchangeRatesQuery( Currency BaseCurrency, - List QuoteCurrencies + List? QuoteCurrencies = null ); diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Constants/CurrencyServiceKeys.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Constants/CurrencyServiceKeys.cs new file mode 100644 index 0000000000..57222554f9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Constants/CurrencyServiceKeys.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateProvider.Domain.Constants; + +public static class CurrencyServiceKeys +{ + public const string CZK = "CZK"; + + public const string Default = CZK; + + private static readonly HashSet SupportedCurrencies = new(StringComparer.OrdinalIgnoreCase) + { + CZK + }; + + public static bool IsSupported(string currencyCode) + { + return !string.IsNullOrWhiteSpace(currencyCode) && SupportedCurrencies.Contains(currencyCode); + } + + public static IReadOnlyCollection GetSupportedCurrencies() + { + return SupportedCurrencies; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateServiceFactory.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateServiceFactory.cs new file mode 100644 index 0000000000..6a09fb72e6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/Interfaces/IExchangeRateServiceFactory.cs @@ -0,0 +1,8 @@ +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Domain.Interfaces; + +public interface IExchangeRateServiceFactory +{ + IExchangeRateService GetService(Currency baseCurrency); +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs similarity index 79% rename from jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs rename to jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs index b86dce442a..cab14a817e 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateResponse.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace ExchangeRateProvider.Infrastructure.ExternalServices; +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; -internal sealed record ExchangeRateResponse( - List Rates +internal sealed record CzkExchangeRateResponse( + List Rates ); -internal sealed record CnbRate +internal sealed record CzkRate { [JsonPropertyName("amount")] public required long Amount { get; init; } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs similarity index 59% rename from jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs rename to jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs index 676f73ef7e..f9fb2d2f66 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -1,20 +1,18 @@ using ExchangeRateProvider.Domain.Interfaces; using ExchangeRateProvider.Domain.ValueObjects; using Microsoft.Extensions.Logging; -using System.Linq; using System.Text.Json; -using System.Text.Json.Serialization; -namespace ExchangeRateProvider.Infrastructure.ExternalServices; +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; -public class ExchangeRateService(HttpClient httpClient, ILogger logger) : IExchangeRateService +public class CzkExchangeRateService(HttpClient httpClient, ILogger logger) : IExchangeRateService { public async Task>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) { var response = await httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"), cancellationToken); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); - var list = root?.Rates ?? new List(); + var root = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); + var list = root?.Rates ?? new List(); return list.Select(r => new ExchangeRate( baseCurrency, diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Factories/ExchangeRateServiceFactory.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Factories/ExchangeRateServiceFactory.cs new file mode 100644 index 0000000000..89cbcccfa1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Factories/ExchangeRateServiceFactory.cs @@ -0,0 +1,18 @@ +using ExchangeRateProvider.Domain.Constants; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Infrastructure.Factories; + +public class ExchangeRateServiceFactory(IServiceProvider serviceProvider) : IExchangeRateServiceFactory +{ + public IExchangeRateService GetService(Currency baseCurrency) + { + return baseCurrency.Code.ToUpperInvariant() switch + { + CurrencyServiceKeys.CZK => serviceProvider.GetRequiredKeyedService(CurrencyServiceKeys.CZK), + _ => serviceProvider.GetRequiredKeyedService(CurrencyServiceKeys.CZK) + }; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index 510b253f37..9d74724a7d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -1,5 +1,7 @@ -using ExchangeRateProvider.Domain.Interfaces; -using ExchangeRateProvider.Infrastructure.ExternalServices; +using ExchangeRateProvider.Domain.Constants; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using ExchangeRateProvider.Infrastructure.Factories; using Microsoft.Extensions.DependencyInjection; namespace ExchangeRateProvider.Infrastructure; @@ -8,12 +10,16 @@ public static class ServiceRegistration { public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) { - services.AddHttpClient(client => + services.AddSingleton(); + + services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.cnb.cz"); client.Timeout = TimeSpan.FromSeconds(30); }); + services.AddKeyedTransient(CurrencyServiceKeys.CZK); + return services; } } diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs new file mode 100644 index 0000000000..efb641a025 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs @@ -0,0 +1,365 @@ +using ExchangeRateProvider.Api.Controllers; +using ExchangeRateProvider.Api.Validators; +using ExchangeRateProvider.Application.Abstractions; +using ExchangeRateProvider.Application.DTOs; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.ValueObjects; +using FakeItEasy; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Shouldly; + +namespace ExchangeRateProvider.Api.Tests.Unit.Controllers; + +public class ExchangeRateControllerTests +{ + private readonly IQueryHandler> _fakeHandler; + private readonly ILogger _fakeLogger; + private readonly ExchangeRateController _controller; + private readonly QuoteCurrenciesValidator _validator; + + public ExchangeRateControllerTests() + { + _fakeHandler = A.Fake>>(); + _fakeLogger = A.Fake>(); + _validator = new QuoteCurrenciesValidator(); + _controller = new ExchangeRateController(_fakeHandler, _validator, _fakeLogger); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithValidBaseCurrency_ReturnsOkWithRates(string baseCurrency) + { + // Arrange + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m), + new(new Currency(baseCurrency), new Currency("GBP"), 0.73m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => q.BaseCurrency.Code == baseCurrency), + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result; + okResult.StatusCode.ShouldBe(StatusCodes.Status200OK); + okResult.Value.ShouldBe(expectedRates); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithQuoteCurrencies_PassesThemToHandlerAndReturnsResult(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "EUR", "GBP" }; + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m), + new(new Currency(baseCurrency), new Currency("GBP"), 0.73m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.BaseCurrency.Code == baseCurrency && + q.QuoteCurrencies.Count == 2 && + q.QuoteCurrencies.Any(c => c.Code == "EUR") && + q.QuoteCurrencies.Any(c => c.Code == "GBP")), + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result; + okResult.Value.ShouldBe(expectedRates); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.QuoteCurrencies.Any(c => c.Code == "EUR") && + q.QuoteCurrencies.Any(c => c.Code == "GBP")), + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GetByCurrency_WithNullBaseCurrency_ReturnsBadRequest() + { + // Act + var result = await _controller.GetByCurrency(null, null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result; + badRequestResult.StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Invalid base currency"); + problemDetails.Detail.ShouldBe("Base currency is required."); + problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); + + A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetByCurrency_WithEmptyBaseCurrency_ReturnsBadRequest() + { + // Act + var result = await _controller.GetByCurrency("", null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result; + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Invalid base currency"); + problemDetails.Detail.ShouldBe("Base currency is required."); + + A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetByCurrency_WithWhitespaceBaseCurrency_ReturnsBadRequest() + { + // Act + var result = await _controller.GetByCurrency(" ", null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result; + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Invalid base currency"); + problemDetails.Detail.ShouldBe("Base currency is required."); + + A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetByCurrency_WithUnsupportedBaseCurrency_ReturnsBadRequest() + { + // Arrange + var unsupportedCurrency = "XXX"; + + // Act + var result = await _controller.GetByCurrency(unsupportedCurrency, null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result; + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Unsupported base currency"); + problemDetails.Detail.ShouldContain($"The currency '{unsupportedCurrency}' is not supported"); + problemDetails.Detail.ShouldContain("Supported currencies:"); + problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); + + A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithInvalidQuoteCurrency_ReturnsBadRequest(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "INVALID" }; + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var badRequestResult = (BadRequestObjectResult)result.Result; + + var problemDetails = badRequestResult.Value.ShouldBeOfType(); + problemDetails.Title.ShouldBe("Invalid quote currencies"); + problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); + + A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithQuoteCurrenciesContainingEmptyStrings_FiltersThemOut(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "EUR", "", " ", "GBP" }; + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m), + new(new Currency(baseCurrency), new Currency("GBP"), 0.73m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.BaseCurrency.Code == baseCurrency && + q.QuoteCurrencies.Count == 2), + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.QuoteCurrencies.Count == 2 && + q.QuoteCurrencies.All(c => !string.IsNullOrWhiteSpace(c.Code))), + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithEmptyQuoteCurrenciesList_PassesNullToHandler(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List(); + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => q.QuoteCurrencies == null), + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => q.QuoteCurrencies == null), + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithLowercaseQuoteCurrencies_ConvertsToUppercase(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "eur", "gbp" }; + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m), + new(new Currency(baseCurrency), new Currency("GBP"), 0.73m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A._, + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.QuoteCurrencies.Any(c => c.Code == "EUR") && + q.QuoteCurrencies.Any(c => c.Code == "GBP")), + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_ReturnsEmptyList_WhenNoRatesAvailable(string baseCurrency) + { + // Arrange + var expectedRates = new List(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A._, + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, null, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + var okResult = (OkObjectResult)result.Result; + var rates = okResult.Value.ShouldBeOfType>(); + rates.ShouldBeEmpty(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithValidThreeLetterQuoteCurrencies_PassesValidation(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "EUR", "GBP", "JPY", "AUD" }; + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A._, + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => q.QuoteCurrencies.Count == 4), + A._)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task GetByCurrency_WithMixedCaseValidQuoteCurrencies_ConvertsToUppercaseAndPassesValidation(string baseCurrency) + { + // Arrange + var quoteCurrencies = new List { "eur", "GBp", "JpY" }; // Mixed case but valid + var expectedRates = new List + { + new(new Currency(baseCurrency), new Currency("EUR"), 0.85m) + }; + + A.CallTo(() => _fakeHandler.HandleAsync( + A._, + A._)) + .Returns(expectedRates); + + // Act + var result = await _controller.GetByCurrency(baseCurrency, quoteCurrencies, CancellationToken.None); + + // Assert + result.Result.ShouldBeOfType(); + + A.CallTo(() => _fakeHandler.HandleAsync( + A.That.Matches(q => + q.QuoteCurrencies.Count == 3 && + q.QuoteCurrencies.All(c => c.Code == c.Code.ToUpperInvariant())), + A._)) + .MustHaveHappenedOnceExactly(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj index b760144708..ae0e99632b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj @@ -4,6 +4,25 @@ net10.0 enable enable + false - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Validators/QuoteCurrenciesValidatorTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Validators/QuoteCurrenciesValidatorTests.cs new file mode 100644 index 0000000000..e8477e25e4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Validators/QuoteCurrenciesValidatorTests.cs @@ -0,0 +1,278 @@ +using ExchangeRateProvider.Api.Validators; +using FluentValidation.TestHelper; +using Shouldly; + +namespace ExchangeRateProvider.Api.Tests.Unit.Validators; + +public class QuoteCurrenciesValidatorTests +{ + private readonly QuoteCurrenciesValidator _validator; + + public QuoteCurrenciesValidatorTests() + { + _validator = new QuoteCurrenciesValidator(); + } + + [Fact] + public async Task Validate_WithEmptyList_ReturnsValid() + { + // Arrange + var quoteCurrencies = new List(); + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithValidThreeLetterCodes_ReturnsValid() + { + // Arrange + var quoteCurrencies = new List { "EUR", "GBP", "USD", "JPY" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithLowercaseValidCodes_ReturnsValid() + { + // Arrange + var quoteCurrencies = new List { "eur", "gbp", "usd" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithMixedCaseValidCodes_ReturnsValid() + { + // Arrange + var quoteCurrencies = new List { "EuR", "GbP", "UsD" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithEmptyStrings_ReturnsValid() + { + // Arrange - Empty/whitespace entries should be skipped, not cause validation errors + var quoteCurrencies = new List { "", " ", "\t" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithMixOfValidAndEmptyStrings_ReturnsValid() + { + // Arrange + var quoteCurrencies = new List { "EUR", "", "GBP", " ", "USD" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Validate_WithTooShortCode_ReturnsInvalid() + { + // Arrange + var quoteCurrencies = new List { "EU" }; // Only 2 letters + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ErrorMessage.ShouldContain("EU"); + result.Errors[0].ErrorMessage.ShouldContain("not a valid ISO 4217 code"); + } + + [Fact] + public async Task Validate_WithTooLongCode_ReturnsInvalid() + { + // Arrange + var quoteCurrencies = new List { "EURO" }; // 4 letters + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ErrorMessage.ShouldContain("EURO"); + result.Errors[0].ErrorMessage.ShouldContain("not a valid ISO 4217 code"); + } + + [Fact] + public async Task Validate_WithNumericCharacters_ReturnsInvalid() + { + // Arrange + var quoteCurrencies = new List { "EU1", "2ND", "AB3" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(3); + result.Errors.ShouldAllBe(e => e.ErrorMessage.Contains("not a valid ISO 4217 code")); + } + + [Fact] + public async Task Validate_WithSpecialCharacters_ReturnsInvalid() + { + // Arrange + var quoteCurrencies = new List { "EU$", "GB-", "U_D" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(3); + result.Errors.ShouldAllBe(e => e.ErrorMessage.Contains("not a valid ISO 4217 code")); + } + + [Fact] + public async Task Validate_WithMultipleInvalidCodes_ReturnsAllErrors() + { + // Arrange + var quoteCurrencies = new List { "EU", "EURO", "12", "A$D" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(4); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("EU")); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("EURO")); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("12")); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("A$D")); + } + + [Fact] + public async Task Validate_WithMixOfValidAndInvalidCodes_ReturnsOnlyInvalidErrors() + { + // Arrange + var quoteCurrencies = new List { "EUR", "INVALID", "GBP", "XX", "USD" }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("INVALID")); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("XX")); + + // Valid codes should not appear in errors + result.Errors.ShouldAllBe(e => !e.ErrorMessage.Contains("EUR")); + result.Errors.ShouldAllBe(e => !e.ErrorMessage.Contains("GBP")); + result.Errors.ShouldAllBe(e => !e.ErrorMessage.Contains("USD")); + } + + [Theory] + [InlineData("A")] + [InlineData("AB")] + [InlineData("ABCD")] + [InlineData("ABCDE")] + public async Task Validate_WithInvalidLength_ReturnsInvalid(string invalidCode) + { + // Arrange + var quoteCurrencies = new List { invalidCode }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ErrorMessage.ShouldContain(invalidCode); + } + + [Theory] + [InlineData("123")] + [InlineData("A23")] + [InlineData("AB3")] + [InlineData("1BC")] + public async Task Validate_WithNumbers_ReturnsInvalid(string invalidCode) + { + // Arrange + var quoteCurrencies = new List { invalidCode }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors[0].ErrorMessage.ShouldContain(invalidCode); + } + + [Theory] + [InlineData("A$D")] + [InlineData("E@R")] + [InlineData("GB#")] + [InlineData("U-D")] + [InlineData("E_R")] + public async Task Validate_WithSpecialChars_ReturnsInvalid(string invalidCode) + { + // Arrange + var quoteCurrencies = new List { invalidCode }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors[0].ErrorMessage.ShouldContain(invalidCode); + } + + [Theory] + [InlineData("EUR")] + [InlineData("GBP")] + [InlineData("USD")] + [InlineData("JPY")] + [InlineData("CHF")] + [InlineData("CAD")] + [InlineData("AUD")] + [InlineData("NZD")] + public async Task Validate_WithValidIsoCodes_ReturnsValid(string validCode) + { + // Arrange + var quoteCurrencies = new List { validCode }; + + // Act + var result = await _validator.TestValidateAsync(quoteCurrencies); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj new file mode 100644 index 0000000000..db296aa2a0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj deleted file mode 100644 index b760144708..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Unit/ExchangeRateProvider.Application.Unit.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net10.0 - enable - enable - - - diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj new file mode 100644 index 0000000000..db296aa2a0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file From 6156f22c7eb4dbc5012f6c62f536336774cac933 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Sun, 7 Dec 2025 22:56:54 +0000 Subject: [PATCH 03/18] Add unit tests to all testable classes and added ExcludeFromCodeCoverage to classes that do not contain logic --- .../ExchangeRateProvider.slnx | 2 + .../OpenApiDocumentTransformer.cs | 2 + .../Constants/ApiEndpoints.cs | 5 +- .../DTOs/ExchangeRateDto.cs | 2 + ...ler.cs => GetExchangeRatesQueryHandler.cs} | 2 +- .../Queries/GetExchangeRatesQuery.cs | 2 + .../ServiceRegistration.cs | 4 +- .../ValueObjects/Currency.cs | 5 +- .../ValueObjects/ExchangeRate.cs | 5 +- .../CZK/CzkExchangeRateResponse.cs | 5 +- .../ServiceRegistration.cs | 2 + ...eRateProvider.Api.Tests.Integration.csproj | 21 + .../UnitTest1.cs | 11 + ...RateProvider.Application.Tests.Unit.csproj | 7 + .../GetExchangeRatesQueryHandlerTests.cs | 408 ++++++++++++++++++ .../Constants/CurrencyServiceKeysTests.cs | 49 +++ ...hangeRateProvider.Domain.Tests.Unit.csproj | 26 ++ ...eProvider.Infrastructure.Tests.Unit.csproj | 7 + .../ExchangeRateServiceFactoryTests.cs | 61 +++ 19 files changed, 620 insertions(+), 6 deletions(-) rename jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/{GetExchangeRateQueryHandler.cs => GetExchangeRatesQueryHandler.cs} (88%) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/Handlers/GetExchangeRatesQueryHandlerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Factories/ExchangeRateServiceFactoryTests.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx index 9ad4da2e68..7690a2b847 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx +++ b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx @@ -6,8 +6,10 @@ + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs index b110a5707f..2c0e326a6d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; +using System.Diagnostics.CodeAnalysis; namespace ExchangeRateProvider.Api.Configuration { + [ExcludeFromCodeCoverage(Justification = "OpenAPI configuration with only metadata assignment - no testable logic")] public class OpenApiDocumentTransformer : IOpenApiDocumentTransformer { public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs index 444eaa7015..63bb20e094 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs @@ -1,5 +1,8 @@ -namespace ExchangeRateProvider.Api.Constants; +using System.Diagnostics.CodeAnalysis; +namespace ExchangeRateProvider.Api.Constants; + +[ExcludeFromCodeCoverage(Justification = "Constants class with only compile-time string values - no executable logic")] public static class ApiEndpoints { public const string ApiVersion = "v1"; diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs index 947e008f28..8da4856cac 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs @@ -1,7 +1,9 @@ using ExchangeRateProvider.Domain.ValueObjects; +using System.Diagnostics.CodeAnalysis; namespace ExchangeRateProvider.Application.DTOs; +[ExcludeFromCodeCoverage(Justification = "DTO record with only auto-generated members - no custom logic to test")] public record ExchangeRateDto( Currency BaseCurrency, Currency QuoteCurrency, diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRatesQueryHandler.cs similarity index 88% rename from jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs rename to jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRatesQueryHandler.cs index b3eead1389..4481ae3eb4 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRatesQueryHandler.cs @@ -6,7 +6,7 @@ namespace ExchangeRateProvider.Application.Handlers; -public class GetExchangeRateQueryHandler(IExchangeRateServiceFactory serviceFactory) : IQueryHandler> +public class GetExchangeRatesQueryHandler(IExchangeRateServiceFactory serviceFactory) : IQueryHandler> { public async Task> HandleAsync(GetExchangeRatesQuery query, CancellationToken cancellationToken = default) { diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs index 8ff4a98d70..3c1eb8284e 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs @@ -1,7 +1,9 @@ using ExchangeRateProvider.Domain.ValueObjects; +using System.Diagnostics.CodeAnalysis; namespace ExchangeRateProvider.Application.Queries; +[ExcludeFromCodeCoverage(Justification = "Query record with only auto-generated members - no custom logic to test")] public record GetExchangeRatesQuery( Currency BaseCurrency, List? QuoteCurrencies = null diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs index d52ccb0c44..09ccc68ab7 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs @@ -3,14 +3,16 @@ using ExchangeRateProvider.Application.Handlers; using ExchangeRateProvider.Application.Queries; using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; namespace ExchangeRateProvider.Application; +[ExcludeFromCodeCoverage(Justification = "Dependency injection configuration - no testable logic, verified through integration tests")] public static class ServiceRegistration { public static IServiceCollection AddApplicationServices(this IServiceCollection services) { - services.AddScoped>, GetExchangeRateQueryHandler>(); + services.AddScoped>, GetExchangeRatesQueryHandler>(); return services; } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs index 97399b9c62..8e3cddfd1f 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs @@ -1,5 +1,8 @@ -namespace ExchangeRateProvider.Domain.ValueObjects; +using System.Diagnostics.CodeAnalysis; +namespace ExchangeRateProvider.Domain.ValueObjects; + +[ExcludeFromCodeCoverage(Justification = "Simple value object with only auto-generated record members - no custom logic")] public record Currency { public Currency(string code) diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs index 81a14c35ee..cacb2be169 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs @@ -1,5 +1,8 @@ -namespace ExchangeRateProvider.Domain.ValueObjects; +using System.Diagnostics.CodeAnalysis; +namespace ExchangeRateProvider.Domain.ValueObjects; + +[ExcludeFromCodeCoverage(Justification = "Simple value object with only auto-generated record members - no custom logic")] public record ExchangeRate { public ExchangeRate(Currency baseCurrency, Currency quoteCurrency, decimal rate) diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs index cab14a817e..d908b63e3d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs @@ -1,11 +1,14 @@ -using System.Text.Json.Serialization; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +[ExcludeFromCodeCoverage(Justification = "External API response DTO with only JSON serialization attributes - no business logic")] internal sealed record CzkExchangeRateResponse( List Rates ); +[ExcludeFromCodeCoverage(Justification = "External API response DTO with only JSON serialization attributes - no business logic")] internal sealed record CzkRate { [JsonPropertyName("amount")] diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index 9d74724a7d..3ed07690c9 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -3,9 +3,11 @@ using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; using ExchangeRateProvider.Infrastructure.Factories; using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; namespace ExchangeRateProvider.Infrastructure; +[ExcludeFromCodeCoverage(Justification = "Dependency injection configuration - no testable logic, verified through integration tests")] public static class ServiceRegistration { public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj new file mode 100644 index 0000000000..db296aa2a0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs new file mode 100644 index 0000000000..19e5679652 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateProvider.Api.Tests.Integration +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj index db296aa2a0..4c93bd2f87 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj @@ -9,11 +9,18 @@ + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/Handlers/GetExchangeRatesQueryHandlerTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/Handlers/GetExchangeRatesQueryHandlerTests.cs new file mode 100644 index 0000000000..fe4ed067f7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/Handlers/GetExchangeRatesQueryHandlerTests.cs @@ -0,0 +1,408 @@ +using ExchangeRateProvider.Application.Handlers; +using ExchangeRateProvider.Application.Queries; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; +using FakeItEasy; +using Shouldly; + +namespace ExchangeRateProvider.Application.Tests.Unit.Handlers; + +public class GetExchangeRatesQueryHandlerTests +{ + private readonly IExchangeRateServiceFactory _fakeServiceFactory; + private readonly IExchangeRateService _fakeExchangeRateService; + private readonly GetExchangeRatesQueryHandler _handler; + + public GetExchangeRatesQueryHandlerTests() + { + _fakeServiceFactory = A.Fake(); + _fakeExchangeRateService = A.Fake(); + _handler = new GetExchangeRatesQueryHandler(_fakeServiceFactory); + + A.CallTo(() => _fakeServiceFactory.GetService(A._)) + .Returns(_fakeExchangeRateService); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithValidQuery_ReturnsAllRates(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency); + + var expectedRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(expectedRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(3); + result[0].BaseCurrency.Code.ShouldBe(baseCurrencyCode); + result[0].QuoteCurrency.Code.ShouldBe("EUR"); + result[0].Rate.ShouldBe(0.04m); + result[1].QuoteCurrency.Code.ShouldBe("USD"); + result[2].QuoteCurrency.Code.ShouldBe("GBP"); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithNullQuoteCurrencies_ReturnsAllRates(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency, null); + + var expectedRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(expectedRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.ShouldAllBe(r => r.BaseCurrency.Code == baseCurrencyCode); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithEmptyQuoteCurrenciesList_ReturnsAllRates(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency, new List()); + + var expectedRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(expectedRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithSpecificQuoteCurrencies_ReturnsFilteredRates(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List { new("EUR"), new("GBP") }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m), + new(new Currency(baseCurrencyCode), new Currency("JPY"), 5.5m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.ShouldContain(r => r.QuoteCurrency.Code == "EUR"); + result.ShouldContain(r => r.QuoteCurrency.Code == "GBP"); + result.ShouldNotContain(r => r.QuoteCurrency.Code == "USD"); + result.ShouldNotContain(r => r.QuoteCurrency.Code == "JPY"); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithSingleQuoteCurrency_ReturnsOnlyMatchingRate(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List { new("EUR") }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(1); + result[0].QuoteCurrency.Code.ShouldBe("EUR"); + result[0].Rate.ShouldBe(0.04m); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithNonMatchingQuoteCurrencies_ReturnsEmptyList(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List { new("JPY"), new("CHF") }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithCaseInsensitiveQuoteCurrency_MatchesCorrectly(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List { new("eur"), new("GBP") }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.ShouldContain(r => r.QuoteCurrency.Code == "EUR"); + result.ShouldContain(r => r.QuoteCurrency.Code == "GBP"); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithMixedCaseQuoteCurrencies_MatchesCorrectly(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List { new("EuR"), new("uSd") }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.ShouldContain(r => r.QuoteCurrency.Code == "EUR"); + result.ShouldContain(r => r.QuoteCurrency.Code == "USD"); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WhenServiceReturnsEmptyList_ReturnsEmptyList(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency); + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(new List()); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + [Theory] + [InlineData("USD")] + [InlineData("CZK")] + [InlineData("EUR")] + public async Task HandleAsync_CallsServiceFactoryWithCorrectBaseCurrency(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency); + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(A._, A._)) + .Returns(new List()); + + // Act + await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + A.CallTo(() => _fakeServiceFactory.GetService(A.That.Matches(c => c.Code == baseCurrencyCode))) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_CallsExchangeRateServiceWithCorrectParameters(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var cancellationToken = new CancellationToken(); + var query = new GetExchangeRatesQuery(baseCurrency); + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(A._, A._)) + .Returns(new List()); + + // Act + await _handler.HandleAsync(query, cancellationToken); + + // Assert + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync( + A.That.Matches(c => c.Code == baseCurrencyCode), + cancellationToken)) + .MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_MapsExchangeRatesToDtosCorrectly(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency); + + var exchangeRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.040123m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045678m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(exchangeRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + + // Verify first DTO mapping + result[0].BaseCurrency.Code.ShouldBe(baseCurrencyCode); + result[0].QuoteCurrency.Code.ShouldBe("EUR"); + result[0].Rate.ShouldBe(0.040123m); + + // Verify second DTO mapping + result[1].BaseCurrency.Code.ShouldBe(baseCurrencyCode); + result[1].QuoteCurrency.Code.ShouldBe("USD"); + result[1].Rate.ShouldBe(0.045678m); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_WithDuplicateQuoteCurrencies_FiltersCorrectly(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var quoteCurrencies = new List + { + new("EUR"), + new("EUR"), // Duplicate + new("USD") + }; + var query = new GetExchangeRatesQuery(baseCurrency, quoteCurrencies); + + var allRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), 0.04m), + new(new Currency(baseCurrencyCode), new Currency("USD"), 0.045m), + new(new Currency(baseCurrencyCode), new Currency("GBP"), 0.035m) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(allRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.ShouldContain(r => r.QuoteCurrency.Code == "EUR"); + result.ShouldContain(r => r.QuoteCurrency.Code == "USD"); + } + + [Theory] + [InlineData("CZK")] + public async Task HandleAsync_PreservesDecimalPrecision(string baseCurrencyCode) + { + // Arrange + var baseCurrency = new Currency(baseCurrencyCode); + var query = new GetExchangeRatesQuery(baseCurrency); + + var precisRate = 0.0401234567890123456789m; + var exchangeRates = new List + { + new(new Currency(baseCurrencyCode), new Currency("EUR"), precisRate) + }; + + A.CallTo(() => _fakeExchangeRateService.GetExchangeRatesAsync(baseCurrency, A._)) + .Returns(exchangeRates); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result[0].Rate.ShouldBe(precisRate); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs new file mode 100644 index 0000000000..ae9c89ec3a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs @@ -0,0 +1,49 @@ +using ExchangeRateProvider.Domain.Constants; +using Shouldly; + +namespace ExchangeRateProvider.Domain.Tests.Unit.Constants; + +public class CurrencyServiceKeysTests +{ + [Theory] + [InlineData("CZK")] + [InlineData("czk")] + [InlineData("Czk")] + public void IsSupported_WithValidCurrency_ReturnsTrue(string currencyCode) + { + CurrencyServiceKeys.IsSupported(currencyCode).ShouldBeTrue(); + } + + [Theory] + [InlineData("USD")] + [InlineData("EUR")] + [InlineData("XXX")] + public void IsSupported_WithUnsupportedCurrency_ReturnsFalse(string currencyCode) + { + CurrencyServiceKeys.IsSupported(currencyCode).ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsSupported_WithNullOrWhitespace_ReturnsFalse(string currencyCode) + { + CurrencyServiceKeys.IsSupported(currencyCode).ShouldBeFalse(); + } + + [Fact] + public void GetSupportedCurrencies_ReturnsAllSupportedCurrencies() + { + var result = CurrencyServiceKeys.GetSupportedCurrencies(); + + result.ShouldNotBeEmpty(); + result.ShouldContain("CZK"); + } + + [Fact] + public void Default_IsCZK() + { + CurrencyServiceKeys.Default.ShouldBe("CZK"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj new file mode 100644 index 0000000000..60c55ce4ad --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj index db296aa2a0..dffe098a88 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -9,11 +9,18 @@ + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Factories/ExchangeRateServiceFactoryTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Factories/ExchangeRateServiceFactoryTests.cs new file mode 100644 index 0000000000..2bdccbcf51 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Factories/ExchangeRateServiceFactoryTests.cs @@ -0,0 +1,61 @@ +using ExchangeRateProvider.Domain.Constants; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure.Factories; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.Factories; + +public class ExchangeRateServiceFactoryTests +{ + [Theory] + [InlineData("CZK")] + [InlineData("czk")] + [InlineData("Czk")] + public void GetService_WithCzkCurrency_ReturnsService(string currencyCode) + { + // Arrange + var services = new ServiceCollection(); + var fakeService = A.Fake(); + services.AddKeyedSingleton(CurrencyServiceKeys.CZK, fakeService); + var factory = new ExchangeRateServiceFactory(services.BuildServiceProvider()); + + // Act + var result = factory.GetService(new Currency(currencyCode)); + + // Assert + result.ShouldBe(fakeService); + } + + [Theory] + [InlineData("USD")] + [InlineData("EUR")] + [InlineData("GBP")] + public void GetService_WithUnsupportedCurrency_FallsBackToCzkService(string currencyCode) + { + // Arrange + var services = new ServiceCollection(); + var fakeService = A.Fake(); + services.AddKeyedSingleton(CurrencyServiceKeys.CZK, fakeService); + var factory = new ExchangeRateServiceFactory(services.BuildServiceProvider()); + + // Act + var result = factory.GetService(new Currency(currencyCode)); + + // Assert + result.ShouldBe(fakeService); + } + + [Fact] + public void GetService_WhenServiceNotRegistered_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var factory = new ExchangeRateServiceFactory(services.BuildServiceProvider()); + + // Act & Assert + Should.Throw(() => factory.GetService(new Currency("CZK"))); + } +} \ No newline at end of file From 1e231c6e7fecdc004b3c45614ee1590638330cb0 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Sun, 7 Dec 2025 23:29:08 +0000 Subject: [PATCH 04/18] Add basic integration tests --- .../CZK/CzkExchangeRateService.cs | 2 + .../ExchangeRateControllerIntegrationTests.cs | 77 +++++++++++++++++++ ...eRateProvider.Api.Tests.Integration.csproj | 8 ++ .../CzkExchangeRateServiceIntegrationTests.cs | 35 +++++++++ ...hangeRateServiceFactoryIntegrationTests.cs | 44 +++++++++++ .../UnitTest1.cs | 11 --- 6 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Controllers/ExchangeRateControllerIntegrationTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Factories/ExchangeRateServiceFactoryIntegrationTests.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs index f9fb2d2f66..a5996af85c 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -10,6 +10,8 @@ public class CzkExchangeRateService(HttpClient httpClient, ILogger>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) { var response = await httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"), cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); var list = root?.Rates ?? new List(); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Controllers/ExchangeRateControllerIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Controllers/ExchangeRateControllerIntegrationTests.cs new file mode 100644 index 0000000000..6632ebdab3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Controllers/ExchangeRateControllerIntegrationTests.cs @@ -0,0 +1,77 @@ +using ExchangeRateProvider.Application.DTOs; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.VisualStudio.TestPlatform.TestHost; +using Shouldly; +using System.Net; +using System.Net.Http.Json; + +namespace ExchangeRateProvider.Api.Tests.Integration.Controllers; + +public class ExchangeRateControllerIntegrationTests : IClassFixture> +{ + private readonly HttpClient _client; + + public ExchangeRateControllerIntegrationTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetByCurrency_WithValidCzkCurrency_ReturnsOkWithRates() + { + // Act + var response = await _client.GetAsync("/v1/api/exchange-rates?baseCurrency=CZK"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var rates = await response.Content.ReadFromJsonAsync>(); + rates.ShouldNotBeNull(); + rates.ShouldNotBeEmpty(); + rates.ShouldAllBe(r => r.BaseCurrency.Code == "CZK"); + } + + [Fact] + public async Task GetByCurrency_WithCzkAndQuoteCurrencies_ReturnsFilteredRates() + { + // Act + var response = await _client.GetAsync("/v1/api/exchange-rates?baseCurrency=CZK"eCurrencies=EUR"eCurrencies=USD"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var rates = await response.Content.ReadFromJsonAsync>(); + rates.ShouldNotBeNull(); + rates.ShouldAllBe(r => r.QuoteCurrency.Code == "EUR" || r.QuoteCurrency.Code == "USD"); + } + + [Fact] + public async Task GetByCurrency_WithInvalidBaseCurrency_ReturnsBadRequest() + { + // Act + var response = await _client.GetAsync("/v1/api/exchange-rates?baseCurrency=XXX"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetByCurrency_WithInvalidQuoteCurrency_ReturnsBadRequest() + { + // Act + var response = await _client.GetAsync("/v1/api/exchange-rates?baseCurrency=CZK"eCurrencies=INVALID"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetByCurrency_WithoutBaseCurrency_ReturnsBadRequest() + { + // Act + var response = await _client.GetAsync("/v1/api/exchange-rates"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj index db296aa2a0..3f3854c079 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj @@ -9,11 +9,19 @@ + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs new file mode 100644 index 0000000000..4944f32cb0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs @@ -0,0 +1,35 @@ +using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ExchangeRateProvider.Api.Tests.Integration.ExternalServices; + +public class CzkExchangeRateServiceIntegrationTests +{ + [Fact] + public async Task GetExchangeRatesAsync_WithRealCnbApi_ReturnsRates() + { + // Arrange + var httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.cnb.cz"), + Timeout = TimeSpan.FromSeconds(30) + }; + var logger = NullLogger.Instance; + var service = new CzkExchangeRateService(httpClient, logger); + + // Act + var rates = await service.GetExchangeRatesAsync(new Currency("CZK"), CancellationToken.None); + + // Assert + rates.ShouldNotBeNull(); + rates.ShouldNotBeEmpty(); + rates.ShouldAllBe(r => r.BaseCurrency.Code == "CZK"); + rates.ShouldAllBe(r => r.Rate > 0); + + // Verify some expected currencies + rates.ShouldContain(r => r.QuoteCurrency.Code == "EUR"); + rates.ShouldContain(r => r.QuoteCurrency.Code == "USD"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Factories/ExchangeRateServiceFactoryIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Factories/ExchangeRateServiceFactoryIntegrationTests.cs new file mode 100644 index 0000000000..96d92b67a7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/Factories/ExchangeRateServiceFactoryIntegrationTests.cs @@ -0,0 +1,44 @@ +using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure; +using ExchangeRateProvider.Infrastructure.Factories; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace ExchangeRateProvider.Api.Tests.Integration.Factories; + +public class ExchangeRateServiceFactoryIntegrationTests +{ + [Theory] + [InlineData("CZK")] + [InlineData("czk")] + [InlineData("USD")] + public void GetService_WithRealDependencies_ResolvesService(string currencyCode) + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddInfrastructureServices(); + + var provider = services.BuildServiceProvider(); + var factory = new ExchangeRateServiceFactory(provider); + + // Act + var service = factory.GetService(new Currency(currencyCode)); + + // Assert + service.ShouldNotBeNull(); + } + + [Fact] + public void GetService_WhenNoServiceRegistered_ThrowsException() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var factory = new ExchangeRateServiceFactory(provider); + + // Act & Assert + Should.Throw(() => + factory.GetService(new Currency("CZK"))); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs deleted file mode 100644 index 19e5679652..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ExchangeRateProvider.Api.Tests.Integration -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} From c334affd8332df186d2bd1240e74a3ae831d76ab Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Sun, 7 Dec 2025 23:50:04 +0000 Subject: [PATCH 05/18] Centralise package management --- .../Directory.Packages.props | 33 +++++++++++++++++++ .../ExchangeRateProvider.Api.csproj | 6 ++-- .../ExchangeRateProvider.Application.csproj | 2 +- ...ExchangeRateProvider.Infrastructure.csproj | 4 +-- ...eRateProvider.Api.Tests.Integration.csproj | 12 +++---- ...ExchangeRateProvider.Api.Tests.Unit.csproj | 12 +++---- ...RateProvider.Application.Tests.Unit.csproj | 12 +++---- ...hangeRateProvider.Domain.Tests.Unit.csproj | 10 +++--- ...eProvider.Infrastructure.Tests.Unit.csproj | 12 +++---- 9 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props diff --git a/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props new file mode 100644 index 0000000000..238eae87cb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props @@ -0,0 +1,33 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj index ccf1d9cb4a..4ac4ed4f1b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj index 0e588987e3..3b09aa4a97 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj @@ -7,7 +7,7 @@ - + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj index daef521dd3..89fe283e0d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj index 3f3854c079..78abe5fa8b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj index ae0e99632b..a283f2d530 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj index 4c93bd2f87..9ed4097e25 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj index 60c55ce4ad..bdaf9fc283 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/ExchangeRateProvider.Domain.Tests.Unit.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj index dffe098a88..196944722a 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + From 669cd3fb5729cb29d78c0c175ba0b1bc1fbed361 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 13:54:06 +0000 Subject: [PATCH 06/18] Configure warnings as errors and fix resulting errors --- .../Directory.Build.props | 5 +++++ .../Controllers/ExchangeRateController.cs | 3 +++ .../CZK/CzkExchangeRateService.cs | 2 ++ .../ExchangeRateControllerTests.cs | 22 +++++++++---------- .../Constants/CurrencyServiceKeysTests.cs | 1 - 5 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/Directory.Build.props diff --git a/jobs/Backend/Task/ExchangeRateProvider/Directory.Build.props b/jobs/Backend/Task/ExchangeRateProvider/Directory.Build.props new file mode 100644 index 0000000000..8f6d9ff3c2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/Directory.Build.props @@ -0,0 +1,5 @@ + + + true + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs index 0736257290..5003743795 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs @@ -29,6 +29,9 @@ public async Task>> GetByCurrency( [FromQuery] List? quoteCurrencies, CancellationToken cancellationToken = default) { + logger.LogInformation("Received request to get exchange rates. BaseCurrency: {BaseCurrency}, QuoteCurrencies: {QuoteCurrencies}", + baseCurrency, quoteCurrencies is not null ? string.Join(", ", quoteCurrencies) : "null"); + if (string.IsNullOrWhiteSpace(baseCurrency)) { return BadRequest(new ProblemDetails diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs index a5996af85c..57786eb896 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -9,6 +9,8 @@ public class CzkExchangeRateService(HttpClient httpClient, ILogger>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) { + logger.LogInformation("Fetching CZK exchange rates from external service."); + var response = await httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"), cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs index efb641a025..b598888aab 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs @@ -68,7 +68,7 @@ public async Task GetByCurrency_WithQuoteCurrencies_PassesThemToHandlerAndReturn A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => q.BaseCurrency.Code == baseCurrency && - q.QuoteCurrencies.Count == 2 && + q.QuoteCurrencies!.Count == 2 && q.QuoteCurrencies.Any(c => c.Code == "EUR") && q.QuoteCurrencies.Any(c => c.Code == "GBP")), A._)) @@ -84,8 +84,8 @@ public async Task GetByCurrency_WithQuoteCurrencies_PassesThemToHandlerAndReturn A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => - q.QuoteCurrencies.Any(c => c.Code == "EUR") && - q.QuoteCurrencies.Any(c => c.Code == "GBP")), + q.QuoteCurrencies!.Any(c => c.Code == "EUR") && + q.QuoteCurrencies!.Any(c => c.Code == "GBP")), A._)) .MustHaveHappenedOnceExactly(); } @@ -161,8 +161,8 @@ public async Task GetByCurrency_WithUnsupportedBaseCurrency_ReturnsBadRequest() var problemDetails = badRequestResult.Value.ShouldBeOfType(); problemDetails.Title.ShouldBe("Unsupported base currency"); - problemDetails.Detail.ShouldContain($"The currency '{unsupportedCurrency}' is not supported"); - problemDetails.Detail.ShouldContain("Supported currencies:"); + problemDetails.Detail!.ShouldContain($"The currency '{unsupportedCurrency}' is not supported"); + problemDetails.Detail!.ShouldContain("Supported currencies:"); problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest); A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) @@ -206,7 +206,7 @@ public async Task GetByCurrency_WithQuoteCurrenciesContainingEmptyStrings_Filter A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => q.BaseCurrency.Code == baseCurrency && - q.QuoteCurrencies.Count == 2), + q.QuoteCurrencies!.Count == 2), A._)) .Returns(expectedRates); @@ -218,7 +218,7 @@ public async Task GetByCurrency_WithQuoteCurrenciesContainingEmptyStrings_Filter A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => - q.QuoteCurrencies.Count == 2 && + q.QuoteCurrencies!.Count == 2 && q.QuoteCurrencies.All(c => !string.IsNullOrWhiteSpace(c.Code))), A._)) .MustHaveHappenedOnceExactly(); @@ -277,8 +277,8 @@ public async Task GetByCurrency_WithLowercaseQuoteCurrencies_ConvertsToUppercase A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => - q.QuoteCurrencies.Any(c => c.Code == "EUR") && - q.QuoteCurrencies.Any(c => c.Code == "GBP")), + q.QuoteCurrencies!.Any(c => c.Code == "EUR") && + q.QuoteCurrencies!.Any(c => c.Code == "GBP")), A._)) .MustHaveHappenedOnceExactly(); } @@ -328,7 +328,7 @@ public async Task GetByCurrency_WithValidThreeLetterQuoteCurrencies_PassesValida result.Result.ShouldBeOfType(); A.CallTo(() => _fakeHandler.HandleAsync( - A.That.Matches(q => q.QuoteCurrencies.Count == 4), + A.That.Matches(q => q.QuoteCurrencies!.Count == 4), A._)) .MustHaveHappenedOnceExactly(); } @@ -357,7 +357,7 @@ public async Task GetByCurrency_WithMixedCaseValidQuoteCurrencies_ConvertsToUppe A.CallTo(() => _fakeHandler.HandleAsync( A.That.Matches(q => - q.QuoteCurrencies.Count == 3 && + q.QuoteCurrencies!.Count == 3 && q.QuoteCurrencies.All(c => c.Code == c.Code.ToUpperInvariant())), A._)) .MustHaveHappenedOnceExactly(); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs index ae9c89ec3a..b32806a107 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs @@ -24,7 +24,6 @@ public void IsSupported_WithUnsupportedCurrency_ReturnsFalse(string currencyCode } [Theory] - [InlineData(null)] [InlineData("")] [InlineData(" ")] public void IsSupported_WithNullOrWhitespace_ReturnsFalse(string currencyCode) From daf9794a1199bc19f113adddd07f1b896bf60459 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 17:20:21 +0000 Subject: [PATCH 07/18] Add dockerfile so that api can be spun up in a container --- .../src/ExchangeRateProvider.Api/Dockerfile | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Dockerfile diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Dockerfile b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Dockerfile new file mode 100644 index 0000000000..4d35b2d3f6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Dockerfile @@ -0,0 +1,38 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy project files +COPY ["Directory.Build.props", "."] +COPY ["Directory.Packages.props", "."] +COPY ["src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj", "src/ExchangeRateProvider.Api/"] +COPY ["src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj", "src/ExchangeRateProvider.Application/"] +COPY ["src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj", "src/ExchangeRateProvider.Domain/"] +COPY ["src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj", "src/ExchangeRateProvider.Infrastructure/"] + +# Restore dependencies +RUN dotnet restore "src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj" + +# Copy all source files +COPY ["src/ExchangeRateProvider.Api/", "ExchangeRateProvider.Api/"] +COPY ["src/ExchangeRateProvider.Application/", "ExchangeRateProvider.Application/"] +COPY ["src/ExchangeRateProvider.Domain/", "ExchangeRateProvider.Domain/"] +COPY ["src/ExchangeRateProvider.Infrastructure/", "ExchangeRateProvider.Infrastructure/"] + +# Build and publish +WORKDIR "/src/ExchangeRateProvider.Api" +RUN dotnet publish "ExchangeRateProvider.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . + +# Set environment variables +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +ENTRYPOINT ["dotnet", "ExchangeRateProvider.Api.dll"] \ No newline at end of file From 00d64408ab221a3530487c073cb7ace82d2ad7d1 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 21:43:04 +0000 Subject: [PATCH 08/18] Add polly retry policy with logging via context --- .../Directory.Packages.props | 3 + ...ExchangeRateProvider.Infrastructure.csproj | 2 + .../CZK/CzkExchangeRateService.cs | 13 ++- .../Policies/PolicyNames.cs | 9 ++ .../Policies/PollyContextExtensions.cs | 19 ++++ .../Policies/PollyContextItems.cs | 9 ++ .../Policies/PollyRegistryExtensions.cs | 33 ++++++ .../ServiceRegistration.cs | 20 +++- .../CzkExchangeRateServiceIntegrationTests.cs | 6 +- ...eProvider.Infrastructure.Tests.Unit.csproj | 1 + .../Policies/PollyContextExtensionsTests.cs | 107 ++++++++++++++++++ .../Policies/PollyRegistryExtensionsTests.cs | 84 ++++++++++++++ 12 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PolicyNames.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextItems.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyRegistryExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyContextExtensionsTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyRegistryExtensionsTests.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props index 238eae87cb..80acd909d0 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props +++ b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props @@ -12,10 +12,13 @@ + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj index 89fe283e0d..cc7840aed3 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -9,6 +9,8 @@ + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs index 57786eb896..b3ceadcf04 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -1,17 +1,26 @@ using ExchangeRateProvider.Domain.Interfaces; using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure.Policies; using Microsoft.Extensions.Logging; +using Polly; +using Polly.Registry; using System.Text.Json; namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; -public class CzkExchangeRateService(HttpClient httpClient, ILogger logger) : IExchangeRateService +public class CzkExchangeRateService(HttpClient httpClient, IReadOnlyPolicyRegistry policyRegistry, ILogger logger) : IExchangeRateService { public async Task>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) { logger.LogInformation("Fetching CZK exchange rates from external service."); - var response = await httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"), cancellationToken); + var retryPolicy = policyRegistry.Get>(PolicyNames.WaitAndRetry) ?? Policy.NoOpAsync(); + var context = new Context($"{nameof(GetExchangeRatesAsync)}-{Guid.NewGuid()}", new Dictionary + { + { PolicyContextItems.Logger, logger } + }); + + var response = await retryPolicy.ExecuteAsync(ctx => httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN")), context); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PolicyNames.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PolicyNames.cs new file mode 100644 index 0000000000..df8ababed9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PolicyNames.cs @@ -0,0 +1,9 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExchangeRateProvider.Infrastructure.Policies; + +[ExcludeFromCodeCoverage(Justification = "Contains only constant string declarations - no testable logic")] +public static class PolicyNames +{ + public const string WaitAndRetry = "wait-and-retry"; +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextExtensions.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextExtensions.cs new file mode 100644 index 0000000000..07ad662286 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; +using Polly; + +namespace ExchangeRateProvider.Infrastructure.Policies; + +public static class PollyContextExtensions +{ + public static bool TryGetLogger(this Context context, out ILogger logger) + { + if (context.TryGetValue(PolicyContextItems.Logger, out var loggerObject) && loggerObject is ILogger theLogger) + { + logger = theLogger; + return true; + } + + logger = null!; + return false; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextItems.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextItems.cs new file mode 100644 index 0000000000..18e4e69c68 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyContextItems.cs @@ -0,0 +1,9 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExchangeRateProvider.Infrastructure.Policies; + +[ExcludeFromCodeCoverage(Justification = "Contains only constant string declarations - no testable logic")] +public static class PolicyContextItems +{ + public const string Logger = "logger"; +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyRegistryExtensions.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyRegistryExtensions.cs new file mode 100644 index 0000000000..e32a399e77 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/Policies/PollyRegistryExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Registry; + +namespace ExchangeRateProvider.Infrastructure.Policies; + +public static class PollyRegistryExtensions +{ + public static IPolicyRegistry AddBasicRetryPolicy(this IPolicyRegistry policyRegistry) + { + var retryPolicy = Policy + .Handle() + .OrResult(r => !r.IsSuccessStatusCode) + .WaitAndRetryAsync(3, retryCount => TimeSpan.FromSeconds(10), (result, timeSpan, retryCount, context) => + { + if (!context.TryGetLogger(out var logger)) return; + + if (result.Exception != null) + { + logger.LogError(result.Exception, "An exception occurred on retry {RetryAttempt} for {PolicyKey}", retryCount, context.PolicyKey); + } + else + { + logger.LogError("A non success code {StatusCode} was received on retry {RetryAttempt} for {PolicyKey}", (int)result.Result.StatusCode, retryCount, context.PolicyKey); + } + }) + .WithPolicyKey(PolicyNames.WaitAndRetry); + + policyRegistry.Add(PolicyNames.WaitAndRetry, retryPolicy); + + return policyRegistry; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index 3ed07690c9..d8490f41ad 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -2,6 +2,7 @@ using ExchangeRateProvider.Domain.Interfaces; using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; using ExchangeRateProvider.Infrastructure.Factories; +using ExchangeRateProvider.Infrastructure.Policies; using Microsoft.Extensions.DependencyInjection; using System.Diagnostics.CodeAnalysis; @@ -13,15 +14,26 @@ public static class ServiceRegistration public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) { services.AddSingleton(); + services.AddKeyedTransient(CurrencyServiceKeys.CZK); + + ConfigurePolicyRegistry(services); + ConfigureHttpClients(services); + return services; + } + + private static void ConfigurePolicyRegistry(IServiceCollection services) + { + var registry = services.AddPolicyRegistry(); + registry.AddBasicRetryPolicy(); + } + + private static void ConfigureHttpClients(IServiceCollection services) + { services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.cnb.cz"); client.Timeout = TimeSpan.FromSeconds(30); }); - - services.AddKeyedTransient(CurrencyServiceKeys.CZK); - - return services; } } diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs index 4944f32cb0..b804125627 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs @@ -1,6 +1,8 @@ using ExchangeRateProvider.Domain.ValueObjects; using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using ExchangeRateProvider.Infrastructure.Policies; using Microsoft.Extensions.Logging.Abstractions; +using Polly.Registry; using Shouldly; namespace ExchangeRateProvider.Api.Tests.Integration.ExternalServices; @@ -16,8 +18,10 @@ public async Task GetExchangeRatesAsync_WithRealCnbApi_ReturnsRates() BaseAddress = new Uri("https://api.cnb.cz"), Timeout = TimeSpan.FromSeconds(30) }; + var logger = NullLogger.Instance; - var service = new CzkExchangeRateService(httpClient, logger); + var policyRegistry = new PolicyRegistry().AddBasicRetryPolicy(); + var service = new CzkExchangeRateService(httpClient, policyRegistry, logger); // Act var rates = await service.GetExchangeRatesAsync(new Currency("CZK"), CancellationToken.None); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj index 196944722a..4a9e44c9ac 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -10,6 +10,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyContextExtensionsTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyContextExtensionsTests.cs new file mode 100644 index 0000000000..09dab13086 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyContextExtensionsTests.cs @@ -0,0 +1,107 @@ +using ExchangeRateProvider.Infrastructure.Policies; +using Microsoft.Extensions.Logging.Testing; +using Polly; +using Shouldly; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.Policies; + +public class PollyContextExtensionsTests +{ + [Fact] + public void TryGetLogger_WithValidLogger_ReturnsTrue() + { + // Arrange + var fakeLogger = new FakeLogger(); + var context = new Context("test", new Dictionary + { + { PolicyContextItems.Logger, fakeLogger } + }); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeTrue(); + logger.ShouldBe(fakeLogger); + } + + [Fact] + public void TryGetLogger_WithoutLogger_ReturnsFalse() + { + // Arrange + var context = new Context("test"); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeFalse(); + logger.ShouldBeNull(); + } + + [Fact] + public void TryGetLogger_WithInvalidLoggerType_ReturnsFalse() + { + // Arrange + var context = new Context("test", new Dictionary + { + { PolicyContextItems.Logger, "not a logger" } + }); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeFalse(); + logger.ShouldBeNull(); + } + + [Fact] + public void TryGetLogger_WithNullValue_ReturnsFalse() + { + // Arrange + var context = new Context("test", new Dictionary + { + { PolicyContextItems.Logger, null! } + }); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeFalse(); + logger.ShouldBeNull(); + } + + [Fact] + public void TryGetLogger_WithDifferentLoggerKey_ReturnsFalse() + { + // Arrange + var fakeLogger = new FakeLogger(); + var context = new Context("test", new Dictionary + { + { "DifferentKey", fakeLogger } + }); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeFalse(); + logger.ShouldBeNull(); + } + + [Fact] + public void TryGetLogger_WithEmptyContext_ReturnsFalse() + { + // Arrange + var context = new Context("test", new Dictionary()); + + // Act + var result = context.TryGetLogger(out var logger); + + // Assert + result.ShouldBeFalse(); + logger.ShouldBeNull(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyRegistryExtensionsTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyRegistryExtensionsTests.cs new file mode 100644 index 0000000000..5930b81944 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/Policies/PollyRegistryExtensionsTests.cs @@ -0,0 +1,84 @@ +using ExchangeRateProvider.Infrastructure.Policies; +using Microsoft.Extensions.Logging.Testing; +using Polly; +using Polly.Registry; +using Shouldly; +using System.Net; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.Policies; + +public class PollyRegistryExtensionsTests +{ + [Fact] + public void AddBasicRetryPolicy_ShouldRegisterPolicyWithCorrectName() + { + // Arrange + var policyRegistry = new PolicyRegistry(); + + // Act + policyRegistry.AddBasicRetryPolicy(); + + // Assert + policyRegistry.ContainsKey(PolicyNames.WaitAndRetry).ShouldBeTrue(); + var policy = policyRegistry.Get>(PolicyNames.WaitAndRetry); + policy.ShouldNotBeNull(); + } + + [Fact] + public async Task AddBasicRetryPolicy_ShouldNotRetryOnSuccessStatusCode() + { + // Arrange + var policyRegistry = new PolicyRegistry(); + policyRegistry.AddBasicRetryPolicy(); + var policy = policyRegistry.Get>(PolicyNames.WaitAndRetry); + + var attemptCount = 0; + var fakeLogger = new FakeLogger(); + var context = new Context(PolicyNames.WaitAndRetry, new Dictionary + { + { PolicyContextItems.Logger, fakeLogger } + }); + + // Act + var result = await policy.ExecuteAsync(ctx => + { + attemptCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }, context); + + // Assert + attemptCount.ShouldBe(1); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + fakeLogger.Collector.Count.ShouldBe(0); + } + + [Fact] + public async Task AddBasicRetryPolicy_ShouldLogRetryAttemptNumber() + { + // Arrange + var policyRegistry = new PolicyRegistry(); + policyRegistry.AddBasicRetryPolicy(); + var policy = policyRegistry.Get>(PolicyNames.WaitAndRetry); + + var fakeLogger = new FakeLogger(); + var context = new Context(PolicyNames.WaitAndRetry, new Dictionary + { + { PolicyContextItems.Logger, fakeLogger } + }); + + // Act + await policy.ExecuteAsync(ctx => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)), + context); + + // Assert + var logs = fakeLogger.Collector.GetSnapshot().ToList(); + logs.Count.ShouldBe(3); // 3 retry attempts logged + + // Verify retry attempt numbers + logs[0].Message.ShouldContain("retry 1"); + logs[1].Message.ShouldContain("retry 2"); + logs[2].Message.ShouldContain("retry 3"); + } +} \ No newline at end of file From 31c36b945aeced38098c0b6abe7d79e07c3cc380 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 22:23:22 +0000 Subject: [PATCH 09/18] Refactored CzkExchangeRateService into APIClient and mapper to seperate concerns Refactored tests to use FakeLogger instead of FakeItEasy to more easily verify through unit tests thats correct logging is in place --- .../ExternalServices/CZK/CzkApiClient.cs | 41 +++++++++ .../CZK/CzkExchangeRateMapper.cs | 27 ++++++ .../CZK/CzkExchangeRateResponse.cs | 4 +- .../CZK/CzkExchangeRateService.cs | 31 ++----- .../ExternalServices/CZK/ICzkApiClient.cs | 6 ++ .../CZK/ICzkExchangeRateMapper.cs | 8 ++ .../ServiceRegistration.cs | 5 +- ...ExchangeRateProvider.Api.Tests.Unit.csproj | 1 + .../ExternalServices/CZK/CzkApiClientTests.cs | 87 +++++++++++++++++++ .../CZK/CzkExchangeRateMapperTests.cs | 80 +++++++++++++++++ .../CZK/CzkExchangeRateServiceTests.cs | 43 +++++++++ 11 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkApiClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkExchangeRateMapper.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateServiceTests.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs new file mode 100644 index 0000000000..a9d081ce1b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs @@ -0,0 +1,41 @@ +using ExchangeRateProvider.Infrastructure.Policies; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Registry; +using System.Text.Json; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; + +public class CzkApiClient(HttpClient httpClient, IReadOnlyPolicyRegistry policyRegistry, ILogger logger) : ICzkApiClient +{ + private const string ApiEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + + public async Task GetExchangeRatesAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Fetching exchange rates from CNB API"); + + var retryPolicy = policyRegistry.Get>(PolicyNames.WaitAndRetry) + ?? Policy.NoOpAsync(); + + var context = new Context($"{nameof(GetExchangeRatesAsync)}-{Guid.NewGuid()}", new Dictionary + { + { PolicyContextItems.Logger, logger } + }); + + var response = await retryPolicy.ExecuteAsync( + ctx => httpClient.GetAsync(new Uri(ApiEndpoint, UriKind.Absolute), cancellationToken), + context); + + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync( + stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, + cancellationToken).ConfigureAwait(false); + + logger.LogInformation("Successfully fetched exchange rates from CNB API"); + + return result; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs new file mode 100644 index 0000000000..0f5ca9bfe5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs @@ -0,0 +1,27 @@ +using ExchangeRateProvider.Domain.ValueObjects; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; + +public class CzkExchangeRateMapper(ILogger logger) : ICzkExchangeRateMapper +{ + public IList MapToExchangeRates(CzkExchangeRateResponse? response, Currency baseCurrency) + { + if (response?.Rates is null) + { + logger.LogWarning("Received null or empty response from CNB API"); + return new List(); + } + + var exchangeRates = response.Rates + .Select(rate => new ExchangeRate( + baseCurrency, + new Currency(rate.CurrencyCode), + rate.Amount / rate.Rate)) + .ToList(); + + logger.LogInformation("Mapped {Count} exchange rates from CNB response", exchangeRates.Count); + + return exchangeRates; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs index d908b63e3d..0dd57b0900 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs @@ -4,12 +4,12 @@ namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; [ExcludeFromCodeCoverage(Justification = "External API response DTO with only JSON serialization attributes - no business logic")] -internal sealed record CzkExchangeRateResponse( +public record CzkExchangeRateResponse( List Rates ); [ExcludeFromCodeCoverage(Justification = "External API response DTO with only JSON serialization attributes - no business logic")] -internal sealed record CzkRate +public record CzkRate { [JsonPropertyName("amount")] public required long Amount { get; init; } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs index b3ceadcf04..fa181b41b3 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -1,36 +1,15 @@ using ExchangeRateProvider.Domain.Interfaces; using ExchangeRateProvider.Domain.ValueObjects; -using ExchangeRateProvider.Infrastructure.Policies; -using Microsoft.Extensions.Logging; -using Polly; -using Polly.Registry; -using System.Text.Json; namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; -public class CzkExchangeRateService(HttpClient httpClient, IReadOnlyPolicyRegistry policyRegistry, ILogger logger) : IExchangeRateService +public class CzkExchangeRateService(ICzkApiClient apiClient, ICzkExchangeRateMapper mapper) : IExchangeRateService { - public async Task>GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) + public async Task> GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) { - logger.LogInformation("Fetching CZK exchange rates from external service."); + var response = await apiClient.GetExchangeRatesAsync(cancellationToken); + var exchangeRates = mapper.MapToExchangeRates(response, baseCurrency); - var retryPolicy = policyRegistry.Get>(PolicyNames.WaitAndRetry) ?? Policy.NoOpAsync(); - var context = new Context($"{nameof(GetExchangeRatesAsync)}-{Guid.NewGuid()}", new Dictionary - { - { PolicyContextItems.Logger, logger } - }); - - var response = await retryPolicy.ExecuteAsync(ctx => httpClient.GetAsync(new Uri("https://api.cnb.cz/cnbapi/exrates/daily?lang=EN")), context); - response.EnsureSuccessStatusCode(); - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); - var list = root?.Rates ?? new List(); - - return list.Select(r => new ExchangeRate( - baseCurrency, - new Currency(r.CurrencyCode), - r.Amount / r.Rate - )).ToList(); + return exchangeRates; } } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkApiClient.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkApiClient.cs new file mode 100644 index 0000000000..1f6b5cd7c9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkApiClient.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; + +public interface ICzkApiClient +{ + Task GetExchangeRatesAsync(CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkExchangeRateMapper.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkExchangeRateMapper.cs new file mode 100644 index 0000000000..608c49f67e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/ICzkExchangeRateMapper.cs @@ -0,0 +1,8 @@ +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; + +public interface ICzkExchangeRateMapper +{ + IList MapToExchangeRates(CzkExchangeRateResponse? response, Currency baseCurrency); +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index d8490f41ad..26d70ae85d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -16,6 +16,9 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti services.AddSingleton(); services.AddKeyedTransient(CurrencyServiceKeys.CZK); + services.AddTransient(); + services.AddTransient(); + ConfigurePolicyRegistry(services); ConfigureHttpClients(services); @@ -30,7 +33,7 @@ private static void ConfigurePolicyRegistry(IServiceCollection services) private static void ConfigureHttpClients(IServiceCollection services) { - services.AddHttpClient(client => + services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.cnb.cz"); client.Timeout = TimeSpan.FromSeconds(30); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj index a283f2d530..ec039defea 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj @@ -10,6 +10,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs new file mode 100644 index 0000000000..8624b4198f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs @@ -0,0 +1,87 @@ +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using ExchangeRateProvider.Infrastructure.Policies; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Polly; +using Polly.Registry; +using Shouldly; +using System.Net; +using System.Text.Json; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.ExternalServices.CZK; + +public class CzkApiClientTests +{ + private readonly IReadOnlyPolicyRegistry _policyRegistry; + private readonly FakeLogger _logger; + + public CzkApiClientTests() + { + _policyRegistry = new PolicyRegistry + { + [PolicyNames.WaitAndRetry] = Policy.NoOpAsync() + }; + _logger = new FakeLogger(); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithSuccessfulResponse_ReturnsDeserializedData() + { + // Arrange + var response = new CzkExchangeRateResponse(new List + { + new() { Amount = 1, CurrencyCode = "USD", Rate = 23.5m, Country = "USA", Currency = "Dollar", Order = 1, ValidFor = DateOnly.FromDateTime(DateTime.Today) } + }); + var httpClient = CreateHttpClientWithResponse(HttpStatusCode.OK, response); + var client = new CzkApiClient(httpClient, _policyRegistry, _logger); + + // Act + var result = await client.GetExchangeRatesAsync(CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Rates.ShouldHaveSingleItem(); + result.Rates[0].CurrencyCode.ShouldBe("USD"); + + var logs = _logger.Collector.GetSnapshot(); + logs.Count.ShouldBe(2); + logs[0].Level.ShouldBe(LogLevel.Information); + logs[0].Message.ShouldContain("Fetching exchange rates from CNB API"); + logs[1].Level.ShouldBe(LogLevel.Information); + logs[1].Message.ShouldContain("Successfully fetched exchange rates from CNB API"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithHttpError_ThrowsHttpRequestException() + { + // Arrange + var httpClient = CreateHttpClientWithResponse(HttpStatusCode.InternalServerError, string.Empty); + var client = new CzkApiClient(httpClient, _policyRegistry, _logger); + + // Act & Assert + await Should.ThrowAsync(() => client.GetExchangeRatesAsync(CancellationToken.None)); + + var logs = _logger.Collector.GetSnapshot(); + logs[0].Level.ShouldBe(LogLevel.Information); + logs[0].Message.ShouldContain("Fetching exchange rates from CNB API"); + } + + private static HttpClient CreateHttpClientWithResponse(HttpStatusCode statusCode, object content) + { + var json = content is string s ? s : JsonSerializer.Serialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var handler = new MockHttpMessageHandler(statusCode, json); + return new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; + } + + private class MockHttpMessageHandler(HttpStatusCode statusCode, string content) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(content) + }); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs new file mode 100644 index 0000000000..7a99a99215 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs @@ -0,0 +1,80 @@ +using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Shouldly; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.ExternalServices.CZK; + +public class CzkExchangeRateMapperTests +{ + private readonly FakeLogger _logger; + private readonly CzkExchangeRateMapper _mapper; + + public CzkExchangeRateMapperTests() + { + _logger = new FakeLogger(); + _mapper = new CzkExchangeRateMapper(_logger); + } + + [Fact] + public void MapToExchangeRates_WithValidResponse_MapsCorrectly() + { + // Arrange + var response = new CzkExchangeRateResponse(new List + { + new() { Amount = 1, CurrencyCode = "USD", Rate = 23.5m, Country = "USA", Currency = "Dollar", Order = 1, ValidFor = DateOnly.FromDateTime(DateTime.Today) }, + new() { Amount = 1, CurrencyCode = "EUR", Rate = 25.0m, Country = "EU", Currency = "Euro", Order = 2, ValidFor = DateOnly.FromDateTime(DateTime.Today) } + }); + var baseCurrency = new Currency("CZK"); + + // Act + var result = _mapper.MapToExchangeRates(response, baseCurrency); + + // Assert + result.ShouldNotBeEmpty(); + result.Count.ShouldBe(2); + result[0].BaseCurrency.Code.ShouldBe("CZK"); + result[0].QuoteCurrency.Code.ShouldBe("USD"); + result[0].Rate.ShouldBe(1m / 23.5m); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain("Mapped 2 exchange rates from CNB response"); + } + + [Fact] + public void MapToExchangeRates_WithNullResponse_ReturnsEmptyList() + { + // Arrange + var baseCurrency = new Currency("CZK"); + + // Act + var result = _mapper.MapToExchangeRates(null, baseCurrency); + + // Assert + result.ShouldBeEmpty(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Warning); + logEntry.Message.ShouldContain("Received null or empty response from CNB API"); + } + + [Fact] + public void MapToExchangeRates_WithNullRates_ReturnsEmptyList() + { + // Arrange + var response = new CzkExchangeRateResponse(null!); + var baseCurrency = new Currency("CZK"); + + // Act + var result = _mapper.MapToExchangeRates(response, baseCurrency); + + // Assert + result.ShouldBeEmpty(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Warning); + logEntry.Message.ShouldContain("Received null or empty response from CNB API"); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateServiceTests.cs new file mode 100644 index 0000000000..833a15db34 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateServiceTests.cs @@ -0,0 +1,43 @@ +using ExchangeRateProvider.Domain.ValueObjects; +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using FakeItEasy; +using Microsoft.Extensions.Logging.Testing; +using Shouldly; + +namespace ExchangeRateProvider.Infrastructure.Tests.Unit.ExternalServices.CZK; + +public class CzkExchangeRateServiceTests +{ + private readonly ICzkApiClient _apiClient; + private readonly ICzkExchangeRateMapper _mapper; + + public CzkExchangeRateServiceTests() + { + _apiClient = A.Fake(); + _mapper = A.Fake(); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithValidResponse_ReturnsMappedRates() + { + // Arrange + var response = new CzkExchangeRateResponse(new List()); + var expectedRates = new List + { + new(new Currency("CZK"), new Currency("USD"), 0.042m) + }; + + A.CallTo(() => _apiClient.GetExchangeRatesAsync(A._)).Returns(response); + A.CallTo(() => _mapper.MapToExchangeRates(response, A._)).Returns(expectedRates); + + var service = new CzkExchangeRateService(_apiClient, _mapper); + + // Act + var result = await service.GetExchangeRatesAsync(new Currency("CZK"), CancellationToken.None); + + // Assert + result.ShouldBe(expectedRates); + A.CallTo(() => _apiClient.GetExchangeRatesAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => _mapper.MapToExchangeRates(response, A._)).MustHaveHappenedOnceExactly(); + } +} From effd1d9282f2aa7060da6c027f14239bf8e6de33 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 22:24:45 +0000 Subject: [PATCH 10/18] Fix integration tests following refactor --- .../CzkExchangeRateServiceIntegrationTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs index b804125627..a084c4cc05 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs @@ -19,9 +19,13 @@ public async Task GetExchangeRatesAsync_WithRealCnbApi_ReturnsRates() Timeout = TimeSpan.FromSeconds(30) }; - var logger = NullLogger.Instance; var policyRegistry = new PolicyRegistry().AddBasicRetryPolicy(); - var service = new CzkExchangeRateService(httpClient, policyRegistry, logger); + var apiClientLogger = NullLogger.Instance; + var mapperLogger = NullLogger.Instance; + + var apiClient = new CzkApiClient(httpClient, policyRegistry, apiClientLogger); + var mapper = new CzkExchangeRateMapper(mapperLogger); + var service = new CzkExchangeRateService(apiClient, mapper); // Act var rates = await service.GetExchangeRatesAsync(new Currency("CZK"), CancellationToken.None); From 09d5bb9d08fb6dff26ef3488aa4d2c73a26460ec Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 22:27:10 +0000 Subject: [PATCH 11/18] verfif logging in controller tests --- .../ExchangeRateControllerTests.cs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs index b598888aab..f3b6694b5a 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Shouldly; namespace ExchangeRateProvider.Api.Tests.Unit.Controllers; @@ -15,16 +16,16 @@ namespace ExchangeRateProvider.Api.Tests.Unit.Controllers; public class ExchangeRateControllerTests { private readonly IQueryHandler> _fakeHandler; - private readonly ILogger _fakeLogger; + private readonly FakeLogger _logger; private readonly ExchangeRateController _controller; private readonly QuoteCurrenciesValidator _validator; public ExchangeRateControllerTests() { _fakeHandler = A.Fake>>(); - _fakeLogger = A.Fake>(); + _logger = new FakeLogger(); _validator = new QuoteCurrenciesValidator(); - _controller = new ExchangeRateController(_fakeHandler, _validator, _fakeLogger); + _controller = new ExchangeRateController(_fakeHandler, _validator, _logger); } [Theory] @@ -51,6 +52,11 @@ public async Task GetByCurrency_WithValidBaseCurrency_ReturnsOkWithRates(string var okResult = (OkObjectResult)result.Result; okResult.StatusCode.ShouldBe(StatusCodes.Status200OK); okResult.Value.ShouldBe(expectedRates); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain($"BaseCurrency: {baseCurrency}"); + logEntry.Message.ShouldContain("QuoteCurrencies: null"); } [Theory] @@ -88,6 +94,11 @@ public async Task GetByCurrency_WithQuoteCurrencies_PassesThemToHandlerAndReturn q.QuoteCurrencies!.Any(c => c.Code == "GBP")), A._)) .MustHaveHappenedOnceExactly(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain($"BaseCurrency: {baseCurrency}"); + logEntry.Message.ShouldContain("QuoteCurrencies: EUR, GBP"); } [Fact] @@ -108,6 +119,10 @@ public async Task GetByCurrency_WithNullBaseCurrency_ReturnsBadRequest() A.CallTo(() => _fakeHandler.HandleAsync(A._, A._)) .MustNotHaveHappened(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain("BaseCurrency: "); } [Fact] From 1d8b504bd9344c240041197c0eb6483b1a0e1d8b Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 23:07:50 +0000 Subject: [PATCH 12/18] Update readme --- .../Task/ExchangeRateProvider/README.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/README.md diff --git a/jobs/Backend/Task/ExchangeRateProvider/README.md b/jobs/Backend/Task/ExchangeRateProvider/README.md new file mode 100644 index 0000000000..0511c6d80c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/README.md @@ -0,0 +1,202 @@ +# Exchange Rate Provider API + +A REST API for retrieving exchange rates for a given currency with optional filtering, built with .NET 10, following clean architecture principles and SOLID design patterns. + +## Architecture + +The solution follows **Clean Architecture** with clear separation of concerns: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ ExchangeRateProvider.Api │ +│ (Controllers, Validators, OpenAPI Config) │ +└────────────────────┬────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────┐ +│ Application Layer │ +│ ExchangeRateProvider.Application │ +│ (Handlers, Queries, DTOs, CQRS) │ +└────────────────────┬────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────┐ +│ Domain Layer │ +│ ExchangeRateProvider.Domain │ +│ (Entities, Value Objects, Interfaces, Constants) │ +└────────────────────┬────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ExchangeRateProvider.Infrastructure │ +│ (External Services, HTTP Clients, Polly Policies) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Getting Started + +### Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [Docker](https://www.docker.com/get-started) (optional, for containerized deployment) + +### Building the Solution + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Build in Release mode +dotnet build -c Release +``` + +### Running Locally + +```bash +# Run the API +cd src/ExchangeRateProvider.Api +dotnet run + +# API will be available at: +# - HTTP: http://localhost:5291 +# - HTTPS: https://localhost:7291 +``` + +### Running Tests + +```bash +# Run all tests +dotnet test + +# Run tests with coverage +dotnet test" +``` + +## Docker + +### Building the Docker Image + +```bash +# Build from solution root +docker build -t exchange-rate-provider-api -f src/ExchangeRateProvider.Api/Dockerfile . +``` + +### Running the Container + +```bash +# Run on port 8080 +docker run -p 8080:8080 --name exchange-rate-api exchange-rate-provider-api +``` + +## API Documentation + +### Accessing API Documentation + +When running in **Development** mode, interactive API documentation is available via **Scalar**: + +``` +http://localhost:5291/scalar/v1 +``` + +Or when running in Docker: +``` +http://localhost:8080/scalar/v1 +``` + +### API Endpoints + +#### Get Exchange Rates + +**Endpoint**: `GET /api/exchangerates` + +**Query Parameters**: +- `baseCurrency` (required): The base currency code (e.g., "CZK") +- `quoteCurrencies` (optional): List of quote currency codes to filter results + +**Example Requests**: + +```bash +# Get all available exchange rates for CZK +curl "http://localhost:8080/api/exchangerates?baseCurrency=CZK" + +# Get specific quote currencies +curl "http://localhost:8080/api/exchangerates?baseCurrency=CZK"eCurrencies=EUR"eCurrencies=USD"eCurrencies=GBP" +``` + +**Example Response** (200 OK): + +```json +[ + { + "baseCurrency": { + "code": "CZK" + }, + "quoteCurrency": { + "code": "EUR" + }, + "rate": 0.04012 + }, + { + "baseCurrency": { + "code": "CZK" + }, + "quoteCurrency": { + "code": "USD" + }, + "rate": 0.04357 + } +] +``` + +**Error Responses**: + +- **400 Bad Request**: Invalid or unsupported currency + ```json + { + "title": "Invalid base currency", + "detail": "Base currency is required.", + "status": 400 + } + ``` + +- **400 Bad Request**: Unsupported currency + ```json + { + "title": "Unsupported base currency", + "detail": "The currency 'XXX' is not supported. Supported currencies: CZK", + "status": 400 + } + ``` + +### Supported Currencies + +**Base Currency**: Currently supports `CZK` (Czech Koruna) + +**Quote Currencies**: All currencies provided by the CNB API, typically including: +- EUR (Euro) +- USD (US Dollar) +- GBP (British Pound) +- JPY (Japanese Yen) +- And 30+ other major world currencies + +The API returns only currencies provided by the source - no calculated inverse rates. + +## Configuration + +### Application Settings + +Configuration is managed via `appsettings.json` and `appsettings.Development.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` From dc3a91286d1c7c27728aa02f40001b38a3a8dfb4 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 23:16:59 +0000 Subject: [PATCH 13/18] Update README --- jobs/Backend/Task/ExchangeRateProvider/README.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/README.md b/jobs/Backend/Task/ExchangeRateProvider/README.md index 0511c6d80c..c86deb6769 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/README.md +++ b/jobs/Backend/Task/ExchangeRateProvider/README.md @@ -8,13 +8,13 @@ The solution follows **Clean Architecture** with clear separation of concerns: ``` ┌─────────────────────────────────────────────────────────┐ -│ Presentation Layer │ +│ Presentation Layer │ │ ExchangeRateProvider.Api │ │ (Controllers, Validators, OpenAPI Config) │ └────────────────────┬────────────────────────────────────┘ │ ┌────────────────────▼────────────────────────────────────┐ -│ Application Layer │ +│ Application Layer │ │ ExchangeRateProvider.Application │ │ (Handlers, Queries, DTOs, CQRS) │ └────────────────────┬────────────────────────────────────┘ @@ -47,9 +47,6 @@ dotnet restore # Build all projects dotnet build - -# Build in Release mode -dotnet build -c Release ``` ### Running Locally @@ -69,9 +66,6 @@ dotnet run ```bash # Run all tests dotnet test - -# Run tests with coverage -dotnet test" ``` ## Docker @@ -109,7 +103,7 @@ http://localhost:8080/scalar/v1 #### Get Exchange Rates -**Endpoint**: `GET /api/exchangerates` +**Endpoint**: `GET v1/api/exchangerates` **Query Parameters**: - `baseCurrency` (required): The base currency code (e.g., "CZK") @@ -119,10 +113,10 @@ http://localhost:8080/scalar/v1 ```bash # Get all available exchange rates for CZK -curl "http://localhost:8080/api/exchangerates?baseCurrency=CZK" +curl "http://localhost:8080/v1/api/exchangerates?baseCurrency=CZK" # Get specific quote currencies -curl "http://localhost:8080/api/exchangerates?baseCurrency=CZK"eCurrencies=EUR"eCurrencies=USD"eCurrencies=GBP" +curl "http://localhost:8080/v1/api/exchangerates?baseCurrency=CZK"eCurrencies=EUR"eCurrencies=USD"eCurrencies=GBP" ``` **Example Response** (200 OK): From 1c9509cd9946c065aa5fda31b426f97e999e8952 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 23:44:26 +0000 Subject: [PATCH 14/18] Use LazyCache to implement caching of external api data --- .../Directory.Packages.props | 8 +-- ...ExchangeRateProvider.Infrastructure.csproj | 1 + .../ExternalServices/CZK/CzkApiClient.cs | 19 ++++++- .../ServiceRegistration.cs | 3 ++ .../CzkExchangeRateServiceIntegrationTests.cs | 4 +- ...eProvider.Infrastructure.Tests.Unit.csproj | 1 + .../ExternalServices/CZK/CzkApiClientTests.cs | 53 ++++++++++++++++--- 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props index 80acd909d0..e9aaa118e6 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props +++ b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props @@ -3,32 +3,28 @@ true - + + - - - - - diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj index cc7840aed3..63ee5b2332 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs index a9d081ce1b..9642e9327a 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs @@ -1,4 +1,5 @@ using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; using Microsoft.Extensions.Logging; using Polly; using Polly.Registry; @@ -6,11 +7,25 @@ namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; -public class CzkApiClient(HttpClient httpClient, IReadOnlyPolicyRegistry policyRegistry, ILogger logger) : ICzkApiClient +public class CzkApiClient( + HttpClient httpClient, + IReadOnlyPolicyRegistry policyRegistry, + IAppCache cache, + ILogger logger) : ICzkApiClient { private const string ApiEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + private const string CacheKey = "CzkExchangeRates"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6); public async Task GetExchangeRatesAsync(CancellationToken cancellationToken = default) + { + return await cache.GetOrAddAsync( + CacheKey, + async () => await FetchFromApiAsync(cancellationToken), + CacheDuration); + } + + private async Task FetchFromApiAsync(CancellationToken cancellationToken) { logger.LogInformation("Fetching exchange rates from CNB API"); @@ -34,7 +49,7 @@ public class CzkApiClient(HttpClient httpClient, IReadOnlyPolicyRegistry new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Successfully fetched exchange rates from CNB API"); + logger.LogInformation("Successfully fetched and cached exchange rates from CNB API"); return result; } diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index 26d70ae85d..f34d2d16e0 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -3,6 +3,7 @@ using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; using ExchangeRateProvider.Infrastructure.Factories; using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; using Microsoft.Extensions.DependencyInjection; using System.Diagnostics.CodeAnalysis; @@ -19,6 +20,8 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti services.AddTransient(); services.AddTransient(); + services.AddLazyCache(); + ConfigurePolicyRegistry(services); ConfigureHttpClients(services); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs index a084c4cc05..d76c13fa27 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs @@ -1,6 +1,7 @@ using ExchangeRateProvider.Domain.ValueObjects; using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; using Microsoft.Extensions.Logging.Abstractions; using Polly.Registry; using Shouldly; @@ -20,10 +21,11 @@ public async Task GetExchangeRatesAsync_WithRealCnbApi_ReturnsRates() }; var policyRegistry = new PolicyRegistry().AddBasicRetryPolicy(); + var cache = new CachingService(); var apiClientLogger = NullLogger.Instance; var mapperLogger = NullLogger.Instance; - var apiClient = new CzkApiClient(httpClient, policyRegistry, apiClientLogger); + var apiClient = new CzkApiClient(httpClient, policyRegistry, cache, apiClientLogger); var mapper = new CzkExchangeRateMapper(mapperLogger); var service = new CzkExchangeRateService(apiClient, mapper); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj index 4a9e44c9ac..177446d36e 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -10,6 +10,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs index 8624b4198f..6f1f32abf7 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs @@ -1,5 +1,8 @@ using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; +using LazyCache.Providers; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Polly; @@ -13,6 +16,7 @@ namespace ExchangeRateProvider.Infrastructure.Tests.Unit.ExternalServices.CZK; public class CzkApiClientTests { private readonly IReadOnlyPolicyRegistry _policyRegistry; + private readonly IAppCache _cache; private readonly FakeLogger _logger; public CzkApiClientTests() @@ -21,6 +25,7 @@ public CzkApiClientTests() { [PolicyNames.WaitAndRetry] = Policy.NoOpAsync() }; + _cache = new CachingService(new MemoryCacheProvider(new MemoryCache(new MemoryCacheOptions()))); _logger = new FakeLogger(); } @@ -33,7 +38,7 @@ public async Task GetExchangeRatesAsync_WithSuccessfulResponse_ReturnsDeserializ new() { Amount = 1, CurrencyCode = "USD", Rate = 23.5m, Country = "USA", Currency = "Dollar", Order = 1, ValidFor = DateOnly.FromDateTime(DateTime.Today) } }); var httpClient = CreateHttpClientWithResponse(HttpStatusCode.OK, response); - var client = new CzkApiClient(httpClient, _policyRegistry, _logger); + var client = new CzkApiClient(httpClient, _policyRegistry, _cache, _logger); // Act var result = await client.GetExchangeRatesAsync(CancellationToken.None); @@ -48,7 +53,31 @@ public async Task GetExchangeRatesAsync_WithSuccessfulResponse_ReturnsDeserializ logs[0].Level.ShouldBe(LogLevel.Information); logs[0].Message.ShouldContain("Fetching exchange rates from CNB API"); logs[1].Level.ShouldBe(LogLevel.Information); - logs[1].Message.ShouldContain("Successfully fetched exchange rates from CNB API"); + logs[1].Message.ShouldContain("Successfully fetched and cached exchange rates from CNB API"); + } + + [Fact] + public async Task GetExchangeRatesAsync_SecondCall_ReturnsCachedData() + { + // Arrange + var response = new CzkExchangeRateResponse(new List + { + new() { Amount = 1, CurrencyCode = "USD", Rate = 23.5m, Country = "USA", Currency = "Dollar", Order = 1, ValidFor = DateOnly.FromDateTime(DateTime.Today) } + }); + + var callCount = 0; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, JsonSerializer.Serialize(response, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }), () => callCount++); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; + var client = new CzkApiClient(httpClient, _policyRegistry, _cache, _logger); + + // Act + var result1 = await client.GetExchangeRatesAsync(CancellationToken.None); + var result2 = await client.GetExchangeRatesAsync(CancellationToken.None); + + // Assert + result1.ShouldNotBeNull(); + result2.ShouldNotBeNull(); + callCount.ShouldBe(1); } [Fact] @@ -56,7 +85,7 @@ public async Task GetExchangeRatesAsync_WithHttpError_ThrowsHttpRequestException { // Arrange var httpClient = CreateHttpClientWithResponse(HttpStatusCode.InternalServerError, string.Empty); - var client = new CzkApiClient(httpClient, _policyRegistry, _logger); + var client = new CzkApiClient(httpClient, _policyRegistry, _cache, _logger); // Act & Assert await Should.ThrowAsync(() => client.GetExchangeRatesAsync(CancellationToken.None)); @@ -73,14 +102,26 @@ private static HttpClient CreateHttpClientWithResponse(HttpStatusCode statusCode return new HttpClient(handler) { BaseAddress = new Uri("https://api.cnb.cz") }; } - private class MockHttpMessageHandler(HttpStatusCode statusCode, string content) : HttpMessageHandler + private class MockHttpMessageHandler : HttpMessageHandler { + private readonly HttpStatusCode _statusCode; + private readonly string _content; + private readonly Action? _onSend; + + public MockHttpMessageHandler(HttpStatusCode statusCode, string content, Action? onSend = null) + { + _statusCode = statusCode; + _content = content; + _onSend = onSend; + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + _onSend?.Invoke(); return Task.FromResult(new HttpResponseMessage { - StatusCode = statusCode, - Content = new StringContent(content) + StatusCode = _statusCode, + Content = new StringContent(_content) }); } } From 88553b5a39545922e6dfc0660e89f93101c95d7f Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Mon, 8 Dec 2025 23:44:56 +0000 Subject: [PATCH 15/18] Add cache reference to tests --- .../ExchangeRateProvider.Api.Tests.Integration.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj index 78abe5fa8b..64aef4e116 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj @@ -9,6 +9,7 @@ + From 9748289b239ac7f6660ab859a8a07242a6e12862 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Tue, 9 Dec 2025 07:05:24 +0000 Subject: [PATCH 16/18] Update cache time --- .../ExternalServices/CZK/CzkApiClient.cs | 2 +- .../ServiceRegistration.cs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs index 9642e9327a..640d602760 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs @@ -15,7 +15,7 @@ public class CzkApiClient( { private const string ApiEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; private const string CacheKey = "CzkExchangeRates"; - private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6); + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); public async Task GetExchangeRatesAsync(CancellationToken cancellationToken = default) { diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs index f34d2d16e0..55b1e5cebd 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -36,10 +36,6 @@ private static void ConfigurePolicyRegistry(IServiceCollection services) private static void ConfigureHttpClients(IServiceCollection services) { - services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://api.cnb.cz"); - client.Timeout = TimeSpan.FromSeconds(30); - }); + services.AddHttpClient(); } } From b41ef205808545cda27dcb10925d8370b5dd4ba5 Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Tue, 9 Dec 2025 07:56:32 +0000 Subject: [PATCH 17/18] Fix bug in data mapping logic --- .../ExternalServices/CZK/CzkExchangeRateMapper.cs | 2 +- .../ExternalServices/CZK/CzkExchangeRateMapperTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs index 0f5ca9bfe5..4876072311 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateMapper.cs @@ -17,7 +17,7 @@ public IList MapToExchangeRates(CzkExchangeRateResponse? response, .Select(rate => new ExchangeRate( baseCurrency, new Currency(rate.CurrencyCode), - rate.Amount / rate.Rate)) + rate.Rate / rate.Amount)) .ToList(); logger.LogInformation("Mapped {Count} exchange rates from CNB response", exchangeRates.Count); diff --git a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs index 7a99a99215..3469f16630 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkExchangeRateMapperTests.cs @@ -36,7 +36,7 @@ public void MapToExchangeRates_WithValidResponse_MapsCorrectly() result.Count.ShouldBe(2); result[0].BaseCurrency.Code.ShouldBe("CZK"); result[0].QuoteCurrency.Code.ShouldBe("USD"); - result[0].Rate.ShouldBe(1m / 23.5m); + result[0].Rate.ShouldBe(23.5m / 1m); var logEntry = _logger.Collector.GetSnapshot().Single(); logEntry.Level.ShouldBe(LogLevel.Information); From abde38ab56d083c4f25849e85ac809c6953411df Mon Sep 17 00:00:00 2001 From: Ashleigh Adams Date: Tue, 9 Dec 2025 18:23:06 +0000 Subject: [PATCH 18/18] Add launch settings, fix typo in readme --- .../Task/ExchangeRateProvider/README.md | 11 +++------ .../Properties/launchSettings.json | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Properties/launchSettings.json diff --git a/jobs/Backend/Task/ExchangeRateProvider/README.md b/jobs/Backend/Task/ExchangeRateProvider/README.md index c86deb6769..b168b03b1b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider/README.md +++ b/jobs/Backend/Task/ExchangeRateProvider/README.md @@ -91,19 +91,14 @@ docker run -p 8080:8080 --name exchange-rate-api exchange-rate-provider-api When running in **Development** mode, interactive API documentation is available via **Scalar**: ``` -http://localhost:5291/scalar/v1 -``` - -Or when running in Docker: -``` -http://localhost:8080/scalar/v1 +https://localhost:5291/scalar/v1 ``` ### API Endpoints #### Get Exchange Rates -**Endpoint**: `GET v1/api/exchangerates` +**Endpoint**: `GET v1/api/exchange-rates` **Query Parameters**: - `baseCurrency` (required): The base currency code (e.g., "CZK") @@ -193,4 +188,4 @@ Configuration is managed via `appsettings.json` and `appsettings.Development.jso }, "AllowedHosts": "*" } -``` +``` \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..7a65b74f83 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5291", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7073;http://localhost:5291", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +}