From 622e5d04c849c11bc0b365f3d6be642d9ba0c0c2 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:34:32 +0100 Subject: [PATCH 1/9] feat: CNB API config --- .../Task/Configuration/CnbApiSettings.cs | 18 ++++++++++++++++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 17 ++++++++++++++++- .../Task/Parsers/Models/CnbExchangeRateData.cs | 11 +++++++++++ jobs/Backend/Task/appsettings.json | 8 ++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 jobs/Backend/Task/Configuration/CnbApiSettings.cs create mode 100644 jobs/Backend/Task/Parsers/Models/CnbExchangeRateData.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task/Configuration/CnbApiSettings.cs b/jobs/Backend/Task/Configuration/CnbApiSettings.cs new file mode 100644 index 0000000000..3fdc664763 --- /dev/null +++ b/jobs/Backend/Task/Configuration/CnbApiSettings.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater.Configuration +{ + // Configuration settings for CNB API. + public record CnbApiSettings + { + // Base URL for the CNB exchange rate API endpoint. + public string BaseUrl { get; init; } = null!; + + // HTTP request timeout in seconds. + public int TimeoutSeconds { get; init; } + + // Number of retry attempts for transient failures. + public int RetryCount { get; init; } + + // Delay between retry attempts in seconds. + public int RetryDelaySeconds { get; init; } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..0959802203 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,22 @@ Exe - net6.0 + net10.0 + enable + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Parsers/Models/CnbExchangeRateData.cs b/jobs/Backend/Task/Parsers/Models/CnbExchangeRateData.cs new file mode 100644 index 0000000000..f9a72c28f2 --- /dev/null +++ b/jobs/Backend/Task/Parsers/Models/CnbExchangeRateData.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateUpdater.Parsers.Models +{ + // Single exchange rate record from CNB data parser. + public record CnbExchangeRateData( + string Country, + string CurrencyName, + int Amount, + string Code, + decimal Rate + ); +} diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..a4dae1e1cf --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,8 @@ +{ + "CnbApi": { + "BaseUrl": "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, + "RetryDelaySeconds": 2 + } +} From 39186c66e32800e840d8f993b3e574ab0da18b30 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:35:28 +0100 Subject: [PATCH 2/9] feat: custom exception --- .../Task/Exceptions/CnbDataParsingException.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 jobs/Backend/Task/Exceptions/CnbDataParsingException.cs diff --git a/jobs/Backend/Task/Exceptions/CnbDataParsingException.cs b/jobs/Backend/Task/Exceptions/CnbDataParsingException.cs new file mode 100644 index 0000000000..60f59a125e --- /dev/null +++ b/jobs/Backend/Task/Exceptions/CnbDataParsingException.cs @@ -0,0 +1,18 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions +{ + // Exception thrown when parsing CNB exchange rate data fails. + public class CnbDataParsingException( + string message, + string? rawData = null, + int? lineNumber = null, + Exception? innerException = null) : Exception(message, innerException) + { + // Raw data that failed to parse. + public string? RawData { get; } = rawData; + + // Line number where the parsing error occurred. + public int? LineNumber { get; } = lineNumber; + } +} From 8b5b231cf1dd02f702832faf4d0cc7abb91f9576 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:36:03 +0100 Subject: [PATCH 3/9] feat: HTTP client --- .../Backend/Task/HttpClients/CnbHttpClient.cs | 51 +++++++++++++++++++ .../Task/HttpClients/ICnbHttpClient.cs | 11 ++++ 2 files changed, 62 insertions(+) create mode 100644 jobs/Backend/Task/HttpClients/CnbHttpClient.cs create mode 100644 jobs/Backend/Task/HttpClients/ICnbHttpClient.cs diff --git a/jobs/Backend/Task/HttpClients/CnbHttpClient.cs b/jobs/Backend/Task/HttpClients/CnbHttpClient.cs new file mode 100644 index 0000000000..960984cd6e --- /dev/null +++ b/jobs/Backend/Task/HttpClients/CnbHttpClient.cs @@ -0,0 +1,51 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using ExchangeRateUpdater.Configuration; + +namespace ExchangeRateUpdater.HttpClients +{ + // HTTP client implementation for CNB API. + public class CnbHttpClient : ICnbHttpClient + { + private readonly HttpClient _httpClient; + private readonly CnbApiSettings _settings; + + public CnbHttpClient(HttpClient httpClient, IOptions settings) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings)); + + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); + } + + // Method's implementation: fetches exchange rate data from the CNB API and returns exchange rate data as a string in pipe-separated format + public async Task GetExchangeRatesAsync() + { + try + { + var response = await _httpClient.GetAsync(_settings.BaseUrl); + // Ensure the HTTP response is success + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + if (string.IsNullOrWhiteSpace(content)) + { + throw new InvalidOperationException("Received empty response from CNB API."); + } + + return content; + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"Failed to fetch exchange rates from CNB API.", ex); + } + catch (TaskCanceledException ex) + { + throw new TimeoutException($"Request to CNB API timed out after {_settings.TimeoutSeconds} seconds.", ex); + } + } + } +} diff --git a/jobs/Backend/Task/HttpClients/ICnbHttpClient.cs b/jobs/Backend/Task/HttpClients/ICnbHttpClient.cs new file mode 100644 index 0000000000..9194896f9c --- /dev/null +++ b/jobs/Backend/Task/HttpClients/ICnbHttpClient.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.HttpClients +{ + // Interface for HTTP client that communicates with CNB API. + public interface ICnbHttpClient + { + // Method's signature: fetches exchange rate data from the CNB API and returns exchange rate data as a string in pipe-separated format + Task GetExchangeRatesAsync(); + } +} From 170fa57286edc72cfe15622928d19322cdf4cdbb Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:36:23 +0100 Subject: [PATCH 4/9] feat: data parser --- jobs/Backend/Task/Parsers/CnbDataParser.cs | 133 ++++++++++++++++++++ jobs/Backend/Task/Parsers/ICnbDataParser.cs | 12 ++ 2 files changed, 145 insertions(+) create mode 100644 jobs/Backend/Task/Parsers/CnbDataParser.cs create mode 100644 jobs/Backend/Task/Parsers/ICnbDataParser.cs diff --git a/jobs/Backend/Task/Parsers/CnbDataParser.cs b/jobs/Backend/Task/Parsers/CnbDataParser.cs new file mode 100644 index 0000000000..ff7dc02e5f --- /dev/null +++ b/jobs/Backend/Task/Parsers/CnbDataParser.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Parsers.Models; + +namespace ExchangeRateUpdater.Parsers +{ + // Parser for CNB exchange rate data in pipe-separated format. + public class CnbDataParser : ICnbDataParser + { + private const string Header = "Country|Currency|Amount|Code|Rate"; + private const char Separator = '|'; + private const int ColumnCount = 5; + + // Method's implementation: parses raw CNB exchange rate data and returns a collection of parsed exchange rate records + public IEnumerable Parse(string rawData) + { + if (string.IsNullOrWhiteSpace(rawData)) + { + throw new CnbDataParsingException("Cannot parse empty or null data."); + } + + var lines = rawData + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .ToArray(); + + if (lines.Length < 3) + { + throw new CnbDataParsingException($"Invalid data format. Expected at least 3 lines (date header, column headers, data), but got {lines.Length}.", rawData); + } + + // Line 0: Date header (skip) + // Line 1: Column headers (validate) + ValidateHeader(lines[1]); + + // Lines 2+: Data rows + var result = new List(); + + // Index i starts from 2 to skip date header and column headers + for (int i = 2; i < lines.Length; i++) + { + var line = lines[i]; + var lineNumber = i + 1; // line number in the txt file for error reporting + + try + { + var data = ParseDataRow(line); + result.Add(data); + } + catch (CnbDataParsingException ex) when (ex.LineNumber == null) + { + // Re-throw with line number context + throw new CnbDataParsingException( + ex.Message, + ex.RawData ?? line, + lineNumber, + ex.InnerException); + } + catch (Exception ex) when (ex is not CnbDataParsingException) + { + throw new CnbDataParsingException( + $"Failed to parse line {lineNumber}: {ex.Message}", + line, + lineNumber, + ex); + } + } + + return result; + } + + private static void ValidateHeader(string headerLine) + { + if (!string.Equals(headerLine, Header, StringComparison.OrdinalIgnoreCase)) + { + throw new CnbDataParsingException($"Invalid column headers. Expected '{Header}', but got '{headerLine}'."); + } + } + + private static CnbExchangeRateData ParseDataRow(string line) + { + var columns = line.Split(Separator); + + if (columns.Length != ColumnCount) + { + throw new CnbDataParsingException($"Invalid number of columns. Expected {ColumnCount}, but got {columns.Length}.", line); + } + + var country = columns[0].Trim(); + var currencyName = columns[1].Trim(); + var amountStr = columns[2].Trim(); + var code = columns[3].Trim(); + var rateStr = columns[4].Trim(); + + // Parse Amount + if (!int.TryParse(amountStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var amount)) + { + throw new CnbDataParsingException($"Failed to parse Amount '{amountStr}' as integer.", line); + } + if (amount <= 0) + { + throw new CnbDataParsingException($"Amount must be positive, but got {amount}.", line); + } + + // Parse Rate + if (!decimal.TryParse(rateStr, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var rate)) + { + throw new CnbDataParsingException($"Failed to parse Rate '{rateStr}' as decimal.", line); + } + if (rate <= 0) + { + throw new CnbDataParsingException($"Rate must be positive, but got {rate}.", line); + } + + // Validate currency code + if (string.IsNullOrWhiteSpace(code) || code.Length != 3) + { + throw new CnbDataParsingException($"Invalid currency code '{code}'. Expected 3-letters code.", line); + } + + return new CnbExchangeRateData( + Country: country, + CurrencyName: currencyName, + Amount: amount, + Code: code.ToUpper(), + Rate: rate + ); + } + } +} diff --git a/jobs/Backend/Task/Parsers/ICnbDataParser.cs b/jobs/Backend/Task/Parsers/ICnbDataParser.cs new file mode 100644 index 0000000000..faa45c7405 --- /dev/null +++ b/jobs/Backend/Task/Parsers/ICnbDataParser.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Parsers.Models; + +namespace ExchangeRateUpdater.Parsers +{ + // Interface for parsing CNB exchange rate data. + public interface ICnbDataParser + { + // Method's signature: parses raw CNB exchange rate data and returns a collection of parsed exchange rate records + IEnumerable Parse(string rawData); + } +} From 303f341f5f43d10e7324745dceb80ca70e98ac2b Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:37:06 +0100 Subject: [PATCH 5/9] feat: business logic implementation --- jobs/Backend/Task/ExchangeRateProvider.cs | 48 +++++++++++--- jobs/Backend/Task/Program.cs | 78 +++++++++++++++++------ 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..915629e839 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,47 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Parsers; namespace ExchangeRateUpdater { - public class ExchangeRateProvider + // Provider for fetching exchange rates from CNB. + public class ExchangeRateProvider(ICnbHttpClient httpClient, ICnbDataParser parser) { - /// - /// 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) + private readonly ICnbHttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private readonly ICnbDataParser _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + + // Fetches exchange rates for the specified currencies parameter from CNB. + // Returns only rates that are explicitly defined by the source - no calculated or inverse rates. + // If a currency is not available from CNB, it is silently ignored. + public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - return Enumerable.Empty(); + // Fetch raw data from CNB API + var rawData = await _httpClient.GetExchangeRatesAsync(); + + // Parse the received data + var parsedData = _parser.Parse(rawData); + + // Filter to only requested currencies (case-insensitive comparison) + var requestedCodes = new HashSet( + currencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase); + + var filteredData = parsedData.Where(d => requestedCodes.Contains(d.Code)); + + // Map to ExchangeRate domain models + // CNB provides: Amount units of foreign currency = Rate CZK + // We want: 1 CZK = X units of foreign currency + // Formula: X = Amount / Rate + var czk = new Currency("CZK"); + + return filteredData.Select(d => new ExchangeRate( + sourceCurrency: czk, + targetCurrency: new Currency(d.Code), + value: d.Amount / d.Rate + )).ToList(); } } } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..0fad5dc9bf 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,30 +1,72 @@ -using System; -using System.Collections.Generic; +using System; +using System.IO; using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Polly; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Parsers; 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) + public static async Task Main(string[] args) { + // Build configuration + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + // Setup dependency injection container + // The DI container automatically resolves and injects dependencies based on constructor parameters. + // When a service is requested (e.g., ExchangeRateProvider), the container: + // 1. Inspects the constructor to see what dependencies are needed + // 2. Creates instances of those dependencies (ICnbHttpClient, ICnbDataParser) + // 3. Calls the constructor with the created instances + // This eliminates manual object creation and enables loose coupling. + var services = new ServiceCollection(); + + // Register configuration + var cnbSettings = configuration.GetSection("CnbApi").Get(); + services.Configure(configuration.GetSection("CnbApi")); + + // Register HttpClient with Polly retry policy + services.AddHttpClient() + .AddTransientHttpErrorPolicy(builder => + builder.WaitAndRetryAsync( + retryCount: cnbSettings?.RetryCount ?? 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(cnbSettings?.RetryDelaySeconds ?? 2))); + + // Register services + services.AddTransient(); + services.AddTransient(); + + // Build service provider container + var serviceProvider = services.BuildServiceProvider(); + + // Execute the application + var 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") + }; + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = serviceProvider.GetRequiredService(); + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) From 98304bc3ba618f7b26fe091a79e45d1f67117154 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 09:38:10 +0100 Subject: [PATCH 6/9] feat: tests --- jobs/Backend/Readme.md | 28 +- .../Task.Tests/ExchangeRateProviderTests.cs | 272 ++++++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 33 +++ .../Task.Tests/Parsers/CnbDataParserTests.cs | 243 ++++++++++++++++ .../Task.Tests/TestData/CnbTestDataBuilder.cs | 18 ++ .../Task.Tests/TestData/TestDataLoader.cs | 24 ++ .../TestData/invalid-bad-amount.txt | 3 + .../Task.Tests/TestData/invalid-bad-rate.txt | 3 + .../Task.Tests/TestData/invalid-empty.txt | 0 .../Task.Tests/TestData/invalid-headers.txt | 3 + .../invalid-less-than-three-lines.txt | 2 + .../TestData/invalid-missing-column.txt | 3 + .../TestData/invalid-negative-amount.txt | 3 + .../TestData/invalid-negative-rate.txt | 3 + .../TestData/invalid-short-code.txt | 3 + .../Task.Tests/TestData/valid-amount-100.txt | 3 + .../Task.Tests/TestData/valid-amount-1000.txt | 3 + .../TestData/valid-full-response.txt | 11 + .../TestData/valid-lowercase-code.txt | 3 + .../TestData/valid-single-currency.txt | 3 + .../TestData/valid-whitespace-fields.txt | 3 + 21 files changed, 664 insertions(+), 3 deletions(-) create mode 100644 jobs/Backend/Task.Tests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs create mode 100644 jobs/Backend/Task.Tests/TestData/CnbTestDataBuilder.cs create mode 100644 jobs/Backend/Task.Tests/TestData/TestDataLoader.cs create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-bad-amount.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-bad-rate.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-empty.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-headers.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-less-than-three-lines.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-missing-column.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-negative-amount.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-negative-rate.txt create mode 100644 jobs/Backend/Task.Tests/TestData/invalid-short-code.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-amount-100.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-amount-1000.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-full-response.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-lowercase-code.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-single-currency.txt create mode 100644 jobs/Backend/Task.Tests/TestData/valid-whitespace-fields.txt diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index f2195e44dd..6989b29dd9 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -1,6 +1,28 @@ # Mews backend developer task -We are focused on multiple backend frameworks at Mews. Depending on the job position you are applying for, you can choose among the following: +## Running the .NET Exchange Rate Updater -* [.NET](DotNet.md) -* [Ruby on Rails](RoR.md) +### Prerequisites +- .NET 10.0 SDK + +### Build and Run the Application + +```bash +# Build the solution +dotnet build Task/ExchangeRateUpdater.sln + +# Run the application +dotnet run --project Task/ExchangeRateUpdater.csproj +``` + +The application will fetch current exchange rates from the Czech National Bank and display them in the console. + +### Run Unit Tests + +```bash +# Run all tests +dotnet test Task.Tests/ExchangeRateUpdater.Tests.csproj + +# Run tests with detailed output +dotnet test Task.Tests/ExchangeRateUpdater.Tests.csproj --verbosity normal +``` \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..2354a50fc4 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,272 @@ +using FluentAssertions; +using Moq; +using ExchangeRateUpdater; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Parsers.Models; +using ExchangeRateUpdater.Tests.TestData; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests + { + private readonly Mock _mockHttpClient; + private readonly Mock _mockParser; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _mockHttpClient = new Mock(); + _mockParser = new Mock(); + _provider = new ExchangeRateProvider(_mockHttpClient.Object, _mockParser.Object); + } + + [Fact] + public async Task GetExchangeRatesAsync_ValidData_ReturnsFilteredRates() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD(), + CnbTestDataBuilder.EUR(), + CnbTestDataBuilder.JPY(), + CnbTestDataBuilder.GBP() + }; + + var currencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("JPY") + }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + var rates = result.ToList(); + + // Assert + rates.Should().HaveCount(3); + rates.Should().Contain(r => r.TargetCurrency.Code == "USD"); + rates.Should().Contain(r => r.TargetCurrency.Code == "EUR"); + rates.Should().Contain(r => r.TargetCurrency.Code == "JPY"); + rates.Should().NotContain(r => r.TargetCurrency.Code == "GBP"); + } + + [Fact] + public async Task GetExchangeRatesAsync_CorrectlyNormalizesRates() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD(), + CnbTestDataBuilder.JPY(), + CnbTestDataBuilder.IDR() + }; + + var currencies = new[] + { + new Currency("USD"), + new Currency("JPY"), + new Currency("IDR") + }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + var rates = result.ToList(); + + // Assert + var usdRate = rates.First(r => r.TargetCurrency.Code == "USD"); + usdRate.Value.Should().BeApproximately(1m / 20.774m, 0.000001m); + + var jpyRate = rates.First(r => r.TargetCurrency.Code == "JPY"); + jpyRate.Value.Should().BeApproximately(100m / 13.278m, 0.000001m); + + var idrRate = rates.First(r => r.TargetCurrency.Code == "IDR"); + idrRate.Value.Should().BeApproximately(1000m / 1.238m, 0.000001m); + } + + [Fact] + public async Task GetExchangeRatesAsync_AllRatesHaveCzkAsSource() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD(), + CnbTestDataBuilder.EUR() + }; + + var currencies = new[] + { + new Currency("USD"), + new Currency("EUR") + }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + var rates = result.ToList(); + + // Assert + rates.Should().AllSatisfy(r => + { + r.SourceCurrency.Code.Should().Be("CZK"); + }); + } + + [Fact] + public async Task GetExchangeRatesAsync_CaseInsensitiveCurrencyMatching() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD() + }; + + var currencies = new[] + { + new Currency("usd"), // lowercase + new Currency("USD"), // uppercase + new Currency("Usd") // mixed case + }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + var rates = result.ToList(); + + // Assert - Should return USD only once despite multiple case variations + rates.Should().HaveCount(1); + rates[0].TargetCurrency.Code.Should().Be("USD"); + } + + [Fact] + public async Task GetExchangeRatesAsync_MissingCurrency_SilentlyIgnored() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD(), + CnbTestDataBuilder.EUR() + }; + + var currencies = new[] + { + new Currency("USD"), + new Currency("XYZ"), // Doesn't exist in parsed data + new Currency("KES") // Doesn't exist in parsed data + }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + var rates = result.ToList(); + + // Assert - Should only return USD, silently ignoring XYZ and KES + rates.Should().HaveCount(1); + rates[0].TargetCurrency.Code.Should().Be("USD"); + } + + [Fact] + public async Task GetExchangeRatesAsync_EmptyRequestedCurrencies_ReturnsEmpty() + { + // Build mock data + var rawData = "mocked CNB data"; + var parsedData = new List + { + CnbTestDataBuilder.USD() + }; + + var currencies = Array.Empty(); + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ReturnsAsync(rawData); + + _mockParser + .Setup(x => x.Parse(rawData)) + .Returns(parsedData); + + // Call provider + var result = await _provider.GetExchangeRatesAsync(currencies); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetExchangeRatesAsync_HttpClientThrows_PropagatesException() + { + // Build mock data + var currencies = new[] { new Currency("USD") }; + + _mockHttpClient + .Setup(x => x.GetExchangeRatesAsync()) + .ThrowsAsync(new InvalidOperationException("Network error")); + + // Call provider & Assert + await _provider.Invoking(p => p.GetExchangeRatesAsync(currencies)) + .Should().ThrowAsync() + .WithMessage("Network error"); + } + + [Fact] + public void Constructor_NullHttpClient_ThrowsArgumentNullException() + { + // Call provider & Assert + var act = () => new ExchangeRateProvider(null!, _mockParser.Object); + act.Should().Throw() + .WithParameterName("httpClient"); + } + + [Fact] + public void Constructor_NullParser_ThrowsArgumentNullException() + { + // Call provider & Assert + var act = () => new ExchangeRateProvider(_mockHttpClient.Object, null!); + act.Should().Throw() + .WithParameterName("parser"); + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..70326c9020 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs b/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs new file mode 100644 index 0000000000..bc698d11b0 --- /dev/null +++ b/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs @@ -0,0 +1,243 @@ +using FluentAssertions; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Tests.TestData; + +namespace ExchangeRateUpdater.Tests.Parsers +{ + public class CnbDataParserTests + { + private readonly CnbDataParser _parser; + + public CnbDataParserTests() + { + _parser = new CnbDataParser(); + } + + [Fact] + public void Parse_ValidData_ReturnsCorrectExchangeRates() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-full-response.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert - Verify all 9 currencies with all fields + result.Should().HaveCount(9); + result.Should().Contain(r => r.Country == "Australia" && r.CurrencyName == "dollar" && r.Amount == 1 && r.Code == "AUD" && r.Rate == 13.986m); + result.Should().Contain(r => r.Country == "Brazil" && r.CurrencyName == "real" && r.Amount == 1 && r.Code == "BRL" && r.Rate == 3.858m); + result.Should().Contain(r => r.Country == "Canada" && r.CurrencyName == "dollar" && r.Amount == 1 && r.Code == "CAD" && r.Rate == 15.053m); + result.Should().Contain(r => r.Country == "Switzerland" && r.CurrencyName == "franc" && r.Amount == 1 && r.Code == "CHF" && r.Rate == 25.035m); + result.Should().Contain(r => r.Country == "China" && r.CurrencyName == "renminbi" && r.Amount == 1 && r.Code == "CNY" && r.Rate == 2.863m); + result.Should().Contain(r => r.Country == "EMU" && r.CurrencyName == "euro" && r.Amount == 1 && r.Code == "EUR" && r.Rate == 24.280m); + result.Should().Contain(r => r.Country == "United Kingdom" && r.CurrencyName == "pound" && r.Amount == 1 && r.Code == "GBP" && r.Rate == 28.022m); + result.Should().Contain(r => r.Country == "Japan" && r.CurrencyName == "yen" && r.Amount == 100 && r.Code == "JPY" && r.Rate == 13.278m); + result.Should().Contain(r => r.Country == "USA" && r.CurrencyName == "dollar" && r.Amount == 1 && r.Code == "USD" && r.Rate == 20.774m); + } + + [Fact] + public void Parse_CurrencyCodesAreCaseInsensitive_ConvertsToUpperCase() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-lowercase-code.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Code.Should().Be("USD"); + } + + [Fact] + public void Parse_EmptyData_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-empty.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("Cannot parse empty or null data."); + } + + [Fact] + public void Parse_NullData_ThrowsException() + { + // Parse test data & Assert + var act = () => _parser.Parse(null!); + act.Should().Throw() + .WithMessage("Cannot parse empty or null data."); + } + + [Fact] + public void Parse_LessThanThreeLines_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-less-than-three-lines.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Expected at least 3 lines*"); + } + + [Fact] + public void Parse_InvalidColumnHeaders_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-headers.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Invalid column headers*"); + } + + [Fact] + public void Parse_InvalidNumberOfColumns_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-missing-column.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Invalid number of columns*") + .Which.LineNumber.Should().Be(3); + } + + [Fact] + public void Parse_InvalidAmount_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-bad-amount.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Failed to parse Amount 'invalid' as integer*"); + } + + [Fact] + public void Parse_NegativeAmount_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-negative-amount.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Amount must be positive*"); + } + + [Fact] + public void Parse_InvalidRate_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-bad-rate.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Failed to parse Rate 'notanumber' as decimal*"); + } + + [Fact] + public void Parse_NegativeRate_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-negative-rate.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Rate must be positive*"); + } + + [Fact] + public void Parse_InvalidCurrencyCode_ThrowsException() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("invalid-short-code.txt"); + + // Parse test data & Assert + var act = () => _parser.Parse(rawData); + act.Should().Throw() + .WithMessage("*Invalid currency code 'XY'*"); + } + + [Fact] + public void Parse_DecimalWithDifferentCulture_ParsesCorrectly() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-single-currency.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Country.Should().Be("USA"); + result[0].CurrencyName.Should().Be("dollar"); + result[0].Amount.Should().Be(1); + result[0].Code.Should().Be("USD"); + result[0].Rate.Should().Be(20.774m); + } + + [Fact] + public void Parse_AmountOf100_ParsesCorrectly() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-amount-100.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Country.Should().Be("Japan"); + result[0].CurrencyName.Should().Be("yen"); + result[0].Amount.Should().Be(100); + result[0].Code.Should().Be("JPY"); + result[0].Rate.Should().Be(13.278m); + } + + [Fact] + public void Parse_AmountOf1000_ParsesCorrectly() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-amount-1000.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Country.Should().Be("Indonesia"); + result[0].CurrencyName.Should().Be("rupiah"); + result[0].Amount.Should().Be(1000); + result[0].Code.Should().Be("IDR"); + result[0].Rate.Should().Be(1.238m); + } + + [Fact] + public void Parse_WhitespaceInFields_TrimsCorrectly() + { + // Read test file + var rawData = TestDataLoader.LoadTestFile("valid-whitespace-fields.txt"); + + // Parse test data + var result = _parser.Parse(rawData).ToList(); + + // Assert + result.Should().HaveCount(1); + result[0].Country.Should().Be("USA"); + result[0].CurrencyName.Should().Be("dollar"); + result[0].Amount.Should().Be(1); + result[0].Code.Should().Be("USD"); + result[0].Rate.Should().Be(20.774m); + } + } +} diff --git a/jobs/Backend/Task.Tests/TestData/CnbTestDataBuilder.cs b/jobs/Backend/Task.Tests/TestData/CnbTestDataBuilder.cs new file mode 100644 index 0000000000..a131d095a1 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/CnbTestDataBuilder.cs @@ -0,0 +1,18 @@ +using ExchangeRateUpdater.Parsers.Models; + +namespace ExchangeRateUpdater.Tests.TestData +{ + // Provides reusable test data for CnbExchangeRateData mocking. + public static class CnbTestDataBuilder + { + public static CnbExchangeRateData USD() => new("USA", "dollar", 1, "USD", 20.774m); + + public static CnbExchangeRateData EUR() => new("EMU", "euro", 1, "EUR", 24.280m); + + public static CnbExchangeRateData JPY() => new("Japan", "yen", 100, "JPY", 13.278m); + + public static CnbExchangeRateData GBP() => new("United Kingdom", "pound", 1, "GBP", 28.022m); + + public static CnbExchangeRateData IDR() => new("Indonesia", "rupiah", 1000, "IDR", 1.238m); + } +} diff --git a/jobs/Backend/Task.Tests/TestData/TestDataLoader.cs b/jobs/Backend/Task.Tests/TestData/TestDataLoader.cs new file mode 100644 index 0000000000..c4d5acb76a --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/TestDataLoader.cs @@ -0,0 +1,24 @@ +using System.IO; +using System.Reflection; + +namespace ExchangeRateUpdater.Tests.TestData +{ + // Helper class for loading test data files. + public static class TestDataLoader + { + private static readonly string TestDataPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "TestData"); + + // Loads a test data file from the TestData directory and return file contents as string + public static string LoadTestFile(string fileName) + { + var path = Path.Combine(TestDataPath, fileName); + + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Test data file not found: {fileName}", path); + } + + return File.ReadAllText(path); + } + } +} diff --git a/jobs/Backend/Task.Tests/TestData/invalid-bad-amount.txt b/jobs/Backend/Task.Tests/TestData/invalid-bad-amount.txt new file mode 100644 index 0000000000..d65a4c61c7 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-bad-amount.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|invalid|USD|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-bad-rate.txt b/jobs/Backend/Task.Tests/TestData/invalid-bad-rate.txt new file mode 100644 index 0000000000..3dc974ad3c --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-bad-rate.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|notanumber \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-empty.txt b/jobs/Backend/Task.Tests/TestData/invalid-empty.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/jobs/Backend/Task.Tests/TestData/invalid-headers.txt b/jobs/Backend/Task.Tests/TestData/invalid-headers.txt new file mode 100644 index 0000000000..3970e0484a --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-headers.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Wrong|Headers|Here +USA|dollar|1|USD|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-less-than-three-lines.txt b/jobs/Backend/Task.Tests/TestData/invalid-less-than-three-lines.txt new file mode 100644 index 0000000000..95ab42a133 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-less-than-three-lines.txt @@ -0,0 +1,2 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-missing-column.txt b/jobs/Backend/Task.Tests/TestData/invalid-missing-column.txt new file mode 100644 index 0000000000..d476928ddc --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-missing-column.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-negative-amount.txt b/jobs/Backend/Task.Tests/TestData/invalid-negative-amount.txt new file mode 100644 index 0000000000..fa19742a70 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-negative-amount.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|-1|USD|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-negative-rate.txt b/jobs/Backend/Task.Tests/TestData/invalid-negative-rate.txt new file mode 100644 index 0000000000..dcf6a11259 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-negative-rate.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|-20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/invalid-short-code.txt b/jobs/Backend/Task.Tests/TestData/invalid-short-code.txt new file mode 100644 index 0000000000..ede540d35c --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/invalid-short-code.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|XY|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-amount-100.txt b/jobs/Backend/Task.Tests/TestData/valid-amount-100.txt new file mode 100644 index 0000000000..e2fc7c8abd --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-amount-100.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +Japan|yen|100|JPY|13.278 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-amount-1000.txt b/jobs/Backend/Task.Tests/TestData/valid-amount-1000.txt new file mode 100644 index 0000000000..04df72aa2e --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-amount-1000.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +Indonesia|rupiah|1000|IDR|1.238 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-full-response.txt b/jobs/Backend/Task.Tests/TestData/valid-full-response.txt new file mode 100644 index 0000000000..06009c6a7b --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-full-response.txt @@ -0,0 +1,11 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|13.986 +Brazil|real|1|BRL|3.858 +Canada|dollar|1|CAD|15.053 +Switzerland|franc|1|CHF|25.035 +China|renminbi|1|CNY|2.863 +EMU|euro|1|EUR|24.280 +United Kingdom|pound|1|GBP|28.022 +Japan|yen|100|JPY|13.278 +USA|dollar|1|USD|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-lowercase-code.txt b/jobs/Backend/Task.Tests/TestData/valid-lowercase-code.txt new file mode 100644 index 0000000000..4c04092418 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-lowercase-code.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|usd|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-single-currency.txt b/jobs/Backend/Task.Tests/TestData/valid-single-currency.txt new file mode 100644 index 0000000000..7a2f428dfa --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-single-currency.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|20.774 \ No newline at end of file diff --git a/jobs/Backend/Task.Tests/TestData/valid-whitespace-fields.txt b/jobs/Backend/Task.Tests/TestData/valid-whitespace-fields.txt new file mode 100644 index 0000000000..cf928f8ff2 --- /dev/null +++ b/jobs/Backend/Task.Tests/TestData/valid-whitespace-fields.txt @@ -0,0 +1,3 @@ +07 Jan 2026 #4 +Country|Currency|Amount|Code|Rate + USA | dollar | 1 | USD | 20.774 \ No newline at end of file From f381aa3f1b2ac4f496518d7c66edf303abafa082 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Fri, 9 Jan 2026 22:50:35 +0100 Subject: [PATCH 7/9] feat: logging --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++++ .../Backend/Task/HttpClients/CnbHttpClient.cs | 8 ++++++- jobs/Backend/Task/Parsers/CnbDataParser.cs | 15 ++++++++++++- jobs/Backend/Task/Program.cs | 22 ++++++++++++++++++- .../Backend/Task/appsettings.Development.json | 20 +++++++++++++++++ jobs/Backend/Task/appsettings.json | 18 +++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 jobs/Backend/Task/appsettings.Development.json diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 0959802203..c3f543b153 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -12,12 +12,16 @@ + PreserveNewest + + PreserveNewest + \ No newline at end of file diff --git a/jobs/Backend/Task/HttpClients/CnbHttpClient.cs b/jobs/Backend/Task/HttpClients/CnbHttpClient.cs index 960984cd6e..a8873f248c 100644 --- a/jobs/Backend/Task/HttpClients/CnbHttpClient.cs +++ b/jobs/Backend/Task/HttpClients/CnbHttpClient.cs @@ -1,6 +1,7 @@ using System; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ExchangeRateUpdater.Configuration; @@ -11,11 +12,13 @@ public class CnbHttpClient : ICnbHttpClient { private readonly HttpClient _httpClient; private readonly CnbApiSettings _settings; + private readonly ILogger _logger; - public CnbHttpClient(HttpClient httpClient, IOptions settings) + public CnbHttpClient(HttpClient httpClient, IOptions settings, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); } @@ -33,6 +36,7 @@ public async Task GetExchangeRatesAsync() if (string.IsNullOrWhiteSpace(content)) { + _logger.LogError("Received empty response from CNB API"); throw new InvalidOperationException("Received empty response from CNB API."); } @@ -40,10 +44,12 @@ public async Task GetExchangeRatesAsync() } catch (HttpRequestException ex) { + _logger.LogError(ex, "HTTP request to CNB API failed: {Url}", _settings.BaseUrl); throw new InvalidOperationException($"Failed to fetch exchange rates from CNB API.", ex); } catch (TaskCanceledException ex) { + _logger.LogError(ex, "Request to CNB API timed out after {TimeoutSeconds} seconds", _settings.TimeoutSeconds); throw new TimeoutException($"Request to CNB API timed out after {_settings.TimeoutSeconds} seconds.", ex); } } diff --git a/jobs/Backend/Task/Parsers/CnbDataParser.cs b/jobs/Backend/Task/Parsers/CnbDataParser.cs index ff7dc02e5f..d736c7e761 100644 --- a/jobs/Backend/Task/Parsers/CnbDataParser.cs +++ b/jobs/Backend/Task/Parsers/CnbDataParser.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Extensions.Logging; using ExchangeRateUpdater.Exceptions; using ExchangeRateUpdater.Parsers.Models; @@ -14,11 +15,19 @@ public class CnbDataParser : ICnbDataParser private const char Separator = '|'; private const int ColumnCount = 5; + private readonly ILogger _logger; + + public CnbDataParser(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + // Method's implementation: parses raw CNB exchange rate data and returns a collection of parsed exchange rate records public IEnumerable Parse(string rawData) { if (string.IsNullOrWhiteSpace(rawData)) { + _logger.LogError("Cannot parse empty or null data"); throw new CnbDataParsingException("Cannot parse empty or null data."); } @@ -29,6 +38,7 @@ public IEnumerable Parse(string rawData) if (lines.Length < 3) { + _logger.LogError("Invalid data format. Expected at least 3 lines, got {LineCount}", lines.Length); throw new CnbDataParsingException($"Invalid data format. Expected at least 3 lines (date header, column headers, data), but got {lines.Length}.", rawData); } @@ -52,6 +62,7 @@ public IEnumerable Parse(string rawData) } catch (CnbDataParsingException ex) when (ex.LineNumber == null) { + _logger.LogError(ex, "Failed to parse line {LineNumber}: {ErrorMessage}", lineNumber, ex.Message); // Re-throw with line number context throw new CnbDataParsingException( ex.Message, @@ -61,6 +72,7 @@ public IEnumerable Parse(string rawData) } catch (Exception ex) when (ex is not CnbDataParsingException) { + _logger.LogError(ex, "Unexpected error parsing line {LineNumber}", lineNumber); throw new CnbDataParsingException( $"Failed to parse line {lineNumber}: {ex.Message}", line, @@ -72,10 +84,11 @@ public IEnumerable Parse(string rawData) return result; } - private static void ValidateHeader(string headerLine) + private void ValidateHeader(string headerLine) { if (!string.Equals(headerLine, Header, StringComparison.OrdinalIgnoreCase)) { + _logger.LogError("Invalid column headers. Expected '{ExpectedHeader}', got '{ActualHeader}'", Header, headerLine); throw new CnbDataParsingException($"Invalid column headers. Expected '{Header}', but got '{headerLine}'."); } } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 0fad5dc9bf..f1f5514d78 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging; using Polly; using ExchangeRateUpdater.Configuration; using ExchangeRateUpdater.HttpClients; @@ -16,10 +17,14 @@ public static class Program { public static async Task Main(string[] args) { + // Determine environment (defaults to Production if not set) + var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; + // Build configuration var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: true) .Build(); // Setup dependency injection container @@ -31,6 +36,13 @@ public static async Task Main(string[] args) // This eliminates manual object creation and enables loose coupling. var services = new ServiceCollection(); + // Configure logging + services.AddLogging(builder => + { + builder.AddConfiguration(configuration.GetSection("Logging")); + builder.AddConsole(); + }); + // Register configuration var cnbSettings = configuration.GetSection("CnbApi").Get(); services.Configure(configuration.GetSection("CnbApi")); @@ -63,12 +75,19 @@ public static async Task Main(string[] args) new Currency("XYZ") }; + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger("ExchangeRateUpdater.Program"); + try { var provider = serviceProvider.GetRequiredService(); + + logger.LogDebug("Starting exchange rate retrieval for {CurrencyCount} currencies", currencies.Length); + var rates = await provider.GetExchangeRatesAsync(currencies); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + logger.LogDebug("Successfully retrieved {RateCount} exchange rates", rates.Count()); + foreach (var rate in rates) { Console.WriteLine(rate.ToString()); @@ -76,6 +95,7 @@ public static async Task Main(string[] args) } catch (Exception e) { + logger.LogError(e, "Failed to retrieve exchange rates"); Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); } diff --git a/jobs/Backend/Task/appsettings.Development.json b/jobs/Backend/Task/appsettings.Development.json new file mode 100644 index 0000000000..ac30c55be8 --- /dev/null +++ b/jobs/Backend/Task/appsettings.Development.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Information", + "Microsoft.Extensions.Http": "Information", + "System.Net.Http.HttpClient": "Debug", + "ExchangeRateUpdater": "Debug" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": false, + "IncludeScopes": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss.fff ", + "UseUtcTimestamp": false + } + } + } +} diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index a4dae1e1cf..17a028bf7a 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,4 +1,22 @@ { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Extensions.Http": "Warning", + "System.Net.Http.HttpClient": "Warning", + "ExchangeRateUpdater": "Information" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "IncludeScopes": false, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", + "UseUtcTimestamp": false + } + } + }, "CnbApi": { "BaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", "TimeoutSeconds": 30, From 718d7c9e13db550c32c3386957346092c08a1f12 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Mon, 12 Jan 2026 22:03:54 +0000 Subject: [PATCH 8/9] fix: logger tests mock and README --- jobs/Backend/Readme.md | 37 +++++++++++++++++++ .../Task.Tests/Parsers/CnbDataParserTests.cs | 3 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index 6989b29dd9..2370d6a1d5 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -1,5 +1,42 @@ # Mews backend developer task +## What This Application Does + +This is a .NET 10.0 console application that fetches current exchange rates from the Czech National Bank (CNB) and displays them in CZK-to-foreign-currency format. + +The application: +- Fetches daily exchange rate data from CNB's public API (pipe-separated text format) +- Parses the data and converts rates to CZK/XXX format (1 CZK = X foreign currency units) +- Returns only explicitly defined rates (no calculated or inverse rates) +- Silently ignores currencies not available from CNB + +## Design Decisions + +**Data Source**: CNB daily exchange rates API (`https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt`) +- Official source, pipe-separated format, updated daily +- Provides rates as: Amount units of foreign currency = Rate CZK + +**Architecture**: +- Dependency injection for loose coupling and testability +- Separation of concerns: HTTP client, parser, and provider +- Polly retry policy for transient HTTP failures (3 retries with 2-second delays) +- Configuration-based settings for timeouts and retry behavior + +**Error Handling**: +- HTTP errors are logged and propagated with clear messages +- Missing currencies are silently ignored per requirements +- Timeout errors are explicitly handled + +## Possible Improvements + +**Caching**: Add in-memory caching with time-based expiration (CNB updates daily at 2:15 PM CET) + +**Rate Conversion**: Support inverse rate calculation (e.g., USD/CZK from CZK/USD) if business requirements change + +**Data Source Fallback**: Add secondary data source in case CNB API is unavailable + +**Observability**: Add structured logging with correlation IDs for better troubleshooting + ## Running the .NET Exchange Rate Updater ### Prerequisites diff --git a/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs b/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs index bc698d11b0..690e00e244 100644 --- a/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs +++ b/jobs/Backend/Task.Tests/Parsers/CnbDataParserTests.cs @@ -2,6 +2,7 @@ using ExchangeRateUpdater.Parsers; using ExchangeRateUpdater.Exceptions; using ExchangeRateUpdater.Tests.TestData; +using Microsoft.Extensions.Logging.Abstractions; namespace ExchangeRateUpdater.Tests.Parsers { @@ -11,7 +12,7 @@ public class CnbDataParserTests public CnbDataParserTests() { - _parser = new CnbDataParser(); + _parser = new CnbDataParser(NullLogger.Instance); } [Fact] From 0ae8773deddee64c1641ca0970f3fd256673b430 Mon Sep 17 00:00:00 2001 From: matteobusnelli Date: Mon, 12 Jan 2026 22:26:45 +0000 Subject: [PATCH 9/9] fix: log message --- jobs/Backend/Task/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index f1f5514d78..ab44de2195 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -96,7 +96,7 @@ public static async Task Main(string[] args) catch (Exception e) { logger.LogError(e, "Failed to retrieve exchange rates"); - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + Console.WriteLine("Could not retrieve exchange rates. Please check the logs for details."); } Console.ReadLine();