diff --git a/.gitignore b/.gitignore
index fd35865456..c0a73718aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,9 @@
node_modules
bower_components
npm-debug.log
+/jobs/Backend/Task/.vs/ExchangeRateUpdater
+/jobs/Backend/Task/.vs/ProjectEvaluation
+/jobs/Backend/Task/.dockerignore
+/jobs/Backend/Task/Dockerfile
+/jobs/Backend/Task/EXCEPTION_HANDLING.md
+/jobs/Backend/Task/README.md
diff --git a/jobs/Backend/Task/Application/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/Application/Interfaces/IExchangeRateService.cs
new file mode 100644
index 0000000000..47d072f6ba
--- /dev/null
+++ b/jobs/Backend/Task/Application/Interfaces/IExchangeRateService.cs
@@ -0,0 +1,19 @@
+using ExchangeRateUpdater.Domain;
+
+namespace ExchangeRateUpdater.Application.Interfaces
+{
+ ///
+ /// Application service interface for retrieving exchange rates.
+ /// Orchestrates multiple exchange rate providers.
+ ///
+ public interface IExchangeRateService
+ {
+ ///
+ /// Retrieves exchange rates for the requested currencies asynchronously.
+ /// Only returns rates explicitly provided by the underlying providers.
+ ///
+ /// Currencies to fetch rates for.
+ /// Collection of exchange rates.
+ Task> GetRatesAsync(IEnumerable requestedCurrencies);
+ }
+}
diff --git a/jobs/Backend/Task/Application/Services/ExchangeRateService.cs b/jobs/Backend/Task/Application/Services/ExchangeRateService.cs
new file mode 100644
index 0000000000..5d07904e8b
--- /dev/null
+++ b/jobs/Backend/Task/Application/Services/ExchangeRateService.cs
@@ -0,0 +1,57 @@
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Domain;
+
+namespace ExchangeRateUpdater.Application.Services
+{
+ ///
+ /// Application service to orchestrate exchange rate retrieval.
+ /// Uses one or more IExchangeRateProvider instances.
+ ///
+ public class ExchangeRateService : IExchangeRateService
+ {
+ private readonly IEnumerable _providers;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes the service with one or more exchange rate providers.
+ ///
+ /// Injected exchange rate providers.
+ public ExchangeRateService(IEnumerable providers, ILogger logger)
+ {
+ _providers = providers ?? throw new ArgumentNullException(nameof(providers));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Retrieves exchange rates for the requested currencies asynchronously.
+ /// Only returns rates explicitly provided by the underlying providers.
+ ///
+ public Task> GetRatesAsync(IEnumerable requestedCurrencies)
+ {
+ if (requestedCurrencies == null)
+ throw new ArgumentNullException(nameof(requestedCurrencies));
+
+ var results = new List();
+
+ foreach (var provider in _providers)
+ {
+ try
+ {
+ var rates = provider.GetExchangeRates(requestedCurrencies);
+ if (rates != null)
+ results.AddRange(rates);
+ }
+ catch (Exception ex) {
+ _logger.LogError(ex, "Exchange rate provider '{Provider}' failed.", provider.GetType().Name);
+ throw new InvalidOperationException(
+ $"Failed to retrieve exchange rates from provider '{provider.GetType().Name}'.", ex);
+ }
+ }
+ var distinctRates = results
+ .GroupBy(r => new { SourceCode= r.Source.Code, TargetCode = r.Target.Code })
+ .Select(g => g.First());
+
+ return Task.FromResult(distinctRates.AsEnumerable());
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Domain/Currency.cs
similarity index 89%
rename from jobs/Backend/Task/Currency.cs
rename to jobs/Backend/Task/Domain/Currency.cs
index f375776f25..041b2dfc73 100644
--- a/jobs/Backend/Task/Currency.cs
+++ b/jobs/Backend/Task/Domain/Currency.cs
@@ -1,4 +1,4 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRateUpdater.Domain
{
public class Currency
{
diff --git a/jobs/Backend/Task/Domain/ExchangeRate.cs b/jobs/Backend/Task/Domain/ExchangeRate.cs
new file mode 100644
index 0000000000..518a6c14cc
--- /dev/null
+++ b/jobs/Backend/Task/Domain/ExchangeRate.cs
@@ -0,0 +1,37 @@
+namespace ExchangeRateUpdater.Domain
+{
+ ///
+ /// Represents an exchange rate between two currencies.
+ /// Immutable and validated.
+ ///
+ public class ExchangeRate
+ {
+ ///
+ /// Source currency of the exchange rate.
+ ///
+ public Currency Source { get; }
+
+ ///
+ /// Target currency of the exchange rate.
+ ///
+ public Currency Target { get; }
+
+ ///
+ /// Exchange rate value (e.g., 1 USD = 23.5 CZK -> Value = 23.5).
+ ///
+ public decimal Value { get; }
+
+ ///
+ /// Creates a new ExchangeRate instance.
+ ///
+ /// Source currency (required).
+ /// Target currency (required).
+ /// Exchange rate value.
+ public ExchangeRate(Currency source, Currency target, decimal value)
+ {
+ Source = source ?? throw new ArgumentNullException(nameof(source));
+ Target = target ?? throw new ArgumentNullException(nameof(target));
+ Value = value;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/Domain/IExchangeRateProvider.cs b/jobs/Backend/Task/Domain/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..4408ff65c2
--- /dev/null
+++ b/jobs/Backend/Task/Domain/IExchangeRateProvider.cs
@@ -0,0 +1,18 @@
+namespace ExchangeRateUpdater.Domain
+{
+ ///
+ /// Defines a contract for exchange rate providers.
+ /// Returns only explicitly defined rates from the source.
+ ///
+ public interface IExchangeRateProvider
+ {
+ ///
+ /// Returns exchange rates for the requested currencies.
+ /// Only returns rates explicitly defined by the source,
+ /// does not calculate inverted rates.
+ ///
+ /// Currencies to retrieve rates for.
+ /// Collection of exchange rates.
+ IEnumerable GetExchangeRates(IEnumerable requestedCurrencies);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs
deleted file mode 100644
index 58c5bb10e0..0000000000
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class ExchangeRate
- {
- public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
- {
- SourceCurrency = sourceCurrency;
- TargetCurrency = targetCurrency;
- Value = value;
- }
-
- public Currency SourceCurrency { get; }
-
- public Currency TargetCurrency { get; }
-
- public decimal Value { get; }
-
- public override string ToString()
- {
- return $"{SourceCurrency}/{TargetCurrency}={Value}";
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
index 2fc654a12b..b1f86a6972 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj
@@ -1,8 +1,53 @@
-
+
- Exe
net6.0
+ enable
+ enable
+ false
+ false
+ Windows
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
index 89be84daff..62bee8dbe3 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -1,10 +1,12 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 14
-VisualStudioVersion = 14.0.25123.0
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36121.58 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "Tests\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,8 +17,15 @@ Global
{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
+ {D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {F71216DE-D6B5-4542-B482-F54372660740}
+ EndGlobalSection
EndGlobal
diff --git a/jobs/Backend/Task/Infrastructure/Sources/CnbExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/Sources/CnbExchangeRateProvider.cs
new file mode 100644
index 0000000000..2fe2811743
--- /dev/null
+++ b/jobs/Backend/Task/Infrastructure/Sources/CnbExchangeRateProvider.cs
@@ -0,0 +1,114 @@
+using ExchangeRateUpdater.Domain;
+using System.Globalization;
+
+namespace ExchangeRateUpdater.Infrastructure.Sources
+{
+ public class CnbExchangeRateProvider : IExchangeRateProvider
+ {
+ private const int HttpTimeoutSeconds = 10;
+ private const string TargetCurrency = "CZK";
+ private readonly List _endpoints = new();
+ private readonly ILogger _logger;
+
+ public CnbExchangeRateProvider(IConfiguration configuration, ILogger logger)
+ {
+ _endpoints.Add(configuration["ExchangeRateApi:exchangeRateEN"]);
+ _endpoints.Add(configuration["ExchangeRateApi:exchangeRateSC"]);
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+ ///
+ /// 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)
+ {
+ if (currencies == null) throw new ArgumentNullException(nameof(currencies));
+
+ var requestedCurrencies = ExtractRequestedCurrencies(currencies);
+
+ var rawData = FetchRatesFromEndpoints();
+ if (string.IsNullOrWhiteSpace(rawData))
+ return Enumerable.Empty();
+
+ return ParseRates(rawData, requestedCurrencies);
+ }
+
+ private HashSet ExtractRequestedCurrencies(IEnumerable currencies)
+ {
+ return new HashSet(
+ currencies.Select(c => c?.Code?.ToUpperInvariant())
+ .Where(s => !string.IsNullOrEmpty(s))
+ );
+ }
+
+ private string FetchRatesFromEndpoints()
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(HttpTimeoutSeconds) };
+ foreach (var url in _endpoints)
+ {
+ try
+ {
+ var response = http.GetAsync(url).GetAwaiter().GetResult();
+ if (response.IsSuccessStatusCode)
+ return response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed requesting CNB rate endpoint {Url}", url);
+ }
+ }
+ _logger.LogError("All CNB rate endpoints failed.");
+ return null;
+ }
+
+ private IEnumerable ParseRates(string rawData, HashSet requestedCurrencies)
+ {
+ var results = new List();
+ var lines = rawData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var line in lines)
+ {
+ var rate = ParseLine(line, requestedCurrencies);
+ if (rate != null)
+ results.Add(rate);
+ }
+ return results;
+ }
+ private ExchangeRate ParseLine(string line, HashSet requestedCurrencies)
+ {
+ try
+ {
+ if (!line.Contains("|"))
+ return null;
+
+ var parts = line.Split('|');
+ if (parts.Length < 5)
+ return null;
+
+ var code = parts[3].Trim().ToUpperInvariant();
+ if (!requestedCurrencies.Contains(code))
+ return null;
+ var amountText = parts[2].Trim();
+ var rateText = parts[4].Trim().Replace(',', '.');
+
+ if (!decimal.TryParse(amountText, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount))
+ amount = 1m;
+
+ if (!decimal.TryParse(rateText, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate))
+ return null;
+
+ if (amount != 0m && amount != 1m)
+ rate = rate / amount;
+
+ return new ExchangeRate(new Currency(code), new Currency(TargetCurrency), rate);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "CNB line parsing failed for line: {Line}", line);
+ return null;
+ }
+ }
+ }
+}
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/Properties/launchSettings.json b/jobs/Backend/Task/Properties/launchSettings.json
new file mode 100644
index 0000000000..90120ba4dc
--- /dev/null
+++ b/jobs/Backend/Task/Properties/launchSettings.json
@@ -0,0 +1,17 @@
+{
+ "profiles": {
+ "ExchangeRateUpdater": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:5000;https://localhost:5001"
+ },
+ "Container (Dockerfile)": {
+ "commandName": "Docker"
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md
new file mode 100644
index 0000000000..d8cb0dc11d
--- /dev/null
+++ b/jobs/Backend/Task/README.md
@@ -0,0 +1,86 @@
+# Exchange Rate Updater — Czech National Bank
+
+A .NET 6.0 application that fetches and provides current exchange rates from the Czech National Bank (CNB) public data source.
+
+## Overview
+
+This project implements an `ExchangeRateProvider` that retrieves daily exchange rates from the CNB and returns only those rates that are defined by the source (foreign currency to Czech Koruna). The implementation strictly adheres to the requirement of not returning calculated or reciprocal exchange rates.
+
+## How It Works
+
+### Data Source
+
+The provider retrieves exchange rates from the Czech National Bank's daily exchange rate fix:
+
+**Primary URLs:**
+- Text: `https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt`
+
+**Fallback URLs (Czech interface):**
+- Text: `https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni.txt`
+
+### Filtering Logic
+
+The provider only returns exchange rates that meet **all** of these criteria:
+
+1. The currency code exists in the CNB source data
+2. The currency code was requested by the caller
+3. CZK (Czech Koruna) was also requested by the caller
+4. The rate is returned in the direction provided by the source (foreign → CZK)
+
+**Example:**
+- If CNB provides `USD → CZK = 20.977`, the provider returns exactly that
+- The provider does **not** calculate and return `CZK → USD = 0.0477...`
+- If USD is requested but CZK is not, no rate is returned
+
+### Health Check
+
+A `/health` endpoint is available to monitor the application's status.
+
+## Building and Running
+
+### Prerequisites
+
+- .NET 6.0 SDK or later
+- Windows PowerShell or compatible shell
+
+### Build
+cd e:\Projects\developers\jobs\Backend\Task
+dotnet build ExchangeRateUpdater.sln -c Debug
+### Run the Test Program
+cd e:\Projects\developers\jobs\Backend\Task
+dotnet run --project ExchangeRateUpdater.csproj
+
+### Example Test Currencies
+
+The default test program (`Program.cs`) requests rates for:
+- USD, EUR, JPY, THB, TRY, KES, RUB, CZK, XYZ
+
+Only rates that CNB provides for these currencies (paired with CZK) are returned.
+
+## API Usage
+
+### Get Exchange Rates
+
+**Endpoint:**GET /api/exchangerates?currencies=USD,EUR,CZK
+**Query Parameters:**
+- `currencies`: Comma-separated ISO 4217 currency codes (e.g., USD, EUR, CZK)
+
+**Response:**[
+ { "source": "USD", "target": "CZK", "value": 20.977 },
+ { "source": "EUR", "target": "CZK", "value": 24.285 }
+]
+**Swagger UI:**
+- Available at `/swagger` for interactive API documentation and testing.
+
+## Running the Web API
+
+- Build and run as usual. The browser will open Swagger UI automatically in development mode.
+- Use the `/api/exchangerates` endpoint to fetch rates for requested currencies.
+
+## Related Files
+
+- `Currency.cs`: Currency model
+- `ExchangeRate.cs`: Exchange rate model
+- `Program.cs`: CLI test program
+- `ExchangeRateUpdater.sln`: Solution file
+- `ExchangeRateUpdater.csproj`: Project file
\ No newline at end of file
diff --git a/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj
new file mode 100644
index 0000000000..626b2b3962
--- /dev/null
+++ b/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+ net6.0
+ false
+ enable
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRatesControllerTests.cs
new file mode 100644
index 0000000000..5dc0c891e5
--- /dev/null
+++ b/jobs/Backend/Task/Tests/ExchangeRateUpdater.Tests/ExchangeRatesControllerTests.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Domain;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.WebAPI.Controllers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Tests
+{
+ internal class FakeExchangeRateService : IExchangeRateService
+ {
+ private readonly Dictionary _definedRates; // maps source currency code -> CZK value
+ public FakeExchangeRateService(Dictionary definedRates)
+ {
+ _definedRates = definedRates.ToDictionary(k => k.Key.ToUpperInvariant(), v => v.Value);
+ }
+ public Task> GetRatesAsync(IEnumerable requestedCurrencies)
+ {
+ var list = new List();
+ foreach (var c in requestedCurrencies)
+ {
+ if (_definedRates.TryGetValue(c.Code.ToUpperInvariant(), out var value))
+ {
+ // Only return explicit source->CZK; never generate inverted CZK->source.
+ list.Add(new ExchangeRate(new Currency(c.Code.ToUpperInvariant()), new Currency("CZK"), value));
+ }
+ }
+ return Task.FromResult>(list);
+ }
+ }
+
+ public class ExchangeRatesControllerTests
+ {
+ private ExchangeRatesController CreateController(Dictionary definedRates)
+ {
+ var service = new FakeExchangeRateService(definedRates);
+ var logger = NullLogger.Instance;
+ return new ExchangeRatesController(service, logger);
+ }
+
+ [Fact]
+ public async Task MissingCurrenciesParamReturnsBadRequest()
+ {
+ var controller = CreateController(new());
+ var result = await controller.Get(null!);
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task EmptyCurrenciesParamReturnsBadRequest()
+ {
+ var controller = CreateController(new());
+ var result = await controller.Get(" ");
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task AllInvalidCodesReturnsBadRequest()
+ {
+ var controller = CreateController(new());
+ var result = await controller.Get("US,EURO,12,TOOLONG,AA"); // none length==3
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task ValidAndInvalidCodesFiltersInvalid()
+ {
+ var controller = CreateController(new() { { "USD", 23.5m }, { "EUR", 25m } });
+ var result = await controller.Get("USD,EURO,EUR, ,usd");
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ // Should contain USD and EUR only once each
+ var entries = payload.Select(x => x.GetType().GetProperty("source")!.GetValue(x)!.ToString()).OrderBy(s => s).ToList();
+ Assert.Equal(new[] { "EUR", "USD" }, entries);
+ }
+
+ [Fact]
+ public async Task DuplicatesAreRemoved()
+ {
+ var controller = CreateController(new() { { "USD", 23.5m } });
+ var result = await controller.Get("USD,usd,Usd");
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ Assert.Single(payload);
+ }
+
+ [Fact]
+ public async Task CaseInsensitiveHandling()
+ {
+ var controller = CreateController(new() { { "USD", 23.5m } });
+ var result = await controller.Get("usd");
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ var entry = Assert.Single(payload);
+ var source = entry.GetType().GetProperty("source")!.GetValue(entry)!.ToString();
+ Assert.Equal("USD", source);
+ }
+
+ [Fact]
+ public async Task UnsupportedCurrenciesIgnored()
+ {
+ var controller = CreateController(new() { { "USD", 20m } });
+ var result = await controller.Get("USD,ABC");
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ var entry = Assert.Single(payload);
+ var source = entry.GetType().GetProperty("source")!.GetValue(entry)!.ToString();
+ Assert.Equal("USD", source);
+ }
+
+ [Fact]
+ public async Task DoesNotReturnInvertedRates()
+ {
+ var controller = CreateController(new() { { "USD", 23.5m } });
+ var result = await controller.Get("USD,CZK"); // CZK requested but service only defines USD->CZK
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ var entry = Assert.Single(payload); // ensures no CZK/USD inversion added
+ var source = entry.GetType().GetProperty("source")!.GetValue(entry)!.ToString();
+ var target = entry.GetType().GetProperty("target")!.GetValue(entry)!.ToString();
+ Assert.Equal("USD", source);
+ Assert.Equal("CZK", target);
+ }
+
+ [Fact]
+ public async Task EmptyResultWhenNoDefinedRatesMatch()
+ {
+ var controller = CreateController(new());
+ var result = await controller.Get("USD,EUR");
+ var ok = Assert.IsType(result.Result);
+ var payload = Assert.IsAssignableFrom>(ok.Value);
+ Assert.Empty(payload);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/WebAPI/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/WebAPI/Controllers/ExchangeRatesController.cs
new file mode 100644
index 0000000000..075ec451c2
--- /dev/null
+++ b/jobs/Backend/Task/WebAPI/Controllers/ExchangeRatesController.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Domain;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.WebAPI.Controllers
+{
+ [ApiController]
+ [Route("api/[controller]")]
+ public class ExchangeRatesController : ControllerBase
+ {
+ private readonly IExchangeRateService _exchangeRateService;
+ private readonly ILogger _logger;
+
+
+ public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger logger)
+ {
+ _exchangeRateService = exchangeRateService;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Gets exchange rates for requested currencies.
+ ///
+ /// Comma-separated ISO 4217 currency codes (e.g., USD,EUR,CZK)
+ /// List of exchange rates
+ [HttpGet]
+ public async Task>> Get([FromQuery] string currencies)
+ {
+ if (string.IsNullOrWhiteSpace(currencies)){
+ _logger.LogWarning("Invalid request: missing 'currencies' parameter.");
+ return BadRequest("Query parameter 'currencies' is required.");
+ }
+
+ var codes = currencies.Split(',').Select(c => c.Trim().ToUpper()).Where(c => c.Length == 3).Distinct();
+ if (codes.Count() == 0){
+ _logger.LogWarning("Invalid request: no valid currency codes provided. Raw input: {Input}", currencies);
+ return BadRequest("No valid currency codes provided.");
+ }
+ var currencyObjs = codes.Select(code => new Currency(code));
+ var rates = await _exchangeRateService.GetRatesAsync(currencyObjs);
+ var result = rates.Select(r => new {
+ source = r.Source.Code,
+ target = r.Target.Code,
+ value = r.Value
+ });
+ _logger.LogInformation("Returned {Count} exchange rates for request: {Currencies}", result.Count(), currencies);
+ return Ok(result);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/WebAPI/Program.cs b/jobs/Backend/Task/WebAPI/Program.cs
new file mode 100644
index 0000000000..a843af6be5
--- /dev/null
+++ b/jobs/Backend/Task/WebAPI/Program.cs
@@ -0,0 +1,97 @@
+using ExchangeRateUpdater.Application.Interfaces;
+using ExchangeRateUpdater.Application.Services;
+using ExchangeRateUpdater.Domain;
+using ExchangeRateUpdater.Infrastructure.Sources;
+using Microsoft.AspNetCore.Diagnostics;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Configuration
+ .SetBasePath(builder.Environment.ContentRootPath)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables();
+
+// Services
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+// CORS - simple permissive policy (adjust for production)
+const string CorsPolicyName = "DefaultCorsPolicy";
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy(CorsPolicyName, policy =>
+ {
+ if (builder.Environment.IsDevelopment())
+ {
+ policy
+ .AllowAnyOrigin()
+ .AllowAnyHeader()
+ .AllowAnyMethod();
+ }
+ else
+ {
+ policy
+ .WithOrigins(
+ "https://example.com",
+ "https://yourfrontend.com"
+ )
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ }
+ });
+});
+
+builder.Services.AddScoped();
+builder.Services.AddTransient();
+
+builder.Logging.ClearProviders();
+builder.Logging.AddConsole();
+
+var app = builder.Build();
+
+// HTTPS redirection
+app.UseHttpsRedirection();
+
+// Exception handling / diagnostics
+if (app.Environment.IsDevelopment())
+{
+ app.UseDeveloperExceptionPage();
+}
+else
+{
+ app.UseExceptionHandler(errorApp =>
+ {
+ errorApp.Run(async context =>
+ {
+ context.Response.ContentType = "application/json";
+ var exceptionHandler = context.Features.Get();
+ var problem = Results.Problem(detail: exceptionHandler?.Error?.Message, title: "An error occurred");
+ await problem.ExecuteAsync(context);
+ });
+ });
+}
+
+// Swagger (UI)
+app.UseSwagger();
+app.UseSwaggerUI(options =>
+{
+ options.RoutePrefix = "swagger";
+ options.SwaggerEndpoint("/swagger/v1/swagger.json", "ExchangeRateUpdater API V1");
+});
+
+// Routing, CORS, Auth
+app.UseRouting();
+app.UseCors(CorsPolicyName);
+//Enabled, when requested!
+// app.UseAuthentication();
+// app.UseAuthorization();
+
+app.MapControllers();
+
+app.MapFallback(() => Results.Redirect("/swagger"));
+app.MapGet("/health", () => Results.Ok(new { status = "Healthy", environment = app.Environment.EnvironmentName }));
+
+app.Run();
diff --git a/jobs/Backend/Task/appsettings.development.json b/jobs/Backend/Task/appsettings.development.json
new file mode 100644
index 0000000000..3259199d58
--- /dev/null
+++ b/jobs/Backend/Task/appsettings.development.json
@@ -0,0 +1,6 @@
+{
+ "ExchangeRateApi": {
+ "exchangeRateEN": "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt",
+ "exchangeRateSC": "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni.txt"
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json
new file mode 100644
index 0000000000..3259199d58
--- /dev/null
+++ b/jobs/Backend/Task/appsettings.json
@@ -0,0 +1,6 @@
+{
+ "ExchangeRateApi": {
+ "exchangeRateEN": "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt",
+ "exchangeRateSC": "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni.txt"
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/appsettings.production.json b/jobs/Backend/Task/appsettings.production.json
new file mode 100644
index 0000000000..3fe0e819ca
--- /dev/null
+++ b/jobs/Backend/Task/appsettings.production.json
@@ -0,0 +1,26 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+
+ "ExchangeRateApi": {
+ "RequestTimeoutSeconds": 10,
+ "RetryCount": 3,
+ "RetryDelaySeconds": 2
+ },
+
+ "Cors": {
+ "AllowedOrigins": [
+ "https://your-production-domain.com"
+ ],
+ "AllowCredentials": true
+ },
+
+ "HttpClientSettings": {
+ "DefaultTimeoutSeconds": 10
+ }
+}