diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs
deleted file mode 100644
index f375776f25..0000000000
--- a/jobs/Backend/Task/Currency.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class Currency
- {
- public Currency(string code)
- {
- Code = code;
- }
-
- ///
- /// Three-letter ISO 4217 code of the currency.
- ///
- public string Code { get; }
-
- public override string ToString()
- {
- return Code;
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs
deleted file mode 100644
index 58c5bb10e0..0000000000
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class ExchangeRate
- {
- public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
- {
- SourceCurrency = sourceCurrency;
- TargetCurrency = targetCurrency;
- Value = value;
- }
-
- public Currency SourceCurrency { get; }
-
- public Currency TargetCurrency { get; }
-
- public decimal Value { get; }
-
- public override string ToString()
- {
- return $"{SourceCurrency}/{TargetCurrency}={Value}";
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs
new file mode 100644
index 0000000000..6075387016
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs
@@ -0,0 +1,30 @@
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Validation;
+
+namespace ExchangeRateUpdater.Api.Endpoints;
+
+public static class ExchangeRateEndpoints
+{
+ public static void MapExchangeRatesEndpoints(this WebApplication app)
+ {
+ app.MapGet("/exchange-rates", async (
+ string[] currencies,
+ IExchangeRateProvider exchangeRateProvider,
+ CancellationToken cancellationToken) =>
+ {
+ var validator = new ExchangeRatesRequestValidator();
+ var validationResult = await validator.ValidateAsync(currencies, cancellationToken);
+ if (!validationResult.IsValid)
+ {
+ return Results.BadRequest(validationResult.Errors.Select(e => e.ErrorMessage));
+ }
+
+ var result = await exchangeRateProvider.GetExchangeRates(currencies, cancellationToken);
+
+ return Results.Ok(result);
+ })
+ .WithName("GetExchangeRates")
+ .WithDescription("Gets exchange rates for provided currencies to CZK from Czech National Bank")
+ .WithTags("Exchange Rates");
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
new file mode 100644
index 0000000000..8acd98d614
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http
new file mode 100644
index 0000000000..a736895ede
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http
@@ -0,0 +1,5 @@
+@HostAddress = https://localhost:7232
+
+### Get exchange rates for EUR and USD
+GET {{HostAddress}}/exchange-rates?currencies=eur¤cies=usd
+Accept: application/json
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs
new file mode 100644
index 0000000000..26c07018d8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs
@@ -0,0 +1,40 @@
+using ExchangeRateUpdater.Api.Endpoints;
+using ExchangeRateUpdater.Application.Configuration;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Services;
+using ExchangeRateUpdater.Infrastructure.HttpClients;
+using Microsoft.Extensions.Options;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddScoped();
+
+builder.Services.Configure(
+ builder.Configuration.GetSection("ExchangeRateConfig"));
+
+builder.Services.AddSingleton(sp =>
+ sp.GetRequiredService>().Value);
+
+builder.Services.AddHttpClient((sp, client) =>
+{
+ var config = sp.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(config.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(config.Timeout);
+});
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseHttpsRedirection();
+
+app.MapExchangeRatesEndpoints();
+
+app.Run();
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json
new file mode 100644
index 0000000000..dd2fc1ba0d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger/index.html",
+ "applicationUrl": "https://localhost:7232;http://localhost:5028",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json
new file mode 100644
index 0000000000..0c208ae918
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json
new file mode 100644
index 0000000000..c241514622
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ExchangeRateConfig": {
+ "DefaultCurrency": "CZK",
+ "BaseUrl": "https://api.cnb.cz/cnbapi/",
+ "Timeout": 30,
+ "RatePrecision": 3
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs
new file mode 100644
index 0000000000..60171ac806
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs
@@ -0,0 +1,12 @@
+namespace ExchangeRateUpdater.Application.Configuration;
+
+public sealed class ExchangeRateConfig
+{
+ public string BaseUrl { get; set; }
+
+ public string DefaultCurrency { get; set; }
+
+ public int Timeout { get; set; }
+
+ public int RatePrecision { get; set; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj
new file mode 100644
index 0000000000..005ede6cfb
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs
new file mode 100644
index 0000000000..b1803bf404
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Application.Models;
+
+namespace ExchangeRateUpdater.Application.Interfaces;
+
+public interface IExchangeRateApiClient
+{
+ Task> GetAllExchangeRatesAsync(CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..6ac3ee2b54
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Application.Models;
+
+namespace ExchangeRateUpdater.Application.Interfaces;
+
+public interface IExchangeRateProvider
+{
+ Task> GetExchangeRates(string[] currencies, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs
new file mode 100644
index 0000000000..ae4468cbce
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Application.Models;
+
+public sealed record CnbExchangeRate(string CurrencyCode, decimal Rate, int Amount);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs
new file mode 100644
index 0000000000..2dd7e7760f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Application.Models;
+
+public sealed record CnbExchangeRateResponse(CnbExchangeRate[] Rates);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs
new file mode 100644
index 0000000000..6f3f5a7f73
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Application.Models;
+
+public sealed record ExchangeRate(string SourceCurrency, string TargetCurrency, decimal Rate);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..bd018d047b
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs
@@ -0,0 +1,51 @@
+using ExchangeRateUpdater.Application.Configuration;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Application.Services;
+
+public sealed class ExchangeRateProvider : IExchangeRateProvider
+{
+ private readonly IExchangeRateApiClient _apiClient;
+ private readonly ExchangeRateConfig _exchangeRateConfig;
+ private readonly ILogger _logger;
+
+ public ExchangeRateProvider(
+ IExchangeRateApiClient apiClient,
+ IOptions exchangeRateConfig,
+ ILogger logger)
+ {
+ _apiClient = apiClient;
+ _exchangeRateConfig = exchangeRateConfig.Value;
+ _logger = logger;
+ }
+
+ public async Task> GetExchangeRates(string[] currencies, CancellationToken cancellationToken)
+ {
+ var cnbExchangeRates = await _apiClient.GetAllExchangeRatesAsync(cancellationToken);
+ return MapExchangeRates(cnbExchangeRates, currencies).ToList();
+ }
+
+ private IEnumerable MapExchangeRates(
+ IEnumerable cnbExchangeRates,
+ string[] currencyCodes)
+ {
+ var rateByCurrencyCode =
+ cnbExchangeRates.ToDictionary(x => x.CurrencyCode, StringComparer.OrdinalIgnoreCase);
+ foreach (var currencyCode in currencyCodes)
+ {
+ if (!rateByCurrencyCode.TryGetValue(currencyCode, out var exchangeRate))
+ {
+ _logger.LogWarning("No matches were found among CNB rates for currency '{@currencyCode}'", currencyCode);
+ continue;
+ }
+
+ yield return new(
+ exchangeRate.CurrencyCode,
+ _exchangeRateConfig.DefaultCurrency,
+ Math.Round(exchangeRate.Rate / exchangeRate.Amount, _exchangeRateConfig.RatePrecision));
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs
new file mode 100644
index 0000000000..c3f4fc0559
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+
+namespace ExchangeRateUpdater.Application.Validation;
+
+public sealed class ExchangeRatesRequestValidator : AbstractValidator
+{
+ public ExchangeRatesRequestValidator()
+ {
+ RuleFor(x => x)
+ .NotEmpty()
+ .WithMessage("No currencies were provided as input");
+
+ RuleForEach(x => x)
+ .NotEmpty()
+ .WithMessage("Currency must not be empty")
+ .Matches("^[A-Za-z]{3}$")
+ .WithMessage("Each currency must be a 3-letter code. Example: USD");
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
new file mode 100644
index 0000000000..353cfa7909
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.DependencyInjection.Abstractions.dll
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs
new file mode 100644
index 0000000000..74528c40cb
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Models;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Infrastructure.HttpClients;
+
+public sealed class ExchangeRateApiClient : IExchangeRateApiClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public ExchangeRateApiClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public async Task> GetAllExchangeRatesAsync(
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = await _httpClient.GetFromJsonAsync("exrates/daily", cancellationToken);
+ if (response == null || response.Rates.Length == 0)
+ {
+ _logger.LogWarning("No exchange rates were provided by CNB");
+ return [];
+ }
+ return response.Rates;
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError("Http request to CNB has failed with exception {@exception}", ex);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs
new file mode 100644
index 0000000000..1a38b0de6f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs
@@ -0,0 +1,32 @@
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Models;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Moq;
+
+namespace ExchangeRateUpdater.IntegrationTests;
+
+public sealed class CustomWebApplicationFactory : WebApplicationFactory
+{
+ public Mock ApiClient;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+
+ ApiClient = new Mock();
+ ApiClient.Setup(x => x.GetAllExchangeRatesAsync(It.IsAny()))
+ .ReturnsAsync(new List()
+ {
+ new("EUR", 20, 1),
+ new("USD", 30, 10)
+ });
+
+ services.AddSingleton(ApiClient.Object);
+ });
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj
new file mode 100644
index 0000000000..8006c64670
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs
new file mode 100644
index 0000000000..25195ab51a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using System.Net.Http.Json;
+using ExchangeRateUpdater.Application.Models;
+using Microsoft.AspNetCore.WebUtilities;
+using Xunit;
+
+namespace ExchangeRateUpdater.IntegrationTests;
+
+public sealed class GetExchangeRatesTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public GetExchangeRatesTests(CustomWebApplicationFactory factory)
+ {
+ _client = factory.CreateClient();
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_WhenValidCurrenciesProvided_SuccessReturns200()
+ {
+ // Act
+ var url = QueryHelpers.AddQueryString(
+ "/exchange-rates",
+ [
+ new KeyValuePair("currencies", "usd"),
+ new KeyValuePair("currencies", "eur")
+ ]);
+
+ var response = await _client.GetAsync(url);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ Assert.Equal([new("USD", "CZK", 3), new("EUR", "CZK", 20)], result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_WhenCurrenciesNotProvided_Returns400()
+ {
+ // Act
+ var response = await _client.GetAsync("/exchange-rates");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_WhenCurrenciesAreWrongFormat_Returns400()
+ {
+ // Act
+ var url = QueryHelpers.AddQueryString(
+ "/exchange-rates",
+ [
+ new KeyValuePair("currencies", "u")
+ ]);
+
+ var response = await _client.GetAsync(url);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj
new file mode 100644
index 0000000000..bd5454aa77
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll
+
+
+ ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs
new file mode 100644
index 0000000000..9e0cca2fc6
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs
@@ -0,0 +1,20 @@
+using Moq;
+using Moq.Language.Flow;
+using Moq.Protected;
+
+namespace ExchangeRateUpdater.UnitTests.Extensions;
+
+public static class HttpMessageHandlerMockExtensions
+{
+ public static ISetup>
+ SetupGet(this Mock handler, string relativeUrl)
+ {
+ return handler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.Is(req =>
+ req.Method == HttpMethod.Get &&
+ req.RequestUri!.AbsolutePath == relativeUrl),
+ ItExpr.IsAny());
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs
new file mode 100644
index 0000000000..66197dceec
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace ExchangeRateUpdater.UnitTests.Extensions;
+
+public static class LoggerExtensions
+{
+ public static void VerifyLogged(
+ this Mock> loggerMock,
+ LogLevel level,
+ string messageSubstring,
+ Times? times = null)
+ {
+ times ??= Times.Once();
+
+ loggerMock.Verify(
+ x => x.Log(
+ level,
+ It.IsAny(),
+ It.Is((v, _) => v.ToString()!.Contains(messageSubstring)),
+ It.IsAny(),
+ It.IsAny>()),
+ times.Value);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs
new file mode 100644
index 0000000000..a4c7c4c159
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs
@@ -0,0 +1,85 @@
+using System.Net;
+using System.Net.Http.Json;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Models;
+using ExchangeRateUpdater.Infrastructure.HttpClients;
+using ExchangeRateUpdater.UnitTests.Extensions;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.UnitTests.HttpClients;
+
+public class ExchangeRateApiClientTests
+{
+ private readonly Mock _httpMessageHandlerMock = new();
+ private readonly Mock> _logger = new();
+ private readonly IExchangeRateApiClient _exchangeRateApiClient;
+
+ public ExchangeRateApiClientTests()
+ {
+ var httpClient = new HttpClient(_httpMessageHandlerMock.Object)
+ {
+ BaseAddress = new Uri("https://test.com/")
+ };
+
+ _exchangeRateApiClient = new ExchangeRateApiClient(httpClient, _logger.Object);
+ }
+
+ [Fact]
+ public async Task GetCnbExchangeRates_ApiReturnsCorrectData_Success()
+ {
+ // Arrange
+ var expectedResponse =
+ new CnbExchangeRateResponse([new CnbExchangeRate("USD", 20, 1), new CnbExchangeRate("EUR", 30, 1)]);
+ _httpMessageHandlerMock
+ .SetupGet("/exrates/daily")
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = JsonContent.Create(expectedResponse)
+ });
+
+ // Act
+ var result = await _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None);
+
+ // Assert
+ CnbExchangeRate[] expected = [new("USD", 20, 1), new("EUR", 30, 1)];
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public async Task GetCnbExchangeRates_ApiReturnsEmptyCollection_LogsWarningReturnsEmptyCollection()
+ {
+ // Arrange
+ var expectedResponse = new CnbExchangeRateResponse([]);
+ _httpMessageHandlerMock
+ .SetupGet("/exrates/daily")
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = JsonContent.Create(expectedResponse)
+ });
+
+ // Act
+ var result = await _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None);
+
+ // Assert
+ Assert.Equal([], result);
+ _logger.VerifyLogged(LogLevel.Warning, "No exchange rates were provided by CNB");
+ }
+
+ [Fact]
+ public async Task GetCnbExchangeRates_ApiThrowsException_LogsErrorThrowsException()
+ {
+ // Arrange
+ _httpMessageHandlerMock
+ .SetupGet("/exrates/daily")
+ .ThrowsAsync(new HttpRequestException());
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None));
+ _logger.VerifyLogged(LogLevel.Error, "Http request to CNB has failed");
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs
new file mode 100644
index 0000000000..c842b35fd2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs
@@ -0,0 +1,72 @@
+using ExchangeRateUpdater.Application.Configuration;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Models;
+using ExchangeRateUpdater.Application.Services;
+using ExchangeRateUpdater.UnitTests.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.UnitTests.Services;
+
+public sealed class ExchangeRateProviderTests
+{
+ private readonly ExchangeRateConfig _exchangeRateConfig = new()
+ {
+ DefaultCurrency = "CZK"
+ };
+
+ private readonly ExchangeRateProvider _exchangeRateProvider;
+ private readonly Mock> _logger = new();
+
+ public ExchangeRateProviderTests()
+ {
+ var mockApi = new Mock();
+ mockApi.Setup(x => x.GetAllExchangeRatesAsync(CancellationToken.None))
+ .ReturnsAsync([
+ new CnbExchangeRate("USD", 10, 1),
+ new CnbExchangeRate("EUR", 20, 1),
+ new CnbExchangeRate("GBP", 30, 10)
+ ]);
+ _exchangeRateProvider =
+ new ExchangeRateProvider(mockApi.Object, Options.Create(_exchangeRateConfig), _logger.Object);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_WithValidCurrencies_ReturnedOnlyProvidedCurrencyRate()
+ {
+ // Arrange
+ string[] currencies = ["USD", "EUR"];
+
+ // Act
+ var result = await _exchangeRateProvider.GetExchangeRates(currencies, CancellationToken.None);
+
+ // Assert
+ var expected = new List
+ {
+ new("USD", "CZK", 10),
+ new("EUR", "CZK", 20),
+ };
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRates_WithInvalidCurrency_ReturnedOnlyValidCurrencyRate()
+ {
+ // Arrange
+ const string nonExistingCurrency = "XXX";
+ string[] currencies = ["USD", nonExistingCurrency];
+
+ // Act
+ var result = await _exchangeRateProvider.GetExchangeRates(currencies, CancellationToken.None);
+
+ // Assert
+ var expected = new List
+ {
+ new("USD", "CZK", 10)
+ };
+ Assert.Equal(expected, result);
+ _logger.VerifyLogged(LogLevel.Warning, "No matches were found among CNB rates");
+ }
+}
\ 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
index 89be84daff..f76ba15feb 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{DC241402-E1F1-4E3F-B5A0-6FA60F979232}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Application", "ExchangeRateUpdater.Application\ExchangeRateUpdater.Application.csproj", "{3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{9E3DA66E-8ED1-4821-8E18-E1A70FECE084}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.IntegrationTests", "ExchangeRateUpdater.IntegrationTests\ExchangeRateUpdater.IntegrationTests.csproj", "{BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -11,10 +19,26 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f8..0000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}
diff --git a/jobs/Backend/Task/Readme.md b/jobs/Backend/Task/Readme.md
new file mode 100644
index 0000000000..e9aa528296
--- /dev/null
+++ b/jobs/Backend/Task/Readme.md
@@ -0,0 +1,61 @@
+# Exchange Rate Provider for Czech National Bank.
+
+ASP.NET Core Web API built on .NET 10 that fetches daily exchange rates from the
+Czech National Bank and exposes an endpoint for consumers.
+I use https://api.cnb.cz/cnbapi/exrates/daily API with default parameters to retrieve rates.
+
+## Architecture
+
+- Minimal API
+- Clean separation between:
+ - API layer
+ - Application layer
+ - Infrastructure
+
+## Infrastructure
+- Cnb api parameters are stored in appsettings.json
+- ILogger from Microsoft.Extensions.Logging is used to log warnings and errors.
+- Provided currency codes are compared with CNB currency codes ignoring case.
+- AbstractValidator from FluentValidation is used to validate input parameters.
+
+## Tests
+
+- xUnit
+- Unit tests for services and api client
+- Integration tests using WebApplicationFactory
+- External API calls are mocked
+
+## Future improvements
+
+- Add caching for exchange rates
+- Implement resilience policies for external API calls
+- Add option to change parameters for CNB API
+- Improve exception handling
+- Write rates to database for persistency and historical data audit
+
+## How to run
+
+
+### CLI
+```bash
+dotnet run --project ExchangeRateUpdater.Api
+```
+
+### Swagger
+Swagger UI is available at `/swagger/index.html` once the application is running.
+The exact port is shown in the application startup logs.
+
+### HTTP requests
+The file ExchangeRateUpdater.Api.http can be used to send requests
+to the API directly from the IDE.
+
+## API
+
+### GET /exchange-rates
+
+Query parameters:
+- `currencies`: list of currency codes
+
+Example:
+```http
+GET /exchange-rates?currencies=usd¤cies=eur
\ No newline at end of file