From 4aac15d2cef82cdf8d6ffa4ee1ea260ec3ca91d3 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 09:51:55 +0200 Subject: [PATCH 01/16] chore: update target framework to .NET 9.0 and add .vs to .gitignore --- .gitignore | 2 ++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fd3586545..81876b85e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log +.vs/ +**/.vs/ \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..260a591e9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net9.0 \ No newline at end of file From 497774a034a6a0d92f3601936b4da37e6b1fae60 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 09:52:05 +0200 Subject: [PATCH 02/16] refactor: change Currency class to record and improve code validation --- jobs/Backend/Task/Currency.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f2..b12ffdcc3 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,16 +1,20 @@ -namespace ExchangeRateUpdater +using System; + +namespace ExchangeRateUpdater { - public class Currency + public record Currency { + public string Code { get; } + public Currency(string code) { - Code = code; - } + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be null or empty.", nameof(code)); + } - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } + Code = code.Trim().ToUpperInvariant(); + } public override string ToString() { From 59bc02d7c232728f67f85aee33086d9522903d8e Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 10:58:18 +0200 Subject: [PATCH 03/16] docs: add README for Exchange Rate Updater project --- jobs/Backend/Task/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 jobs/Backend/Task/README.md diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..ae2c396e7 --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,31 @@ +# Exchange Rate Updater + +## Overview + +This project is a C# console application designed to fetch and update foreign exchange rates using data provided by the Czech National Bank (CNB). The application retrieves the latest daily exchange rates and can be integrated into financial systems or used as a standalone tool for currency conversion and rate analysis. + +## Features +- Fetches up-to-date exchange rates from the official CNB source +- Parses and processes the daily exchange rate data +- Supports multiple currencies as provided by the CNB +- Modular and extensible codebase for easy integration + +## Data Source +- **Provider:** Czech National Bank (CNB) +- **URL:** [CNB Daily Exchange Rates](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) +- The data is updated daily by the CNB and includes rates for a wide range of currencies against the Czech Koruna (CZK). + +## Usage +1. Build the project using the provided solution file (`ExchangeRateUpdater.sln`). +2. Run the application. It will automatically fetch the latest exchange rates from the CNB and process them. +3. The application can be extended to store rates in a database, expose them via an API, or integrate with other systems as needed. + +## Project Structure +- `Currency.cs`: Defines currency-related data structures. +- `ExchangeRate.cs`: Represents exchange rate information. +- `ExchangeRateProvider.cs`: Handles fetching and parsing of exchange rate data from the CNB. +- `Program.cs`: Entry point of the application. + +## Requirements +- .NET 9.0 or higher +- Internet connection to access the CNB data source From f3133245110e843cabb5b493a06ee7c867102368 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 10:58:30 +0200 Subject: [PATCH 04/16] feat: implement CNBExchangeRateProvider and CNBOptions with dependency injection setup --- .../Infrastructure/CNBExchangeRateProvider.cs | 19 +++++++++++++++++++ .../Backend/Task/Infrastructure/CNBOptions.cs | 10 ++++++++++ .../InfrastructureServiceCollection.cs | 17 +++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Infrastructure/CNBOptions.cs create mode 100644 jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs new file mode 100644 index 000000000..f34054113 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure +{ + internal sealed class CNBExchangeRateProvider : IExchangeRateProvider + { + /// + /// 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 Task> GetExchangeRates(IEnumerable currencies) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNBOptions.cs b/jobs/Backend/Task/Infrastructure/CNBOptions.cs new file mode 100644 index 000000000..6fe87f9c9 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNBOptions.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure +{ + internal record CNBOptions(string BaseUrl); +} diff --git a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs new file mode 100644 index 000000000..654fb1e29 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Infrastructure.Registry +{ + // The infrastrucutre folder could be (in a bigger project) a separate project. + internal static class InfrastructureServiceCollection + { + public static IServiceCollection AddExchangeRateUpdater(this IServiceCollection services, IConfiguration config) + => services + .AddCNBOptions(config) + .AddSingleton(); + + private static IServiceCollection AddCNBOptions(this IServiceCollection services, IConfiguration config) => + services.Configure(config.GetSection(key: nameof(CNBOptions))); + } +} From 930ccf7297ddff953683188cd55d960ec86a100a Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 10:58:49 +0200 Subject: [PATCH 05/16] feat: refactor ExchangeRateProvider to use dependency injection and add appsettings.json configuration --- jobs/Backend/Task/ExchangeRateProvider.cs | 19 ----------- jobs/Backend/Task/ExchangeRateUpdater.csproj | 13 ++++++++ jobs/Backend/Task/Program.cs | 34 ++++++++++++++++++-- jobs/Backend/Task/appsettings.json | 11 +++++++ 4 files changed, 55 insertions(+), 22 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/appsettings.json 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 260a591e9..6173b2e88 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,17 @@ net9.0 + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..e57e2c8ff 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Infrastructure.Registry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -19,12 +25,34 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + // The only purpose of the host here is to use the dependecy injection container and add configuration. + // In a bigger project, we would probably use the host to configure other services as well. + var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(config => + { + config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + }) + .ConfigureLogging((context, loggingBuilder) => + { + // We should probably use a more advanced logging provider, but for the sake of simplicity + // we will use the console logger. + loggingBuilder.AddConsole(); + // Read logging settings from appsettings.json + loggingBuilder.AddConfiguration(context.Configuration.GetSection("Logging")); + }) + .ConfigureServices((context, services) => + { + + services.AddExchangeRateUpdater(context.Configuration); + }) + .Build(); + + var provider = host.Services.GetRequiredService(); + var rates = await provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..b71d065ef --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + } + }, + "CNBOptions": { + "BaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing" + } +} \ No newline at end of file From 2ad84c8c6dd8c5bb3cba63da3da2282e28546875 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 10:59:15 +0200 Subject: [PATCH 06/16] feat: add IExchangeRateProvider interface for exchange rate retrieval --- jobs/Backend/Task/IExchangeRateProvider.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 jobs/Backend/Task/IExchangeRateProvider.cs diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs new file mode 100644 index 000000000..acde34e8d --- /dev/null +++ b/jobs/Backend/Task/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + internal interface IExchangeRateProvider + { + public Task> GetExchangeRates(IEnumerable currencies); + } +} From b2fd90a574dcabff1afb68de1301e707f8637af0 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 11:21:13 +0200 Subject: [PATCH 07/16] refactor: simplify ExchangeRate class constructor and properties --- jobs/Backend/Task/ExchangeRate.cs | 15 +-------------- jobs/Backend/Task/appsettings.json | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e..34cb1ef38 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,20 +1,7 @@ namespace ExchangeRateUpdater { - public class ExchangeRate + public class ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value) { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - public override string ToString() { return $"{SourceCurrency}/{TargetCurrency}={Value}"; diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index b71d065ef..9b9198745 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft": "Warning", + "Microsoft": "Warning" } }, "CNBOptions": { From 6b0b97a499d422627d2794ca091e7dafde01074a Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 11:53:56 +0200 Subject: [PATCH 08/16] chore(config): update appsettings.json configuration Remove optional and reloadOnChange parameters from the AddJsonFile method, making appsettings.json a required configuration file without automatic reload on changes. --- 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 e57e2c8ff..a6a2b08b9 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -34,7 +34,7 @@ public static async Task Main(string[] args) var host = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration(config => { - config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + config.AddJsonFile("appsettings.json"); }) .ConfigureLogging((context, loggingBuilder) => { From ce842dd241abda258831fda1cc584be38c5a2817 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 11:54:21 +0200 Subject: [PATCH 09/16] refactor(options): change CNBOptions to a mutable class Update CNBOptions from a record to a class with mutable properties. Modify InfrastructureServiceCollection to include necessary using directives and configure HttpClient for IExchangeRateProvider using the BaseUrl from CNBOptions. --- jobs/Backend/Task/Infrastructure/CNBOptions.cs | 5 ++++- .../Registry/InfrastructureServiceCollection.cs | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/jobs/Backend/Task/Infrastructure/CNBOptions.cs b/jobs/Backend/Task/Infrastructure/CNBOptions.cs index 6fe87f9c9..c831ca912 100644 --- a/jobs/Backend/Task/Infrastructure/CNBOptions.cs +++ b/jobs/Backend/Task/Infrastructure/CNBOptions.cs @@ -6,5 +6,8 @@ namespace ExchangeRateUpdater.Infrastructure { - internal record CNBOptions(string BaseUrl); + internal record CNBOptions + { + public string BaseUrl { get; set; } + } } diff --git a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs index 654fb1e29..87b66de1b 100644 --- a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs +++ b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs @@ -1,5 +1,7 @@ -using Microsoft.Extensions.Configuration; +using System; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace ExchangeRateUpdater.Infrastructure.Registry { @@ -9,7 +11,15 @@ internal static class InfrastructureServiceCollection public static IServiceCollection AddExchangeRateUpdater(this IServiceCollection services, IConfiguration config) => services .AddCNBOptions(config) - .AddSingleton(); + .AddSingleton() + .AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>() + .Value; + + client.BaseAddress = new Uri(options.BaseUrl); + }) + .Services; private static IServiceCollection AddCNBOptions(this IServiceCollection services, IConfiguration config) => services.Configure(config.GetSection(key: nameof(CNBOptions))); From 8213579cb7542fddb5f5d7c0406629cea2803980 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 11:54:48 +0200 Subject: [PATCH 10/16] feat(provider): enhance CNB exchange rate fetching Refactor CNBExchangeRateProvider to use HttpClient for asynchronous fetching of exchange rates from the CNB API, improving data retrieval and parsing. --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 +- .../Infrastructure/CNBExchangeRateProvider.cs | 74 +++++++++++++++---- jobs/Backend/Task/appsettings.json | 2 +- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 6173b2e88..869778dbc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,6 +3,7 @@ Exe net9.0 + 8bbe5d99-db8b-403b-9849-2f28ed7c8031 @@ -10,11 +11,12 @@ + - PreserveNewest + Always diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs index f34054113..d072b4c7b 100644 --- a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs @@ -1,19 +1,63 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace ExchangeRateUpdater.Infrastructure -{ - internal sealed class CNBExchangeRateProvider : IExchangeRateProvider +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using System.Linq; +using System.Globalization; + +namespace ExchangeRateUpdater.Infrastructure +{ + internal sealed class CNBExchangeRateProvider(HttpClient httpClient) : IExchangeRateProvider { - /// - /// 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. + private const string RatesSegment = "daily.txt"; + + private readonly HttpClient httpClient = httpClient; + + /// + /// 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 Task> GetExchangeRates(IEnumerable currencies) + public async Task> GetExchangeRates(IEnumerable currencies) { - throw new System.NotImplementedException(); + // Prepare the request URL (always fetch latest for now) + var response = await httpClient.GetAsync(RatesSegment); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + + // Parse the CNB file + var lines = content.Split('\n'); + if (lines.Length < 2) + return new List(); + + // The first line is the header, the second line is column names, then data + var rates = new List(); + var currencySet = new HashSet(currencies.Select(c => c.Code), System.StringComparer.OrdinalIgnoreCase); + foreach (var line in lines.Skip(2)) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) + continue; + var parts = trimmed.Split('|'); + if (parts.Length < 5) + continue; + // Format: Country|Currency|Amount|Code|Rate + var code = parts[3].Trim(); + if (!currencySet.Contains(code) && !currencySet.Contains("CZK")) + continue; + if (!decimal.TryParse(parts[2], out var amount)) + continue; + if (!decimal.TryParse(parts[4].Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var rate)) + continue; + // CNB rates are always per 1 CZK to X foreign currency + // So, 1 CZK = rate/amount X + // We want CZK -> X (as provided by CNB) + if (currencySet.Contains("CZK") && currencySet.Contains(code)) + { + rates.Add(new ExchangeRate(new Currency("CZK"), new Currency(code), rate / amount)); + } + } + return rates; } - } -} + } +} diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index 9b9198745..224eebfc3 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -6,6 +6,6 @@ } }, "CNBOptions": { - "BaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing" + "BaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/" } } \ No newline at end of file From 2b5676177bd950b6c9e852cb88e95ae2f0df504a Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sat, 17 May 2025 15:02:31 +0200 Subject: [PATCH 11/16] feat(parser): add exchange rate parser and refactor provider --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 1 + .../Infrastructure/CNBExchangeRateParser.cs | 64 +++++++++++++++++++ .../Infrastructure/CNBExchangeRateProvider.cs | 44 +++---------- .../Backend/Task/Infrastructure/CNBOptions.cs | 8 +-- .../Infrastructure/IExchangeRateParser.cs | 9 +++ .../InfrastructureServiceCollection.cs | 1 + 6 files changed, 84 insertions(+), 43 deletions(-) create mode 100644 jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs create mode 100644 jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 869778dbc..4ed843d05 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -7,6 +7,7 @@ + diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs b/jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs new file mode 100644 index 000000000..cea91fb96 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using CsvHelper; +using CsvHelper.Configuration; + +namespace ExchangeRateUpdater.Infrastructure +{ + internal sealed class CNBExchangeRateParser : IExchangeRateParser + { + private readonly CsvConfiguration config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + PrepareHeaderForMatch = args => args.Header.Trim().ToLower(), + Delimiter = "|", + }; + + public IEnumerable Parse(string exchangeRate) + { + using(var reader = new StringReader(exchangeRate)) + using (var csvReader = new CsvReader(reader, config)) + { + var dateInfo = reader.ReadLine(); + var metadata = ParseMetadata(dateInfo); + + var records = csvReader.GetRecords(); + return records.Select(records => new ExchangeRate( + new Currency("CZK"), + new Currency(records.Code), + records.Rate / records.Amount)) + .ToArray(); + } + } + + private static ExchangeRateMetadata ParseMetadata(string dateInfo) + { + // Example: dateInfo = "16 May 2025 #93" + DateTime? parsedDate = null; + int? identifier = null; + + if (!string.IsNullOrWhiteSpace(dateInfo)) + { + var parts = dateInfo.Split('#'); + var datePart = parts[0].Trim(); + + if (DateTime.TryParseExact(datePart, "d MMM yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + parsedDate = dt; + + if (parts.Length > 1 && int.TryParse(parts[1].Trim(), out var id)) + identifier = id; + + return new ExchangeRateMetadata(parsedDate ?? DateTime.MinValue, identifier ?? 0); + } + + return new ExchangeRateMetadata(DateTime.MinValue, 0); + + } + + private record class ExchangeRateRecord(string Country, string Currency, decimal Amount, string Code, decimal Rate); + + private record ExchangeRateMetadata(DateTime DateTime, int Identifier); + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs index d072b4c7b..8d71fefe9 100644 --- a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs @@ -3,14 +3,18 @@ using System.Threading.Tasks; using System.Linq; using System.Globalization; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater.Infrastructure { - internal sealed class CNBExchangeRateProvider(HttpClient httpClient) : IExchangeRateProvider + internal sealed class CNBExchangeRateProvider( + HttpClient httpClient, + IExchangeRateParser parser) : IExchangeRateProvider { private const string RatesSegment = "daily.txt"; private readonly HttpClient httpClient = httpClient; + private readonly IExchangeRateParser parser = parser; /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined @@ -19,45 +23,13 @@ internal sealed class CNBExchangeRateProvider(HttpClient httpClient) : IExchange /// some of the currencies, ignore them. /// public async Task> GetExchangeRates(IEnumerable currencies) - { - // Prepare the request URL (always fetch latest for now) + { var response = await httpClient.GetAsync(RatesSegment); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStringAsync(); - // Parse the CNB file - var lines = content.Split('\n'); - if (lines.Length < 2) - return new List(); + var content = await response.Content.ReadAsStringAsync(); - // The first line is the header, the second line is column names, then data - var rates = new List(); - var currencySet = new HashSet(currencies.Select(c => c.Code), System.StringComparer.OrdinalIgnoreCase); - foreach (var line in lines.Skip(2)) - { - var trimmed = line.Trim(); - if (string.IsNullOrEmpty(trimmed)) - continue; - var parts = trimmed.Split('|'); - if (parts.Length < 5) - continue; - // Format: Country|Currency|Amount|Code|Rate - var code = parts[3].Trim(); - if (!currencySet.Contains(code) && !currencySet.Contains("CZK")) - continue; - if (!decimal.TryParse(parts[2], out var amount)) - continue; - if (!decimal.TryParse(parts[4].Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var rate)) - continue; - // CNB rates are always per 1 CZK to X foreign currency - // So, 1 CZK = rate/amount X - // We want CZK -> X (as provided by CNB) - if (currencySet.Contains("CZK") && currencySet.Contains(code)) - { - rates.Add(new ExchangeRate(new Currency("CZK"), new Currency(code), rate / amount)); - } - } - return rates; + return parser.Parse(content); } } } diff --git a/jobs/Backend/Task/Infrastructure/CNBOptions.cs b/jobs/Backend/Task/Infrastructure/CNBOptions.cs index c831ca912..1db621611 100644 --- a/jobs/Backend/Task/Infrastructure/CNBOptions.cs +++ b/jobs/Backend/Task/Infrastructure/CNBOptions.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace ExchangeRateUpdater.Infrastructure +namespace ExchangeRateUpdater.Infrastructure { internal record CNBOptions { diff --git a/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs b/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs new file mode 100644 index 000000000..95c4f3b85 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Infrastructure +{ + internal interface IExchangeRateParser + { + IEnumerable Parse(string exchangeRate); + } +} diff --git a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs index 87b66de1b..498071278 100644 --- a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs +++ b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs @@ -11,6 +11,7 @@ internal static class InfrastructureServiceCollection public static IServiceCollection AddExchangeRateUpdater(this IServiceCollection services, IConfiguration config) => services .AddCNBOptions(config) + .AddSingleton() .AddSingleton() .AddHttpClient((serviceProvider, client) => { From e9c9eec63826d321fa406e2bb3344d5bfd350ab2 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sun, 18 May 2025 10:29:07 +0200 Subject: [PATCH 12/16] feat(domain): add currency and exchange rate models to domain folder --- jobs/Backend/Task/Domain/Currency.cs | 24 ++++++++++++++++++ jobs/Backend/Task/Domain/ExchangeRate.cs | 10 ++++++++ .../Task/Domain/ExchangeRateProvider.cs | 25 +++++++++++++++++++ .../Task/Domain/IExchangeRateFetcher.cs | 11 ++++++++ 4 files changed, 70 insertions(+) create mode 100644 jobs/Backend/Task/Domain/Currency.cs create mode 100644 jobs/Backend/Task/Domain/ExchangeRate.cs create mode 100644 jobs/Backend/Task/Domain/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Domain/IExchangeRateFetcher.cs diff --git a/jobs/Backend/Task/Domain/Currency.cs b/jobs/Backend/Task/Domain/Currency.cs new file mode 100644 index 000000000..e55a998d0 --- /dev/null +++ b/jobs/Backend/Task/Domain/Currency.cs @@ -0,0 +1,24 @@ +using System; + +namespace ExchangeRateUpdater.Domain +{ + public record Currency + { + public string Code { get; } + + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be null or empty.", nameof(code)); + } + + Code = code.Trim().ToUpperInvariant(); + } + + public override string ToString() + { + return Code; + } + } +} diff --git a/jobs/Backend/Task/Domain/ExchangeRate.cs b/jobs/Backend/Task/Domain/ExchangeRate.cs new file mode 100644 index 000000000..5070a0636 --- /dev/null +++ b/jobs/Backend/Task/Domain/ExchangeRate.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Domain +{ + public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value) + { + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } + } +} diff --git a/jobs/Backend/Task/Domain/ExchangeRateProvider.cs b/jobs/Backend/Task/Domain/ExchangeRateProvider.cs new file mode 100644 index 000000000..e21719465 --- /dev/null +++ b/jobs/Backend/Task/Domain/ExchangeRateProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Domain +{ + internal sealed class ExchangeRateProvider(IExchangeRateFetcher exchangeRateFetcher) + { + private readonly IExchangeRateFetcher exchangeRateFetcher = exchangeRateFetcher; + + /// + /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined + /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", + /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide + /// some of the currencies, ignore them. + /// + public async Task> GetExchangeRates(IEnumerable currencies) + { + var currenciesSet = new HashSet(currencies); + + var rates = await exchangeRateFetcher.GetExchangeRates(); + return rates.Where(r => currenciesSet.Contains(r.TargetCurrency)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Domain/IExchangeRateFetcher.cs b/jobs/Backend/Task/Domain/IExchangeRateFetcher.cs new file mode 100644 index 000000000..1f8e46325 --- /dev/null +++ b/jobs/Backend/Task/Domain/IExchangeRateFetcher.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Domain +{ + internal interface IExchangeRateFetcher + { + Task> GetExchangeRates(CancellationToken cancellationToken = default); + } +} From 4b6f5fbf49c266f3695949f677339b6cc0255f11 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sun, 18 May 2025 10:29:34 +0200 Subject: [PATCH 13/16] feat(observability): add Metrics class and service registration Introduce a new `Metrics` class to manage observability metrics for exchange rate updates, including counters for HTTP client retries and circuit breaker events. Add an extension method `AddObservabilityInfrastructure` to register the `Metrics` class as a singleton and configure OpenTelemetry for tracing and metrics, ensuring proper instrumentation and console exporting. --- .../Infrastructure/Observability/Metrics.cs | 31 +++++++++++++++++++ .../ObservabilityServiceCollection.cs | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 jobs/Backend/Task/Infrastructure/Observability/Metrics.cs create mode 100644 jobs/Backend/Task/Infrastructure/Observability/Registry/ObservabilityServiceCollection.cs diff --git a/jobs/Backend/Task/Infrastructure/Observability/Metrics.cs b/jobs/Backend/Task/Infrastructure/Observability/Metrics.cs new file mode 100644 index 000000000..7bc171a60 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/Observability/Metrics.cs @@ -0,0 +1,31 @@ +using System; +using System.Diagnostics.Metrics; + +namespace ExchangeRateUpdater.Infrastructure.Observability +{ + internal sealed class Metrics : IDisposable + { + public const string ExchangeRateUpdateMeterName = "ExchangeRateUpdate.Metrics"; + + private readonly Meter meter; + + public Metrics(IMeterFactory meterFactory) + { + meter = meterFactory.Create(ExchangeRateUpdateMeterName, "1.0.0"); + RetryCounter = meter.CreateCounter("httpclient_retry_count", description: "Number of retries"); + CircuitBreakerOpened = meter.CreateCounter("circuit_breaker_opened_count", description: "Number of times the circuit breaker opened"); + CircuitBreakerReset = meter.CreateCounter("circuit_breaker_reset_count", description: "Number of times the circuit breaker reset"); + } + + public Counter RetryCounter { get; } + + public Counter CircuitBreakerOpened { get; } + + public Counter CircuitBreakerReset { get; } + + public void Dispose() + { + meter.Dispose(); + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/Observability/Registry/ObservabilityServiceCollection.cs b/jobs/Backend/Task/Infrastructure/Observability/Registry/ObservabilityServiceCollection.cs new file mode 100644 index 000000000..5018465d0 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/Observability/Registry/ObservabilityServiceCollection.cs @@ -0,0 +1,29 @@ +using ExchangeRateUpdater.Infrastructure.Observability; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace ExchangeRateUpdater.Infrastructure.CNB.Registry +{ + internal static class ObservabilityServiceCollection + { + public static IServiceCollection AddObservabilityInfrastructure(this IServiceCollection services) + => services + .AddSingleton() + .AddOpenTelemetry() + .ConfigureResource(builder => builder.AddService("ExchangeRateUpdater")) + .WithTracing(builder => + { + builder.AddHttpClientInstrumentation(); + builder.AddConsoleExporter(); + }) + .WithMetrics(builder => + { + builder.AddHttpClientInstrumentation(); + builder.AddMeter([Metrics.ExchangeRateUpdateMeterName]); + builder.AddConsoleExporter(); + }).Services; + } +} From d9e889de1685bf3289fd69ea2684ac8e57779580 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sun, 18 May 2025 10:30:46 +0200 Subject: [PATCH 14/16] refactor(core): improve currency handling and exchange rate fetching. Add resilience handlers to retry and circuit break the provider --- jobs/Backend/Task/Currency.cs | 24 ----- jobs/Backend/Task/ExchangeRate.cs | 10 -- jobs/Backend/Task/ExchangeRateUpdater.csproj | 6 ++ jobs/Backend/Task/IExchangeRateProvider.cs | 10 -- .../CNB/CNBExchangeRateFetcher.cs | 44 ++++++++ .../{ => CNB}/CNBExchangeRateParser.cs | 31 ++++-- .../Task/Infrastructure/CNB/CNBOptions.cs | 11 ++ .../CNB/CachedExchangeRateFetcher.cs | 46 ++++++++ .../InfrastructureServiceCollection.cs | 100 ++++++++++++++++++ .../Infrastructure/CNBExchangeRateProvider.cs | 35 ------ .../Backend/Task/Infrastructure/CNBOptions.cs | 7 -- .../Infrastructure/IExchangeRateParser.cs | 9 -- .../InfrastructureServiceCollection.cs | 28 ----- jobs/Backend/Task/Program.cs | 12 ++- jobs/Backend/Task/appsettings.json | 1 + 15 files changed, 237 insertions(+), 137 deletions(-) delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateFetcher.cs rename jobs/Backend/Task/Infrastructure/{ => CNB}/CNBExchangeRateParser.cs (62%) create mode 100644 jobs/Backend/Task/Infrastructure/CNB/CNBOptions.cs create mode 100644 jobs/Backend/Task/Infrastructure/CNB/CachedExchangeRateFetcher.cs create mode 100644 jobs/Backend/Task/Infrastructure/CNB/Registry/InfrastructureServiceCollection.cs delete mode 100644 jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/Infrastructure/CNBOptions.cs delete mode 100644 jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs delete mode 100644 jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index b12ffdcc3..000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace ExchangeRateUpdater -{ - public record Currency - { - public string Code { get; } - - public Currency(string code) - { - if (string.IsNullOrWhiteSpace(code)) - { - throw new ArgumentException("Currency code cannot be null or empty.", nameof(code)); - } - - Code = code.Trim().ToUpperInvariant(); - } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 34cb1ef38..000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value) - { - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 4ed843d05..0f0ce8035 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -8,12 +8,18 @@ + + + + + + diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs deleted file mode 100644 index acde34e8d..000000000 --- a/jobs/Backend/Task/IExchangeRateProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace ExchangeRateUpdater -{ - internal interface IExchangeRateProvider - { - public Task> GetExchangeRates(IEnumerable currencies); - } -} diff --git a/jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateFetcher.cs b/jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateFetcher.cs new file mode 100644 index 000000000..6db0c5078 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateFetcher.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Domain; +using Microsoft.Extensions.Logging; +using Polly.CircuitBreaker; + +namespace ExchangeRateUpdater.Infrastructure.CNB +{ + internal sealed class CNBExchangeRateFetcher( + HttpClient httpClient, + IExchangeRateParser parser, + ILogger logger) : IExchangeRateFetcher + { + private const string RatesSegment = "daily.txt"; + + private readonly HttpClient httpClient = httpClient; + private readonly IExchangeRateParser parser = parser; + + public async Task> GetExchangeRates(CancellationToken cancellationToken = default) + { + logger.LogTrace("Getting exchange rates from CNB"); + + try + { + var response = await httpClient.GetAsync(RatesSegment, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + return parser.Parse(content).Records; + } + catch (BrokenCircuitException) + { + // We would want to retrieve the info from other source or use "old" data. + // Returning empty collection for simplicy here. + logger.LogWarning("Circuit open when fetching data. Using alternative path"); + + return []; + } + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs b/jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateParser.cs similarity index 62% rename from jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs rename to jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateParser.cs index cea91fb96..fd64fd2b4 100644 --- a/jobs/Backend/Task/Infrastructure/CNBExchangeRateParser.cs +++ b/jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateParser.cs @@ -5,31 +5,46 @@ using System.Linq; using CsvHelper; using CsvHelper.Configuration; +using ExchangeRateUpdater.Domain; +using Microsoft.Extensions.Logging; -namespace ExchangeRateUpdater.Infrastructure +namespace ExchangeRateUpdater.Infrastructure.CNB { - internal sealed class CNBExchangeRateParser : IExchangeRateParser + internal interface IExchangeRateParser { + (ExchangeRateMetadata Metadata, IEnumerable Records) Parse(string exchangeRate); + } + + internal record ExchangeRateMetadata(DateTime DateTime, int Identifier); + + internal sealed class CNBExchangeRateParser(ILogger logger) : IExchangeRateParser + { + private const string CheckCurrencyCode = "CZK"; private readonly CsvConfiguration config = new CsvConfiguration(CultureInfo.InvariantCulture) { PrepareHeaderForMatch = args => args.Header.Trim().ToLower(), Delimiter = "|", }; + private readonly ILogger logger = logger; - public IEnumerable Parse(string exchangeRate) + public (ExchangeRateMetadata Metadata, IEnumerable Records) Parse(string exchangeRate) { - using(var reader = new StringReader(exchangeRate)) + logger.LogTrace("Parsing exchange rate data"); + + using (var reader = new StringReader(exchangeRate)) using (var csvReader = new CsvReader(reader, config)) { var dateInfo = reader.ReadLine(); var metadata = ParseMetadata(dateInfo); + logger.LogTrace("Parsed metadata: {Date} {Identifier}", metadata.DateTime, metadata.Identifier); + var records = csvReader.GetRecords(); - return records.Select(records => new ExchangeRate( - new Currency("CZK"), + return (metadata, records.Select(records => new ExchangeRate( + new Currency(CheckCurrencyCode), new Currency(records.Code), records.Rate / records.Amount)) - .ToArray(); + .ToArray()); } } @@ -58,7 +73,5 @@ private static ExchangeRateMetadata ParseMetadata(string dateInfo) } private record class ExchangeRateRecord(string Country, string Currency, decimal Amount, string Code, decimal Rate); - - private record ExchangeRateMetadata(DateTime DateTime, int Identifier); } } diff --git a/jobs/Backend/Task/Infrastructure/CNB/CNBOptions.cs b/jobs/Backend/Task/Infrastructure/CNB/CNBOptions.cs new file mode 100644 index 000000000..67af1d7fc --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNB/CNBOptions.cs @@ -0,0 +1,11 @@ +using System; + +namespace ExchangeRateUpdater.Infrastructure.CNB +{ + internal record CNBOptions + { + public string BaseUrl { get; set; } + + public TimeOnly NewDataSchedule { get; set; } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNB/CachedExchangeRateFetcher.cs b/jobs/Backend/Task/Infrastructure/CNB/CachedExchangeRateFetcher.cs new file mode 100644 index 000000000..a84e0c463 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNB/CachedExchangeRateFetcher.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Domain; +using Microsoft.Extensions.Caching.Distributed; + +namespace ExchangeRateUpdater.Infrastructure.CNB +{ + // We could use a basic dictionary for caching, but we will use a distributed cache + // to allow for better scalability and to avoid issues with multiple instances of the application. + // We assumed 5 minutes of sliding expiration, but this could be changed to a more suitable value. + internal sealed class CachedExchangeRateFetcher( + IDistributedCache cache, + IExchangeRateFetcher underlying) : IExchangeRateFetcher + { + private readonly IDistributedCache cache = cache; + private readonly IExchangeRateFetcher underlying = underlying; + + public async Task> GetExchangeRates(CancellationToken cancellationToken = default) + { + const string cacheKey = "exchange-rates"; + var cached = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (!string.IsNullOrEmpty(cached)) + { + return JsonSerializer.Deserialize>(cached)!; + } + + var rates = await underlying.GetExchangeRates(cancellationToken); + var serialized = JsonSerializer.Serialize(rates); + + await cache.SetStringAsync( + cacheKey, + serialized, + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(5), + }, + cancellationToken); + + return rates; + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNB/Registry/InfrastructureServiceCollection.cs b/jobs/Backend/Task/Infrastructure/CNB/Registry/InfrastructureServiceCollection.cs new file mode 100644 index 000000000..bce9afbd0 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CNB/Registry/InfrastructureServiceCollection.cs @@ -0,0 +1,100 @@ +using System; +using System.Net.Http; +using ExchangeRateUpdater.Domain; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly.Extensions.Http; +using Polly; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Infrastructure.Observability; + +namespace ExchangeRateUpdater.Infrastructure.CNB.Registry +{ + // The infrastrucutre folder could be (in a bigger project) a separated project. + internal static class InfrastructureServiceCollection + { + public static IServiceCollection AddCNBInfrastructure(this IServiceCollection services, IConfiguration config) + => services + .AddCNBOptions(config) + .AddSingleton() + .AddSingleton() + .AddSingleton(TimeProvider.System) + .AddSingleton(serviceProvider => + { + return new CachedExchangeRateFetcher( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + }) + .AddDistributedMemoryCache() + .AddHttpClient(config) + .Services; + + private static IHttpClientBuilder AddHttpClient(this IServiceCollection services, IConfiguration config) => + services + .AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>() + .Value; + + client.BaseAddress = new Uri(options.BaseUrl); + }) + .AddPolicyHandler((serviceProvider, request) => + { + var logger = serviceProvider.GetRequiredService().CreateLogger("RetryPolicy"); + return GetRetryPolicy(logger, serviceProvider.GetRequiredService()); + }) + .AddPolicyHandler((serviceProvider, request) => + { + var logger = serviceProvider.GetRequiredService().CreateLogger("CircuitBreakerPolicy"); + return GetCircuitBreakerPolicy(logger, serviceProvider.GetRequiredService()); + }); + + private static IServiceCollection AddCNBOptions(this IServiceCollection services, IConfiguration config) => + services.Configure(config.GetSection(key: nameof(CNBOptions))); + + private static IAsyncPolicy GetRetryPolicy(ILogger logger, Metrics metrics) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound) + .WaitAndRetryAsync( + retryCount: 3, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryAttempt, context) => + { + logger.LogWarning("Retry {RetryAttempt} for {PolicyKey} at {Time}. Reason: {Result}", + retryAttempt, + context.PolicyKey, + DateTime.UtcNow, + outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()); + + metrics.RetryCounter.Add(1); + }); + } + + private static IAsyncPolicy GetCircuitBreakerPolicy(ILogger logger, Metrics metrics) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (request, timespan) => + { + logger.LogWarning("Circuit breaker opened at {Time}.", + DateTime.UtcNow); + + metrics.CircuitBreakerOpened.Add(1); + }, + onReset: () => + { + logger.LogWarning("Circuit breaker reset at {Time}.", + DateTime.UtcNow); + + metrics.CircuitBreakerReset.Add(1); + }); + } + } +} diff --git a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs deleted file mode 100644 index 8d71fefe9..000000000 --- a/jobs/Backend/Task/Infrastructure/CNBExchangeRateProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using System.Linq; -using System.Globalization; -using Microsoft.Extensions.Logging; - -namespace ExchangeRateUpdater.Infrastructure -{ - internal sealed class CNBExchangeRateProvider( - HttpClient httpClient, - IExchangeRateParser parser) : IExchangeRateProvider - { - private const string RatesSegment = "daily.txt"; - - private readonly HttpClient httpClient = httpClient; - private readonly IExchangeRateParser parser = 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 async Task> GetExchangeRates(IEnumerable currencies) - { - var response = await httpClient.GetAsync(RatesSegment); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - - return parser.Parse(content); - } - } -} diff --git a/jobs/Backend/Task/Infrastructure/CNBOptions.cs b/jobs/Backend/Task/Infrastructure/CNBOptions.cs deleted file mode 100644 index 1db621611..000000000 --- a/jobs/Backend/Task/Infrastructure/CNBOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ExchangeRateUpdater.Infrastructure -{ - internal record CNBOptions - { - public string BaseUrl { get; set; } - } -} diff --git a/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs b/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs deleted file mode 100644 index 95c4f3b85..000000000 --- a/jobs/Backend/Task/Infrastructure/IExchangeRateParser.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace ExchangeRateUpdater.Infrastructure -{ - internal interface IExchangeRateParser - { - IEnumerable Parse(string exchangeRate); - } -} diff --git a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs b/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs deleted file mode 100644 index 498071278..000000000 --- a/jobs/Backend/Task/Infrastructure/Registry/InfrastructureServiceCollection.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace ExchangeRateUpdater.Infrastructure.Registry -{ - // The infrastrucutre folder could be (in a bigger project) a separate project. - internal static class InfrastructureServiceCollection - { - public static IServiceCollection AddExchangeRateUpdater(this IServiceCollection services, IConfiguration config) - => services - .AddCNBOptions(config) - .AddSingleton() - .AddSingleton() - .AddHttpClient((serviceProvider, client) => - { - var options = serviceProvider.GetRequiredService>() - .Value; - - client.BaseAddress = new Uri(options.BaseUrl); - }) - .Services; - - private static IServiceCollection AddCNBOptions(this IServiceCollection services, IConfiguration config) => - services.Configure(config.GetSection(key: nameof(CNBOptions))); - } -} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index a6a2b08b9..b193f60a6 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using ExchangeRateUpdater.Infrastructure.Registry; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Infrastructure.CNB.Registry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -41,17 +42,18 @@ public static async Task Main(string[] args) // We should probably use a more advanced logging provider, but for the sake of simplicity // we will use the console logger. loggingBuilder.AddConsole(); - // Read logging settings from appsettings.json loggingBuilder.AddConfiguration(context.Configuration.GetSection("Logging")); }) .ConfigureServices((context, services) => { - - services.AddExchangeRateUpdater(context.Configuration); + services + .AddCNBInfrastructure(context.Configuration) + .AddObservabilityInfrastructure() + .AddSingleton(); }) .Build(); - var provider = host.Services.GetRequiredService(); + var provider = host.Services.GetRequiredService(); var rates = await provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index 224eebfc3..51847921f 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -6,6 +6,7 @@ } }, "CNBOptions": { + "NewDataSchedule": "14:30", "BaseUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/" } } \ No newline at end of file From 7233db578ce2d23db214fc72d1a8ca9e10a6b246 Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sun, 18 May 2025 11:19:44 +0200 Subject: [PATCH 15/16] feat(tests): add unit tests for exchange rate functionality Update the project to include `InternalsVisibleTo` for testing, add a new test project for `ExchangeRateUpdaterTests`, and implement unit tests for `ExchangeRateProvider`, `CNBExchangeRateFetcher`, `CNBExchangeRateParser`, and `CachedExchangeRateFetcher`. Update solution and project files to target .NET 9.0 and include necessary testing packages. --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 5 + jobs/Backend/Task/ExchangeRateUpdater.sln | 13 ++- .../ExchangeRateProviderTests.cs | 83 +++++++++++++++++ .../ExchangeRateUpdaterTests.csproj | 27 ++++++ .../CNB/CNBExchangeRateFetcherTests.cs | 91 +++++++++++++++++++ .../CNB/CNBExchangeRateParserTests.cs | 60 ++++++++++++ .../CNB/CachedExchangeRateFetcherTests.cs | 88 ++++++++++++++++++ 7 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj create mode 100644 jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateFetcherTests.cs create mode 100644 jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateParserTests.cs create mode 100644 jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CachedExchangeRateFetcherTests.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 0f0ce8035..8641af057 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -27,4 +27,9 @@ + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..f1055c575 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.13.35931.197 d17.13 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}") = "ExchangeRateUpdaterTests", "..\Tests\ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {02958319-B4BB-49EE-92B0-3B8A23C3A4EE} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..8a451e94e --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs @@ -0,0 +1,83 @@ +using ExchangeRateUpdater.Domain; +using FluentAssertions; +using Moq; + +namespace ExchangeRateUpdaterTests +{ + public class ExchangeRateProviderTests + { + private const string ExpectedErrorMessage = "Network error"; + private readonly Mock mockFetcher = new(); + private readonly ExchangeRateProvider sut; + public ExchangeRateProviderTests() + { + sut = new ExchangeRateProvider(mockFetcher.Object); + mockFetcher.Reset(); + } + + [Fact] + public async Task GivenCurrencies_WhenAllRatesAreAvailable_ThenReturnRates() + { + // Arrange + var allRates = new List + { + new(new Currency("CZK"), new Currency("USD"), 0.045m), + new(new Currency("CZK"), new Currency("EUR"), 0.04m), + new(new Currency("CZK"), new Currency("JPY"), 6.5m) + }; + + mockFetcher.Setup(f => f.GetExchangeRates(It.IsAny())) + .ReturnsAsync(allRates); + + var requestedCurrencies = new[] { new Currency("USD"), new Currency("EUR") }; + + // Act + var result = await sut.GetExchangeRates(requestedCurrencies); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().Contain(r => r.TargetCurrency.Code == "USD"); + result.Should().Contain(r => r.TargetCurrency.Code == "EUR"); + result.Should().NotContain(r => r.TargetCurrency.Code == "JPY"); + } + + [Fact] + public async Task GivenCurrencies_WhenRateIsNotAvailable_ThenReturnEmptyRates() + { + // Arrange + var allRates = new List + { + new(new Currency("CZK"), new Currency("USD"), 0.045m), + }; + + mockFetcher.Setup(f => f.GetExchangeRates(It.IsAny())) + .ReturnsAsync(allRates); + + var requestedCurrencies = new[] { new Currency("JPY") }; + + // Act + var result = await sut.GetExchangeRates(requestedCurrencies); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GivenCurrencies_WhenFetcherThrows_ThenThrowException() + { + // Arrange + mockFetcher.Setup(f => f.GetExchangeRates(It.IsAny())) + .ThrowsAsync(new Exception("Network error")); + + var requestedCurrencies = new[] { new Currency("JPY") }; + + // Act + var act = async () => await sut.GetExchangeRates(requestedCurrencies); + + // Assert + await act.Should().ThrowAsync().WithMessage(ExpectedErrorMessage); + } + } +} diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj new file mode 100644 index 000000000..c80f6c542 --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateFetcherTests.cs b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateFetcherTests.cs new file mode 100644 index 000000000..e5334e86a --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateFetcherTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Infrastructure.CNB; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; +using Polly.CircuitBreaker; + +namespace ExchangeRateUpdaterTests.Infrastructure.CNB +{ + public class CNBExchangeRateFetcherTests + { + private readonly Mock mockHandler = new(MockBehavior.Strict); + private readonly Mock mockParser = new(); + private readonly Mock> mockLogger = new(); + private readonly HttpClient httpClient; + private readonly CNBExchangeRateFetcher sut; + + public CNBExchangeRateFetcherTests() + { + httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri("https://cnb.cz/") + }; + sut = new CNBExchangeRateFetcher(httpClient, mockParser.Object, mockLogger.Object); + } + + [Fact] + public async Task GivenValidResponse_WhenGetExchangeRates_ThenReturnsParsedRates() + { + // Arrange + var responseContent = "test-data"; + var expectedRates = new List { new(new Currency("CZK"), new Currency("USD"), 1.23m) }; + var parserResult = (new ExchangeRateMetadata(DateTime.Today, 1), expectedRates); + + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get && req.RequestUri!.PathAndQuery == "/daily.txt"), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }); + mockParser.Setup(p => p.Parse(responseContent)).Returns(parserResult); + + // Act + var result = await sut.GetExchangeRates(); + + // Assert + result.Should().BeEquivalentTo(expectedRates); + } + + [Fact] + public async Task GivenHttpError_WhenGetExchangeRates_ThenThrows() + { + // Arrange + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get && req.RequestUri!.PathAndQuery == "/daily.txt"), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + // Act + var act = async () => await sut.GetExchangeRates(); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GivenBrokenCircuitException_WhenGetExchangeRates_ThenReturnsEmpty() + { + // Arrange + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get && req.RequestUri!.PathAndQuery == "/daily.txt"), + ItExpr.IsAny()) + .ThrowsAsync(new BrokenCircuitException()); + + // Act + var result = await sut.GetExchangeRates(); + + // Assert + result.Should().BeEmpty(); + } + } +} diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateParserTests.cs b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateParserTests.cs new file mode 100644 index 000000000..d1d0e9546 --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CNBExchangeRateParserTests.cs @@ -0,0 +1,60 @@ +using ExchangeRateUpdater.Infrastructure.CNB; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdaterTests.Infrastructure.CNB +{ + public class CNBExchangeRateParserTests + { + private readonly Mock> mockLogger = new(); + private readonly CNBExchangeRateParser sut; + + public CNBExchangeRateParserTests() + { + sut = new CNBExchangeRateParser(mockLogger.Object); + } + + [Fact] + public void GivenValidCNBData_WhenParse_ThenReturnsRatesAndMetadata() + { + // Arrange + var cnbData = "16 May 2025 #93\r\nCountry|Currency|Amount|Code|Rate\r\nUSA|dollar|1|USD|22.222\r\nEMU|euro|1|EUR|24.444"; + + // Act + var (metadata, records) = sut.Parse(cnbData); + + // Assert + metadata.DateTime.Should().Be(new DateTime(2025, 5, 16)); + metadata.Identifier.Should().Be(93); + records.Should().HaveCount(2); + records.Should().Contain(r => r.TargetCurrency.Code == "USD" && r.Value == 22.222m); + records.Should().Contain(r => r.TargetCurrency.Code == "EUR" && r.Value == 24.444m); + } + + [Fact] + public void GivenEmptyData_WhenParse_ThenReturnsEmpty() + { + // Act + var (metadata, records) = sut.Parse(""); + + // Assert + records.Should().BeEmpty(); + metadata.DateTime.Should().Be(DateTime.MinValue); + } + + [Fact] + public void GivenMalformedHeader_WhenParse_ThenReturnsEmpty() + { + // Arrange + var cnbData = "not a date\r\nCountry|Currency|Amount|Code|Rate"; + + // Act + var (metadata, records) = sut.Parse(cnbData); + + // Assert + records.Should().BeEmpty(); + metadata.DateTime.Should().Be(DateTime.MinValue); + } + } +} diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CachedExchangeRateFetcherTests.cs b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CachedExchangeRateFetcherTests.cs new file mode 100644 index 000000000..83cf0a1a5 --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/Infrastructure/CNB/CachedExchangeRateFetcherTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Infrastructure.CNB; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateUpdaterTests.Infrastructure.CNB +{ + public class CachedExchangeRateFetcherTests + { + private const string cacheKey = "exchange-rates"; + private readonly IDistributedCache cache; + private readonly Mock mockFetcher = new(); + private readonly CachedExchangeRateFetcher sut; + + public CachedExchangeRateFetcherTests() + { + var opts = Options.Create(new MemoryDistributedCacheOptions()); + cache = new MemoryDistributedCache(opts); + sut = new CachedExchangeRateFetcher(cache, mockFetcher.Object); + mockFetcher.Reset(); + } + + [Fact] + public async Task GivenCacheHit_WhenGetExchangeRates_ThenReturnsCachedRates() + { + // Arrange + var expectedRates = new List + { + new(new Currency("CZK"), new Currency("USD"), 0.045m), + new(new Currency("CZK"), new Currency("EUR"), 0.04m) + }; + var serialized = JsonSerializer.Serialize(expectedRates); + cache.SetString(cacheKey, serialized); + + // Act + var result = await sut.GetExchangeRates(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(expectedRates); + mockFetcher.Verify(f => f.GetExchangeRates(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GivenCacheMiss_WhenGetExchangeRates_ThenFetchesAndCachesRates() + { + // Arrange + var expectedRates = new List + { + new(new Currency("CZK"), new Currency("USD"), 0.045m), + new(new Currency("CZK"), new Currency("EUR"), 0.04m) + }; + + cache.Remove(cacheKey); + + mockFetcher.Setup(f => f.GetExchangeRates(It.IsAny())) + .ReturnsAsync(expectedRates); + + // Act + var result = await sut.GetExchangeRates(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(expectedRates); + mockFetcher.Verify(f => f.GetExchangeRates(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GivenFetcherThrows_WhenGetExchangeRates_ThenThrowsException() + { + // Arrange + cache.Remove(cacheKey); + + mockFetcher.Setup(f => f.GetExchangeRates(It.IsAny())) + .ThrowsAsync(new Exception("Fetcher error")); + + // Act + var act = async () => await sut.GetExchangeRates(); + + // Assert + await act.Should().ThrowAsync().WithMessage("Fetcher error"); + } + } +} From be491d74d64e6a12087af64dd4c082c2828b5bce Mon Sep 17 00:00:00 2001 From: Diego Pandiello Date: Sun, 18 May 2025 11:24:56 +0200 Subject: [PATCH 16/16] docs: update README.md with new features and structure --- jobs/Backend/Task/README.md | 66 +++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index ae2c396e7..dc9fcdd39 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -9,23 +9,77 @@ This project is a C# console application designed to fetch and update foreign ex - Parses and processes the daily exchange rate data - Supports multiple currencies as provided by the CNB - Modular and extensible codebase for easy integration +- Resilient to network errors and supports circuit breaker pattern +- Easily testable and extensible for new providers ## Data Source - **Provider:** Czech National Bank (CNB) - **URL:** [CNB Daily Exchange Rates](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) - The data is updated daily by the CNB and includes rates for a wide range of currencies against the Czech Koruna (CZK). +## Component and Project Structure + +The solution is organized for clarity, maintainability, and extensibility: + +- **Domain Layer** (`Domain/`): + - `Currency.cs`: Value object representing a currency. + - `ExchangeRate.cs`: Value object representing an exchange rate between two currencies. + - `ExchangeRateProvider.cs`: Exchange rate provider. + - `IExchangeRateFetcher.cs`: Interface for fetching exchange rates. + +- **Infrastructure Layer** (`Infrastructure/`): + - `CNB/`: + - `CNBExchangeRateFetcher.cs`: Fetches and parses rates from the CNB API. + - `CNBExchangeRateParser.cs`: Parses the CNB data format. + - `CachedExchangeRateFetcher.cs`: Adds caching to the fetcher. + - `CNBOptions.cs`: Configuration options for the CNB provider. + - `Observability/`: + - `Metrics.cs`: Exposes application metrics. + +- **Application Entry Point**: + - `Program.cs`: Main entry point, orchestrates fetching and processing of exchange rates. + +- **Configuration**: + - `appsettings.json`: Application configuration (e.g., endpoints, logging). + +- **Testing** (`Tests/ExchangeRateUpdaterTests/`): + - Contains unit and integration tests for core components, using xUnit, Moq, and FluentAssertions. + +This structure supports separation of concerns, testability, and future extension (e.g., adding new providers or output formats). + +## Architecture & Design Decisions +- Uses dependency injection for testability and flexibility. +- Applies the circuit breaker pattern (via Polly) for resilience against network or provider failures. +- Follows SOLID principles and clean architecture for maintainability. +- Logging is integrated for observability and troubleshooting. + +## Testing +- Unit tests are provided for all major components. +- Tests use xUnit, Moq for mocking dependencies, and FluentAssertions for expressive assertions. +- To run tests: + 1. Navigate to the `Tests/ExchangeRateUpdaterTests/` directory. + 2. Run `dotnet test`. + +## Extensibility +- To add a new exchange rate provider, implement the `IExchangeRateFetcher` interface and register it in the DI container. +- To support new data formats, implement a new parser and inject it into the fetcher. + +## Error Handling & Resilience +- Network and provider errors are handled gracefully. +- Circuit breaker pattern ensures the application remains responsive during provider outages. + +## Configuration +- Endpoints, logging, and other settings are managed via `appsettings.json`. +- Update configuration as needed for different environments. + ## Usage 1. Build the project using the provided solution file (`ExchangeRateUpdater.sln`). 2. Run the application. It will automatically fetch the latest exchange rates from the CNB and process them. 3. The application can be extended to store rates in a database, expose them via an API, or integrate with other systems as needed. -## Project Structure -- `Currency.cs`: Defines currency-related data structures. -- `ExchangeRate.cs`: Represents exchange rate information. -- `ExchangeRateProvider.cs`: Handles fetching and parsing of exchange rate data from the CNB. -- `Program.cs`: Entry point of the application. - ## Requirements - .NET 9.0 or higher - Internet connection to access the CNB data source + +## License +This project is provided as an exercise for recruitment purposes. License terms may be specified by the author or company.