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