diff --git a/jobs/Backend/Task/.gitignore b/jobs/Backend/Task/.gitignore
new file mode 100644
index 0000000000..e0b6416dd6
--- /dev/null
+++ b/jobs/Backend/Task/.gitignore
@@ -0,0 +1,17 @@
+bin/
+obj/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+
+[Dd]ebug/
+[Rr]elease/
+bld/
+
+.idea/
+*.iml
+.DS_Store
+
+*.swp
+*.swo
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.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
index 2fc654a12b..a2199986bb 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj
@@ -3,6 +3,33 @@
Exe
net6.0
+ enable
+ enable
+ ExchangeRateUpdater
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
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/appsettings.json b/jobs/Backend/Task/appsettings.json
new file mode 100644
index 0000000000..2ecb0528ba
--- /dev/null
+++ b/jobs/Backend/Task/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Extensions.Http": "Warning",
+ "ExchangeRateUpdater": "Information"
+ }
+ },
+ "CnbApi": {
+ "BaseUrl": "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml",
+ "TimeoutSeconds": 30,
+ "RetryCount": 3,
+ "CircuitBreakerFailureThreshold": 5,
+ "CircuitBreakerDurationSeconds": 30
+ }
+}
+
diff --git a/jobs/Backend/Task/src/Configuration/CnbApiOptions.cs b/jobs/Backend/Task/src/Configuration/CnbApiOptions.cs
new file mode 100644
index 0000000000..a4a7b1abd2
--- /dev/null
+++ b/jobs/Backend/Task/src/Configuration/CnbApiOptions.cs
@@ -0,0 +1,12 @@
+namespace ExchangeRateUpdater;
+
+public class CnbApiOptions
+{
+ public const string SectionName = "CnbApi";
+
+ public string BaseUrl { get; set; } = string.Empty;
+ public int TimeoutSeconds { get; set; }
+ public int RetryCount { get; set; }
+ public int CircuitBreakerFailureThreshold { get; set; }
+ public int CircuitBreakerDurationSeconds { get; set; }
+}
diff --git a/jobs/Backend/Task/src/Configuration/DependencyInjectionExtensions.cs b/jobs/Backend/Task/src/Configuration/DependencyInjectionExtensions.cs
new file mode 100644
index 0000000000..71c966e59e
--- /dev/null
+++ b/jobs/Backend/Task/src/Configuration/DependencyInjectionExtensions.cs
@@ -0,0 +1,42 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Extensions.Http;
+
+namespace ExchangeRateUpdater;
+
+public static class DependencyInjectionExtensions
+{
+ public static IServiceCollection ConfigureServices(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection(CnbApiOptions.SectionName));
+ services.AddScoped();
+
+ services.AddHttpClient()
+ .AddPolicyHandler((serviceProvider, _) => GetRetryPolicy(serviceProvider))
+ .AddPolicyHandler((serviceProvider, _) => GetCircuitBreakerPolicy(serviceProvider));
+
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static IAsyncPolicy GetRetryPolicy(IServiceProvider serviceProvider)
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+
+ return HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .WaitAndRetryAsync(options.RetryCount, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
+ }
+
+ private static IAsyncPolicy GetCircuitBreakerPolicy(IServiceProvider serviceProvider)
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+
+ return HttpPolicyExtensions
+ .HandleTransientHttpError()
+ .CircuitBreakerAsync(options.CircuitBreakerFailureThreshold, TimeSpan.FromSeconds(options.CircuitBreakerDurationSeconds));
+ }
+}
diff --git a/jobs/Backend/Task/src/Constants/CnbConstants.cs b/jobs/Backend/Task/src/Constants/CnbConstants.cs
new file mode 100644
index 0000000000..80232d5a7b
--- /dev/null
+++ b/jobs/Backend/Task/src/Constants/CnbConstants.cs
@@ -0,0 +1,19 @@
+namespace ExchangeRateUpdater;
+
+public static class CnbConstants
+{
+ public const string BaseCurrencyCode = "CZK";
+ public const string DateFormat = "dd.MM.yyyy";
+
+ // XML element and attribute names from CNB response (in Czech)
+ public const string XmlRootElementName = "kurzy";
+ public const string XmlRowElementName = "radek";
+ public const string DateAttributeName = "datum";
+ public const string CodeAttributeName = "kod";
+ public const string AmountAttributeName = "mnozstvi";
+ public const string RateAttributeName = "kurz";
+
+ // Culture and query parameters
+ public const string CzechCultureCode = "cs-CZ";
+ public const string DateQueryParameter = "date";
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/src/Exceptions/CnbApiException.cs b/jobs/Backend/Task/src/Exceptions/CnbApiException.cs
new file mode 100644
index 0000000000..236f4d2c00
--- /dev/null
+++ b/jobs/Backend/Task/src/Exceptions/CnbApiException.cs
@@ -0,0 +1,12 @@
+namespace ExchangeRateUpdater;
+
+public class CnbApiException : Exception
+{
+ public CnbApiException(string message) : base(message)
+ {
+ }
+
+ public CnbApiException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/jobs/Backend/Task/src/Models/Currency.cs b/jobs/Backend/Task/src/Models/Currency.cs
new file mode 100644
index 0000000000..0251133709
--- /dev/null
+++ b/jobs/Backend/Task/src/Models/Currency.cs
@@ -0,0 +1,21 @@
+namespace ExchangeRateUpdater;
+
+public class Currency
+{
+ public string Code { get; }
+
+ ///
+ /// Three-letter ISO 4217 code of the currency.
+ ///
+ public Currency(string code)
+ {
+ if (string.IsNullOrWhiteSpace(code) || code.Length != 3)
+ {
+ throw new ArgumentException($"Currency code must be exactly 3 characters, got '{code}'", nameof(code));
+ }
+
+ Code = code.ToUpperInvariant();
+ }
+
+ public override string ToString() => Code;
+}
diff --git a/jobs/Backend/Task/src/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Models/ExchangeRate.cs
new file mode 100644
index 0000000000..9d085fe994
--- /dev/null
+++ b/jobs/Backend/Task/src/Models/ExchangeRate.cs
@@ -0,0 +1,23 @@
+namespace ExchangeRateUpdater;
+
+public class ExchangeRate
+{
+ public Currency SourceCurrency { get; }
+ public Currency TargetCurrency { get; }
+ public decimal Value { get; }
+
+ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
+ {
+ SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency));
+ TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency));
+
+ if (value <= 0)
+ {
+ throw new ArgumentException("Exchange rate must be greater than zero", nameof(value));
+ }
+
+ Value = value;
+ }
+
+ public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}";
+}
diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs
new file mode 100644
index 0000000000..765e735ed9
--- /dev/null
+++ b/jobs/Backend/Task/src/Program.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater;
+
+public static class Program
+{
+ private static readonly Currency[] 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("INR")
+ };
+
+ public static async Task Main()
+ {
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .Build();
+
+ var services = new ServiceCollection();
+
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole();
+ builder.AddConfiguration(configuration.GetSection("Logging"));
+ });
+
+ services.ConfigureServices(configuration);
+
+ using var serviceProvider = services.BuildServiceProvider();
+ var provider = serviceProvider.GetRequiredService();
+
+ try
+ {
+ var rates = await provider.GetExchangeRatesAsync(Currencies);
+ var ratesList = rates.ToList();
+
+ if (ratesList.Count == 0)
+ {
+ Console.WriteLine("No exchange rates were retrieved.");
+ return;
+ }
+
+ foreach (var rate in ratesList)
+ {
+ Console.WriteLine(rate.ToString());
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Could not retrieve exchange rates: '{ex.Message}'.");
+ }
+
+ Console.ReadLine();
+ }
+}
diff --git a/jobs/Backend/Task/src/Services/CnbExchangeRateService.cs b/jobs/Backend/Task/src/Services/CnbExchangeRateService.cs
new file mode 100644
index 0000000000..bcbc8a2bbe
--- /dev/null
+++ b/jobs/Backend/Task/src/Services/CnbExchangeRateService.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater;
+
+public class CnbExchangeRateService : ICnbExchangeRateService
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly CnbApiOptions _options;
+ private readonly ICnbResponseParser _parser;
+
+ public CnbExchangeRateService(
+ HttpClient httpClient,
+ ILogger logger,
+ IOptions options,
+ ICnbResponseParser parser)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
+ _parser = parser ?? throw new ArgumentNullException(nameof(parser));
+
+ _httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
+ }
+
+ public async Task> GetDailyRatesAsync(DateTime? date = null, CancellationToken cancellationToken = default)
+ {
+ var targetDate = date ?? DateTime.Today;
+ var url = $"{_options.BaseUrl}?{CnbConstants.DateQueryParameter}={targetDate.ToString(CnbConstants.DateFormat)}";
+
+ try
+ {
+ _logger.LogDebug("Fetching exchange rates from CNB for date {Date}", targetDate.ToString(CnbConstants.DateFormat));
+
+ var response = await _httpClient.GetAsync(url, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogDebug("Retrieved {Length} characters from CNB API", xmlContent.Length);
+
+ return _parser.Parse(xmlContent, targetDate);
+ }
+ catch (OperationCanceledException ex) when (ex is TaskCanceledException)
+ {
+ _logger.LogError(ex, "Request to CNB timed out after {TimeoutSeconds} seconds", _options.TimeoutSeconds);
+ throw new CnbApiException($"Request to CNB timed out after {_options.TimeoutSeconds} seconds", ex);
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, "Failed to fetch rates from CNB");
+ throw new CnbApiException($"Failed to retrieve exchange rates from CNB: {ex.Message}", ex);
+ }
+ catch (Exception ex) when (ex is not CnbApiException)
+ {
+ _logger.LogError(ex, "Unexpected error retrieving rates from CNB");
+ throw new CnbApiException("Unexpected error retrieving exchange rates", ex);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..364c38026c
--- /dev/null
+++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater;
+
+public class ExchangeRateProvider
+{
+ private readonly ICnbExchangeRateService _cnbService;
+ private readonly ILogger _logger;
+ private static readonly Currency BaseCurrency = new(CnbConstants.BaseCurrencyCode);
+
+ public ExchangeRateProvider(ICnbExchangeRateService cnbService, ILogger logger)
+ {
+ _cnbService = cnbService ?? throw new ArgumentNullException(nameof(cnbService));
+ _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 async Task> GetExchangeRatesAsync(IEnumerable currencies, CancellationToken cancellationToken = default)
+ {
+ if (currencies == null)
+ {
+ throw new ArgumentNullException(nameof(currencies));
+ }
+
+ var currencyList = currencies.ToList();
+ if (currencyList.Count == 0)
+ {
+ return Enumerable.Empty();
+ }
+
+ var cnbRates = await _cnbService.GetDailyRatesAsync(cancellationToken: cancellationToken);
+ if (cnbRates == null)
+ {
+ return Enumerable.Empty();
+ }
+
+ var result = new List();
+
+ foreach (var currency in currencyList)
+ {
+ if (currency == null)
+ {
+ continue;
+ }
+
+ if (currency.Code.Equals(BaseCurrency.Code, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (cnbRates.TryGetValue(currency.Code, out var rateValue) && rateValue > 0)
+ {
+ result.Add(new ExchangeRate(BaseCurrency, currency, rateValue));
+ }
+ }
+
+ _logger.LogInformation("Retrieved {Count} exchange rates", result.Count);
+ return result;
+ }
+}
diff --git a/jobs/Backend/Task/src/Services/ICnbExchangeRateService.cs b/jobs/Backend/Task/src/Services/ICnbExchangeRateService.cs
new file mode 100644
index 0000000000..f3649293b4
--- /dev/null
+++ b/jobs/Backend/Task/src/Services/ICnbExchangeRateService.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater;
+
+public interface ICnbExchangeRateService
+{
+ Task> GetDailyRatesAsync(DateTime? date = null, CancellationToken cancellationToken = default);
+}
diff --git a/jobs/Backend/Task/src/Services/Parsers/CnbResponseParser.cs b/jobs/Backend/Task/src/Services/Parsers/CnbResponseParser.cs
new file mode 100644
index 0000000000..4ca857484b
--- /dev/null
+++ b/jobs/Backend/Task/src/Services/Parsers/CnbResponseParser.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+using System.Xml;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater;
+
+public class CnbResponseParser : ICnbResponseParser
+{
+ private readonly ILogger _logger;
+
+ public CnbResponseParser(ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Dictionary Parse(string xmlContent, DateTime requestedDate)
+ {
+ var rates = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (string.IsNullOrWhiteSpace(xmlContent))
+ {
+ _logger.LogWarning("Empty XML content received from CNB");
+ return rates;
+ }
+
+ var doc = new XmlDocument();
+ try
+ {
+ doc.LoadXml(xmlContent);
+ }
+ catch (XmlException ex)
+ {
+ _logger.LogError(ex, "Failed to parse XML from CNB");
+ throw new CnbApiException("Invalid XML structure from CNB", ex);
+ }
+
+ var rootNode = doc.DocumentElement;
+ if (rootNode == null || rootNode.Name != CnbConstants.XmlRootElementName)
+ {
+ _logger.LogError("Invalid XML root element. Expected '{Expected}', got '{Element}'", CnbConstants.XmlRootElementName, rootNode?.Name ?? "null");
+ throw new CnbApiException($"Invalid XML structure: root element '{CnbConstants.XmlRootElementName}' not found");
+ }
+
+ ValidateResponseDate(rootNode, requestedDate);
+
+ var rows = doc.SelectNodes($"//{CnbConstants.XmlRowElementName}");
+ if (rows == null || rows.Count == 0)
+ {
+ _logger.LogWarning("No '{ElementName}' elements found in XML response", CnbConstants.XmlRowElementName);
+ return rates;
+ }
+
+ var czechCulture = CultureInfo.GetCultureInfo(CnbConstants.CzechCultureCode);
+
+ foreach (XmlNode row in rows)
+ {
+ var currencyCode = row.Attributes?[CnbConstants.CodeAttributeName]?.Value;
+ var amountString = row.Attributes?[CnbConstants.AmountAttributeName]?.Value;
+ var rateString = row.Attributes?[CnbConstants.RateAttributeName]?.Value;
+
+ if (string.IsNullOrWhiteSpace(currencyCode) || string.IsNullOrWhiteSpace(amountString) || string.IsNullOrWhiteSpace(rateString))
+ {
+ continue;
+ }
+
+ if (!decimal.TryParse(amountString, NumberStyles.AllowDecimalPoint, czechCulture, out var amount) ||
+ !decimal.TryParse(rateString, NumberStyles.AllowDecimalPoint, czechCulture, out var rate))
+ {
+ continue;
+ }
+
+ if (amount <= 0 || rate <= 0)
+ {
+ continue;
+ }
+
+ var normalizedRate = amount > 0 ? rate / amount : rate;
+ if (normalizedRate > 0)
+ {
+ rates[currencyCode] = normalizedRate;
+ }
+ }
+
+ if (rates.Count == 0)
+ {
+ _logger.LogWarning("No valid exchange rates found in XML response");
+ }
+ else
+ {
+ _logger.LogDebug("Successfully parsed {Count} exchange rates from CNB XML", rates.Count);
+ }
+
+ return rates;
+ }
+
+ private void ValidateResponseDate(XmlElement rootNode, DateTime requestedDate)
+ {
+ var dateAttribute = rootNode.Attributes?[CnbConstants.DateAttributeName]?.Value;
+ if (string.IsNullOrWhiteSpace(dateAttribute))
+ {
+ _logger.LogWarning("Date attribute '{AttributeName}' not found in XML response", CnbConstants.DateAttributeName);
+ return;
+ }
+
+ if (DateTime.TryParseExact(dateAttribute, CnbConstants.DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var responseDate))
+ {
+ if (responseDate.Date != requestedDate.Date)
+ {
+ _logger.LogWarning(
+ "Date mismatch: Requested date {RequestedDate}, but CNB returned data for {ResponseDate}",
+ requestedDate.ToString(CnbConstants.DateFormat),
+ responseDate.ToString(CnbConstants.DateFormat));
+ }
+ else
+ {
+ _logger.LogDebug("Date validation passed: Response date {ResponseDate} matches requested date", responseDate.ToString(CnbConstants.DateFormat));
+ }
+ }
+ else
+ {
+ _logger.LogWarning("Could not parse date from XML response: '{DateAttribute}'", dateAttribute);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/src/Services/Parsers/ICnbResponseParser.cs b/jobs/Backend/Task/src/Services/Parsers/ICnbResponseParser.cs
new file mode 100644
index 0000000000..e7acd8e479
--- /dev/null
+++ b/jobs/Backend/Task/src/Services/Parsers/ICnbResponseParser.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater;
+
+public interface ICnbResponseParser
+{
+ Dictionary Parse(string xmlContent, DateTime requestedDate);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/test/CnbExchangeRateServiceTests.cs b/jobs/Backend/Task/test/CnbExchangeRateServiceTests.cs
new file mode 100644
index 0000000000..e025fa5a18
--- /dev/null
+++ b/jobs/Backend/Task/test/CnbExchangeRateServiceTests.cs
@@ -0,0 +1,184 @@
+using ExchangeRateUpdater;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using RichardSzalay.MockHttp;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class CnbExchangeRateServiceTests
+{
+ private Mock> CreateLoggerMock()
+ {
+ return new Mock>();
+ }
+
+ private Mock> CreateParserLoggerMock()
+ {
+ return new Mock>();
+ }
+
+ private ICnbResponseParser CreateParser()
+ {
+ return new CnbResponseParser(CreateParserLoggerMock().Object);
+ }
+
+ private MockHttpMessageHandler CreateMockHttp()
+ {
+ return new MockHttpMessageHandler();
+ }
+
+ private IOptions CreateOptions(string baseUrl = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml", int timeoutSeconds = 30)
+ {
+ var options = new CnbApiOptions
+ {
+ BaseUrl = baseUrl,
+ TimeoutSeconds = timeoutSeconds,
+ RetryCount = 3,
+ CircuitBreakerFailureThreshold = 5,
+ CircuitBreakerDurationSeconds = 30
+ };
+ return Options.Create(options);
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_EmptyXml_ReturnsEmptyDictionary()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ var today = DateTime.Today;
+ var xmlResponse = $@"";
+
+ mockHttp
+ .When(HttpMethod.Get, "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml*")
+ .Respond("application/xml", xmlResponse);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act
+ var result = await service.GetDailyRatesAsync();
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_DateMismatch_ReturnsRates()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ var requestedDate = DateTime.Today;
+ var responseDate = requestedDate.AddDays(-1);
+ // Use comma for decimal separator (Czech format)
+ var xmlResponse = $@"";
+
+ mockHttp
+ .When(HttpMethod.Get, $"https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml?date={requestedDate:dd.MM.yyyy}")
+ .Respond("application/xml", xmlResponse);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act
+ var result = await service.GetDailyRatesAsync(requestedDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("USD"));
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_MissingDateAttribute_ReturnsRates()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ // Use comma for decimal separator (Czech format)
+ var xmlResponse = @"";
+
+ mockHttp
+ .When(HttpMethod.Get, "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml*")
+ .Respond("application/xml", xmlResponse);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act
+ var result = await service.GetDailyRatesAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("USD"));
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_InvalidRootElement_ThrowsException()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ var xmlResponse = @"";
+
+ mockHttp
+ .When(HttpMethod.Get, "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml*")
+ .Respond("application/xml", xmlResponse);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => service.GetDailyRatesAsync());
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_HttpError_ThrowsException()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ mockHttp
+ .When(HttpMethod.Get, "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml*")
+ .Respond(System.Net.HttpStatusCode.InternalServerError);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => service.GetDailyRatesAsync());
+ }
+
+ [Fact]
+ public async Task GetDailyRatesAsync_InvalidXml_ThrowsException()
+ {
+ // Arrange
+ var mockHttp = CreateMockHttp();
+ var loggerMock = CreateLoggerMock();
+ var invalidXml = "This is not valid XML";
+
+ mockHttp
+ .When(HttpMethod.Get, "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.xml*")
+ .Respond("application/xml", invalidXml);
+
+ var httpClient = mockHttp.ToHttpClient();
+ var options = CreateOptions();
+ var parser = CreateParser();
+ var service = new CnbExchangeRateService(httpClient, loggerMock.Object, options, parser);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => service.GetDailyRatesAsync());
+ }
+}
diff --git a/jobs/Backend/Task/test/CnbResponseParserTests.cs b/jobs/Backend/Task/test/CnbResponseParserTests.cs
new file mode 100644
index 0000000000..8999f179ee
--- /dev/null
+++ b/jobs/Backend/Task/test/CnbResponseParserTests.cs
@@ -0,0 +1,194 @@
+using ExchangeRateUpdater;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class CnbResponseParserTests
+{
+ private Mock> CreateLoggerMock()
+ {
+ return new Mock>();
+ }
+
+ private ICnbResponseParser CreateParser()
+ {
+ return new CnbResponseParser(CreateLoggerMock().Object);
+ }
+
+ [Fact]
+ public void Parse_ValidXml_ReturnsRates()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = $@"
+
+
+
+
+
+";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.True(result.ContainsKey("USD"));
+ Assert.True(result.ContainsKey("EUR"));
+ Assert.Equal(21.048m, result["USD"]);
+ Assert.Equal(22.345m, result["EUR"]);
+ }
+
+ [Fact]
+ public void Parse_EmptyXml_ReturnsEmptyDictionary()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = $@"";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Parse_DateMismatch_StillReturnsRates()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var responseDate = requestedDate.AddDays(-1);
+ var xmlContent = $@"
+
+
+
+
+";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("USD"));
+ }
+
+ [Fact]
+ public void Parse_InvalidRootElement_ThrowsException()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = @"";
+
+ // Act & Assert
+ Assert.Throws(() => parser.Parse(xmlContent, requestedDate));
+ }
+
+ [Fact]
+ public void Parse_InvalidXml_ThrowsException()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = "This is not valid XML";
+
+ // Act & Assert
+ Assert.Throws(() => parser.Parse(xmlContent, requestedDate));
+ }
+
+ [Fact]
+ public void Parse_NullOrWhiteSpace_ReturnsEmptyDictionary()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+
+ // Act
+ var result1 = parser.Parse(null!, requestedDate);
+ var result2 = parser.Parse("", requestedDate);
+ var result3 = parser.Parse(" ", requestedDate);
+
+ // Assert
+ Assert.Empty(result1);
+ Assert.Empty(result2);
+ Assert.Empty(result3);
+ }
+
+ [Fact]
+ public void Parse_CurrencyWithAmount_CalculatesCorrectRate()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ // JPY comes as per 100, so rate should be divided by 100
+ var xmlContent = $@"
+
+
+
+
+";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(0.13425m, result["JPY"]); // 13.425 / 100
+ }
+
+ [Fact]
+ public void Parse_MissingDateAttribute_StillReturnsRates()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = @"
+
+
+
+
+";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("USD"));
+ }
+
+ [Fact]
+ public void Parse_MissingRequiredAttributes_SkipsInvalidRows()
+ {
+ // Arrange
+ var parser = CreateParser();
+ var requestedDate = DateTime.Today;
+ var xmlContent = $@"
+
+
+
+
+
+
+
+";
+
+ // Act
+ var result = parser.Parse(xmlContent, requestedDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("USD"));
+ Assert.False(result.ContainsKey("EUR"));
+ Assert.False(result.ContainsKey("GBP"));
+ }
+}
+
+
diff --git a/jobs/Backend/Task/test/CurrencyTests.cs b/jobs/Backend/Task/test/CurrencyTests.cs
new file mode 100644
index 0000000000..78e50fb061
--- /dev/null
+++ b/jobs/Backend/Task/test/CurrencyTests.cs
@@ -0,0 +1,65 @@
+using ExchangeRateUpdater;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class CurrencyTests
+{
+ [Fact]
+ public void Constructor_ValidCode_SetsCode()
+ {
+ // Arrange
+ var code = "USD";
+
+ // Act
+ var currency = new Currency(code);
+
+ // Assert
+ Assert.Equal("USD", currency.Code);
+ }
+
+ [Fact]
+ public void Constructor_LowercaseCode_ConvertsToUppercase()
+ {
+ // Arrange
+ var code = "usd";
+
+ // Act
+ var currency = new Currency(code);
+
+ // Assert
+ Assert.Equal("USD", currency.Code);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(" ")]
+ public void Constructor_NullOrEmptyCode_ThrowsArgumentException(string? code)
+ {
+ // Act & Assert
+ Assert.Throws(() => new Currency(code!));
+ }
+
+ [Theory]
+ [InlineData("US")]
+ [InlineData("USDD")]
+ [InlineData("A")]
+ [InlineData("ABCD")]
+ public void Constructor_InvalidLength_ThrowsArgumentException(string code)
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new Currency(code));
+ Assert.Contains("3 characters", exception.Message);
+ }
+
+ [Fact]
+ public void ToString_ReturnsCode()
+ {
+ // Arrange
+ var currency = new Currency("USD");
+
+ // Act & Assert
+ Assert.Equal("USD", currency.ToString());
+ }
+}
diff --git a/jobs/Backend/Task/test/ExchangeRateEdgeCaseTests.cs b/jobs/Backend/Task/test/ExchangeRateEdgeCaseTests.cs
new file mode 100644
index 0000000000..a24852c762
--- /dev/null
+++ b/jobs/Backend/Task/test/ExchangeRateEdgeCaseTests.cs
@@ -0,0 +1,110 @@
+using ExchangeRateUpdater;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class ExchangeRateEdgeCaseTests
+{
+ [Fact]
+ public void Constructor_ZeroValue_ThrowsException()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("USD");
+
+ // Act & Assert
+ var exception = Assert.Throws(() => new ExchangeRate(source, target, 0m));
+ Assert.Contains("greater than zero", exception.Message);
+ }
+
+ [Fact]
+ public void Constructor_NegativeValue_ThrowsException()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("USD");
+
+ // Act & Assert
+ var exception = Assert.Throws(() => new ExchangeRate(source, target, -1m));
+ Assert.Contains("greater than zero", exception.Message);
+ }
+
+ [Fact]
+ public void Constructor_NullSourceCurrency_ThrowsException()
+ {
+ // Arrange
+ var target = new Currency("USD");
+
+ // Act & Assert
+ Assert.Throws(() => new ExchangeRate(null!, target, 24.5m));
+ }
+
+ [Fact]
+ public void Constructor_NullTargetCurrency_ThrowsException()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+
+ // Act & Assert
+ Assert.Throws(() => new ExchangeRate(source, null!, 24.5m));
+ }
+
+ [Fact]
+ public void Constructor_VerySmallValue_Works()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("VND");
+
+ // Act
+ var rate = new ExchangeRate(source, target, 0.0001m);
+
+ // Assert
+ Assert.Equal(0.0001m, rate.Value);
+ }
+
+ [Fact]
+ public void Constructor_VeryLargeValue_Works()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("IRR");
+
+ // Act
+ var rate = new ExchangeRate(source, target, 999999.99m);
+
+ // Assert
+ Assert.Equal(999999.99m, rate.Value);
+ }
+
+ [Fact]
+ public void ToString_ReturnsCorrectFormat()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("USD");
+ var rate = new ExchangeRate(source, target, 24.5m);
+
+ // Act
+ var result = rate.ToString();
+
+ // Assert
+ Assert.Equal("CZK/USD=24.5", result);
+ }
+
+ [Fact]
+ public void ToString_WithDecimalPlaces_IncludesDecimals()
+ {
+ // Arrange
+ var source = new Currency("CZK");
+ var target = new Currency("JPY");
+ var rate = new ExchangeRate(source, target, 0.13425m);
+
+ // Act
+ var result = rate.ToString();
+
+ // Assert
+ Assert.Contains("0.13425", result);
+ }
+}
+
diff --git a/jobs/Backend/Task/test/ExchangeRateProviderEdgeCaseTests.cs b/jobs/Backend/Task/test/ExchangeRateProviderEdgeCaseTests.cs
new file mode 100644
index 0000000000..a24208fcf9
--- /dev/null
+++ b/jobs/Backend/Task/test/ExchangeRateProviderEdgeCaseTests.cs
@@ -0,0 +1,235 @@
+using ExchangeRateUpdater;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class ExchangeRateProviderEdgeCaseTests
+{
+ private Mock CreateCnbServiceMock()
+ {
+ return new Mock();
+ }
+
+ private Mock> CreateLoggerMock()
+ {
+ return new Mock>();
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_DuplicateCurrencies()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[]
+ {
+ new Currency("USD"),
+ new Currency("USD"), // Duplicate
+ new Currency("EUR")
+ };
+
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m },
+ { "EUR", 26.0m }
+ };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.True(ratesList.Count >= 2);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_AllCurrenciesNotFound_ReturnsEmpty()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[]
+ {
+ new Currency("XYZ"),
+ new Currency("ABC"),
+ new Currency("DEF")
+ };
+
+ var cnbRates = new Dictionary();
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_EmptyCnbResponse_ReturnsEmpty()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[] { new Currency("USD"), new Currency("EUR") };
+ var cnbRates = new Dictionary();
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_CnbReturnsOnlyOneCurrency()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[]
+ {
+ new Currency("USD"),
+ new Currency("EUR"),
+ new Currency("GBP")
+ };
+
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m }
+ };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.Single(ratesList);
+ Assert.Equal("USD", ratesList[0].TargetCurrency.Code);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_NullCurrencyInCollection()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new Currency?[]
+ {
+ new Currency("USD"),
+ null,
+ new Currency("EUR")
+ };
+
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m },
+ { "EUR", 26.0m }
+ };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var validCurrencies = currencies.Where(c => c != null).Cast();
+ var result = await provider.GetExchangeRatesAsync(validCurrencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.Equal(2, ratesList.Count);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_OperationCanceled_ThrowsException()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[] { new Currency("USD") };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ provider.GetExchangeRatesAsync(currencies));
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_CurrencyWithWhitespace()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[] { new Currency("USD") };
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m }
+ };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_ServiceReturnsNullDictionary()
+ {
+ // Arrange
+ var cnbServiceMock = CreateCnbServiceMock();
+ var loggerMock = CreateLoggerMock();
+ var provider = new ExchangeRateProvider(cnbServiceMock.Object, loggerMock.Object);
+
+ var currencies = new[] { new Currency("USD") };
+
+ cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((Dictionary?)null!);
+
+ // Act
+ var result = await provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ Assert.Empty(result);
+ }
+}
+
diff --git a/jobs/Backend/Task/test/ExchangeRateProviderTests.cs b/jobs/Backend/Task/test/ExchangeRateProviderTests.cs
new file mode 100644
index 0000000000..74768dc249
--- /dev/null
+++ b/jobs/Backend/Task/test/ExchangeRateProviderTests.cs
@@ -0,0 +1,142 @@
+using ExchangeRateUpdater;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class ExchangeRateProviderTests
+{
+ private readonly Mock _cnbServiceMock;
+ private readonly Mock> _loggerMock;
+ private readonly ExchangeRateProvider _provider;
+
+ public ExchangeRateProviderTests()
+ {
+ _cnbServiceMock = new Mock();
+ _loggerMock = new Mock>();
+ _provider = new ExchangeRateProvider(_cnbServiceMock.Object, _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_ValidCurrencies_ReturnsMatchingRates()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD"), new Currency("EUR") };
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m },
+ { "EUR", 25.0m }
+ };
+
+ _cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await _provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.Equal(2, ratesList.Count);
+
+ var usdRate = ratesList.FirstOrDefault(r => r.TargetCurrency.Code == "USD");
+ Assert.NotNull(usdRate);
+ Assert.Equal("CZK", usdRate!.SourceCurrency.Code);
+ Assert.Equal(24.5m, usdRate.Value);
+
+ var eurRate = ratesList.FirstOrDefault(r => r.TargetCurrency.Code == "EUR");
+ Assert.NotNull(eurRate);
+ Assert.Equal("CZK", eurRate!.SourceCurrency.Code);
+ Assert.Equal(25.0m, eurRate.Value);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_CurrencyNotInCnb_IgnoresCurrency()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD"), new Currency("XYZ") };
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m }
+ };
+
+ _cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await _provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.Single(ratesList);
+ Assert.Equal("USD", ratesList[0].TargetCurrency.Code);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_CzkCurrency_SkipsCzk()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD"), new Currency("CZK") };
+ var cnbRates = new Dictionary
+ {
+ { "USD", 24.5m }
+ };
+
+ _cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await _provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ var ratesList = result.ToList();
+ Assert.Single(ratesList);
+ Assert.Equal("USD", ratesList[0].TargetCurrency.Code);
+ Assert.DoesNotContain(ratesList, r => r.TargetCurrency.Code == "CZK");
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_EmptyCurrencies_ReturnsEmpty()
+ {
+ // Arrange
+ var currencies = Array.Empty();
+ var cnbRates = new Dictionary();
+
+ _cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(cnbRates);
+
+ // Act
+ var result = await _provider.GetExchangeRatesAsync(currencies);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_NullCurrencies_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _provider.GetExchangeRatesAsync(null!));
+ }
+
+ [Fact]
+ public async Task GetExchangeRatesAsync_CnbServiceThrows_PropagatesException()
+ {
+ // Arrange
+ var currencies = new[] { new Currency("USD") };
+ _cnbServiceMock
+ .Setup(s => s.GetDailyRatesAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new CnbApiException("CNB service error"));
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ _provider.GetExchangeRatesAsync(currencies));
+ Assert.Contains("CNB service error", exception.Message);
+ }
+
+}
diff --git a/jobs/Backend/Task/test/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests.csproj
new file mode 100644
index 0000000000..763876969e
--- /dev/null
+++ b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net6.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/test/Usings.cs b/jobs/Backend/Task/test/Usings.cs
new file mode 100644
index 0000000000..8c927eb747
--- /dev/null
+++ b/jobs/Backend/Task/test/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
\ No newline at end of file