From 53b33481139c5364bfebff870799e43b6bdddf62 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Tue, 23 Dec 2025 16:09:57 -0300 Subject: [PATCH 01/21] migrate console application to .NET 10 --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++-- jobs/Backend/Task/ExchangeRateUpdater.slnx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.slnx diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..469d2086aa 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,8 @@ - + Exe - net6.0 + net10.0 \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx new file mode 100644 index 0000000000..98f33b2628 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx @@ -0,0 +1,3 @@ + + + From 4d654e03f0f852ed74f2817eee1c710fb01034a9 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Tue, 23 Dec 2025 16:10:23 -0300 Subject: [PATCH 02/21] delete old sln --- jobs/Backend/Task/ExchangeRateUpdater.sln | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.sln diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daff..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal From daa6eed29b594d888c328b3669457cc769cf04a2 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Wed, 24 Dec 2025 08:22:27 -0300 Subject: [PATCH 03/21] add new skeleton --- jobs/Backend/Task/Currency.cs | 20 --------- jobs/Backend/Task/ExchangeRate.cs | 23 ---------- jobs/Backend/Task/ExchangeRateProvider.cs | 19 -------- jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 ---- jobs/Backend/Task/ExchangeRateUpdater.slnx | 11 ++++- jobs/Backend/Task/Program.cs | 43 ------------------- .../Task/src/Application/Application.csproj | 14 ++++++ jobs/Backend/Task/src/Application/Program.cs | 43 +++++++++++++++++++ .../Configuration/ProviderOptions.cs | 18 ++++++++ .../CzechNationalBankHttpClient.cs | 15 +++++++ .../CzechNationalBank/ExchangeRateProvider.cs | 14 ++++++ .../CzechNationalBank/IResponseParser.cs | 17 ++++++++ .../CzechNationalBank/Models/DailyRecord.cs | 17 ++++++++ .../CzechNationalBank/Models/DailyResponse.cs | 16 +++++++ .../PipeSeparatedResponseParser.cs | 27 ++++++++++++ jobs/Backend/Task/src/Domain/Currency.cs | 19 ++++++++ jobs/Backend/Task/src/Domain/Domain.csproj | 9 ++++ jobs/Backend/Task/src/Domain/ExchangeRate.cs | 22 ++++++++++ .../Task/src/Domain/IExchangeRateProvider.cs | 8 ++++ jobs/Backend/Task/src/sample_data | 35 +++++++++++++++ .../Application.UnitTests.csproj | 21 +++++++++ .../tests/Application.UnitTests/UnitTest1.cs | 10 +++++ 22 files changed, 314 insertions(+), 115 deletions(-) delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/src/Application/Application.csproj create mode 100644 jobs/Backend/Task/src/Application/Program.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs create mode 100644 jobs/Backend/Task/src/Domain/Currency.cs create mode 100644 jobs/Backend/Task/src/Domain/Domain.csproj create mode 100644 jobs/Backend/Task/src/Domain/ExchangeRate.cs create mode 100644 jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/sample_data create mode 100644 jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj create mode 100644 jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 469d2086aa..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net10.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx index 98f33b2628..8917949260 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.slnx +++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx @@ -1,3 +1,10 @@ - - + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/src/Application/Application.csproj b/jobs/Backend/Task/src/Application/Application.csproj new file mode 100644 index 0000000000..559cf68ef7 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Application.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs new file mode 100644 index 0000000000..bbdbb4b581 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Domain; + +namespace ExchangeRateUpdater; + +public static class Program +{ + private static IEnumerable currencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + }; + + public static void Main(string[] args) + { + try + { + var provider = new ExchangeRateProvider(); + var rates = provider.GetExchangeRates(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + foreach (var rate in rates) + { + Console.WriteLine(rate.ToString()); + } + } + catch (Exception e) + { + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + } + + Console.ReadLine(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs new file mode 100644 index 0000000000..d2b6419977 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -0,0 +1,18 @@ +using System; + +namespace ExchangeRateUpdater.Providers.CzechNationalBank.Configuration; + +/// +/// Configuration options for the Czech National Bank exchange rate provider. +/// +public class ProviderOptions +{ + public const string SectionName = "CnbProvider"; + public string BaseUrl { get; set; } = "https://www.cnb.cz"; + public string DailyRatesPath { get; set; } = "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; + public int TimeoutSeconds { get; set; } = 10; + public int RetryCount { get; set; } = 3; + public string UserAgent { get; set; } = "ExchangeRateUpdater/1.0"; + + public string FullUrl => $"{BaseUrl.TrimEnd('/')}{DailyRatesPath}"; +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs new file mode 100644 index 0000000000..dd59aafd33 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + + +/// +/// HTTP client for fetching Czech National Bank exchange rate data. +/// +public class CzechNationalBankHttpClient +{ + public async Task GetDailyRatesAsync(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs new file mode 100644 index 0000000000..cb7557e654 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using ExchangeRateUpdater.Domain; + +public class ExchangeRateProvider : IExchangeRateProvider +{ + /// + /// Returns exchange rates for specified currencies + /// + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs new file mode 100644 index 0000000000..fa33bd9fef --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Providers.CzechNationalBank; + +using ExchangeRateUpdater.Providers.CzechNationalBank.Models; + +/// +/// Defines a contract for parsing CNB exchange rate data. +/// +public interface IResponseParser +{ + /// + /// Parses raw CNB data into structured format. + /// + /// Raw text data from CNB. + /// Parsed exchange rate data. + /// Thrown when data cannot be parsed. + DailyResponse Parse(string rawData); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs new file mode 100644 index 0000000000..7e24575631 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; + +/// +/// Represents a single exchange rate record from the CNB daily file. +/// +/// Country name. +/// Currency name. +/// The amount of foreign currency (typically 1, 100, or 1000). +/// Three-letter ISO 4217 currency code. +/// Exchange rate to CZK for the specified amount. +public record DailyRecord( + string Country, + string CurrencyName, + int Amount, + string Code, + decimal Rate +); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs new file mode 100644 index 0000000000..e33497c792 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; + +/// +/// Represents the complete daily exchange rate data from CNB. +/// +/// The date of the exchange rates. +/// Sequential number of the publication. Represents the number of working day according to Czech bank holidays +/// Collection of exchange rate records. +public record DailyResponse( + DateOnly Date, + int Sequence, + IReadOnlyList Records +); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs new file mode 100644 index 0000000000..f183a10aa0 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs @@ -0,0 +1,27 @@ + +using System; +using ExchangeRateUpdater.Providers.CzechNationalBank.Models; + +namespace ExchangeRateUpdater.Providers.CzechNationalBank; + +/// +/// Parses exchange rate data returned by the Czech National Bank daily rates endpoint. +/// +/// +/// Expected format (simplified): +/// +/// 23 Dec 2025 #248 +/// Country|Currency|Amount|Code|Rate +// Australia|dollar|1|AUD|13.818 +// Brazil|real|1|BRL|3.694 +/// +/// Source: +/// https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt +/// +public class PipeSeparatedResponseParser : IResponseParser +{ + public DailyResponse Parse(string rawData) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Currency.cs b/jobs/Backend/Task/src/Domain/Currency.cs new file mode 100644 index 0000000000..ac3e1eff2f --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Currency.cs @@ -0,0 +1,19 @@ +namespace ExchangeRateUpdater.Domain; + +public class Currency +{ + public Currency(string code) + { + Code = code; + } + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } + + public override string ToString() + { + return Code; + } +} diff --git a/jobs/Backend/Task/src/Domain/Domain.csproj b/jobs/Backend/Task/src/Domain/Domain.csproj new file mode 100644 index 0000000000..b760144708 --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/jobs/Backend/Task/src/Domain/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/ExchangeRate.cs new file mode 100644 index 0000000000..babe901fee --- /dev/null +++ b/jobs/Backend/Task/src/Domain/ExchangeRate.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateUpdater.Domain; + +public class ExchangeRate +{ + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + } + + public Currency SourceCurrency { get; } + + public Currency TargetCurrency { get; } + + public decimal Value { get; } + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency} = {Value:F4}"; + } +} diff --git a/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs new file mode 100644 index 0000000000..b59d1c761b --- /dev/null +++ b/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Domain; + +public interface IExchangeRateProvider +{ + IEnumerable GetExchangeRates(IEnumerable currencies); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/sample_data b/jobs/Backend/Task/src/sample_data new file mode 100644 index 0000000000..e00e5cbe3e --- /dev/null +++ b/jobs/Backend/Task/src/sample_data @@ -0,0 +1,35 @@ +//https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt + +23 Dec 2025 #248 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|13.818 +Brazil|real|1|BRL|3.694 +Bulgaria|lev|1|BGN|12.434 +Canada|dollar|1|CAD|15.064 +China|renminbi|1|CNY|2.936 +Denmark|krone|1|DKK|3.256 +EMU|euro|1|EUR|24.320 +Hongkong|dollar|1|HKD|2.652 +Hungary|forint|100|HUF|6.218 +Iceland|krona|100|ISK|16.432 +IMF|SDR|1|XDR|28.206 +India|rupee|100|INR|23.043 +Indonesia|rupiah|1000|IDR|1.230 +Israel|new shekel|1|ILS|6.452 +Japan|yen|100|JPY|13.226 +Malaysia|ringgit|1|MYR|5.077 +Mexico|peso|1|MXN|1.150 +New Zealand|dollar|1|NZD|12.049 +Norway|krone|1|NOK|2.051 +Philippines|peso|100|PHP|35.072 +Poland|zloty|1|PLN|5.747 +Romania|leu|1|RON|4.778 +Singapore|dollar|1|SGD|16.051 +South Africa|rand|1|ZAR|1.237 +South Korea|won|100|KRW|1.393 +Sweden|krona|1|SEK|2.247 +Switzerland|franc|1|CHF|26.193 +Thailand|baht|100|THB|66.364 +Turkey|lira|100|TRY|48.183 +United Kingdom|pound|1|GBP|27.861 +USA|dollar|1|USD|20.631 \ No newline at end of file diff --git a/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj b/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj new file mode 100644 index 0000000000..db296aa2a0 --- /dev/null +++ b/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs b/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs new file mode 100644 index 0000000000..9e2df8bedf --- /dev/null +++ b/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.UnitTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} From fccb36c64fe29ed0a062b2a3c834635a4bb56990 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Wed, 24 Dec 2025 09:17:29 -0300 Subject: [PATCH 04/21] start implementing PipeSeparatedParser with tdd --- .../Task/src/Application/Application.csproj | 1 - .../PipeSeparatedResponseParser.cs | 34 ++++++++++++- jobs/Backend/Task/src/Domain/Domain.csproj | 1 - .../Application.UnitTests.csproj | 5 +- .../PipeSeparatedResponseParserTests.cs | 51 +++++++++++++++++++ .../tests/Application.UnitTests/UnitTest1.cs | 10 ---- 6 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs delete mode 100644 jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs diff --git a/jobs/Backend/Task/src/Application/Application.csproj b/jobs/Backend/Task/src/Application/Application.csproj index 559cf68ef7..1633502062 100644 --- a/jobs/Backend/Task/src/Application/Application.csproj +++ b/jobs/Backend/Task/src/Application/Application.csproj @@ -4,7 +4,6 @@ Exe net10.0 enable - enable diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs index f183a10aa0..e5f1900459 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs @@ -22,6 +22,38 @@ public class PipeSeparatedResponseParser : IResponseParser { public DailyResponse Parse(string rawData) { - throw new NotImplementedException(); + // read header split by # + // read column names for validation + // read lines split by | + + if (string.IsNullOrWhiteSpace(rawData)) + { + throw new CnbParsingException("Raw data is empty, null or white space."); + } + + var contents = rawData.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var headerParts = contents[0].Split('#', StringSplitOptions.TrimEntries); + if (headerParts.Length != 2) + { + throw new CnbParsingException("Header is not in expected format."); + } + + var columnNames = contents[1]; + if (columnNames != "Country|Currency|Amount|Code|Rate") + { + throw new CnbParsingException("Column names are not in expected format."); + } + + + return null; + } +} + + +public class CnbParsingException : Exception +{ + public CnbParsingException(string message) + : base(message) + { } } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Domain.csproj b/jobs/Backend/Task/src/Domain/Domain.csproj index b760144708..d1c2dc5118 100644 --- a/jobs/Backend/Task/src/Domain/Domain.csproj +++ b/jobs/Backend/Task/src/Domain/Domain.csproj @@ -3,7 +3,6 @@ net10.0 enable - enable diff --git a/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj b/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj index db296aa2a0..4632e4c209 100644 --- a/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj +++ b/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj @@ -3,7 +3,6 @@ net10.0 enable - enable false @@ -14,6 +13,10 @@ + + + + diff --git a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs new file mode 100644 index 0000000000..84312457cd --- /dev/null +++ b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Providers.CzechNationalBank; + +namespace ExchangeRateUpdater.UnitTests; + +public class PipeSeparatedResponseParserTests +{ + private readonly PipeSeparatedResponseParser _sut = new(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_NullRawData_ShouldThrowException(string input) + { + var actual = Assert.Throws(() => _sut.Parse(input)); + + Assert.Equal("Raw data is empty, null or white space.", actual.Message); + } + + [Theory] + [InlineData("Anything without hash symbol")] + [InlineData("Anything with more than 1 hash symbol ##")] + public void Parse_HeaderContainsOtherThanTwoPartsSparatedByHash_ShouldThrowException(string input) + { + var actual = Assert.Throws(() => _sut.Parse(input)); + + Assert.Equal("Header is not in expected format.", actual.Message); + } + + [Theory] + [MemberData(nameof(ValidHeaderButInvalidColumns))] + public void Parse_UnexpectedColumnNames_ShouldThrowException(string input) + { + var actual = Assert.Throws(() => _sut.Parse(input)); + + Assert.Equal("Column names are not in expected format.", actual.Message); + } + + public static string ValidHeader => "23 Dec 2025 #248"; + public static string ValidColumns => "Country|Currency|Amount|Code|Rate"; + + public static IEnumerable ValidHeaderButInvalidColumns() + { + yield return new object[] { RawData(ValidHeader, "WrongColumn1|WrongColumn2") }; + yield return new object[] { RawData(ValidHeader, "Country,Currency,Amount,Code,Rate") }; + yield return new object[] { RawData(ValidHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn") }; + } + + private static string RawData(params string[] parts) + => string.Join(Environment.NewLine, parts); +} diff --git a/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs b/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs deleted file mode 100644 index 9e2df8bedf..0000000000 --- a/jobs/Backend/Task/tests/Application.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ExchangeRateUpdater.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} From bdec62a7175a96d683dded19a2bd71a8d4185147 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Wed, 24 Dec 2025 11:15:53 -0300 Subject: [PATCH 05/21] add implementation of PipeSeparatedParser --- jobs/Backend/Task/src/Application/Program.cs | 5 +- .../CzechNationalBank/Models/DailyResponse.cs | 3 - .../PipeSeparatedResponseParser.cs | 30 +++- .../PipeSeparatedResponseParserTests.cs | 143 +++++++++++++++--- 4 files changed, 147 insertions(+), 34 deletions(-) diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs index bbdbb4b581..33b02997cc 100644 --- a/jobs/Backend/Task/src/Application/Program.cs +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Domain; namespace ExchangeRateUpdater; diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs index e33497c792..cecfd81a9b 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; /// diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs index e5f1900459..c3ad47f833 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs @@ -1,5 +1,3 @@ - -using System; using ExchangeRateUpdater.Providers.CzechNationalBank.Models; namespace ExchangeRateUpdater.Providers.CzechNationalBank; @@ -44,8 +42,34 @@ public DailyResponse Parse(string rawData) throw new CnbParsingException("Column names are not in expected format."); } + if (!DateOnly.TryParse(headerParts[0], out var date)) + { + throw new CnbParsingException("Header date is not in expected format."); + } + + if (!int.TryParse(headerParts[1], out var sequence)) + { + throw new CnbParsingException("Header sequence is not in expected format."); + } - return null; + return new DailyResponse + ( + Date: date, + Sequence: sequence, + Records: contents[2..] + .Select(line => + { + var parts = line.Split('|', StringSplitOptions.TrimEntries); + return new DailyRecord( + Country: parts[0], + CurrencyName: parts[1], + Amount: int.Parse(parts[2]), + Code: parts[3], + Rate: decimal.Parse(parts[4]) + ); + } + ).ToArray() + ); } } diff --git a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs index 84312457cd..74251ce564 100644 --- a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs +++ b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs @@ -5,47 +5,142 @@ namespace ExchangeRateUpdater.UnitTests; public class PipeSeparatedResponseParserTests { private readonly PipeSeparatedResponseParser _sut = new(); + private readonly DateOnly _testDate = new(2025, 12, 23); + private const int _testSequence = 248; + private const string _validHeader = "23 Dec 2025 #248"; + private const string _validColumns = "Country|Currency|Amount|Code|Rate"; + + private static string RawData(params string[] parts) + => string.Join(Environment.NewLine, parts); + + [Fact] + public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsNull() + { + var exception = Assert.Throws(() => _sut.Parse(null)); + + Assert.Equal("Raw data is empty, null or white space.", exception.Message); + } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Parse_NullRawData_ShouldThrowException(string input) + [InlineData("")] // Empty string + [InlineData(" ")] // Whitespace only + [InlineData("\t")] // Tab character + [InlineData("\n")] // Newline character + [InlineData("\r\n")] // Carriage return and newline + public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsWhitespace(string input) { - var actual = Assert.Throws(() => _sut.Parse(input)); + var exception = Assert.Throws(() => _sut.Parse(input)); - Assert.Equal("Raw data is empty, null or white space.", actual.Message); + Assert.Equal("Raw data is empty, null or white space.", exception.Message); } [Theory] - [InlineData("Anything without hash symbol")] - [InlineData("Anything with more than 1 hash symbol ##")] - public void Parse_HeaderContainsOtherThanTwoPartsSparatedByHash_ShouldThrowException(string input) + [InlineData("Anything without hash symbol")] // No hash symbol + [InlineData("Multiple##hash##symbols")] // More than one hash symbol + [InlineData("##StartsWithHash")] // Hash at the beginning + public void Parse_ShouldThrowCnbParsingException_WhenHeaderIsInvalid(string input) { - var actual = Assert.Throws(() => _sut.Parse(input)); + var exception = Assert.Throws(() => _sut.Parse(input)); - Assert.Equal("Header is not in expected format.", actual.Message); + Assert.Equal("Header is not in expected format.", exception.Message); } [Theory] - [MemberData(nameof(ValidHeaderButInvalidColumns))] - public void Parse_UnexpectedColumnNames_ShouldThrowException(string input) + [MemberData(nameof(GetInvalidColumnFormats))] + public void Parse_ShouldThrowCnbParsingException_WhenColumnNamesAreInvalid(string input) { - var actual = Assert.Throws(() => _sut.Parse(input)); + var exception = Assert.Throws(() => _sut.Parse(input)); - Assert.Equal("Column names are not in expected format.", actual.Message); + Assert.Equal("Column names are not in expected format.", exception.Message); } - public static string ValidHeader => "23 Dec 2025 #248"; - public static string ValidColumns => "Country|Currency|Amount|Code|Rate"; + public static IEnumerable GetInvalidColumnFormats() + { + // Wrong column names + yield return new object[] { RawData(_validHeader, "WrongColumn1|WrongColumn2|WrongColumn3|WrongColumn4|WrongColumn5") }; + // Comma separator instead of pipe + yield return new object[] { RawData(_validHeader, "Country,Currency,Amount,Code,Rate") }; + // Too many columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn") }; + // Too few columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount") }; + // Missing Rate column + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code") }; + // Lowercase column names + yield return new object[] { RawData(_validHeader, "country|currency|amount|code|rate") }; + } - public static IEnumerable ValidHeaderButInvalidColumns() + [Fact] + public void Parse_ShouldReturnExchangeRates_WhenDataIsValid() { - yield return new object[] { RawData(ValidHeader, "WrongColumn1|WrongColumn2") }; - yield return new object[] { RawData(ValidHeader, "Country,Currency,Amount,Code,Rate") }; - yield return new object[] { RawData(ValidHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn") }; + var validData = RawData( + _validHeader, + _validColumns, + "Australia|dollar|1|AUD|13.818", + "Brazil|real|1|BRL|3.694", + "Canada|dollar|1|CAD|15.064" + ); + + var actual = _sut.Parse(validData); + + Assert.NotNull(actual); + Assert.Equal(_testDate, actual.Date); + Assert.Equal(_testSequence, actual.Sequence); + + Assert.Equal(3, actual.Records.Count); + + var firstRecord = actual.Records[0]; + Assert.Equal("Australia", firstRecord.Country); + Assert.Equal("dollar", firstRecord.CurrencyName); + Assert.Equal(1, firstRecord.Amount); + Assert.Equal("AUD", firstRecord.Code); + Assert.Equal(13.818m, firstRecord.Rate); + + var secondRecord = actual.Records[1]; + Assert.Equal("Brazil", secondRecord.Country); + Assert.Equal("real", secondRecord.CurrencyName); + Assert.Equal(1, secondRecord.Amount); + Assert.Equal("BRL", secondRecord.Code); + Assert.Equal(3.694m, secondRecord.Rate); + + var thirdRecord = actual.Records[2]; + Assert.Equal("Canada", thirdRecord.Country); + Assert.Equal("dollar", thirdRecord.CurrencyName); + Assert.Equal(1, thirdRecord.Amount); + Assert.Equal("CAD", thirdRecord.Code); + Assert.Equal(15.064m, thirdRecord.Rate); } - private static string RawData(params string[] parts) - => string.Join(Environment.NewLine, parts); -} + [Fact] + public void Parse_ShouldReturnEmptyCollection_WhenOnlyHeaderAndColumnsProvided() + { + var dataWithoutRates = RawData(_validHeader, _validColumns); + + var actual = _sut.Parse(dataWithoutRates); + + Assert.NotNull(actual); + Assert.Equal(_testDate, actual.Date); + Assert.Equal(_testSequence, actual.Sequence); + Assert.Empty(actual.Records); + } + + + [Fact] + public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() + { + var dataWithExtraWhitespace = RawData( + _validHeader, + _validColumns, + "Australia|dollar|1|AUD|13.818", + "", + "" + ); + + var actual = _sut.Parse(dataWithExtraWhitespace); + + Assert.NotNull(actual); + Assert.Equal(_testDate, actual.Date); + Assert.Equal(_testSequence, actual.Sequence); + Assert.Single(actual.Records); + } +} \ No newline at end of file From 4b2a725395a36a402ed2511b5503cd775cf56cb5 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Wed, 24 Dec 2025 12:27:23 -0300 Subject: [PATCH 06/21] add edge case tests to PipeSeparatedParser --- .../PipeSeparatedResponseParser.cs | 113 +++++++---- .../PipeSeparatedResponseParserTests.cs | 185 ++++++++++++------ 2 files changed, 197 insertions(+), 101 deletions(-) diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs index c3ad47f833..1438c440f0 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs @@ -1,3 +1,4 @@ +using System.Globalization; using ExchangeRateUpdater.Providers.CzechNationalBank.Models; namespace ExchangeRateUpdater.Providers.CzechNationalBank; @@ -18,66 +19,104 @@ namespace ExchangeRateUpdater.Providers.CzechNationalBank; /// public class PipeSeparatedResponseParser : IResponseParser { + private static readonly char[] _newLineCharacters = ['\r', '\n']; + private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; public DailyResponse Parse(string rawData) { - // read header split by # - // read column names for validation - // read lines split by | - if (string.IsNullOrWhiteSpace(rawData)) { throw new CnbParsingException("Raw data is empty, null or white space."); } - var contents = rawData.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - var headerParts = contents[0].Split('#', StringSplitOptions.TrimEntries); - if (headerParts.Length != 2) + var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); + if (contents.Length < 3) { - throw new CnbParsingException("Header is not in expected format."); + throw new CnbParsingException("Response does not contain enough lines."); + } + + var header = contents[0]; + var headerParts = GetHeaderParts(header); + var exchangeDate = GetExchangeDate(headerParts); + var exchangeSequence = GetExchangeSequence(headerParts); + + ValidateColumnNames(contents[1]); + + return new DailyResponse + ( + Date: exchangeDate, + Sequence: exchangeSequence, + Records: contents[2..] + .Select(ParseRecord) + .ToArray() + ); + } + private static DailyRecord ParseRecord(string line) + { + var parts = line.Split('|', StringSplitOptions.TrimEntries); + + if (parts.Length != 5) + { + throw new CnbParsingException($"Invalid record format: '{line}'"); } - var columnNames = contents[1]; - if (columnNames != "Country|Currency|Amount|Code|Rate") + if (!int.TryParse(parts[2], out var amount)) { - throw new CnbParsingException("Column names are not in expected format."); + throw new CnbParsingException($"Invalid amount: '{parts[2]}'"); } - if (!DateOnly.TryParse(headerParts[0], out var date)) + if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) { - throw new CnbParsingException("Header date is not in expected format."); + throw new CnbParsingException($"Invalid rate: '{parts[4]}'"); } + return new DailyRecord( + Country: parts[0], + CurrencyName: parts[1], + Amount: amount, + Code: parts[3], + Rate: rate + ); + } + private static int GetExchangeSequence(string[] headerParts) + { if (!int.TryParse(headerParts[1], out var sequence)) { throw new CnbParsingException("Header sequence is not in expected format."); } - return new DailyResponse - ( - Date: date, - Sequence: sequence, - Records: contents[2..] - .Select(line => - { - var parts = line.Split('|', StringSplitOptions.TrimEntries); - return new DailyRecord( - Country: parts[0], - CurrencyName: parts[1], - Amount: int.Parse(parts[2]), - Code: parts[3], - Rate: decimal.Parse(parts[4]) - ); - } - ).ToArray() - ); + return sequence; } -} + private static DateOnly GetExchangeDate(string[] headerParts) + { + if (!DateOnly.TryParse(headerParts[0], CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + throw new CnbParsingException("Header date is not in expected format."); + } -public class CnbParsingException : Exception -{ - public CnbParsingException(string message) - : base(message) + return date; + } + + private static string[] GetHeaderParts(string header) { + var headerParts = header.Split('#', StringSplitOptions.TrimEntries); + + if (headerParts.Length != 2) + { + throw new CnbParsingException("Header is not in expected format."); + } + + return headerParts; } -} \ No newline at end of file + + private static void ValidateColumnNames(string columnNames) + { + if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) + { + throw new CnbParsingException("Column names are not in expected format."); + } + } +} + + +public class CnbParsingException(string message) : Exception(message) { } \ No newline at end of file diff --git a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs index 74251ce564..b1e4a3c992 100644 --- a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs +++ b/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs @@ -8,7 +8,7 @@ public class PipeSeparatedResponseParserTests private readonly DateOnly _testDate = new(2025, 12, 23); private const int _testSequence = 248; private const string _validHeader = "23 Dec 2025 #248"; - private const string _validColumns = "Country|Currency|Amount|Code|Rate"; + private const string _validColumnNames = "Country|Currency|Amount|Code|Rate"; private static string RawData(params string[] parts) => string.Join(Environment.NewLine, parts); @@ -16,9 +16,7 @@ private static string RawData(params string[] parts) [Fact] public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsNull() { - var exception = Assert.Throws(() => _sut.Parse(null)); - - Assert.Equal("Raw data is empty, null or white space.", exception.Message); + AssertThrowsWithMessage(() => _sut.Parse(null), "Raw data is empty, null or white space."); } [Theory] @@ -27,47 +25,108 @@ public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsNull() [InlineData("\t")] // Tab character [InlineData("\n")] // Newline character [InlineData("\r\n")] // Carriage return and newline - public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsWhitespace(string input) + public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsEmptyOrWhitespace(string input) { - var exception = Assert.Throws(() => _sut.Parse(input)); - - Assert.Equal("Raw data is empty, null or white space.", exception.Message); + AssertThrowsWithMessage(() => _sut.Parse(input), "Raw data is empty, null or white space."); } [Theory] - [InlineData("Anything without hash symbol")] // No hash symbol - [InlineData("Multiple##hash##symbols")] // More than one hash symbol - [InlineData("##StartsWithHash")] // Hash at the beginning + [InlineData("invalidheader\n anything \n anything")] // No hash symbolon header + [InlineData("Multiple##hash##symbols\n anything \n anything")] // More than one hash symbol + [InlineData("##StartsWithHash\n anything \n anything")] // Hash at the beginning public void Parse_ShouldThrowCnbParsingException_WhenHeaderIsInvalid(string input) { - var exception = Assert.Throws(() => _sut.Parse(input)); + AssertThrowsWithMessage(() => _sut.Parse(input), "Header is not in expected format."); + } - Assert.Equal("Header is not in expected format.", exception.Message); + + public static IEnumerable GetInvalidColumnFormats() + { + // Wrong column names + yield return new object[] { RawData(_validHeader, "WrongColumn1|WrongColumn2|WrongColumn3|WrongColumn4|WrongColumn5", "anything") }; + // Comma separator instead of pipe + yield return new object[] { RawData(_validHeader, "Country,Currency,Amount,Code,Rate", "anything") }; + // Too many columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn", "anything") }; + // Too few columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount", "anything") }; + // Missing Rate column + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code", "anything") }; } [Theory] [MemberData(nameof(GetInvalidColumnFormats))] public void Parse_ShouldThrowCnbParsingException_WhenColumnNamesAreInvalid(string input) { - var exception = Assert.Throws(() => _sut.Parse(input)); + AssertThrowsWithMessage(() => _sut.Parse(input), "Column names are not in expected format."); + } + + [Fact] + public void Parse_ShouldThrowCnbParsingException_WhenRecordIsMalformed() + { + var malformedData = RawData( + _validHeader, + _validColumnNames, + "||" // missing columns + ); - Assert.Equal("Column names are not in expected format.", exception.Message); + AssertThrowsWithMessage(() => _sut.Parse(malformedData), "Invalid record format"); } - public static IEnumerable GetInvalidColumnFormats() + [Fact] + public void Parse_ShouldThrow_WhenAmountIsNotNumeric() { - // Wrong column names - yield return new object[] { RawData(_validHeader, "WrongColumn1|WrongColumn2|WrongColumn3|WrongColumn4|WrongColumn5") }; - // Comma separator instead of pipe - yield return new object[] { RawData(_validHeader, "Country,Currency,Amount,Code,Rate") }; - // Too many columns - yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn") }; - // Too few columns - yield return new object[] { RawData(_validHeader, "Country|Currency|Amount") }; - // Missing Rate column - yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code") }; - // Lowercase column names - yield return new object[] { RawData(_validHeader, "country|currency|amount|code|rate") }; + var data = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|X|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Invalid amount"); + } + + [Fact] + public void Parse_ShouldThrow_WhenRateIsNotNumeric() + { + var data = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|1|AUD|abc" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Invalid rate"); + } + + [Fact] + public void Parse_ShouldThrow_WhenHeaderDateIsInvalid() + { + var data = RawData( + "99 Dec 99999 #248", // wrong format + _validColumnNames, + "Australia|dollar|1|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Header date is not in expected format."); + } + + [Fact] + public void Parse_ShouldThrow_WhenHeaderSequenceIsInvalid() + { + var data = RawData( + "23 Dec 2025 #ABC", + _validColumnNames, + "Australia|dollar|1|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Header sequence is not in expected format."); + } + + [Fact] + public void Parse_ShouldThrow_WhenNoRecordsAreProvided() + { + var data = RawData(_validHeader, _validColumnNames); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Response does not contain enough lines."); } [Fact] @@ -75,7 +134,7 @@ public void Parse_ShouldReturnExchangeRates_WhenDataIsValid() { var validData = RawData( _validHeader, - _validColumns, + _validColumnNames, "Australia|dollar|1|AUD|13.818", "Brazil|real|1|BRL|3.694", "Canada|dollar|1|CAD|15.064" @@ -89,48 +148,40 @@ public void Parse_ShouldReturnExchangeRates_WhenDataIsValid() Assert.Equal(3, actual.Records.Count); - var firstRecord = actual.Records[0]; - Assert.Equal("Australia", firstRecord.Country); - Assert.Equal("dollar", firstRecord.CurrencyName); - Assert.Equal(1, firstRecord.Amount); - Assert.Equal("AUD", firstRecord.Code); - Assert.Equal(13.818m, firstRecord.Rate); - - var secondRecord = actual.Records[1]; - Assert.Equal("Brazil", secondRecord.Country); - Assert.Equal("real", secondRecord.CurrencyName); - Assert.Equal(1, secondRecord.Amount); - Assert.Equal("BRL", secondRecord.Code); - Assert.Equal(3.694m, secondRecord.Rate); - - var thirdRecord = actual.Records[2]; - Assert.Equal("Canada", thirdRecord.Country); - Assert.Equal("dollar", thirdRecord.CurrencyName); - Assert.Equal(1, thirdRecord.Amount); - Assert.Equal("CAD", thirdRecord.Code); - Assert.Equal(15.064m, thirdRecord.Rate); - } - - [Fact] - public void Parse_ShouldReturnEmptyCollection_WhenOnlyHeaderAndColumnsProvided() - { - var dataWithoutRates = RawData(_validHeader, _validColumns); - - var actual = _sut.Parse(dataWithoutRates); - - Assert.NotNull(actual); - Assert.Equal(_testDate, actual.Date); - Assert.Equal(_testSequence, actual.Sequence); - Assert.Empty(actual.Records); + Assert.Collection(actual.Records, + record => + { + Assert.Equal("Australia", record.Country); + Assert.Equal("dollar", record.CurrencyName); + Assert.Equal(1, record.Amount); + Assert.Equal("AUD", record.Code); + Assert.Equal(13.818m, record.Rate); + }, + record => + { + Assert.Equal("Brazil", record.Country); + Assert.Equal("real", record.CurrencyName); + Assert.Equal(1, record.Amount); + Assert.Equal("BRL", record.Code); + Assert.Equal(3.694m, record.Rate); + }, + record => + { + Assert.Equal("Canada", record.Country); + Assert.Equal("dollar", record.CurrencyName); + Assert.Equal(1, record.Amount); + Assert.Equal("CAD", record.Code); + Assert.Equal(15.064m, record.Rate); + } + ); } - [Fact] public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() { var dataWithExtraWhitespace = RawData( _validHeader, - _validColumns, + _validColumnNames, "Australia|dollar|1|AUD|13.818", "", "" @@ -143,4 +194,10 @@ public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() Assert.Equal(_testSequence, actual.Sequence); Assert.Single(actual.Records); } + + private static void AssertThrowsWithMessage(Action act, string expectedMessage) + { + var ex = Assert.Throws(act); + Assert.Contains(expectedMessage, ex.Message); + } } \ No newline at end of file From 3f58866d76cc4e780e510fcd74d9ebc2aa8d8d92 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Wed, 24 Dec 2025 12:41:31 -0300 Subject: [PATCH 07/21] add better exception messages --- .../PipeSeparatedResponseParser.cs | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs index 1438c440f0..2dabbd4a18 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs @@ -28,11 +28,7 @@ public DailyResponse Parse(string rawData) throw new CnbParsingException("Raw data is empty, null or white space."); } - var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); - if (contents.Length < 3) - { - throw new CnbParsingException("Response does not contain enough lines."); - } + var contents = GetContents(rawData); var header = contents[0]; var headerParts = GetHeaderParts(header); @@ -50,23 +46,37 @@ public DailyResponse Parse(string rawData) .ToArray() ); } + + private static string[] GetContents(string rawData) + { + var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); + if (contents.Length < 3) + { + throw new CnbParsingException($"Response does not contain enough lines. Lines found: {contents.Length}."); + } + + return contents; + } + private static DailyRecord ParseRecord(string line) { var parts = line.Split('|', StringSplitOptions.TrimEntries); if (parts.Length != 5) { - throw new CnbParsingException($"Invalid record format: '{line}'"); + throw new CnbParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); } - if (!int.TryParse(parts[2], out var amount)) + var amountPart = parts[2]; + if (!int.TryParse(amountPart, out var amount)) { - throw new CnbParsingException($"Invalid amount: '{parts[2]}'"); + throw new CnbParsingException($"Invalid amount: '{amountPart}'"); } - if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + var ratePart = parts[4]; + if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) { - throw new CnbParsingException($"Invalid rate: '{parts[4]}'"); + throw new CnbParsingException($"Invalid rate: '{ratePart}'"); } return new DailyRecord( @@ -77,24 +87,27 @@ private static DailyRecord ParseRecord(string line) Rate: rate ); } - private static int GetExchangeSequence(string[] headerParts) + + private static DateOnly GetExchangeDate(string[] headerParts) { - if (!int.TryParse(headerParts[1], out var sequence)) + var value = headerParts[0]; + if (!DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { - throw new CnbParsingException("Header sequence is not in expected format."); + throw new CnbParsingException($"Header date is not in expected format. Value: '{value}'."); } - return sequence; + return date; } - private static DateOnly GetExchangeDate(string[] headerParts) + private static int GetExchangeSequence(string[] headerParts) { - if (!DateOnly.TryParse(headerParts[0], CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + var value = headerParts[1]; + if (!int.TryParse(value, out var sequence)) { - throw new CnbParsingException("Header date is not in expected format."); + throw new CnbParsingException($"Header sequence is not in expected format. Value: '{value}'."); } - return date; + return sequence; } private static string[] GetHeaderParts(string header) @@ -103,7 +116,7 @@ private static string[] GetHeaderParts(string header) if (headerParts.Length != 2) { - throw new CnbParsingException("Header is not in expected format."); + throw new CnbParsingException($"Header is not in expected format. Header value: '{header}'."); } return headerParts; @@ -113,7 +126,7 @@ private static void ValidateColumnNames(string columnNames) { if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) { - throw new CnbParsingException("Column names are not in expected format."); + throw new CnbParsingException($"Column names are not in expected format. Value: '{columnNames}'."); } } } From 163f47b68d72e8b478a4bc4167543e29188e9626 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 10:17:38 -0300 Subject: [PATCH 08/21] add remaining implementation and tests --- jobs/Backend/Task/ExchangeRateUpdater.slnx | 2 +- jobs/Backend/Task/docs/readme.md | 160 ++++++++++++++++++ .../Task/src/Application/Application.csproj | 14 ++ .../Configuration/PollyPolicies.cs | 43 +++++ .../Task/src/Application/ExchangeRateApp.cs | 49 ++++++ jobs/Backend/Task/src/Application/Program.cs | 119 ++++++++++--- .../Clients/CNBHttpClient.cs | 64 +++++++ .../CzechNationalBank/Clients/ICNBClient.cs | 6 + .../Configuration/ProviderOptions.cs | 42 ++++- .../CzechNationalBankHttpClient.cs | 15 -- .../Exceptions/CnbParsingException.cs | 3 + .../CzechNationalBank/ExchangeRateProvider.cs | 93 +++++++++- ...ponse.cs => DailyExchangeRatesResponse.cs} | 8 +- .../{DailyRecord.cs => ExchangeRate.cs} | 4 +- .../IDailyExchangeRatesResponseParser.cs} | 8 +- .../PipeSeparatedResponseParser.cs | 81 +++++---- .../Task/src/Application/appsettings.json | 16 ++ jobs/Backend/Task/src/Domain/Currency.cs | 19 --- .../Task/src/Domain/Entities/Currency.cs | 23 +++ .../Task/src/Domain/Entities/ExchangeRate.cs | 28 +++ .../Exceptions/ExchangeRateException.cs | 0 jobs/Backend/Task/src/Domain/ExchangeRate.cs | 22 --- .../Task/src/Domain/IExchangeRateProvider.cs | 8 - .../Interfaces/IExchangeRateProvider.cs | 10 ++ .../PipeSeparatedResponseParserTests.cs | 44 ++--- .../Domain/Entities/CurrencyTests.cs | 32 ++++ .../Domain/Entities/ExchangeRateTests.cs | 48 ++++++ .../UnitTests.csproj} | 0 28 files changed, 778 insertions(+), 183 deletions(-) create mode 100644 jobs/Backend/Task/docs/readme.md create mode 100644 jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs create mode 100644 jobs/Backend/Task/src/Application/ExchangeRateApp.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs delete mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs create mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs rename jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/{DailyResponse.cs => DailyExchangeRatesResponse.cs} (58%) rename jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/{DailyRecord.cs => ExchangeRate.cs} (83%) rename jobs/Backend/Task/src/Application/Providers/CzechNationalBank/{IResponseParser.cs => Parsers/IDailyExchangeRatesResponseParser.cs} (60%) rename jobs/Backend/Task/src/Application/Providers/CzechNationalBank/{ => Parsers}/PipeSeparatedResponseParser.cs (89%) create mode 100644 jobs/Backend/Task/src/Application/appsettings.json delete mode 100644 jobs/Backend/Task/src/Domain/Currency.cs create mode 100644 jobs/Backend/Task/src/Domain/Entities/Currency.cs create mode 100644 jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs create mode 100644 jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs delete mode 100644 jobs/Backend/Task/src/Domain/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs rename jobs/Backend/Task/tests/{Application.UnitTests => UnitTests/Application/Providers/CzechNationalBank/Parsers}/PipeSeparatedResponseParserTests.cs (84%) create mode 100644 jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs create mode 100644 jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs rename jobs/Backend/Task/tests/{Application.UnitTests/Application.UnitTests.csproj => UnitTests/UnitTests.csproj} (100%) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx index 8917949260..b1242635d2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.slnx +++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx @@ -5,6 +5,6 @@ - + \ No newline at end of file diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md new file mode 100644 index 0000000000..ffea33b2e3 --- /dev/null +++ b/jobs/Backend/Task/docs/readme.md @@ -0,0 +1,160 @@ +# Exchange Rate Updater – Notes & Considerations + +## 1. Problem Statement +The goal of this challenge is to fetch daily foreign exchange rates published by the Czech National Bank (CNB), parse the returned data, and expose the rates in a clean, domain-driven model. + +The CNB endpoint returns exchange rates as a plain-text, pipe-separated file containing: +- A header with the publication date and working day number +- A column definition row +- One row per currency exchange rate + +The solution is expected to: +- Retrieve the data over HTTP +- Parse and validate the response format +- Convert the raw data into strongly typed domain objects +- Handle invalid or unexpected input gracefully + +--- + +## 2. Assumptions +The following assumptions were made to keep the solution focused and explicit: + +- The CNB response format is stable and follows the documented structure +- All exchange rate values are positive +- The target currency is implicitly CZK, as defined by the CNB source +- The full response fits comfortably in memory +- This is a read-only, batch-style operation (no persistence required) + + +--- + +## 3. Approach +The solution is structured around a clear separation of concerns: + +1. **HTTP Client Layer** + - Responsible for retrieving raw data from the CNB endpoint + - Configured with timeouts, retries, and resilience policies + +2. **Parsing Layer** + - Converts the pipe-separated text response into structured models + - Validates headers, column definitions, and data rows and fails early in case the contract ever changes + - Produces deterministic parsing results or meaningful exceptions + +3. **Domain Layer** + - Models currencies and exchange rates as immutable value objects + - Enforces basic domain invariants (e.g. positive exchange rates) + +4. **Application Layer** + - Orchestrates the flow between client, parser, and domain + - Exposes a clean API for consuming exchange rate data + +The design favors clarity and correctness over premature optimization. + +--- + +## 4. Design Decisions + +### Immutability +- Domain entities and DTOs are implemented as immutable types +- Value-based equality is used where identity is defined purely by data +- This reduces side effects and simplifies reasoning and testing + +### Separation of Concerns +- Parsing logic is isolated from HTTP and orchestration logic +- Domain models are independent of infrastructure concerns +- This makes individual components easier to test and evolve + +### Explicit Validation +- The parser validates headers, column definitions, and row structure +- Failures are detected early and reported with clear exception messages + +### Resilience +- HTTP calls are protected using retry and circuit breaker policies +- Transient failures are handled without leaking infrastructure concerns into the domain + + +--- + +## 5. Immutability & Modeling +The following modeling strategy was applied: + +- **Records** + - Domain value objects such as `Currency` and `ExchangeRate` + - DTOs and parsing result models + - Configuration objects loaded from application settings + +- **Classes** + - Services, clients, and parsers + - Components that encapsulate behavior or orchestration + +This distinction reflects the difference between *data* and *behavior* in the system. + +--- + +## 6. Edge Cases & Error Handling +The solution explicitly handles: + +- Empty or whitespace-only responses +- Missing or malformed headers +- Unexpected column definitions +- Invalid numeric values +- Incomplete or malformed data rows + +When errors occur, the system: +- Fails fast during parsing +- Throws domain-specific exceptions with descriptive messages +- Avoids silently ignoring invalid data + +--- + +## 7. Testing Strategy +The testing approach focuses on correctness and determinism: + +- Unit tests for domain entities and invariants +- Comprehensive parser tests covering: + - Valid inputs + - Invalid formats + - Edge cases and error scenarios +- Tests are isolated, fast, and do not rely on external systems + +UnitTests to other Application behavior and Integration tests were intentionally omitted due to scope. + +--- + +## 8. Limitations +Known limitations include: + +- No caching of results between runs +- No dinamically parsing program args as currencies input +- No persistence layer +- No localization or formatting concerns addressed +- No retry backoff customization exposed via configuration + +These were accepted trade-offs given the scope of the challenge. + +--- + +## 9. Possible Improvements +With additional time, the following enhancements could be made: + +- Add integration tests with a mocked HTTP server +- Support additional rate providers via a common abstraction +- Expose richer observability (metrics, structured logs) + + +--- + +## 10. How to Run + +```bash +# restore dependencies +dotnet restore + +# build the solution +dotnet build + +# run unit tests +dotnet test + +# run the application +dotnet run --project src/Application.csproj \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Application.csproj b/jobs/Backend/Task/src/Application/Application.csproj index 1633502062..ef5185099c 100644 --- a/jobs/Backend/Task/src/Application/Application.csproj +++ b/jobs/Backend/Task/src/Application/Application.csproj @@ -10,4 +10,18 @@ + + + PreserveNewest + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs b/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs new file mode 100644 index 0000000000..2ae19ce256 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs @@ -0,0 +1,43 @@ +using Polly; +using Polly.Extensions.Http; +using Serilog; + +namespace ExchangeRateUpdater.Application.Configuration; + +public static class PollyPolicies +{ + public static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, context) => + { + Log.Warning("Retry {RetryCount} after {Delay}s due to {Exception}", + retryCount, timespan.TotalSeconds, outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()); + }); + } + + public static IAsyncPolicy GetCircuitBreakerPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (outcome, duration) => + { + Log.Error("Circuit breaker opened for {Duration}s", duration.TotalSeconds); + }, + onReset: () => + { + Log.Information("Circuit breaker reset"); + }); + } + + public static IAsyncPolicy GetTimeoutPolicy() => + Policy.TimeoutAsync(TimeSpan.FromSeconds(10)); + +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs new file mode 100644 index 0000000000..c5ed250bed --- /dev/null +++ b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs @@ -0,0 +1,49 @@ +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Application; + +public class ExchangeRateApp +{ + private readonly IExchangeRateProvider _provider; + private readonly ILogger _logger; + + public ExchangeRateApp( + IExchangeRateProvider provider, + ILogger logger) + { + _provider = provider; + _logger = logger; + } + + public async Task RunAsync(IEnumerable currencies) + { + try + { + var rates = await _provider.GetExchangeRatesAsync(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + + foreach (var rate in rates) + { + Console.WriteLine(rate); + } + + _logger.LogInformation( + "Application completed successfully. Retrieved {Count} exchange rates", + rates.Count() + ); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error while retrieving exchange rates"); + Console.WriteLine("Unable to retrieve exchange rates due to network error. Please check your connection."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while retrieving exchange rates"); + Console.WriteLine("Unable to retrieve exchange rates at this time. Please try again later."); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs index 33b02997cc..83919a408e 100644 --- a/jobs/Backend/Task/src/Application/Program.cs +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -1,40 +1,105 @@ -using ExchangeRateUpdater.Domain; +using Application.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Application.Configuration; +using ExchangeRateUpdater.Application.Providers.CzechNationalBank; +using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Domain.Entities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Serilog; -namespace ExchangeRateUpdater; +namespace ExchangeRateUpdater.Application; 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) + private static IEnumerable _currencies = + [ + 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 async Task Main(string[] args) { + ConfigureLogging(); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } + Log.Information("Starting Exchange Rate Updater Application"); + + var host = CreateHostBuilder(args).Build(); + + using var scope = host.Services.CreateScope(); + var app = scope.ServiceProvider.GetRequiredService(); + await app.RunAsync(_currencies); + + return 0; + } + catch (Exception ex) + { + Log.Fatal(ex, "Application terminated unexpectedly"); + Console.WriteLine($"Could not retrieve exchange rates: '{ex.Message}'."); + return 1; } - catch (Exception e) + finally { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + Log.CloseAndFlush(); } + } - Console.ReadLine(); + private static void ConfigureLogging() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseSerilog() + .UseDefaultServiceProvider(options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + } + ) + .ConfigureServices((context, services) => + { + services + .AddOptions() + .Bind(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)) + .Validate(opts => + { + opts.Validate(); + return true; + } + ); + + services.Configure(context.Configuration.GetSection("CnbProvider")); + + services.AddSingleton(sp => sp.GetRequiredService>().Value); + + services + .AddHttpClient() + .AddPolicyHandler(PollyPolicies.GetRetryPolicy()) + .AddPolicyHandler(PollyPolicies.GetCircuitBreakerPolicy()) + .AddPolicyHandler(PollyPolicies.GetTimeoutPolicy()); + + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + } + ); + } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs new file mode 100644 index 0000000000..2476eadc1e --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs @@ -0,0 +1,64 @@ +using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; +using Microsoft.Extensions.Logging; + +namespace Application.Providers.CzechNationalBank.Clients; + +/// +/// HTTP client for fetching Czech National Bank exchange rate data. +/// +public class CNBHttpClient : ICNBClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ProviderOptions _options; + + public CNBHttpClient( + HttpClient httpClient, + ProviderOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options; + _logger = logger; + + _httpClient.BaseAddress = new Uri(_options.BaseUrl); + } + + public async Task GetDailyRatesAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "Fetching daily exchange rates from CNB: {Url}", + new Uri(_httpClient.BaseAddress!, _options.DailyRatesPath) + ); + + try + { + var response = await _httpClient.GetAsync(_options.DailyRatesPath, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogInformation( + "Successfully retrieved exchange rates data ({Size} bytes)", + content.Length + ); + + _logger.LogDebug("Response content preview: {Preview}", + content.Length > 200 ? content.Substring(0, 200) + "..." : content + ); + + return content; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed while fetching exchange rates from CNB"); + throw; + } + catch (TaskCanceledException ex) + { + _logger.LogError(ex, "Request timed out while fetching exchange rates from CNB"); + throw; + } + } +} diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs new file mode 100644 index 0000000000..982c420ba0 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs @@ -0,0 +1,6 @@ +namespace Application.Providers.CzechNationalBank.Clients; + +public interface ICNBClient +{ + Task GetDailyRatesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs index d2b6419977..983332e12b 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -1,18 +1,42 @@ -using System; - -namespace ExchangeRateUpdater.Providers.CzechNationalBank.Configuration; +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; /// /// Configuration options for the Czech National Bank exchange rate provider. /// -public class ProviderOptions +public record ProviderOptions { - public const string SectionName = "CnbProvider"; - public string BaseUrl { get; set; } = "https://www.cnb.cz"; - public string DailyRatesPath { get; set; } = "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; + public static string ConfigurationSectionName => "CnbProvider"; + public string BaseUrl { get; set; } = string.Empty; + public string DailyRatesPath { get; set; } = string.Empty; public int TimeoutSeconds { get; set; } = 10; public int RetryCount { get; set; } = 3; - public string UserAgent { get; set; } = "ExchangeRateUpdater/1.0"; + public string UserAgent { get; set; } = string.Empty; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + throw new InvalidOperationException("BaseUrl is required"); + } + + if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _)) + { + throw new InvalidOperationException("BaseUrl must be a valid URL"); + } + + if (string.IsNullOrWhiteSpace(DailyRatesPath)) + { + throw new InvalidOperationException($"{nameof(DailyRatesPath)} is required"); + } + + if (TimeoutSeconds <= 0) + { + throw new InvalidOperationException($"{nameof(TimeoutSeconds)} must be positive"); + } - public string FullUrl => $"{BaseUrl.TrimEnd('/')}{DailyRatesPath}"; + if (RetryCount < 0) + { + throw new InvalidOperationException($"{nameof(RetryCount)} cannot be negative"); + } + } } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs deleted file mode 100644 index dd59aafd33..0000000000 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/CzechNationalBankHttpClient.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - - -/// -/// HTTP client for fetching Czech National Bank exchange rate data. -/// -public class CzechNationalBankHttpClient -{ - public async Task GetDailyRatesAsync(string url, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs new file mode 100644 index 0000000000..66292dd877 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; + +public class CnbParsingException(string message) : Exception(message) { } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs index cb7557e654..b06dd98df1 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -1,14 +1,91 @@ -using System; -using System.Collections.Generic; +using Application.Providers.CzechNationalBank.Clients; using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Domain.Entities; +using Microsoft.Extensions.Logging; -public class ExchangeRateProvider : IExchangeRateProvider +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; + +public class CzezhNationalBankExchangeRateProvider : IExchangeRateProvider { - /// - /// Returns exchange rates for specified currencies - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) + private static readonly Currency TargetCurrency = new("CZK"); + + private readonly ICNBClient _cnbClient; + private readonly IDailyExchangeRatesResponseParser _parser; + private readonly ILogger _logger; + + public CzezhNationalBankExchangeRateProvider( + ICNBClient cnbClient, + IDailyExchangeRatesResponseParser parser, + ILogger logger) + { + _cnbClient = cnbClient; + _parser = parser; + _logger = logger; + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + var currencyList = currencies.ToList(); + _logger.LogInformation("Fetching exchange rates for {Count} currencies: {Currencies}", + currencyList.Count, + string.Join(", ", currencyList.Select(c => c.Code))); + + try + { + var rawData = await _cnbClient.GetDailyRatesAsync(cancellationToken); + var data = _parser.Parse(rawData); + + var requestedCodes = new HashSet( + currencyList.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase + ); + + var exchangeRates = data + .ExchangeRates + .Where(model => requestedCodes.Contains(model.Code)) + .Select(DomainExchangeRate) + .ToList(); + + _logger.LogInformation("Successfully retrieved {Count} exchange rates out of {Requested} requested", + exchangeRates.Count, currencyList.Count); + + LogNotFoundCurrencies(currencyList, exchangeRates); + + return exchangeRates; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve exchange rates"); + throw; + } + } + + private void LogNotFoundCurrencies(List currencyList, List exchangeRates) { - throw new NotImplementedException(); + var foundCodes = new HashSet( + exchangeRates.Select(rate => rate.SourceCurrency.Code), + StringComparer.OrdinalIgnoreCase + ); + + var missingCurrencies = currencyList + .Where(rate => !foundCodes.Contains(rate.Code)) + .Select(rate => rate.Code) + .ToList(); + + if (missingCurrencies.Any()) + { + _logger.LogWarning("Could not find exchange rates for currencies: {Currencies}", + string.Join(", ", missingCurrencies) + ); + } } + + private static ExchangeRate DomainExchangeRate(Models.ExchangeRate model) => + new( + sourceCurrency: new Currency(model.Code), + targetCurrency: TargetCurrency, + value: model.Rate / model.Amount + ); } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs similarity index 58% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs rename to jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs index cecfd81a9b..22c1ed3c36 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyResponse.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs @@ -1,13 +1,13 @@ -namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; /// /// Represents the complete daily exchange rate data from CNB. /// /// The date of the exchange rates. /// Sequential number of the publication. Represents the number of working day according to Czech bank holidays -/// Collection of exchange rate records. -public record DailyResponse( +/// Collection of exchange rate records. +public record DailyExchangeRatesResponse( DateOnly Date, int Sequence, - IReadOnlyList Records + IReadOnlyList ExchangeRates ); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs similarity index 83% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs rename to jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs index 7e24575631..624c3192a9 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyRecord.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; /// /// Represents a single exchange rate record from the CNB daily file. @@ -8,7 +8,7 @@ namespace ExchangeRateUpdater.Providers.CzechNationalBank.Models; /// The amount of foreign currency (typically 1, 100, or 1000). /// Three-letter ISO 4217 currency code. /// Exchange rate to CZK for the specified amount. -public record DailyRecord( +public record ExchangeRate( string Country, string CurrencyName, int Amount, diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs similarity index 60% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs rename to jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs index fa33bd9fef..754674c9cc 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/IResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs @@ -1,11 +1,11 @@ -namespace ExchangeRateUpdater.Providers.CzechNationalBank; +using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; -using ExchangeRateUpdater.Providers.CzechNationalBank.Models; +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; /// /// Defines a contract for parsing CNB exchange rate data. /// -public interface IResponseParser +public interface IDailyExchangeRatesResponseParser { /// /// Parses raw CNB data into structured format. @@ -13,5 +13,5 @@ public interface IResponseParser /// Raw text data from CNB. /// Parsed exchange rate data. /// Thrown when data cannot be parsed. - DailyResponse Parse(string rawData); + DailyExchangeRatesResponse Parse(string rawData); } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs similarity index 89% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs rename to jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs index 2dabbd4a18..d3afae41f3 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs @@ -1,7 +1,7 @@ using System.Globalization; -using ExchangeRateUpdater.Providers.CzechNationalBank.Models; +using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; -namespace ExchangeRateUpdater.Providers.CzechNationalBank; +namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; /// /// Parses exchange rate data returned by the Czech National Bank daily rates endpoint. @@ -17,11 +17,11 @@ namespace ExchangeRateUpdater.Providers.CzechNationalBank; /// Source: /// https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt /// -public class PipeSeparatedResponseParser : IResponseParser +public sealed class PipeSeparatedResponseParser : IDailyExchangeRatesResponseParser { private static readonly char[] _newLineCharacters = ['\r', '\n']; private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; - public DailyResponse Parse(string rawData) + public DailyExchangeRatesResponse Parse(string rawData) { if (string.IsNullOrWhiteSpace(rawData)) { @@ -37,11 +37,11 @@ public DailyResponse Parse(string rawData) ValidateColumnNames(contents[1]); - return new DailyResponse + return new DailyExchangeRatesResponse ( Date: exchangeDate, Sequence: exchangeSequence, - Records: contents[2..] + ExchangeRates: contents[2..] .Select(ParseRecord) .ToArray() ); @@ -58,34 +58,16 @@ private static string[] GetContents(string rawData) return contents; } - private static DailyRecord ParseRecord(string line) + private static string[] GetHeaderParts(string header) { - var parts = line.Split('|', StringSplitOptions.TrimEntries); - - if (parts.Length != 5) - { - throw new CnbParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); - } - - var amountPart = parts[2]; - if (!int.TryParse(amountPart, out var amount)) - { - throw new CnbParsingException($"Invalid amount: '{amountPart}'"); - } + var headerParts = header.Split('#', StringSplitOptions.TrimEntries); - var ratePart = parts[4]; - if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + if (headerParts.Length != 2) { - throw new CnbParsingException($"Invalid rate: '{ratePart}'"); + throw new CnbParsingException($"Header is not in expected format. Header value: '{header}'."); } - return new DailyRecord( - Country: parts[0], - CurrencyName: parts[1], - Amount: amount, - Code: parts[3], - Rate: rate - ); + return headerParts; } private static DateOnly GetExchangeDate(string[] headerParts) @@ -110,26 +92,41 @@ private static int GetExchangeSequence(string[] headerParts) return sequence; } - private static string[] GetHeaderParts(string header) + private static void ValidateColumnNames(string columnNames) { - var headerParts = header.Split('#', StringSplitOptions.TrimEntries); - - if (headerParts.Length != 2) + if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) { - throw new CnbParsingException($"Header is not in expected format. Header value: '{header}'."); + throw new CnbParsingException($"Column names are not in expected format. Value: '{columnNames}'."); } - - return headerParts; } - private static void ValidateColumnNames(string columnNames) + private static ExchangeRate ParseRecord(string line) { - if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) + var parts = line.Split('|', StringSplitOptions.TrimEntries); + + if (parts.Length != 5) { - throw new CnbParsingException($"Column names are not in expected format. Value: '{columnNames}'."); + throw new CnbParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); } - } -} + var amountPart = parts[2]; + if (!int.TryParse(amountPart, out var amount)) + { + throw new CnbParsingException($"Invalid amount: '{amountPart}'"); + } + + var ratePart = parts[4]; + if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + { + throw new CnbParsingException($"Invalid rate: '{ratePart}'"); + } -public class CnbParsingException(string message) : Exception(message) { } \ No newline at end of file + return new ExchangeRate( + Country: parts[0], + CurrencyName: parts[1], + Amount: amount, + Code: parts[3], + Rate: rate + ); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/appsettings.json b/jobs/Backend/Task/src/Application/appsettings.json new file mode 100644 index 0000000000..2ea3296e68 --- /dev/null +++ b/jobs/Backend/Task/src/Application/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + }, + "CnbProvider": { + "BaseUrl": "https://www.cnb.cz", + "DailyRatesPath": "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 10, + "RetryCount": 3, + "UserAgent": "Chrome/120.0.0.0 Safari/537.36" + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Currency.cs b/jobs/Backend/Task/src/Domain/Currency.cs deleted file mode 100644 index ac3e1eff2f..0000000000 --- a/jobs/Backend/Task/src/Domain/Currency.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ExchangeRateUpdater.Domain; - -public class Currency -{ - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } -} diff --git a/jobs/Backend/Task/src/Domain/Entities/Currency.cs b/jobs/Backend/Task/src/Domain/Entities/Currency.cs new file mode 100644 index 0000000000..613defbab8 --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Entities/Currency.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +public class Currency +{ + public string Code { get; } + + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be empty", nameof(code)); + } + + if (code.Length != 3) + { + throw new ArgumentException("Currency code must be 3 characters", nameof(code)); + } + + Code = code.ToUpperInvariant(); + } + + public override string ToString() => Code; +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs new file mode 100644 index 0000000000..d923dcdb9e --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs @@ -0,0 +1,28 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +public record ExchangeRate +{ + public Currency SourceCurrency { get; } + + public Currency TargetCurrency { get; } + + public decimal Value { get; } + + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency)); + TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency)); + + if (value <= 0) + { + throw new ArgumentException("Exchange rate value must be positive", nameof(value)); + } + + Value = value; + } + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency} = {Value}"; + } +} diff --git a/jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs b/jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/jobs/Backend/Task/src/Domain/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/ExchangeRate.cs deleted file mode 100644 index babe901fee..0000000000 --- a/jobs/Backend/Task/src/Domain/ExchangeRate.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ExchangeRateUpdater.Domain; - -public class ExchangeRate -{ - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency} = {Value:F4}"; - } -} diff --git a/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs deleted file mode 100644 index b59d1c761b..0000000000 --- a/jobs/Backend/Task/src/Domain/IExchangeRateProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Collections.Generic; - -namespace ExchangeRateUpdater.Domain; - -public interface IExchangeRateProvider -{ - IEnumerable GetExchangeRates(IEnumerable currencies); -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..3d4c8ff796 --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.Domain; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs similarity index 84% rename from jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs rename to jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs index b1e4a3c992..ff686a3004 100644 --- a/jobs/Backend/Task/tests/Application.UnitTests/PipeSeparatedResponseParserTests.cs +++ b/jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs @@ -1,4 +1,4 @@ -using ExchangeRateUpdater.Providers.CzechNationalBank; +using ExchangeRateUpdater.Application.Providers.CzechNationalBank; namespace ExchangeRateUpdater.UnitTests; @@ -146,32 +146,32 @@ public void Parse_ShouldReturnExchangeRates_WhenDataIsValid() Assert.Equal(_testDate, actual.Date); Assert.Equal(_testSequence, actual.Sequence); - Assert.Equal(3, actual.Records.Count); + Assert.Equal(3, actual.ExchangeRates.Count); - Assert.Collection(actual.Records, - record => + Assert.Collection(actual.ExchangeRates, + result => { - Assert.Equal("Australia", record.Country); - Assert.Equal("dollar", record.CurrencyName); - Assert.Equal(1, record.Amount); - Assert.Equal("AUD", record.Code); - Assert.Equal(13.818m, record.Rate); + Assert.Equal("Australia", result.Country); + Assert.Equal("dollar", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("AUD", result.Code); + Assert.Equal(13.818m, result.Rate); }, - record => + result => { - Assert.Equal("Brazil", record.Country); - Assert.Equal("real", record.CurrencyName); - Assert.Equal(1, record.Amount); - Assert.Equal("BRL", record.Code); - Assert.Equal(3.694m, record.Rate); + Assert.Equal("Brazil", result.Country); + Assert.Equal("real", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("BRL", result.Code); + Assert.Equal(3.694m, result.Rate); }, - record => + result => { - Assert.Equal("Canada", record.Country); - Assert.Equal("dollar", record.CurrencyName); - Assert.Equal(1, record.Amount); - Assert.Equal("CAD", record.Code); - Assert.Equal(15.064m, record.Rate); + Assert.Equal("Canada", result.Country); + Assert.Equal("dollar", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("CAD", result.Code); + Assert.Equal(15.064m, result.Rate); } ); } @@ -192,7 +192,7 @@ public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() Assert.NotNull(actual); Assert.Equal(_testDate, actual.Date); Assert.Equal(_testSequence, actual.Sequence); - Assert.Single(actual.Records); + Assert.Single(actual.ExchangeRates); } private static void AssertThrowsWithMessage(Action act, string expectedMessage) diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs new file mode 100644 index 0000000000..1f7ed31437 --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs @@ -0,0 +1,32 @@ +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.UnitTests; + +public class CurrencyTests +{ + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsEmpty() + { + Assert.Throws(() => new Currency(string.Empty)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsWhitespace() + { + Assert.Throws(() => new Currency(" ")); + } + + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsNotThreeCharacters() + { + Assert.Throws(() => new Currency("US")); + Assert.Throws(() => new Currency("USDA")); + } + + [Fact] + public void Constructor_ShouldSetCode_ToUpperInvariant() + { + var currency = new Currency("usd"); + Assert.Equal("USD", currency.Code); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs new file mode 100644 index 0000000000..0cef5f660a --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs @@ -0,0 +1,48 @@ + +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.UnitTests; + +public class ExchangeRateTests +{ + [Fact] + public void Constructor_ShouldThrow_WhenSourceCurrencyIsNull() + { + var targetCurrency = new Currency("USD"); + var value = 1; + + Assert.Throws(() => new ExchangeRate(null, targetCurrency, value)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenTargetCurrencyIsNull() + { + var sourceCurrency = new Currency("USD"); + var value = 1; + + Assert.Throws(() => new ExchangeRate(sourceCurrency, null, value)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_ShouldThrow_WhenValueIsNotPositive(int value) + { + var sourceCurrency = new Currency("USD"); + var targetCurrency = new Currency("EUR"); + + Assert.Throws(() => new ExchangeRate(sourceCurrency, targetCurrency, value)); + } + + [Fact] + public void ToString_ShouldReturnCorrectFormat() + { + var sourceCurrency = new Currency("USD"); + var targetCurrency = new Currency("EUR"); + var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, 1.2m); + + var result = exchangeRate.ToString(); + + Assert.Equal("USD/EUR = 1.2", result); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj b/jobs/Backend/Task/tests/UnitTests/UnitTests.csproj similarity index 100% rename from jobs/Backend/Task/tests/Application.UnitTests/Application.UnitTests.csproj rename to jobs/Backend/Task/tests/UnitTests/UnitTests.csproj From 0ddae1d94a0f7a536f6b8b3884998fb2e114b90c Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 12:08:17 -0300 Subject: [PATCH 09/21] add small tweaks to readme and code --- jobs/Backend/Task/docs/readme.md | 79 +++++++++++-------- .../Configuration/ProviderOptions.cs | 10 +-- .../Task/src/Domain/Entities/ExchangeRate.cs | 14 ++-- .../Task/src/{sample_data => sample_data.txt} | 2 - .../Domain/Entities/ExchangeRateTests.cs | 4 +- 5 files changed, 60 insertions(+), 49 deletions(-) rename jobs/Backend/Task/src/{sample_data => sample_data.txt} (86%) diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md index ffea33b2e3..8efe28889d 100644 --- a/jobs/Backend/Task/docs/readme.md +++ b/jobs/Backend/Task/docs/readme.md @@ -1,6 +1,36 @@ # Exchange Rate Updater – Notes & Considerations +> **TL;DR** +> A clean, immutable, and test-focused solution that fetches and parses daily CNB exchange rates, emphasizing correctness, validation, and separation of concerns + +
+ + +### _How to Run The Application_ + +```bash +# restore dependencies +dotnet restore + +# build the solution +dotnet build + +# run unit tests +dotnet test + +# run the application +dotnet run --project src/Application/Application.csproj +``` + +
+
+ + ## 1. Problem Statement + +_See Challenge Instructions here: [Mews Backend .NET Challenge Instructions](https://github.com/MewsSystems/developers/blob/master/jobs/Backend/DotNet.md)_ + + The goal of this challenge is to fetch daily foreign exchange rates published by the Czech National Bank (CNB), parse the returned data, and expose the rates in a clean, domain-driven model. The CNB endpoint returns exchange rates as a plain-text, pipe-separated file containing: @@ -14,23 +44,25 @@ The solution is expected to: - Convert the raw data into strongly typed domain objects - Handle invalid or unexpected input gracefully ---- +
## 2. Assumptions The following assumptions were made to keep the solution focused and explicit: -- The CNB response format is stable and follows the documented structure +- The CNB response format is stable and follows the structure found in the [sample_data.txt](../src/sample_data.txt) - All exchange rate values are positive -- The target currency is implicitly CZK, as defined by the CNB source +- The application aims to display the convertion of 1 unit of given currency. Example: 1 EUR -> CZK +- The target currency is implicitly CZK, as implied by the [CNB source](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) - The full response fits comfortably in memory - This is a read-only, batch-style operation (no persistence required) - ---- +
## 3. Approach The solution is structured around a clear separation of concerns: +_It uses IoC and DI to allow new implementations to be added without breaking existing ones_ + 1. **HTTP Client Layer** - Responsible for retrieving raw data from the CNB endpoint - Configured with timeouts, retries, and resilience policies @@ -50,7 +82,7 @@ The solution is structured around a clear separation of concerns: The design favors clarity and correctness over premature optimization. ---- +
## 4. Design Decisions @@ -72,8 +104,7 @@ The design favors clarity and correctness over premature optimization. - HTTP calls are protected using retry and circuit breaker policies - Transient failures are handled without leaking infrastructure concerns into the domain - ---- +
## 5. Immutability & Modeling The following modeling strategy was applied: @@ -89,7 +120,7 @@ The following modeling strategy was applied: This distinction reflects the difference between *data* and *behavior* in the system. ---- +
## 6. Edge Cases & Error Handling The solution explicitly handles: @@ -105,7 +136,7 @@ When errors occur, the system: - Throws domain-specific exceptions with descriptive messages - Avoids silently ignoring invalid data ---- +
## 7. Testing Strategy The testing approach focuses on correctness and determinism: @@ -117,44 +148,26 @@ The testing approach focuses on correctness and determinism: - Edge cases and error scenarios - Tests are isolated, fast, and do not rely on external systems -UnitTests to other Application behavior and Integration tests were intentionally omitted due to scope. +Unit tests for higher-level application behavior and Integration tests were intentionally omitted due to scope. ---- +
## 8. Limitations Known limitations include: - No caching of results between runs -- No dinamically parsing program args as currencies input +- No dynamically parsing program args as currencies input - No persistence layer - No localization or formatting concerns addressed - No retry backoff customization exposed via configuration These were accepted trade-offs given the scope of the challenge. ---- +
## 9. Possible Improvements With additional time, the following enhancements could be made: - Add integration tests with a mocked HTTP server - Support additional rate providers via a common abstraction -- Expose richer observability (metrics, structured logs) - - ---- - -## 10. How to Run - -```bash -# restore dependencies -dotnet restore - -# build the solution -dotnet build - -# run unit tests -dotnet test - -# run the application -dotnet run --project src/Application.csproj \ No newline at end of file +- Expose richer observability (metrics, structured logs) \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs index 983332e12b..6e6db20183 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs +++ b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -6,11 +6,11 @@ namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configurat public record ProviderOptions { public static string ConfigurationSectionName => "CnbProvider"; - public string BaseUrl { get; set; } = string.Empty; - public string DailyRatesPath { get; set; } = string.Empty; - public int TimeoutSeconds { get; set; } = 10; - public int RetryCount { get; set; } = 3; - public string UserAgent { get; set; } = string.Empty; + public required string BaseUrl { get; set; } = string.Empty; + public required string DailyRatesPath { get; set; } = string.Empty; + public required int TimeoutSeconds { get; set; } = 10; + public required int RetryCount { get; set; } = 3; + public required string UserAgent { get; set; } = string.Empty; public void Validate() { diff --git a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs index d923dcdb9e..6693cd59a9 100644 --- a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs +++ b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs @@ -10,19 +10,19 @@ public record ExchangeRate public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { - SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency)); - TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency)); - + ArgumentNullException.ThrowIfNull(sourceCurrency, nameof(sourceCurrency)); + ArgumentNullException.ThrowIfNull(targetCurrency, nameof(targetCurrency)); if (value <= 0) { throw new ArgumentException("Exchange rate value must be positive", nameof(value)); } + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; Value = value; } - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency} = {Value}"; - } + public override string ToString() => + $"{SourceCurrency}/{TargetCurrency} = {Value}"; + } diff --git a/jobs/Backend/Task/src/sample_data b/jobs/Backend/Task/src/sample_data.txt similarity index 86% rename from jobs/Backend/Task/src/sample_data rename to jobs/Backend/Task/src/sample_data.txt index e00e5cbe3e..d6f580d4cb 100644 --- a/jobs/Backend/Task/src/sample_data +++ b/jobs/Backend/Task/src/sample_data.txt @@ -1,5 +1,3 @@ -//https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt - 23 Dec 2025 #248 Country|Currency|Amount|Code|Rate Australia|dollar|1|AUD|13.818 diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs index 0cef5f660a..f6fea0807e 100644 --- a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs @@ -11,7 +11,7 @@ public void Constructor_ShouldThrow_WhenSourceCurrencyIsNull() var targetCurrency = new Currency("USD"); var value = 1; - Assert.Throws(() => new ExchangeRate(null, targetCurrency, value)); + Assert.Throws(() => new ExchangeRate(null, targetCurrency, value)); } [Fact] @@ -20,7 +20,7 @@ public void Constructor_ShouldThrow_WhenTargetCurrencyIsNull() var sourceCurrency = new Currency("USD"); var value = 1; - Assert.Throws(() => new ExchangeRate(sourceCurrency, null, value)); + Assert.Throws(() => new ExchangeRate(sourceCurrency, null, value)); } [Theory] From f5b63d43f162b2747dafffe08fa171e49372625f Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 13:00:05 -0300 Subject: [PATCH 10/21] add tweaks to solution structure --- jobs/Backend/Task/ExchangeRateUpdater.slnx | 7 ++-- .../Task/{src => docs}/sample_data.txt | 0 .../Task/src/Application/ExchangeRateApp.cs | 2 +- ...=> ExchangeRateUpdater.Application.csproj} | 3 +- jobs/Backend/Task/src/Application/Program.cs | 16 +++++---- .../CzechNationalBank/Clients/ICNBClient.cs | 6 ---- .../Exceptions/CnbParsingException.cs | 3 -- .../IDailyExchangeRatesResponseParser.cs | 17 --------- .../Task/src/Domain/Entities/Currency.cs | 3 ++ .../Task/src/Domain/Entities/ExchangeRate.cs | 18 ++++++++++ .../Exceptions/ExchangeRateException.cs | 0 ...proj => ExchangeRateUpdater.Domain.csproj} | 0 .../Interfaces/IExchangeRateProvider.cs | 14 +++++++- .../Clients/CzechNationalBankHttpClient.cs} | 12 +++---- .../Clients/ICzechNationalBankClient.cs | 6 ++++ .../Configuration/ProviderOptions.cs | 2 +- .../CzechNationalBankParsingException.cs | 7 ++++ .../CzechNationalBank/ExchangeRateProvider.cs | 25 ++++++++----- ...Updater.Providers.CzechNationalBank.csproj | 17 +++++++++ .../Models/DailyExchangeRatesResponse.cs | 2 +- .../CzechNationalBank/Models/ExchangeRate.cs | 2 +- .../IDailyExchangeRatesResponseParser.cs | 20 +++++++++++ ...peSeparatedDailyExchangeResponseParser.cs} | 36 +++++++++---------- ...j => ExchangeRateUpdater.UnitTests.csproj} | 2 +- .../PipeSeparatedResponseParserTests.cs | 7 ++-- 25 files changed, 147 insertions(+), 80 deletions(-) rename jobs/Backend/Task/{src => docs}/sample_data.txt (100%) rename jobs/Backend/Task/src/Application/{Application.csproj => ExchangeRateUpdater.Application.csproj} (79%) delete mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs delete mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs delete mode 100644 jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs delete mode 100644 jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs rename jobs/Backend/Task/src/Domain/{Domain.csproj => ExchangeRateUpdater.Domain.csproj} (100%) rename jobs/Backend/Task/src/{Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs => Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs} (81%) create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs rename jobs/Backend/Task/src/{Application => Infrastructure}/Providers/CzechNationalBank/Configuration/ProviderOptions.cs (93%) create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs rename jobs/Backend/Task/src/{Application => Infrastructure}/Providers/CzechNationalBank/ExchangeRateProvider.cs (77%) create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj rename jobs/Backend/Task/src/{Application => Infrastructure}/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs (86%) rename jobs/Backend/Task/src/{Application => Infrastructure}/Providers/CzechNationalBank/Models/ExchangeRate.cs (87%) create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs rename jobs/Backend/Task/src/{Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs => Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs} (66%) rename jobs/Backend/Task/tests/UnitTests/{UnitTests.csproj => ExchangeRateUpdater.UnitTests.csproj} (86%) rename jobs/Backend/Task/tests/UnitTests/{Application => Infrastructure}/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs (95%) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx index b1242635d2..5a4e224552 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.slnx +++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx @@ -1,10 +1,11 @@ - - + + + - + \ No newline at end of file diff --git a/jobs/Backend/Task/src/sample_data.txt b/jobs/Backend/Task/docs/sample_data.txt similarity index 100% rename from jobs/Backend/Task/src/sample_data.txt rename to jobs/Backend/Task/docs/sample_data.txt diff --git a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs index c5ed250bed..3eb6145c91 100644 --- a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs +++ b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs @@ -1,5 +1,5 @@ -using ExchangeRateUpdater.Domain; using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Interfaces; using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater.Application; diff --git a/jobs/Backend/Task/src/Application/Application.csproj b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj similarity index 79% rename from jobs/Backend/Task/src/Application/Application.csproj rename to jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj index ef5185099c..5c544b1ce6 100644 --- a/jobs/Backend/Task/src/Application/Application.csproj +++ b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj @@ -7,7 +7,8 @@ - + + diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs index 83919a408e..9735f699ee 100644 --- a/jobs/Backend/Task/src/Application/Program.cs +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -1,9 +1,11 @@ -using Application.Providers.CzechNationalBank.Clients; -using ExchangeRateUpdater.Application.Configuration; -using ExchangeRateUpdater.Application.Providers.CzechNationalBank; -using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Application.Configuration; using ExchangeRateUpdater.Domain; using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -91,13 +93,13 @@ private static IHostBuilder CreateHostBuilder(string[] args) => services.AddSingleton(sp => sp.GetRequiredService>().Value); services - .AddHttpClient() + .AddHttpClient() .AddPolicyHandler(PollyPolicies.GetRetryPolicy()) .AddPolicyHandler(PollyPolicies.GetCircuitBreakerPolicy()) .AddPolicyHandler(PollyPolicies.GetTimeoutPolicy()); - services.AddSingleton(); - services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); services.AddTransient(); } ); diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs deleted file mode 100644 index 982c420ba0..0000000000 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/ICNBClient.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Application.Providers.CzechNationalBank.Clients; - -public interface ICNBClient -{ - Task GetDailyRatesAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs deleted file mode 100644 index 66292dd877..0000000000 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Exceptions/CnbParsingException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; - -public class CnbParsingException(string message) : Exception(message) { } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs b/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs deleted file mode 100644 index 754674c9cc..0000000000 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs +++ /dev/null @@ -1,17 +0,0 @@ -using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; - -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; - -/// -/// Defines a contract for parsing CNB exchange rate data. -/// -public interface IDailyExchangeRatesResponseParser -{ - /// - /// Parses raw CNB data into structured format. - /// - /// Raw text data from CNB. - /// Parsed exchange rate data. - /// Thrown when data cannot be parsed. - DailyExchangeRatesResponse Parse(string rawData); -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Entities/Currency.cs b/jobs/Backend/Task/src/Domain/Entities/Currency.cs index 613defbab8..a5d6ca9958 100644 --- a/jobs/Backend/Task/src/Domain/Entities/Currency.cs +++ b/jobs/Backend/Task/src/Domain/Entities/Currency.cs @@ -1,5 +1,8 @@ namespace ExchangeRateUpdater.Domain.Entities; +/// +/// Represents a currency using its ISO 4217 code. +/// public class Currency { public string Code { get; } diff --git a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs index 6693cd59a9..933f1870fc 100644 --- a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs +++ b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs @@ -1,5 +1,11 @@ namespace ExchangeRateUpdater.Domain.Entities; +/// +/// Represents an exchange rate between two currencies. +/// +/// +/// The value expresses how much of the target currency equals one unit of the source currency. +/// public record ExchangeRate { public Currency SourceCurrency { get; } @@ -8,6 +14,18 @@ public record ExchangeRate public decimal Value { get; } + /// + /// Creates a new exchange rate. + /// + /// The base currency. + /// The currency being quoted. + /// The exchange rate value. Must be greater than zero. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is less than or equal to zero. + /// public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { ArgumentNullException.ThrowIfNull(sourceCurrency, nameof(sourceCurrency)); diff --git a/jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs b/jobs/Backend/Task/src/Domain/Exceptions/ExchangeRateException.cs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/jobs/Backend/Task/src/Domain/Domain.csproj b/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj similarity index 100% rename from jobs/Backend/Task/src/Domain/Domain.csproj rename to jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj diff --git a/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs index 3d4c8ff796..f668048fba 100644 --- a/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs @@ -1,9 +1,21 @@ using ExchangeRateUpdater.Domain.Entities; -namespace ExchangeRateUpdater.Domain; +namespace ExchangeRateUpdater.Domain.Interfaces; +/// +/// Provides exchange rates from an external source. +/// public interface IExchangeRateProvider { + /// + /// Retrieves the latest available exchange rates. + /// + /// + /// A collection of exchange rates indexed by currency code. + /// + /// + /// Thrown when exchange rates cannot be retrieved. + /// Task> GetExchangeRatesAsync( IEnumerable currencies, CancellationToken cancellationToken = default); diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs similarity index 81% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs index 2476eadc1e..17efb0ba78 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Clients/CNBHttpClient.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs @@ -1,21 +1,21 @@ -using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; using Microsoft.Extensions.Logging; -namespace Application.Providers.CzechNationalBank.Clients; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; /// /// HTTP client for fetching Czech National Bank exchange rate data. /// -public class CNBHttpClient : ICNBClient +public class CzechNationalBankHttpClient : ICzechNationalBankClient { private readonly HttpClient _httpClient; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ProviderOptions _options; - public CNBHttpClient( + public CzechNationalBankHttpClient( HttpClient httpClient, ProviderOptions options, - ILogger logger) + ILogger logger) { _httpClient = httpClient; _options = options; diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs new file mode 100644 index 0000000000..78bc15c381 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; + +public interface ICzechNationalBankClient +{ + Task GetDailyRatesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs similarity index 93% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs index 6e6db20183..3094a14d3f 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Configuration/ProviderOptions.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Configuration; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; /// /// Configuration options for the Czech National Bank exchange rate provider. diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs new file mode 100644 index 0000000000..67c65c721b --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; + +/// +/// Represents errors that occur while parsing CNB exchange rate responses. +/// +public class CzechNationalBankParsingException(string message) : Exception(message) +{ } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs similarity index 77% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs index b06dd98df1..4b06cf6303 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -1,22 +1,29 @@ -using Application.Providers.CzechNationalBank.Clients; -using ExchangeRateUpdater.Domain; using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; using Microsoft.Extensions.Logging; -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; -public class CzezhNationalBankExchangeRateProvider : IExchangeRateProvider +/// +/// Exchange rate provider backed by the Czech National Bank daily rates feed. +/// +/// +/// This provider fetches and parses daily exchange rates published by the CNB. +/// +public class ExchangeRateProvider : IExchangeRateProvider { private static readonly Currency TargetCurrency = new("CZK"); - private readonly ICNBClient _cnbClient; + private readonly ICzechNationalBankClient _cnbClient; private readonly IDailyExchangeRatesResponseParser _parser; - private readonly ILogger _logger; + private readonly ILogger _logger; - public CzezhNationalBankExchangeRateProvider( - ICNBClient cnbClient, + public ExchangeRateProvider( + ICzechNationalBankClient cnbClient, IDailyExchangeRatesResponseParser parser, - ILogger logger) + ILogger logger) { _cnbClient = cnbClient; _parser = parser; diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj new file mode 100644 index 0000000000..48ad4c6712 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs similarity index 86% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs index 22c1ed3c36..68a8130e58 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; /// /// Represents the complete daily exchange rate data from CNB. diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs similarity index 87% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs index 624c3192a9..8a0ceaae34 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Models/ExchangeRate.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; /// /// Represents a single exchange rate record from the CNB daily file. diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs new file mode 100644 index 0000000000..720143e27e --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs @@ -0,0 +1,20 @@ +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; + +/// +/// Parses a raw daily exchange rates response into a structured model. +/// +public interface IDailyExchangeRatesResponseParser +{ + /// + /// Parses the raw response content. + /// + /// Raw response body returned by the provider. + /// The parsed daily exchange rates. + /// + /// Thrown when the response format is invalid or unsupported. + /// + DailyExchangeRatesResponse Parse(string rawData); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs similarity index 66% rename from jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs rename to jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs index d3afae41f3..39b86fcdb6 100644 --- a/jobs/Backend/Task/src/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParser.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs @@ -1,23 +1,21 @@ using System.Globalization; -using ExchangeRateUpdater.Application.Providers.CzechNationalBank.Models; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; -namespace ExchangeRateUpdater.Application.Providers.CzechNationalBank; +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; /// /// Parses exchange rate data returned by the Czech National Bank daily rates endpoint. /// /// -/// Expected format (simplified): -/// +/// Expected format: +/// /// 23 Dec 2025 #248 /// Country|Currency|Amount|Code|Rate -// Australia|dollar|1|AUD|13.818 -// Brazil|real|1|BRL|3.694 -/// -/// Source: -/// https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt +/// Australia|dollar|1|AUD|13.818 +/// /// -public sealed class PipeSeparatedResponseParser : IDailyExchangeRatesResponseParser +public sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser { private static readonly char[] _newLineCharacters = ['\r', '\n']; private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; @@ -25,7 +23,7 @@ public DailyExchangeRatesResponse Parse(string rawData) { if (string.IsNullOrWhiteSpace(rawData)) { - throw new CnbParsingException("Raw data is empty, null or white space."); + throw new CzechNationalBankParsingException("Raw data is empty, null or white space."); } var contents = GetContents(rawData); @@ -52,7 +50,7 @@ private static string[] GetContents(string rawData) var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); if (contents.Length < 3) { - throw new CnbParsingException($"Response does not contain enough lines. Lines found: {contents.Length}."); + throw new CzechNationalBankParsingException($"Response does not contain enough lines. Lines found: {contents.Length}."); } return contents; @@ -64,7 +62,7 @@ private static string[] GetHeaderParts(string header) if (headerParts.Length != 2) { - throw new CnbParsingException($"Header is not in expected format. Header value: '{header}'."); + throw new CzechNationalBankParsingException($"Header is not in expected format. Header value: '{header}'."); } return headerParts; @@ -75,7 +73,7 @@ private static DateOnly GetExchangeDate(string[] headerParts) var value = headerParts[0]; if (!DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { - throw new CnbParsingException($"Header date is not in expected format. Value: '{value}'."); + throw new CzechNationalBankParsingException($"Header date is not in expected format. Value: '{value}'."); } return date; @@ -86,7 +84,7 @@ private static int GetExchangeSequence(string[] headerParts) var value = headerParts[1]; if (!int.TryParse(value, out var sequence)) { - throw new CnbParsingException($"Header sequence is not in expected format. Value: '{value}'."); + throw new CzechNationalBankParsingException($"Header sequence is not in expected format. Value: '{value}'."); } return sequence; @@ -96,7 +94,7 @@ private static void ValidateColumnNames(string columnNames) { if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) { - throw new CnbParsingException($"Column names are not in expected format. Value: '{columnNames}'."); + throw new CzechNationalBankParsingException($"Column names are not in expected format. Value: '{columnNames}'."); } } @@ -106,19 +104,19 @@ private static ExchangeRate ParseRecord(string line) if (parts.Length != 5) { - throw new CnbParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); + throw new CzechNationalBankParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); } var amountPart = parts[2]; if (!int.TryParse(amountPart, out var amount)) { - throw new CnbParsingException($"Invalid amount: '{amountPart}'"); + throw new CzechNationalBankParsingException($"Invalid amount: '{amountPart}'"); } var ratePart = parts[4]; if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) { - throw new CnbParsingException($"Invalid rate: '{ratePart}'"); + throw new CzechNationalBankParsingException($"Invalid rate: '{ratePart}'"); } return new ExchangeRate( diff --git a/jobs/Backend/Task/tests/UnitTests/UnitTests.csproj b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj similarity index 86% rename from jobs/Backend/Task/tests/UnitTests/UnitTests.csproj rename to jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj index 4632e4c209..18b01414aa 100644 --- a/jobs/Backend/Task/tests/UnitTests/UnitTests.csproj +++ b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -14,7 +14,7 @@ - + diff --git a/jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs similarity index 95% rename from jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs rename to jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs index ff686a3004..7e8da2f55b 100644 --- a/jobs/Backend/Task/tests/UnitTests/Application/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs +++ b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs @@ -1,10 +1,11 @@ -using ExchangeRateUpdater.Application.Providers.CzechNationalBank; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; namespace ExchangeRateUpdater.UnitTests; public class PipeSeparatedResponseParserTests { - private readonly PipeSeparatedResponseParser _sut = new(); + private readonly PipeSeparatedDailyExchangeResponseParser _sut = new(); private readonly DateOnly _testDate = new(2025, 12, 23); private const int _testSequence = 248; private const string _validHeader = "23 Dec 2025 #248"; @@ -197,7 +198,7 @@ public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() private static void AssertThrowsWithMessage(Action act, string expectedMessage) { - var ex = Assert.Throws(act); + var ex = Assert.Throws(act); Assert.Contains(expectedMessage, ex.Message); } } \ No newline at end of file From ca37b0fe8c11e75e3af7d42456b4c73408f5377c Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 13:02:29 -0300 Subject: [PATCH 11/21] update readme.md --- jobs/Backend/Task/docs/readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md index 8efe28889d..f1b6a7b26b 100644 --- a/jobs/Backend/Task/docs/readme.md +++ b/jobs/Backend/Task/docs/readme.md @@ -16,10 +16,10 @@ dotnet restore dotnet build # run unit tests -dotnet test + dotnet test # run the application -dotnet run --project src/Application/Application.csproj +dotnet run --project src/Application/ExchangeRateUpdater.Application.csproj ```
@@ -49,7 +49,7 @@ The solution is expected to: ## 2. Assumptions The following assumptions were made to keep the solution focused and explicit: -- The CNB response format is stable and follows the structure found in the [sample_data.txt](../src/sample_data.txt) +- The CNB response format is stable and follows the structure found in the [sample_data.txt](sample_data.txt) - All exchange rate values are positive - The application aims to display the convertion of 1 unit of given currency. Example: 1 EUR -> CZK - The target currency is implicitly CZK, as implied by the [CNB source](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) From 29b741ba8fb869e3ff1feeb9c82fa307a26c52fa Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 13:33:27 -0300 Subject: [PATCH 12/21] add registration extension for infrastructure for better incapsulation and separation of concerns --- jobs/Backend/Task/src/Application/Program.cs | 24 +---------- .../Clients/CzechNationalBankHttpClient.cs | 2 +- .../Configuration/ProviderOptions.cs | 2 +- .../CzechNationalBank/ExchangeRateProvider.cs | 2 +- ...Updater.Providers.CzechNationalBank.csproj | 12 ++++++ ...ipeSeparatedDailyExchangeResponseParser.cs | 2 +- .../CzechNationalBank/Registration.cs | 40 +++++++++++++++++++ 7 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs index 9735f699ee..8300f4495e 100644 --- a/jobs/Backend/Task/src/Application/Program.cs +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -1,14 +1,8 @@ using ExchangeRateUpdater.Application.Configuration; -using ExchangeRateUpdater.Domain; using ExchangeRateUpdater.Domain.Entities; -using ExchangeRateUpdater.Domain.Interfaces; using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; -using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; -using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; -using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using Serilog; namespace ExchangeRateUpdater.Application; @@ -79,27 +73,11 @@ private static IHostBuilder CreateHostBuilder(string[] args) => .ConfigureServices((context, services) => { services - .AddOptions() - .Bind(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)) - .Validate(opts => - { - opts.Validate(); - return true; - } - ); - - services.Configure(context.Configuration.GetSection("CnbProvider")); - - services.AddSingleton(sp => sp.GetRequiredService>().Value); - - services - .AddHttpClient() + .UseCzechNationalBankProvider(context) .AddPolicyHandler(PollyPolicies.GetRetryPolicy()) .AddPolicyHandler(PollyPolicies.GetCircuitBreakerPolicy()) .AddPolicyHandler(PollyPolicies.GetTimeoutPolicy()); - services.AddSingleton(); - services.AddScoped(); services.AddTransient(); } ); diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs index 17efb0ba78..8e284eec7a 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs @@ -6,7 +6,7 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients /// /// HTTP client for fetching Czech National Bank exchange rate data. /// -public class CzechNationalBankHttpClient : ICzechNationalBankClient +sealed internal class CzechNationalBankHttpClient : ICzechNationalBankClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs index 3094a14d3f..f20d00af19 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -3,7 +3,7 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configu /// /// Configuration options for the Czech National Bank exchange rate provider. /// -public record ProviderOptions +internal record ProviderOptions { public static string ConfigurationSectionName => "CnbProvider"; public required string BaseUrl { get; set; } = string.Empty; diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs index 4b06cf6303..ae297cb4b9 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -12,7 +12,7 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; /// /// This provider fetches and parses daily exchange rates published by the CNB. /// -public class ExchangeRateProvider : IExchangeRateProvider +internal class ExchangeRateProvider : IExchangeRateProvider { private static readonly Currency TargetCurrency = new("CZK"); diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj index 48ad4c6712..d7c8ed585f 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj @@ -6,12 +6,24 @@ enable + + + <_Parameter1>ExchangeRateUpdater.UnitTests + + + + + + + + + diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs index 39b86fcdb6..206adc0428 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs @@ -15,7 +15,7 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers /// Australia|dollar|1|AUD|13.818 /// /// -public sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser +internal sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser { private static readonly char[] _newLineCharacters = ['\r', '\n']; private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs new file mode 100644 index 0000000000..f6d4887bf9 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; + + +public static class Registration +{ + public static IHttpClientBuilder UseCzechNationalBankProvider( + this IServiceCollection services, + HostBuilderContext context) + { + services + .AddOptions() + .Bind(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)) + .Validate(opts => + { + opts.Validate(); + return true; + } + ); + + services.Configure(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)); + + services.AddSingleton(sp => sp.GetRequiredService>().Value); + + + services + .AddSingleton() + .AddScoped(); + + return services + .AddHttpClient(); + } +} \ No newline at end of file From aee0be3bd99c9c509bd07dd5aec7cf6bbe91c820 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 13:36:51 -0300 Subject: [PATCH 13/21] adds catch for CzechNationalBankParsingException --- .../Providers/CzechNationalBank/ExchangeRateProvider.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs index ae297cb4b9..4b610fb40d 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -1,6 +1,7 @@ using ExchangeRateUpdater.Domain.Entities; using ExchangeRateUpdater.Domain.Interfaces; using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; using Microsoft.Extensions.Logging; @@ -62,6 +63,11 @@ public async Task> GetExchangeRatesAsync( return exchangeRates; } + catch (CzechNationalBankParsingException ex) + { + _logger.LogError(ex, "Failed to parse exchange rates from Czech National Bank response"); + throw; + } catch (Exception ex) { _logger.LogError(ex, "Failed to retrieve exchange rates"); From ba308351640755b02bd998880df968fe2284c9c4 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 14:10:59 -0300 Subject: [PATCH 14/21] remove magic numbers --- .../PipeSeparatedDailyExchangeResponseParser.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs index 206adc0428..b5d00d0e51 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs @@ -18,6 +18,9 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers internal sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser { private static readonly char[] _newLineCharacters = ['\r', '\n']; + private const int _expectedColumnCount = 5; + private const int _minimumLineCount = 3; // Header + Column names + at least one record + private const int _expectedHeaderPartsCount = 2; private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; public DailyExchangeRatesResponse Parse(string rawData) { @@ -48,7 +51,7 @@ public DailyExchangeRatesResponse Parse(string rawData) private static string[] GetContents(string rawData) { var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); - if (contents.Length < 3) + if (contents.Length < _minimumLineCount) { throw new CzechNationalBankParsingException($"Response does not contain enough lines. Lines found: {contents.Length}."); } @@ -60,7 +63,7 @@ private static string[] GetHeaderParts(string header) { var headerParts = header.Split('#', StringSplitOptions.TrimEntries); - if (headerParts.Length != 2) + if (headerParts.Length != _expectedHeaderPartsCount) { throw new CzechNationalBankParsingException($"Header is not in expected format. Header value: '{header}'."); } @@ -102,9 +105,9 @@ private static ExchangeRate ParseRecord(string line) { var parts = line.Split('|', StringSplitOptions.TrimEntries); - if (parts.Length != 5) + if (parts.Length != _expectedColumnCount) { - throw new CzechNationalBankParsingException($"Invalid record format: '{line}'. Expected 5 pipe-separated columns."); + throw new CzechNationalBankParsingException($"Invalid record format: '{line}'. Expected {_expectedColumnCount} pipe-separated columns."); } var amountPart = parts[2]; From 161bc6576868f122c57d11003c5efa3106ddebc5 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 14:11:14 -0300 Subject: [PATCH 15/21] made currecy a record --- jobs/Backend/Task/src/Domain/Entities/Currency.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/Backend/Task/src/Domain/Entities/Currency.cs b/jobs/Backend/Task/src/Domain/Entities/Currency.cs index a5d6ca9958..fc0e244608 100644 --- a/jobs/Backend/Task/src/Domain/Entities/Currency.cs +++ b/jobs/Backend/Task/src/Domain/Entities/Currency.cs @@ -3,7 +3,7 @@ /// /// Represents a currency using its ISO 4217 code. /// -public class Currency +public record Currency { public string Code { get; } From b0cc12b85ef23bbe8d666a57bb103d32792c6bb3 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 14:11:46 -0300 Subject: [PATCH 16/21] remove duplicated registration --- .../Infrastructure/Providers/CzechNationalBank/Registration.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs index f6d4887bf9..33951cc0c1 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs @@ -25,11 +25,8 @@ public static IHttpClientBuilder UseCzechNationalBankProvider( } ); - services.Configure(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)); - services.AddSingleton(sp => sp.GetRequiredService>().Value); - services .AddSingleton() .AddScoped(); From a1548756c36fedb37e1fce9957229d793cd01288 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 14:12:12 -0300 Subject: [PATCH 17/21] add currency equality test --- .../Domain/Entities/CurrencyTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs index 1f7ed31437..15e4151a62 100644 --- a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs @@ -29,4 +29,23 @@ public void Constructor_ShouldSetCode_ToUpperInvariant() var currency = new Currency("usd"); Assert.Equal("USD", currency.Code); } + + [Fact] + public void TwoCurrencies_WithSameCode_ShouldBeEqual() + { + var currency1 = new Currency("USD"); + var currency2 = new Currency("USD"); + + Assert.Equal(currency1, currency2); + Assert.True(currency1 == currency2); + } + + [Fact] + public void Currency_ShouldBeCaseInsensitive() + { + var currency1 = new Currency("USD"); + var currency2 = new Currency("usd"); + + Assert.Equal(currency1, currency2); + } } \ No newline at end of file From 0e52741a4e3993a6b480f18c475a21e85895c72b Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 16:17:17 -0300 Subject: [PATCH 18/21] move polly policies to infrastructure and reads its values from appsettings --- .../Configuration/PollyPolicies.cs | 43 ----------------- .../ExchangeRateUpdater.Application.csproj | 1 - jobs/Backend/Task/src/Application/Program.cs | 8 +--- .../Task/src/Application/appsettings.json | 1 + .../Clients/CzechNationalBankHttpClient.cs | 1 + .../Configuration/PollyPolicies.cs | 46 +++++++++++++++++++ .../Configuration/ProviderOptions.cs | 6 +++ .../CzechNationalBank/ExchangeRateProvider.cs | 12 ++--- ...Updater.Providers.CzechNationalBank.csproj | 6 +-- .../CzechNationalBank/Registration.cs | 12 +++-- 10 files changed, 73 insertions(+), 63 deletions(-) delete mode 100644 jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs create mode 100644 jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs diff --git a/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs b/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs deleted file mode 100644 index 2ae19ce256..0000000000 --- a/jobs/Backend/Task/src/Application/Configuration/PollyPolicies.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Polly; -using Polly.Extensions.Http; -using Serilog; - -namespace ExchangeRateUpdater.Application.Configuration; - -public static class PollyPolicies -{ - public static IAsyncPolicy GetRetryPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .WaitAndRetryAsync( - retryCount: 3, - sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), - onRetry: (outcome, timespan, retryCount, context) => - { - Log.Warning("Retry {RetryCount} after {Delay}s due to {Exception}", - retryCount, timespan.TotalSeconds, outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()); - }); - } - - public static IAsyncPolicy GetCircuitBreakerPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .CircuitBreakerAsync( - handledEventsAllowedBeforeBreaking: 5, - durationOfBreak: TimeSpan.FromSeconds(30), - onBreak: (outcome, duration) => - { - Log.Error("Circuit breaker opened for {Duration}s", duration.TotalSeconds); - }, - onReset: () => - { - Log.Information("Circuit breaker reset"); - }); - } - - public static IAsyncPolicy GetTimeoutPolicy() => - Policy.TimeoutAsync(TimeSpan.FromSeconds(10)); - -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj index 5c544b1ce6..b0b8f22fef 100644 --- a/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj @@ -20,7 +20,6 @@ - diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs index 8300f4495e..ebce75ae35 100644 --- a/jobs/Backend/Task/src/Application/Program.cs +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -1,5 +1,4 @@ -using ExchangeRateUpdater.Application.Configuration; -using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Entities; using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -73,10 +72,7 @@ private static IHostBuilder CreateHostBuilder(string[] args) => .ConfigureServices((context, services) => { services - .UseCzechNationalBankProvider(context) - .AddPolicyHandler(PollyPolicies.GetRetryPolicy()) - .AddPolicyHandler(PollyPolicies.GetCircuitBreakerPolicy()) - .AddPolicyHandler(PollyPolicies.GetTimeoutPolicy()); + .UseCzechNationalBankProvider(context); services.AddTransient(); } diff --git a/jobs/Backend/Task/src/Application/appsettings.json b/jobs/Backend/Task/src/Application/appsettings.json index 2ea3296e68..fe1c0141a0 100644 --- a/jobs/Backend/Task/src/Application/appsettings.json +++ b/jobs/Backend/Task/src/Application/appsettings.json @@ -10,6 +10,7 @@ "BaseUrl": "https://www.cnb.cz", "DailyRatesPath": "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", "TimeoutSeconds": 10, + "DurationOfCircuitBreakSeconds": 30, "RetryCount": 3, "UserAgent": "Chrome/120.0.0.0 Safari/537.36" } diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs index 8e284eec7a..f2f2ececa2 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs @@ -22,6 +22,7 @@ public CzechNationalBankHttpClient( _logger = logger; _httpClient.BaseAddress = new Uri(_options.BaseUrl); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_options.UserAgent); } public async Task GetDailyRatesAsync(CancellationToken cancellationToken = default) diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs new file mode 100644 index 0000000000..11a92c87ba --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; + +internal sealed class PollyPolicies( + ProviderOptions options, + ILogger logger) +{ + public IAsyncPolicy RetryPolicy => + HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: options.RetryCount, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, _) => + { + logger.LogWarning( + "Retry {RetryCount} after {Delay}s due to {Reason}", + retryCount, + timespan.TotalSeconds, + outcome.Exception?.Message + ?? outcome.Result.StatusCode.ToString()); + }); + + public IAsyncPolicy CircuitBreakerPolicy => + HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: options.RetryCount, + durationOfBreak: TimeSpan.FromSeconds(options.DurationOfCircuitBreakSeconds), + onBreak: (_, duration) => + { + logger.LogError("Circuit breaker opened for {Duration}s", duration.TotalSeconds); + }, + onReset: () => + { + logger.LogInformation("Circuit breaker reset"); + }); + + public IAsyncPolicy TimeoutPolicy => + Policy.TimeoutAsync( + TimeSpan.FromSeconds(options.TimeoutSeconds)); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs index f20d00af19..4694caf3a4 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -9,6 +9,7 @@ internal record ProviderOptions public required string BaseUrl { get; set; } = string.Empty; public required string DailyRatesPath { get; set; } = string.Empty; public required int TimeoutSeconds { get; set; } = 10; + public required int DurationOfCircuitBreakSeconds { get; set; } = 30; public required int RetryCount { get; set; } = 3; public required string UserAgent { get; set; } = string.Empty; @@ -34,6 +35,11 @@ public void Validate() throw new InvalidOperationException($"{nameof(TimeoutSeconds)} must be positive"); } + if (DurationOfCircuitBreakSeconds <= 0) + { + throw new InvalidOperationException($"{nameof(DurationOfCircuitBreakSeconds)} must be positive"); + } + if (RetryCount < 0) { throw new InvalidOperationException($"{nameof(RetryCount)} cannot be negative"); diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs index 4b610fb40d..50b57f1770 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -15,18 +15,18 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; /// internal class ExchangeRateProvider : IExchangeRateProvider { - private static readonly Currency TargetCurrency = new("CZK"); + private static readonly Currency _targetCurrency = new("CZK"); - private readonly ICzechNationalBankClient _cnbClient; + private readonly ICzechNationalBankClient _client; private readonly IDailyExchangeRatesResponseParser _parser; private readonly ILogger _logger; public ExchangeRateProvider( - ICzechNationalBankClient cnbClient, + ICzechNationalBankClient client, IDailyExchangeRatesResponseParser parser, ILogger logger) { - _cnbClient = cnbClient; + _client = client; _parser = parser; _logger = logger; } @@ -42,7 +42,7 @@ public async Task> GetExchangeRatesAsync( try { - var rawData = await _cnbClient.GetDailyRatesAsync(cancellationToken); + var rawData = await _client.GetDailyRatesAsync(cancellationToken); var data = _parser.Parse(rawData); var requestedCodes = new HashSet( @@ -98,7 +98,7 @@ private void LogNotFoundCurrencies(List currencyList, List new( sourceCurrency: new Currency(model.Code), - targetCurrency: TargetCurrency, + targetCurrency: _targetCurrency, value: model.Rate / model.Amount ); } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj index d7c8ed585f..7b76e29bb1 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj @@ -19,11 +19,9 @@ - - - - + + diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs index 33951cc0c1..de48174f84 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs @@ -11,7 +11,7 @@ namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; public static class Registration { - public static IHttpClientBuilder UseCzechNationalBankProvider( + public static IServiceCollection UseCzechNationalBankProvider( this IServiceCollection services, HostBuilderContext context) { @@ -31,7 +31,13 @@ public static IHttpClientBuilder UseCzechNationalBankProvider( .AddSingleton() .AddScoped(); - return services - .AddHttpClient(); + services + .AddSingleton() + .AddHttpClient() + .AddPolicyHandler((sp, _) => sp.GetRequiredService().RetryPolicy) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().CircuitBreakerPolicy) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().TimeoutPolicy); + + return services; } } \ No newline at end of file From a5a9bb1603376e8786f859de1aa9e3c91d5476fc Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Thu, 25 Dec 2025 20:40:27 -0300 Subject: [PATCH 19/21] cosmetic refactorings --- .../Task/src/Application/ExchangeRateApp.cs | 21 +++++-------------- .../CzechNationalBank/ExchangeRateProvider.cs | 16 +++++++++----- ...ipeSeparatedDailyExchangeResponseParser.cs | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs index 3eb6145c91..66f3c32820 100644 --- a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs +++ b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs @@ -4,24 +4,13 @@ namespace ExchangeRateUpdater.Application; -public class ExchangeRateApp +public class ExchangeRateApp(IExchangeRateProvider provider, ILogger logger) { - private readonly IExchangeRateProvider _provider; - private readonly ILogger _logger; - - public ExchangeRateApp( - IExchangeRateProvider provider, - ILogger logger) - { - _provider = provider; - _logger = logger; - } - public async Task RunAsync(IEnumerable currencies) { try { - var rates = await _provider.GetExchangeRatesAsync(currencies); + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); @@ -30,19 +19,19 @@ public async Task RunAsync(IEnumerable currencies) Console.WriteLine(rate); } - _logger.LogInformation( + logger.LogInformation( "Application completed successfully. Retrieved {Count} exchange rates", rates.Count() ); } catch (HttpRequestException ex) { - _logger.LogError(ex, "Network error while retrieving exchange rates"); + logger.LogError(ex, "Network error while retrieving exchange rates"); Console.WriteLine("Unable to retrieve exchange rates due to network error. Please check your connection."); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error while retrieving exchange rates"); + logger.LogError(ex, "Unexpected error while retrieving exchange rates"); Console.WriteLine("Unable to retrieve exchange rates at this time. Please try again later."); } } diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs index 50b57f1770..586e4f9183 100644 --- a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -36,9 +36,11 @@ public async Task> GetExchangeRatesAsync( CancellationToken cancellationToken = default) { var currencyList = currencies.ToList(); - _logger.LogInformation("Fetching exchange rates for {Count} currencies: {Currencies}", + _logger.LogInformation( + "Fetching exchange rates for {Count} currencies: {Currencies}", currencyList.Count, - string.Join(", ", currencyList.Select(c => c.Code))); + string.Join(", ", currencyList.Select(c => c.Code)) + ); try { @@ -56,8 +58,11 @@ public async Task> GetExchangeRatesAsync( .Select(DomainExchangeRate) .ToList(); - _logger.LogInformation("Successfully retrieved {Count} exchange rates out of {Requested} requested", - exchangeRates.Count, currencyList.Count); + _logger.LogInformation( + "Successfully retrieved {Count} exchange rates out of {Requested} requested", + exchangeRates.Count, + currencyList.Count + ); LogNotFoundCurrencies(currencyList, exchangeRates); @@ -89,7 +94,8 @@ private void LogNotFoundCurrencies(List currencyList, List Date: Thu, 25 Dec 2025 20:55:39 -0300 Subject: [PATCH 20/21] update readme --- jobs/Backend/Task/docs/readme.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md index f1b6a7b26b..872e66a8cc 100644 --- a/jobs/Backend/Task/docs/readme.md +++ b/jobs/Backend/Task/docs/readme.md @@ -169,5 +169,8 @@ These were accepted trade-offs given the scope of the challenge. With additional time, the following enhancements could be made: - Add integration tests with a mocked HTTP server +- Implement better Domain Exceptions for proper retries / logging +- Implement greceful failures for malformed Exchange Rates +- Consider adding caching - Support additional rate providers via a common abstraction - Expose richer observability (metrics, structured logs) \ No newline at end of file From 1775f577f504854803c25fbecfbca616bb00ab41 Mon Sep 17 00:00:00 2001 From: Pablo Carvalho Date: Fri, 26 Dec 2025 21:51:21 -0300 Subject: [PATCH 21/21] update readme --- jobs/Backend/Task/docs/readme.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md index 872e66a8cc..2967902d90 100644 --- a/jobs/Backend/Task/docs/readme.md +++ b/jobs/Backend/Task/docs/readme.md @@ -9,6 +9,9 @@ ### _How to Run The Application_ ```bash +# navigate to src/Application folder +cd src/Application + # restore dependencies dotnet restore @@ -16,10 +19,10 @@ dotnet restore dotnet build # run unit tests - dotnet test +dotnet test # run the application -dotnet run --project src/Application/ExchangeRateUpdater.Application.csproj +dotnet run --project ExchangeRateUpdater.Application.csproj ```