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/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/Directory.Packages.props b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props new file mode 100644 index 0000000000..e9aaa118e6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/Directory.Packages.props @@ -0,0 +1,32 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx new file mode 100644 index 0000000000..7690a2b847 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/ExchangeRateProvider.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProvider/README.md b/jobs/Backend/Task/ExchangeRateProvider/README.md new file mode 100644 index 0000000000..b168b03b1b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/README.md @@ -0,0 +1,191 @@ +# 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 +``` + +### 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 +``` + +## 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**: + +``` +https://localhost:5291/scalar/v1 +``` + +### API Endpoints + +#### Get Exchange Rates + +**Endpoint**: `GET v1/api/exchange-rates` + +**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/v1/api/exchangerates?baseCurrency=CZK" + +# Get specific quote currencies +curl "http://localhost:8080/v1/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": "*" +} +``` \ No newline at end of file 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..2c0e326a6d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Configuration/OpenApiDocumentTransformer.cs @@ -0,0 +1,28 @@ +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) + { + 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..63bb20e094 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Constants/ApiEndpoints.cs @@ -0,0 +1,16 @@ +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"; + 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..5003743795 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Controllers/ExchangeRateController.cs @@ -0,0 +1,86 @@ +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; + +namespace ExchangeRateProvider.Api.Controllers +{ + [ApiController] + [Produces("application/json")] + [Tags("Exchange Rates")] + public class ExchangeRateController( + IQueryHandler> handler, + IQuoteCurrenciesValidator quoteCurrenciesValidator, + 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) + { + 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 + { + 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(); + + var query = new GetExchangeRatesQuery( + new Currency(baseCurrency), + quotes?.Count > 0 ? quotes : null + ); + + var rates = await handler.HandleAsync(query, cancellationToken); + return Ok(rates); + } + } +} 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 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..4ac4ed4f1b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -0,0 +1,20 @@ + + + + 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..46618a42b5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Api/Program.cs @@ -0,0 +1,43 @@ +using ExchangeRateProvider.Api.Configuration; +using ExchangeRateProvider.Api.Validators; +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.AddScoped(); + +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/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" + } + } + } +} 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.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..8da4856cac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/DTOs/ExchangeRateDto.cs @@ -0,0 +1,11 @@ +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, + 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..3b09aa4a97 --- /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/GetExchangeRatesQueryHandler.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRatesQueryHandler.cs new file mode 100644 index 0000000000..4481ae3eb4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Handlers/GetExchangeRatesQueryHandler.cs @@ -0,0 +1,27 @@ +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 GetExchangeRatesQueryHandler(IExchangeRateServiceFactory serviceFactory) : IQueryHandler> +{ + public async Task> HandleAsync(GetExchangeRatesQuery query, CancellationToken cancellationToken = default) + { + var exchangeRateService = serviceFactory.GetService(query.BaseCurrency); + + 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 new file mode 100644 index 0000000000..3c1eb8284e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/Queries/GetExchangeRatesQuery.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000000..09ccc68ab7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Application/ServiceRegistration.cs @@ -0,0 +1,19 @@ +using ExchangeRateProvider.Application.Abstractions; +using ExchangeRateProvider.Application.DTOs; +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>, GetExchangeRatesQueryHandler>(); + + return services; + } +} 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/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/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.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs new file mode 100644 index 0000000000..8e3cddfd1f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/Currency.cs @@ -0,0 +1,14 @@ +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) + { + 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..cacb2be169 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Domain/ValueObjects/ExchangeRate.cs @@ -0,0 +1,20 @@ +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) + { + 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..63ee5b2332 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + 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..640d602760 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkApiClient.cs @@ -0,0 +1,56 @@ +using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; +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, + 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(1); + + 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"); + + 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 and cached 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..4876072311 --- /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.Rate / rate.Amount)) + .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 new file mode 100644 index 0000000000..0dd57b0900 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateResponse.cs @@ -0,0 +1,34 @@ +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")] +public record CzkExchangeRateResponse( + List Rates +); + +[ExcludeFromCodeCoverage(Justification = "External API response DTO with only JSON serialization attributes - no business logic")] +public record CzkRate +{ + [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/CZK/CzkExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs new file mode 100644 index 0000000000..fa181b41b3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ExternalServices/CZK/CzkExchangeRateService.cs @@ -0,0 +1,15 @@ +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Domain.ValueObjects; + +namespace ExchangeRateProvider.Infrastructure.ExternalServices.CZK; + +public class CzkExchangeRateService(ICzkApiClient apiClient, ICzkExchangeRateMapper mapper) : IExchangeRateService +{ + public async Task> GetExchangeRatesAsync(Currency baseCurrency, CancellationToken cancellationToken = default) + { + var response = await apiClient.GetExchangeRatesAsync(cancellationToken); + var exchangeRates = mapper.MapToExchangeRates(response, baseCurrency); + + 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/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/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 new file mode 100644 index 0000000000..55b1e5cebd --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/src/ExchangeRateProvider.Infrastructure/ServiceRegistration.cs @@ -0,0 +1,41 @@ +using ExchangeRateProvider.Domain.Constants; +using ExchangeRateProvider.Domain.Interfaces; +using ExchangeRateProvider.Infrastructure.ExternalServices.CZK; +using ExchangeRateProvider.Infrastructure.Factories; +using ExchangeRateProvider.Infrastructure.Policies; +using LazyCache; +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) + { + services.AddSingleton(); + services.AddKeyedTransient(CurrencyServiceKeys.CZK); + + services.AddTransient(); + services.AddTransient(); + + services.AddLazyCache(); + + 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(); + } +} 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 new file mode 100644 index 0000000000..64aef4e116 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExchangeRateProvider.Api.Tests.Integration.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..d76c13fa27 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Integration/ExternalServices/CzkExchangeRateServiceIntegrationTests.cs @@ -0,0 +1,45 @@ +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; + +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 policyRegistry = new PolicyRegistry().AddBasicRetryPolicy(); + var cache = new CachingService(); + var apiClientLogger = NullLogger.Instance; + var mapperLogger = NullLogger.Instance; + + var apiClient = new CzkApiClient(httpClient, policyRegistry, cache, apiClientLogger); + var mapper = new CzkExchangeRateMapper(mapperLogger); + var service = new CzkExchangeRateService(apiClient, mapper); + + // 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.Unit/Controllers/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs new file mode 100644 index 0000000000..f3b6694b5a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/Controllers/ExchangeRateControllerTests.cs @@ -0,0 +1,380 @@ +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 Microsoft.Extensions.Logging.Testing; +using Shouldly; + +namespace ExchangeRateProvider.Api.Tests.Unit.Controllers; + +public class ExchangeRateControllerTests +{ + private readonly IQueryHandler> _fakeHandler; + private readonly FakeLogger _logger; + private readonly ExchangeRateController _controller; + private readonly QuoteCurrenciesValidator _validator; + + public ExchangeRateControllerTests() + { + _fakeHandler = A.Fake>>(); + _logger = new FakeLogger(); + _validator = new QuoteCurrenciesValidator(); + _controller = new ExchangeRateController(_fakeHandler, _validator, _logger); + } + + [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); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain($"BaseCurrency: {baseCurrency}"); + logEntry.Message.ShouldContain("QuoteCurrencies: null"); + } + + [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(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain($"BaseCurrency: {baseCurrency}"); + logEntry.Message.ShouldContain("QuoteCurrencies: EUR, GBP"); + } + + [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(); + + var logEntry = _logger.Collector.GetSnapshot().Single(); + logEntry.Level.ShouldBe(LogLevel.Information); + logEntry.Message.ShouldContain("BaseCurrency: "); + } + + [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 new file mode 100644 index 0000000000..ec039defea --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Api.Tests.Unit/ExchangeRateProvider.Api.Tests.Unit.csproj @@ -0,0 +1,29 @@ + + + + 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..9ed4097e25 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Application.Tests.Unit/ExchangeRateProvider.Application.Tests.Unit.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..b32806a107 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Domain.Tests.Unit/Constants/CurrencyServiceKeysTests.cs @@ -0,0 +1,48 @@ +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("")] + [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..bdaf9fc283 --- /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 new file mode 100644 index 0000000000..177446d36e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExchangeRateProvider.Infrastructure.Tests.Unit.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..6f1f32abf7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider/tests/ExchangeRateProvider.Infrastructure.Tests.Unit/ExternalServices/CZK/CzkApiClientTests.cs @@ -0,0 +1,128 @@ +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; +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 IAppCache _cache; + private readonly FakeLogger _logger; + + public CzkApiClientTests() + { + _policyRegistry = new PolicyRegistry + { + [PolicyNames.WaitAndRetry] = Policy.NoOpAsync() + }; + _cache = new CachingService(new MemoryCacheProvider(new MemoryCache(new MemoryCacheOptions()))); + _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, _cache, _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 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] + public async Task GetExchangeRatesAsync_WithHttpError_ThrowsHttpRequestException() + { + // Arrange + var httpClient = CreateHttpClientWithResponse(HttpStatusCode.InternalServerError, string.Empty); + var client = new CzkApiClient(httpClient, _policyRegistry, _cache, _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 : 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) + }); + } + } +} 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..3469f16630 --- /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(23.5m / 1m); + + 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(); + } +} 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 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 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(); - } - } -}