From 16c1f339d820b224a3967dd1e46139d41b09fac0 Mon Sep 17 00:00:00 2001 From: Ferdinando Sendyka Date: Fri, 15 Aug 2025 21:34:06 +0100 Subject: [PATCH 1/3] Added .vs folder to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fd3586545..c59f3159a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ [Tt]humbs.db *.tgz *.sublime-* +.vs/ node_modules bower_components From 9ce610cd810fb55abc646f461172af2c4f36cd9e Mon Sep 17 00:00:00 2001 From: Ferdinando Sendyka Date: Tue, 19 Aug 2025 19:16:07 +0100 Subject: [PATCH 2/3] Implement CNB exchange rate provider with clean architecture --- .../Task/Configuration/CnbApiConfiguration.cs | 24 ++++ jobs/Backend/Task/Constants/CnbConstants.cs | 37 +++++ jobs/Backend/Task/Errors/CnbException.cs | 27 ++++ jobs/Backend/Task/ExchangeRateProvider.cs | 19 --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 19 +++ .../Extensions/ServiceCollectionExtensions.cs | 44 ++++++ .../Task/Models/CnbExchangeRateData.cs | 19 +++ jobs/Backend/Task/{ => Models}/Currency.cs | 2 +- .../Backend/Task/{ => Models}/ExchangeRate.cs | 2 +- jobs/Backend/Task/Program.cs | 20 ++- .../Services/Builders/ExchangeRateBuilder.cs | 36 +++++ .../Services/Builders/IExchangeRateBuilder.cs | 10 ++ .../Task/Services/Clients/CnbApiClient.cs | 61 ++++++++ .../Task/Services/Clients/ICnbApiClient.cs | 10 ++ .../Task/Services/ExchangeRateProvider.cs | 92 ++++++++++++ .../Task/Services/Handlers/ErrorHandler.cs | 29 ++++ .../Task/Services/Parsers/CnbDataParser.cs | 134 ++++++++++++++++++ .../Task/Services/Parsers/ICnbDataParser.cs | 9 ++ jobs/Backend/Task/appsettings.json | 13 ++ 19 files changed, 584 insertions(+), 23 deletions(-) create mode 100644 jobs/Backend/Task/Configuration/CnbApiConfiguration.cs create mode 100644 jobs/Backend/Task/Constants/CnbConstants.cs create mode 100644 jobs/Backend/Task/Errors/CnbException.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/Models/CnbExchangeRateData.cs rename jobs/Backend/Task/{ => Models}/Currency.cs (89%) rename jobs/Backend/Task/{ => Models}/ExchangeRate.cs (93%) create mode 100644 jobs/Backend/Task/Services/Builders/ExchangeRateBuilder.cs create mode 100644 jobs/Backend/Task/Services/Builders/IExchangeRateBuilder.cs create mode 100644 jobs/Backend/Task/Services/Clients/CnbApiClient.cs create mode 100644 jobs/Backend/Task/Services/Clients/ICnbApiClient.cs create mode 100644 jobs/Backend/Task/Services/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Services/Handlers/ErrorHandler.cs create mode 100644 jobs/Backend/Task/Services/Parsers/CnbDataParser.cs create mode 100644 jobs/Backend/Task/Services/Parsers/ICnbDataParser.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task/Configuration/CnbApiConfiguration.cs b/jobs/Backend/Task/Configuration/CnbApiConfiguration.cs new file mode 100644 index 000000000..9e4d227b7 --- /dev/null +++ b/jobs/Backend/Task/Configuration/CnbApiConfiguration.cs @@ -0,0 +1,24 @@ +namespace ExchangeRateUpdater.Configuration; + +/// +/// Configuration settings for the Czech National Bank API client. +/// +public class CnbApiConfiguration +{ + /// + /// The URL endpoint for retrieving daily exchange rates from CNB. + /// + public string CnbDailyRatesUrl { get; set; } + + /// + /// The timeout duration in seconds for HTTP requests to the CNB API. + /// Default value is 30 seconds. + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// The number of retry attempts for failed HTTP requests. + /// Default value is 3 attempts. + /// + public int RetryCount { get; set; } = 3; +} diff --git a/jobs/Backend/Task/Constants/CnbConstants.cs b/jobs/Backend/Task/Constants/CnbConstants.cs new file mode 100644 index 000000000..7f9fb4ef3 --- /dev/null +++ b/jobs/Backend/Task/Constants/CnbConstants.cs @@ -0,0 +1,37 @@ +namespace ExchangeRateUpdater.Constants; + +/// +/// Constants defining the Czech National Bank data format and business rules +/// +public static class CnbConstants +{ + /// + /// The base currency code for Czech National Bank + /// + public const string CurrencyCode = "CZK"; + + /// + /// Expected date format in CNB data files + /// + public const string DateFormat = "d MMM yyyy"; + + /// + /// Minimum number of lines expected in a valid CNB response + /// + public const int ExpectedMinimumLines = 3; + + /// + /// Minimum number of space-separated parts expected in the date line for parsing + /// + public const int MinimumDateParts = 3; + + /// + /// Number of fields expected in each exchange rate line + /// + public const int ExpectedFieldCount = 5; + + /// + /// Field separator character used in CNB data format + /// + public const char FieldSeparator = '|'; +} diff --git a/jobs/Backend/Task/Errors/CnbException.cs b/jobs/Backend/Task/Errors/CnbException.cs new file mode 100644 index 000000000..a004d2078 --- /dev/null +++ b/jobs/Backend/Task/Errors/CnbException.cs @@ -0,0 +1,27 @@ +using System; + +namespace ExchangeRateUpdater.Errors; + +public class CnbException : Exception +{ + public CnbErrorCode ErrorCode { get; } + + public CnbException(CnbErrorCode errorCode, string message = null, Exception innerException = null) + : base(message ?? errorCode.ToString(), innerException) + { + ErrorCode = errorCode; + } +} + +public enum CnbErrorCode +{ + EmptyResponse, + InsufficientData, + InvalidDateFormat, + InvalidHeaderFormat, + NoValidRates, + NetworkError, + TimeoutError, + ParsingError, + UnexpectedError +} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- 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 2fc654a12..80b78525e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,23 @@ net6.0 + + + + + + + + + + + + + + + + + PreserveNewest + + \ No newline at end of file diff --git a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a9b513242 --- /dev/null +++ b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Builders; +using ExchangeRateUpdater.Services.Clients; +using ExchangeRateUpdater.Services.Parsers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Contrib.WaitAndRetry; +using System; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace ExchangeRateUpdater.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExchangeRateServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddLogging(builder => + { + builder.AddConfiguration(configuration.GetSection("Logging")); + builder.AddConsole(); + }); + + services.Configure(configuration.GetSection("CnbApi")); + var cnbConfig = configuration.GetSection("CnbApi").Get(); + + services.AddHttpClient((serviceProvider, client) => + { + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Text.Plain)); + }) + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(cnbConfig.TimeoutSeconds)) + .AddTransientHttpErrorPolicy(policyBuilder => policyBuilder + .WaitAndRetryAsync(Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), cnbConfig.RetryCount))); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/jobs/Backend/Task/Models/CnbExchangeRateData.cs b/jobs/Backend/Task/Models/CnbExchangeRateData.cs new file mode 100644 index 000000000..10ab55e0f --- /dev/null +++ b/jobs/Backend/Task/Models/CnbExchangeRateData.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Models; + +public class CnbExchangeRateData +{ + public DateTime Date { get; set; } + public List Rates { get; set; } = new List(); +} + +public class CnbExchangeRateEntry +{ + public string Country { get; set; } + public string Currency { get; set; } + public int Amount { get; set; } + public string Code { get; set; } + public decimal Rate { get; set; } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Models/Currency.cs similarity index 89% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Models/Currency.cs index f375776f2..8336d740e 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/Models/ExchangeRate.cs index 58c5bb10e..2133586d4 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..e2fa7e953 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,5 +1,11 @@ -using System; +using ExchangeRateUpdater.Extensions; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace ExchangeRateUpdater @@ -21,9 +27,19 @@ public static class Program public static void Main(string[] args) { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + using var serviceProvider = new ServiceCollection() + .AddSingleton(configuration) + .AddExchangeRateServices(configuration) + .BuildServiceProvider(); + try { - var provider = new ExchangeRateProvider(); + var provider = serviceProvider.GetRequiredService(); var rates = provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); diff --git a/jobs/Backend/Task/Services/Builders/ExchangeRateBuilder.cs b/jobs/Backend/Task/Services/Builders/ExchangeRateBuilder.cs new file mode 100644 index 000000000..197544bfb --- /dev/null +++ b/jobs/Backend/Task/Services/Builders/ExchangeRateBuilder.cs @@ -0,0 +1,36 @@ +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Models; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ExchangeRateUpdater.Services.Builders; + +public class ExchangeRateBuilder : IExchangeRateBuilder +{ + private static readonly Currency CzkCurrency = new(CnbConstants.CurrencyCode); + + public IEnumerable BuildExchangeRates(IEnumerable requestedCurrencies, CnbExchangeRateData cnbData) + { + var exchangeRates = new List(); + var requestedCurrencyCodes = new HashSet( + requestedCurrencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase); + + foreach (var rate in cnbData.Rates) + { + if (requestedCurrencyCodes.Contains(rate.Code) && + requestedCurrencyCodes.Contains(CzkCurrency.Code)) + { + var foreignToCzk = new ExchangeRate( + new Currency(rate.Code), + CzkCurrency, + rate.Rate / rate.Amount + ); + exchangeRates.Add(foreignToCzk); + } + } + + return exchangeRates; + } +} diff --git a/jobs/Backend/Task/Services/Builders/IExchangeRateBuilder.cs b/jobs/Backend/Task/Services/Builders/IExchangeRateBuilder.cs new file mode 100644 index 000000000..434ccce7f --- /dev/null +++ b/jobs/Backend/Task/Services/Builders/IExchangeRateBuilder.cs @@ -0,0 +1,10 @@ +using ExchangeRateUpdater.Models; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Services.Builders +{ + public interface IExchangeRateBuilder + { + IEnumerable BuildExchangeRates(IEnumerable requestedCurrencies, CnbExchangeRateData cnbData); + } +} diff --git a/jobs/Backend/Task/Services/Clients/CnbApiClient.cs b/jobs/Backend/Task/Services/Clients/CnbApiClient.cs new file mode 100644 index 000000000..f43216db0 --- /dev/null +++ b/jobs/Backend/Task/Services/Clients/CnbApiClient.cs @@ -0,0 +1,61 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Services.Handlers; +using FluentResults; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Services.Clients; + +public class CnbApiClient : ICnbApiClient +{ + private readonly HttpClient _httpClient; + private readonly CnbApiConfiguration _configuration; + private readonly ILogger _logger; + + public CnbApiClient(HttpClient httpClient, ILogger logger, IOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configuration = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task> GetExchangeRateDataAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Fetching exchange rates from CNB API"); + return await ExecuteRequestAsync(_configuration.CnbDailyRatesUrl, cancellationToken).ConfigureAwait(false); + } + + protected virtual async Task> ExecuteRequestAsync(string requestUri, CancellationToken cancellationToken) + { + try + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("CNB API returned {StatusCode}", response.StatusCode); + return ErrorHandler.Handle(CnbErrorCode.NetworkError, $"API returned {response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Retrieved {Length} characters of exchange rate data", content.Length); + return Result.Ok(content); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to retrieve data from CNB API"); + return ErrorHandler.Handle(CnbErrorCode.NetworkError, "Network error occurred"); + } + catch (TaskCanceledException ex) + { + _logger.LogError(ex, "Request to CNB API timed out"); + return ErrorHandler.Handle(CnbErrorCode.TimeoutError, $"Request timed out after {_configuration.TimeoutSeconds} seconds"); + } + } +} diff --git a/jobs/Backend/Task/Services/Clients/ICnbApiClient.cs b/jobs/Backend/Task/Services/Clients/ICnbApiClient.cs new file mode 100644 index 000000000..7ed2373f0 --- /dev/null +++ b/jobs/Backend/Task/Services/Clients/ICnbApiClient.cs @@ -0,0 +1,10 @@ +using FluentResults; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Services.Clients; + +public interface ICnbApiClient +{ + Task> GetExchangeRateDataAsync(CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..88fb9723c --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,92 @@ +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services.Builders; +using ExchangeRateUpdater.Services.Clients; +using ExchangeRateUpdater.Services.Handlers; +using ExchangeRateUpdater.Services.Parsers; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProvider + { + private readonly ICnbApiClient _apiClient; + private readonly ICnbDataParser _parser; + private readonly IExchangeRateBuilder _builder; + private readonly ILogger _logger; + + public ExchangeRateProvider( + ICnbApiClient apiClient, + ICnbDataParser parser, + IExchangeRateBuilder builder, + ILogger logger) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _builder = builder ?? throw new ArgumentNullException(nameof(builder)); + _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 currencyList = currencies.ToList(); + if (!currencyList.Any()) + { + return Enumerable.Empty(); + } + + _logger.LogInformation("Getting exchange rates for {Count} currencies", currencyList.Count); + + try + { + var result = _apiClient.GetExchangeRateDataAsync(CancellationToken.None).GetAwaiter().GetResult(); + + if (result.IsFailed) + { + var error = ErrorHandler.ExtractError(result); + _logger.LogError("Failed to get exchange rates: {ErrorCode} - {Message}", + error.ErrorCode, error.Message); + throw error; + } + + var parsedDataResult = _parser.Parse(result.Value); + if (parsedDataResult.IsFailed) + { + var error = ErrorHandler.ExtractError(parsedDataResult); + _logger.LogError("Failed to parse exchange rates: {ErrorCode} - {Message}", + error.ErrorCode, error.Message); + throw error; + } + + var rates = _builder.BuildExchangeRates(currencyList, parsedDataResult.Value); + _logger.LogInformation("Successfully retrieved {Count} exchange rates", rates.Count()); + + return rates; + } + catch (CnbException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while retrieving exchange rates"); + throw new CnbException(CnbErrorCode.UnexpectedError, "Failed to process exchange rates", ex); + } + } + } +} diff --git a/jobs/Backend/Task/Services/Handlers/ErrorHandler.cs b/jobs/Backend/Task/Services/Handlers/ErrorHandler.cs new file mode 100644 index 000000000..fb0be629e --- /dev/null +++ b/jobs/Backend/Task/Services/Handlers/ErrorHandler.cs @@ -0,0 +1,29 @@ +using ExchangeRateUpdater.Errors; +using FluentResults; +using System.Linq; + +namespace ExchangeRateUpdater.Services.Handlers; + +public static class ErrorHandler +{ + public static Result Handle(CnbErrorCode errorCode, string message) + { + return Result.Fail(new Error(message) + .WithMetadata("ErrorCode", errorCode)); + } + + public static CnbException ExtractError(IResultBase result) + { + var error = result.Errors.FirstOrDefault(); + if (error == null) + { + return new CnbException(CnbErrorCode.UnexpectedError, "Unknown error"); + } + + var errorCode = error.Metadata.TryGetValue("ErrorCode", out var code) && code is CnbErrorCode cnbCode + ? cnbCode + : CnbErrorCode.UnexpectedError; + + return new CnbException(errorCode, error.Message); + } +} diff --git a/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs b/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs new file mode 100644 index 000000000..70ebd8c04 --- /dev/null +++ b/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs @@ -0,0 +1,134 @@ +using ExchangeRateUpdater.Constants; +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services.Handlers; +using FluentResults; +using Microsoft.Extensions.Logging; +using System; +using System.Globalization; +using System.Linq; + +namespace ExchangeRateUpdater.Services.Parsers; + +public class CnbDataParser : ICnbDataParser +{ + private readonly ILogger _logger; + + public CnbDataParser(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Result Parse(string rawData) + { + _logger.LogDebug("Starting to parse CNB data"); + + if (string.IsNullOrWhiteSpace(rawData)) + { + return ErrorHandler.Handle(CnbErrorCode.EmptyResponse, "Empty response from CNB"); + } + + var lines = rawData.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + _logger.LogDebug("Parsing {LineCount} lines of CNB data", lines.Length); + + if (lines.Length < CnbConstants.ExpectedMinimumLines) + { + _logger.LogError("Insufficient data: expected at least {Expected} lines, got {Actual}", CnbConstants.ExpectedMinimumLines, lines.Length); + return ErrorHandler.Handle(CnbErrorCode.InsufficientData, + $"Expected at least {CnbConstants.ExpectedMinimumLines} lines, got {lines.Length}"); + } + + var result = new CnbExchangeRateData + { + Date = ParseDate(lines[0]) + }; + _logger.LogDebug("Parsed date: {Date}", result.Date); + + ValidateHeader(lines[1]); + + for (int i = 2; i < lines.Length; i++) + { + if (TryParseExchangeRateEntry(lines[i], out var entry)) + { + result.Rates.Add(entry); + } + } + + _logger.LogInformation("Successfully parsed {Count} exchange rates", result.Rates.Count); + + if (!result.Rates.Any()) + { + _logger.LogError("No valid exchange rates found in CNB data"); + return ErrorHandler.Handle(CnbErrorCode.NoValidRates, + "No valid exchange rates found in CNB data"); + } + + return Result.Ok(result); + } + + private static DateTime ParseDate(string dateLine) + { + var parts = dateLine.Split(' '); + if (parts.Length < CnbConstants.MinimumDateParts) + { + throw new CnbException(CnbErrorCode.InvalidDateFormat, dateLine); + } + + var dateString = $"{parts[0]} {parts[1]} {parts[2]}"; + if (!DateTime.TryParseExact(dateString, CnbConstants.DateFormat, + CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + throw new CnbException(CnbErrorCode.InvalidDateFormat, dateString); + } + + return date; + } + + private static void ValidateHeader(string headerLine) + { + if (!headerLine.Contains(CnbConstants.FieldSeparator) || + !headerLine.Contains("Country") || + !headerLine.Contains("Rate")) + { + throw new CnbException(CnbErrorCode.InvalidHeaderFormat, headerLine); + } + } + + private static bool TryParseExchangeRateEntry(string line, out CnbExchangeRateEntry entry) + { + entry = null; + var parts = line.Split(CnbConstants.FieldSeparator); + + if (parts.Length != CnbConstants.ExpectedFieldCount) + { + return false; + } + + try + { + entry = new CnbExchangeRateEntry + { + Country = parts[0].Trim(), + Currency = parts[1].Trim(), + Amount = int.Parse(parts[2].Trim(), CultureInfo.InvariantCulture), + Code = parts[3].Trim(), + Rate = decimal.Parse(parts[4].Trim(), CultureInfo.InvariantCulture) + }; + + return IsValidEntry(entry); + } + catch + { + entry = null; + return false; + } + } + + private static bool IsValidEntry(CnbExchangeRateEntry entry) + { + return !string.IsNullOrWhiteSpace(entry.Code) && + entry.Code.Length == 3 && + entry.Amount > 0 && + entry.Rate > 0; + } +} diff --git a/jobs/Backend/Task/Services/Parsers/ICnbDataParser.cs b/jobs/Backend/Task/Services/Parsers/ICnbDataParser.cs new file mode 100644 index 000000000..4ed5b895b --- /dev/null +++ b/jobs/Backend/Task/Services/Parsers/ICnbDataParser.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Models; +using FluentResults; + +namespace ExchangeRateUpdater.Services.Parsers; + +public interface ICnbDataParser +{ + Result Parse(string rawData); +} diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..32e7d8354 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,13 @@ +{ + "CnbApi": { + "CnbDailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30, + "RetryCount": 3 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "System.Net.Http.HttpClient": "Warning" + } + } +} From 25ebeee8da00fdceb9bb35f2ed2c012cf90f8f9f Mon Sep 17 00:00:00 2001 From: Ferdinando Sendyka Date: Tue, 19 Aug 2025 20:40:19 +0100 Subject: [PATCH 3/3] Added unit test for CNB exchange rate provider --- .../Builders/ExchangeRateBuilderTests.cs | 147 +++++++++++ .../Clients/CnbApiClientTests.cs | 248 ++++++++++++++++++ .../ExchangeRateProviderTests.cs | 167 ++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 30 +++ .../Handlers/ErrorHandlerTests.cs | 75 ++++++ .../Parsers/CnbDataParserTests.cs | 166 ++++++++++++ .../ExchangeRateUpdater.Tests/Usings.cs | 1 + jobs/Backend/Task/ExchangeRateUpdater.sln | 10 +- .../Task/Services/Parsers/CnbDataParser.cs | 37 ++- 9 files changed, 868 insertions(+), 13 deletions(-) create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/Builders/ExchangeRateBuilderTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/Clients/CnbApiClientTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/Handlers/ErrorHandlerTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/Parsers/CnbDataParserTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Builders/ExchangeRateBuilderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Builders/ExchangeRateBuilderTests.cs new file mode 100644 index 000000000..4a0b24828 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Builders/ExchangeRateBuilderTests.cs @@ -0,0 +1,147 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services.Builders; +using FluentAssertions; + +namespace ExchangeRateUpdater.Tests.Builders; + +public class ExchangeRateBuilderTests +{ + private readonly ExchangeRateBuilder _builder; + + public ExchangeRateBuilderTests() + { + _builder = new ExchangeRateBuilder(); + } + + [Fact] + public void BuildExchangeRates_WithMatchingCurrencies_ReturnsCorrectRates() + { + // Arrange + var currencies = new List + { + new Currency("EUR"), + new Currency("USD"), + new Currency("CZK") + }; + + var cnbData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List + { + new CnbExchangeRateEntry { Code = "EUR", Rate = 24.455m, Amount = 1 }, + new CnbExchangeRateEntry { Code = "USD", Rate = 20.927m, Amount = 1 } + } + }; + + // Act + var result = _builder.BuildExchangeRates(currencies, cnbData).ToList(); + + // Assert + result.Should().HaveCount(2); + + var eurToCzk = result.Single(r => r.SourceCurrency.Code == "EUR"); + eurToCzk.TargetCurrency.Code.Should().Be("CZK"); + eurToCzk.Value.Should().Be(24.455m); + + var usdToCzk = result.Single(r => r.SourceCurrency.Code == "USD"); + usdToCzk.TargetCurrency.Code.Should().Be("CZK"); + usdToCzk.Value.Should().Be(20.927m); + } + + [Fact] + public void BuildExchangeRates_WithAmountGreaterThanOne_CalculatesCorrectRate() + { + var currencies = new List { new Currency("JPY"), new Currency("CZK") }; + var cnbData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List + { + new CnbExchangeRateEntry + { + Code = "JPY", + Rate = 14.165m, + Amount = 100, + Country = "Japan", + Currency = "yen" + } + } + }; + + // Act + var result = _builder.BuildExchangeRates(currencies, cnbData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Value.Should().Be(0.14165m); + } + + [Fact] + public void BuildExchangeRates_WithCzkCurrency_ReturnsNoRate() + { + // Arrange + var currencies = new List { new Currency("CZK") }; + var cnbData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List + { + new CnbExchangeRateEntry { Code = "EUR", Rate = 24.455m, Amount = 1 } + } + }; + + // Act + var result = _builder.BuildExchangeRates(currencies, cnbData).ToList(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void BuildExchangeRates_WithEmptyCurrencyList_ReturnsEmpty() + { + // Arrange + var currencies = new List(); + var cnbData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List + { + new CnbExchangeRateEntry { Code = "EUR", Rate = 24.455m, Amount = 1 } + } + }; + + // Act + var result = _builder.BuildExchangeRates(currencies, cnbData); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void BuildExchangeRates_WithNullCurrencies_ThrowsArgumentNullException() + { + // Arrange + var cnbData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List() + }; + + // Act & Assert + var exception = Assert.Throws(() => _builder.BuildExchangeRates(null, cnbData).ToList()); + + exception.ParamName.Should().Be("source"); + } + + [Fact] + public void BuildExchangeRates_WithNullCnbData_ThrowsNullReferenceException() + { + // Arrange + var currencies = new List { new Currency("EUR") }; + + // Act & Assert + Assert.Throws(() => _builder.BuildExchangeRates(currencies, null).ToList()); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Clients/CnbApiClientTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Clients/CnbApiClientTests.cs new file mode 100644 index 000000000..cba3c492e --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Clients/CnbApiClientTests.cs @@ -0,0 +1,248 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Services.Clients; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System.Net; + +namespace ExchangeRateUpdater.Tests.Clients; + +public class CnbApiClientTests +{ + private readonly Mock _httpMessageHandlerMock; + private readonly HttpClient _httpClient; + private readonly Mock> _loggerMock; + private readonly CnbApiConfiguration _configuration; + private readonly CnbApiClient _client; + + public CnbApiClientTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpMessageHandlerMock.Object) + { + BaseAddress = new Uri("https://test.cnb.cz/") + }; + _loggerMock = new Mock>(); + _configuration = new CnbApiConfiguration + { + CnbDailyRatesUrl = "https://test.cnb.cz/daily.txt", + TimeoutSeconds = 30, + RetryCount = 3 + }; + + var options = Options.Create(_configuration); + _client = new CnbApiClient(_httpClient, _loggerMock.Object, options); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithSuccessfulResponse_ReturnsContent() + { + // Arrange + var expectedContent = @"19 Aug 2025 #159 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.165"; + + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(expectedContent) + }; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + var result = await _client.GetExchangeRateDataAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(expectedContent); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Retrieved") && v.ToString().Contains("characters")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithHttpError_ReturnsNetworkError() + { + // Arrange + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError, + ReasonPhrase = "Internal Server Error" + }; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + var result = await _client.GetExchangeRateDataAsync(); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.NetworkError); + result.Errors[0].Message.Should().Contain("API returned InternalServerError"); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("CNB API returned")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithTimeout_ReturnsTimeoutError() + { + // Arrange + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new TaskCanceledException("The operation was canceled.")); + + // Act + var result = await _client.GetExchangeRateDataAsync(); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.TimeoutError); + result.Errors[0].Message.Should().Contain($"Request timed out after {_configuration.TimeoutSeconds} seconds"); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithHttpRequestException_ReturnsNetworkError() + { + // Arrange + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act + var result = await _client.GetExchangeRateDataAsync(); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.NetworkError); + result.Errors[0].Message.Should().Be("Network error occurred"); + } + + [Fact] + public async Task GetExchangeRateDataAsync_UsesCorrectUrl() + { + // Arrange + HttpRequestMessage capturedRequest = null; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("test content") + }; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, _) => capturedRequest = request) + .ReturnsAsync(response); + + // Act + await _client.GetExchangeRateDataAsync(); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest.Method.Should().Be(HttpMethod.Get); + capturedRequest.RequestUri.ToString().Should().Be(_configuration.CnbDailyRatesUrl); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithCancellation_PropagatesCancellation() + { + // Arrange + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.Is(ct => ct.IsCancellationRequested)) + .ThrowsAsync(new OperationCanceledException()); + + // Act + var result = await _client.GetExchangeRateDataAsync(cts.Token); + + // Assert + result.IsFailed.Should().BeTrue(); + } + + [Fact] + public async Task GetExchangeRateDataAsync_WithEmptyResponse_ReturnsSuccessWithEmptyString() + { + // Arrange + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("") + }; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + var result = await _client.GetExchangeRateDataAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WithNullDependencies_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => + new CnbApiClient(null, _loggerMock.Object, Options.Create(_configuration))); + + Assert.Throws(() => + new CnbApiClient(_httpClient, null, Options.Create(_configuration))); + + Assert.Throws(() => + new CnbApiClient(_httpClient, _loggerMock.Object, null)); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..6f13ee034 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,167 @@ +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Builders; +using ExchangeRateUpdater.Services.Clients; +using ExchangeRateUpdater.Services.Handlers; +using ExchangeRateUpdater.Services.Parsers; +using FluentAssertions; +using FluentResults; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests + { + private readonly Mock _apiClientMock; + private readonly Mock _parserMock; + private readonly Mock _builderMock; + private readonly Mock> _loggerMock; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _apiClientMock = new Mock(); + _parserMock = new Mock(); + _builderMock = new Mock(); + _loggerMock = new Mock>(); + + _provider = new ExchangeRateProvider( + _apiClientMock.Object, + _parserMock.Object, + _builderMock.Object, + _loggerMock.Object); + } + + [Fact] + public void GetExchangeRates_WithValidData_ReturnsExchangeRates() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + var apiData = "sample CNB data"; + var parsedData = new CnbExchangeRateData + { + Date = DateTime.Now, + Rates = new List + { + new() { Code = "USD", Rate = 20.5m, Amount = 1 }, + new() { Code = "EUR", Rate = 24.5m, Amount = 1 } + } + }; + var expectedRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 20.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.5m) + }; + + _apiClientMock.Setup(x => x.GetExchangeRateDataAsync(It.IsAny())) + .ReturnsAsync(Result.Ok(apiData)); + + _parserMock.Setup(x => x.Parse(apiData)) + .Returns(Result.Ok(parsedData)); + + _builderMock.Setup(x => x.BuildExchangeRates(currencies, parsedData)) + .Returns(expectedRates); + + // Act + var result = _provider.GetExchangeRates(currencies); + + // Assert + result.Should().HaveCount(2); + result.Should().BeEquivalentTo(expectedRates); + + _apiClientMock.Verify(x => x.GetExchangeRateDataAsync(It.IsAny()), Times.Once); + _parserMock.Verify(x => x.Parse(apiData), Times.Once); + _builderMock.Verify(x => x.BuildExchangeRates(currencies, parsedData), Times.Once); + } + + [Fact] + public void GetExchangeRates_WhenBuilderThrowsException_ThrowsCnbExceptionWithUnexpectedError() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + var apiData = "sample data"; + var parsedData = new CnbExchangeRateData(); + + _apiClientMock.Setup(x => x.GetExchangeRateDataAsync(It.IsAny())) + .ReturnsAsync(Result.Ok(apiData)); + + _parserMock.Setup(x => x.Parse(apiData)) + .Returns(Result.Ok(parsedData)); + + _builderMock.Setup(x => x.BuildExchangeRates(currencies, parsedData)) + .Throws(new InvalidOperationException("Builder error")); + + // Act & Assert + var exception = Assert.Throws(() => _provider.GetExchangeRates(currencies)); + exception.ErrorCode.Should().Be(CnbErrorCode.UnexpectedError); + exception.InnerException.Should().BeOfType(); + } + + [Fact] + public void GetExchangeRates_WithNullCurrencies_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => _provider.GetExchangeRates(null)); + exception.ParamName.Should().Be("currencies"); + } + + [Fact] + public void GetExchangeRates_WithEmptyCurrencies_ReturnsEmptyCollection() + { + // Arrange + var currencies = Enumerable.Empty(); + + // Act + var result = _provider.GetExchangeRates(currencies); + + // Assert + result.Should().BeEmpty(); + _apiClientMock.Verify(x => x.GetExchangeRateDataAsync(It.IsAny()), Times.Never); + _parserMock.Verify(x => x.Parse(It.IsAny()), Times.Never); + _builderMock.Verify(x => x.BuildExchangeRates(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public void GetExchangeRates_WhenApiClientFails_ThrowsCnbException() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + var apiError = ErrorHandler.Handle(CnbErrorCode.NetworkError, "Network connection failed"); + + _apiClientMock.Setup(x => x.GetExchangeRateDataAsync(It.IsAny())) + .ReturnsAsync(apiError); + + // Act & Assert + var exception = Assert.Throws(() => _provider.GetExchangeRates(currencies)); + exception.ErrorCode.Should().Be(CnbErrorCode.NetworkError); + exception.Message.Should().Be("Network connection failed"); + + _parserMock.Verify(x => x.Parse(It.IsAny()), Times.Never); + _builderMock.Verify(x => x.BuildExchangeRates(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public void GetExchangeRates_WhenParserFails_ThrowsCnbException() + { + // Arrange + var currencies = new[] { new Currency("EUR") }; + var apiData = "invalid data format"; + var parseError = ErrorHandler.Handle(CnbErrorCode.InvalidDateFormat, "Unable to parse date"); + + _apiClientMock.Setup(x => x.GetExchangeRateDataAsync(It.IsAny())) + .ReturnsAsync(Result.Ok(apiData)); + + _parserMock.Setup(x => x.Parse(apiData)) + .Returns(parseError); + + // Act & Assert + var exception = Assert.Throws(() => _provider.GetExchangeRates(currencies)); + exception.ErrorCode.Should().Be(CnbErrorCode.InvalidDateFormat); + exception.Message.Should().Be("Unable to parse date"); + + _builderMock.Verify(x => x.BuildExchangeRates(It.IsAny>(), It.IsAny()), Times.Never); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..9905e61d3 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,30 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Handlers/ErrorHandlerTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Handlers/ErrorHandlerTests.cs new file mode 100644 index 000000000..243000dc0 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Handlers/ErrorHandlerTests.cs @@ -0,0 +1,75 @@ +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Services.Handlers; +using FluentAssertions; +using FluentResults; + +namespace ExchangeRateUpdater.Tests.Handlers +{ + public class ErrorHandlerTests + { + [Fact] + public void Handle_CreatesFailedResultWithCorrectErrorCode() + { + // Arrange + var errorCode = CnbErrorCode.NetworkError; + var message = "Test network error"; + + // Act + var result = ErrorHandler.Handle(errorCode, message); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(); + result.Errors[0].Message.Should().Be(message); + result.Errors[0].Metadata.Should().ContainKey("ErrorCode"); + result.Errors[0].Metadata["ErrorCode"].Should().Be(errorCode); + } + + [Fact] + public void ExtractError_WithErrorCodeInMetadata_ReturnsCorrectCnbException() + { + // Arrange + var expectedCode = CnbErrorCode.TimeoutError; + var expectedMessage = "Request timed out"; + var result = Result.Fail(new Error(expectedMessage) + .WithMetadata("ErrorCode", expectedCode)); + + // Act + var exception = ErrorHandler.ExtractError(result); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(expectedCode); + exception.Message.Should().Be(expectedMessage); + } + + [Fact] + public void ExtractError_WithoutErrorCodeInMetadata_ReturnsUnexpectedError() + { + // Arrange + var result = Result.Fail("Some error without metadata"); + + // Act + var exception = ErrorHandler.ExtractError(result); + + // Assert + exception.ErrorCode.Should().Be(CnbErrorCode.UnexpectedError); + exception.Message.Should().Be("Some error without metadata"); + } + + [Fact] + public void ExtractError_WithEmptyErrors_ReturnsUnexpectedError() + { + // Arrange + var result = Result.Ok(); + + // Act + var exception = ErrorHandler.ExtractError(result); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(CnbErrorCode.UnexpectedError); + exception.Message.Should().Be("Unknown error"); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Parsers/CnbDataParserTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Parsers/CnbDataParserTests.cs new file mode 100644 index 000000000..9e67df076 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Parsers/CnbDataParserTests.cs @@ -0,0 +1,166 @@ +using ExchangeRateUpdater.Errors; +using ExchangeRateUpdater.Services.Parsers; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdater.Tests.Parsers; + +public class CnbDataParserTests +{ + private readonly CnbDataParser _parser; + private readonly Mock> _loggerMock; + + public CnbDataParserTests() + { + _loggerMock = new Mock>(); + _parser = new CnbDataParser(_loggerMock.Object); + } + + [Fact] + public void Parse_WithValidData_ReturnsSuccessResult() + { + // Arrange + var validData = @"19 Aug 2025 #159 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.165 + Brazil|real|1|BRL|3.745 + EMU|euro|1|EUR|24.455 + Japan|yen|100|JPY|14.165"; + + // Act + var result = _parser.Parse(validData); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Date.Should().Be(new DateTime(2025, 8, 19)); + result.Value.Rates.Should().HaveCount(4); + + var eurRate = result.Value.Rates.Find(r => r.Code == "EUR"); + eurRate.Should().NotBeNull(); + eurRate.Rate.Should().Be(24.455m); + eurRate.Amount.Should().Be(1); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_WithEmptyOrNullInput_ReturnsEmptyResponseError(string input) + { + // Act + var result = _parser.Parse(input); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(); + result.Errors[0].Metadata.Should().ContainKey("ErrorCode"); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.EmptyResponse); + } + + [Fact] + public void Parse_WithInsufficientLines_ReturnsInsufficientDataError() + { + // Arrange + var insufficientData = "19 Aug 2025 #159"; + + // Act + var result = _parser.Parse(insufficientData); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.InsufficientData); + } + + [Fact] + public void Parse_WithInvalidDateFormat_ReturnsInvalidDateFormatError() + { + // Arrange + var invalidDateData = @"Invalid Date Format + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.165"; + + // Act + var result = _parser.Parse(invalidDateData); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.InvalidDateFormat); + } + + [Fact] + public void Parse_WithInvalidHeader_ReturnsInvalidHeaderFormatError() + { + // Arrange + var invalidHeaderData = @"19 Aug 2025 #159 + Invalid|Header|Format + Australia|dollar|1|AUD|14.165"; + + // Act + var result = _parser.Parse(invalidHeaderData); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.InvalidHeaderFormat); + } + + [Fact] + public void Parse_WithNoValidRates_ReturnsNoValidRatesError() + { + // Arrange + var noValidRatesData = @"19 Aug 2025 #159 + Country|Currency|Amount|Code|Rate + Invalid|Data|Format + Another|Invalid|Line"; + + // Act + var result = _parser.Parse(noValidRatesData); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors[0].Metadata["ErrorCode"].Should().Be(CnbErrorCode.NoValidRates); + } + + [Fact] + public void Parse_WithMixedValidAndInvalidRates_ReturnsOnlyValidRates() + { + // Arrange + var mixedData = @"19 Aug 2025 #159 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.165 + Invalid|Data|Format + Brazil|real|1|BRL|3.745 + Another|Invalid|0|XXX|-5"; + + // Act + var result = _parser.Parse(mixedData); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Rates.Should().HaveCount(2); + result.Value.Rates.Should().OnlyContain(r => r.Code == "AUD" || r.Code == "BRL"); + } + + [Fact] + public void Parse_LogsDebugInformation() + { + // Arrange + var validData = @"19 Aug 2025 #159 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.165"; + + // Act + _parser.Parse(validData); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Starting to parse CNB data")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs new file mode 100644 index 000000000..8c927eb74 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..ff86b5eea 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.12.35527.113 d17.12 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", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{8E64D4DC-56C1-43F7-A8F2-264CA60EA597}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ 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 + {8E64D4DC-56C1-43F7-A8F2-264CA60EA597}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E64D4DC-56C1-43F7-A8F2-264CA60EA597}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E64D4DC-56C1-43F7-A8F2-264CA60EA597}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E64D4DC-56C1-43F7-A8F2-264CA60EA597}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs b/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs index 70ebd8c04..0f4f1c768 100644 --- a/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs +++ b/jobs/Backend/Task/Services/Parsers/CnbDataParser.cs @@ -38,14 +38,24 @@ public Result Parse(string rawData) $"Expected at least {CnbConstants.ExpectedMinimumLines} lines, got {lines.Length}"); } + var dateResult = ParseDate(lines[0]); + if (dateResult.IsFailed) + { + return Result.Fail(dateResult.Errors); + } + + var headerResult = ValidateHeader(lines[1]); + if (headerResult.IsFailed) + { + return Result.Fail(headerResult.Errors); + } + var result = new CnbExchangeRateData { - Date = ParseDate(lines[0]) + Date = dateResult.Value }; _logger.LogDebug("Parsed date: {Date}", result.Date); - ValidateHeader(lines[1]); - for (int i = 2; i < lines.Length; i++) { if (TryParseExchangeRateEntry(lines[i], out var entry)) @@ -66,32 +76,37 @@ public Result Parse(string rawData) return Result.Ok(result); } - private static DateTime ParseDate(string dateLine) + private static Result ParseDate(string dateLine) { var parts = dateLine.Split(' '); if (parts.Length < CnbConstants.MinimumDateParts) { - throw new CnbException(CnbErrorCode.InvalidDateFormat, dateLine); + return ErrorHandler.Handle(CnbErrorCode.InvalidDateFormat, + $"Invalid date format: {dateLine}"); } var dateString = $"{parts[0]} {parts[1]} {parts[2]}"; if (!DateTime.TryParseExact(dateString, CnbConstants.DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { - throw new CnbException(CnbErrorCode.InvalidDateFormat, dateString); + return ErrorHandler.Handle(CnbErrorCode.InvalidDateFormat, + $"Unable to parse date: {dateString}"); } - return date; + return Result.Ok(date); } - private static void ValidateHeader(string headerLine) + private static Result ValidateHeader(string headerLine) { if (!headerLine.Contains(CnbConstants.FieldSeparator) || - !headerLine.Contains("Country") || - !headerLine.Contains("Rate")) + !headerLine.Contains("Country") || + !headerLine.Contains("Rate")) { - throw new CnbException(CnbErrorCode.InvalidHeaderFormat, headerLine); + return ErrorHandler.Handle(CnbErrorCode.InvalidHeaderFormat, + $"Invalid header format: {headerLine}").ToResult(); } + + return Result.Ok(true); } private static bool TryParseExchangeRateEntry(string line, out CnbExchangeRateEntry entry)