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();
- }
- }
-}