From 2812baf766dd1538a6cb30fd1f695f0b2710e596 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 10:09:18 +0200 Subject: [PATCH 01/20] Migrate target framework from net6.0 to net10.0 .NET 6 reached EOL in November 2024. Upgrading to .NET 10 LTS which is supported until November 2028. Co-Authored-By: Claude Sonnet 4.6 --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..38170f35e0 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 \ No newline at end of file From c5da622e127ddb7a2e8ce9f7f21de87650e33ccf Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:32:11 +0200 Subject: [PATCH 02/20] chore: scaffold net10 solution and domain value objects --- jobs/Backend/Task.Tests/Task.Tests.csproj | 11 ++++++ jobs/Backend/Task/Currency.cs | 20 ----------- jobs/Backend/Task/Domain/Currency.cs | 16 +++++++++ jobs/Backend/Task/Domain/ExchangeRate.cs | 22 ++++++++++++ jobs/Backend/Task/ExchangeRate.cs | 23 ------------- jobs/Backend/Task/ExchangeRateProvider.cs | 19 ----------- jobs/Backend/Task/ExchangeRateUpdater.sln | 11 ++++-- jobs/Backend/Task/Program.cs | 41 ++--------------------- jobs/Backend/Task/appsettings.json | 8 +++++ 9 files changed, 68 insertions(+), 103 deletions(-) create mode 100644 jobs/Backend/Task.Tests/Task.Tests.csproj delete mode 100644 jobs/Backend/Task/Currency.cs create mode 100644 jobs/Backend/Task/Domain/Currency.cs create mode 100644 jobs/Backend/Task/Domain/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task.Tests/Task.Tests.csproj b/jobs/Backend/Task.Tests/Task.Tests.csproj new file mode 100644 index 0000000000..8f42f31950 --- /dev/null +++ b/jobs/Backend/Task.Tests/Task.Tests.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + 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/Domain/Currency.cs b/jobs/Backend/Task/Domain/Currency.cs new file mode 100644 index 0000000000..5070e6deb2 --- /dev/null +++ b/jobs/Backend/Task/Domain/Currency.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Domain; + +public sealed class Currency +{ + public Currency(string code) + { + Code = code; + } + + public string Code { get; } + + public override string ToString() + { + return Code; + } +} diff --git a/jobs/Backend/Task/Domain/ExchangeRate.cs b/jobs/Backend/Task/Domain/ExchangeRate.cs new file mode 100644 index 0000000000..e36191aaef --- /dev/null +++ b/jobs/Backend/Task/Domain/ExchangeRate.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateUpdater.Domain; + +public sealed 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/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.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..5d12a66bc5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,11 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Task.Tests", "..\Task.Tests\Task.Tests.csproj", "{908D413A-9D1C-4F03-990A-23D5352AAFA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +16,10 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {908D413A-9D1C-4F03-990A-23D5352AAFA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {908D413A-9D1C-4F03-990A-23D5352AAFA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {908D413A-9D1C-4F03-990A-23D5352AAFA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {908D413A-9D1C-4F03-990A-23D5352AAFA6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..b926d2b099 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,43 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; +namespace ExchangeRateUpdater; -namespace ExchangeRateUpdater +public static class Program { - public static class Program + public static void Main(string[] args) { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..5bfc49ded2 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,8 @@ +{ + "Cnb": { + "Url": "", + "TimeoutSeconds": "", + "MaxRetries": "", + "CacheTtlSeconds": "" + } +} From 2e9622817a10e4b71a4de666aa467bf56a6d769b Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:34:45 +0200 Subject: [PATCH 03/20] =?UTF-8?q?feat(domain):=20CnbDailyTxtParser=20?= =?UTF-8?q?=E2=80=94=20happy=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/CnbDailyTxtParserTests.cs | 60 +++++++++++++++++++ jobs/Backend/Task.Tests/Task.Tests.csproj | 10 ++++ jobs/Backend/Task/Domain/CnbDailyTxtParser.cs | 53 ++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs create mode 100644 jobs/Backend/Task/Domain/CnbDailyTxtParser.cs diff --git a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs new file mode 100644 index 0000000000..952e06be04 --- /dev/null +++ b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs @@ -0,0 +1,60 @@ +using ExchangeRateUpdater.Domain; +using Xunit; + +namespace Task.Tests.Domain; + +public sealed class CnbDailyTxtParserTests +{ + [Fact] + public void Parse_valid_payload_returns_correct_rates() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14,123 + United States|dollar|1|usd|22,640 + """; + + ExchangeRate[] rates = CnbDailyTxtParser.Parse(payload).ToArray(); + + Assert.Equal(2, rates.Length); + Assert.Equal("AUD", rates[0].SourceCurrency.Code); + Assert.Equal("CZK", rates[0].TargetCurrency.Code); + Assert.Equal(14.123m, rates[0].Value); + Assert.Equal("USD", rates[1].SourceCurrency.Code); + Assert.Equal(22.640m, rates[1].Value); + } + + [Fact] + public void Parse_amount_greater_than_one_normalizes_value() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Japan|yen|100|JPY|15,320 + """; + + ExchangeRate rate = CnbDailyTxtParser.Parse(payload).Single(); + + Assert.Equal("JPY", rate.SourceCurrency.Code); + Assert.Equal(0.1532m, rate.Value); + } + + [Fact] + public void Parse_skips_header_lines_correctly() + { + string payload = """ + 22.04.2026 #78 + This line is metadata + Another metadata line + Country|Currency|Amount|Code|Rate + Sweden|krona|1|sek|2,078 + """; + + ExchangeRate rate = CnbDailyTxtParser.Parse(payload).Single(); + + Assert.Equal("SEK", rate.SourceCurrency.Code); + Assert.Equal("CZK", rate.TargetCurrency.Code); + Assert.Equal(2.078m, rate.Value); + } +} diff --git a/jobs/Backend/Task.Tests/Task.Tests.csproj b/jobs/Backend/Task.Tests/Task.Tests.csproj index 8f42f31950..4c874a59b4 100644 --- a/jobs/Backend/Task.Tests/Task.Tests.csproj +++ b/jobs/Backend/Task.Tests/Task.Tests.csproj @@ -3,9 +3,19 @@ net10.0 enable enable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs new file mode 100644 index 0000000000..d795137d13 --- /dev/null +++ b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Domain; + +public static class CnbDailyTxtParser +{ + private const string Header = "Country|Currency|Amount|Code|Rate"; + private static readonly CultureInfo CsCulture = CultureInfo.GetCultureInfo("cs-CZ"); + + public static IEnumerable Parse(string rawContent) + { + ArgumentNullException.ThrowIfNull(rawContent); + + string[] lines = rawContent + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + int headerIndex = Array.FindIndex(lines, line => line == Header); + if (headerIndex < 0) + { + throw new FormatException("Missing CNB header line."); + } + + for (int i = headerIndex + 1; i < lines.Length; i++) + { + string line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] parts = line.Split('|'); + if (parts.Length != 5) + { + throw new FormatException($"Malformed CNB line: '{line}'."); + } + + if (!decimal.TryParse(parts[2], NumberStyles.Number, CsCulture, out decimal amount)) + { + throw new FormatException($"Invalid amount in CNB line: '{line}'."); + } + + if (!decimal.TryParse(parts[4], NumberStyles.Number, CsCulture, out decimal rate)) + { + throw new FormatException($"Invalid rate in CNB line: '{line}'."); + } + + string code = parts[3].Trim().ToUpperInvariant(); + yield return new ExchangeRate(new Currency(code), new Currency("CZK"), rate / amount); + } + } +} From 7508fabbeb5b36544233f9b515b0196bb2370ba9 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:36:44 +0200 Subject: [PATCH 04/20] =?UTF-8?q?feat(domain):=20CnbDailyTxtParser=20?= =?UTF-8?q?=E2=80=94=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/CnbDailyTxtParserTests.cs | 74 +++++++++++++++++++ jobs/Backend/Task/Domain/CnbDailyTxtParser.cs | 39 ++++++++-- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs index 952e06be04..9884e4e248 100644 --- a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs +++ b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs @@ -57,4 +57,78 @@ Another metadata line Assert.Equal("CZK", rate.TargetCurrency.Code); Assert.Equal(2.078m, rate.Value); } + + [Fact] + public void Parse_malformed_line_throws_with_line_number() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("Malformed CNB line.", ex.Message); + Assert.Contains("Line 3", ex.Message); + } + + [Fact] + public void Parse_invalid_decimal_throws() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|oops|AUD|14,123 + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("Invalid amount in CNB line.", ex.Message); + Assert.Contains("Line 3", ex.Message); + } + + [Fact] + public void Parse_missing_header_throws() + { + string payload = """ + 22.04.2026 #78 + Metadata line + Australia|dollar|1|AUD|14,123 + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("Missing CNB header line.", ex.Message); + } + + [Fact] + public void Parse_rejects_zero_or_negative_amount() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|0|AUD|14,123 + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("Amount must be greater than zero.", ex.Message); + Assert.Contains("Line 3", ex.Message); + } + + [Fact] + public void Parse_rejects_zero_or_negative_rate() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|0 + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("Rate must be greater than zero.", ex.Message); + Assert.Contains("Line 3", ex.Message); + } } diff --git a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs index d795137d13..19b1107d23 100644 --- a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs +++ b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs @@ -1,12 +1,14 @@ using System; using System.Globalization; using System.Collections.Generic; +using System.Linq; namespace ExchangeRateUpdater.Domain; public static class CnbDailyTxtParser { private const string Header = "Country|Currency|Amount|Code|Rate"; + private const int MaxLinePreviewLength = 100; private static readonly CultureInfo CsCulture = CultureInfo.GetCultureInfo("cs-CZ"); public static IEnumerable Parse(string rawContent) @@ -14,9 +16,11 @@ public static IEnumerable Parse(string rawContent) ArgumentNullException.ThrowIfNull(rawContent); string[] lines = rawContent - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); - int headerIndex = Array.FindIndex(lines, line => line == Header); + int headerIndex = Array.FindIndex(lines, line => line.Trim() == Header); if (headerIndex < 0) { throw new FormatException("Missing CNB header line."); @@ -24,7 +28,7 @@ public static IEnumerable Parse(string rawContent) for (int i = headerIndex + 1; i < lines.Length; i++) { - string line = lines[i]; + string line = lines[i].Trim(); if (string.IsNullOrWhiteSpace(line)) { continue; @@ -33,21 +37,40 @@ public static IEnumerable Parse(string rawContent) string[] parts = line.Split('|'); if (parts.Length != 5) { - throw new FormatException($"Malformed CNB line: '{line}'."); + throw CreateFormatException(i + 1, line, "Malformed CNB line."); } - if (!decimal.TryParse(parts[2], NumberStyles.Number, CsCulture, out decimal amount)) + if (!decimal.TryParse(parts[2].Trim(), NumberStyles.Number, CsCulture, out decimal amount)) { - throw new FormatException($"Invalid amount in CNB line: '{line}'."); + throw CreateFormatException(i + 1, line, "Invalid amount in CNB line."); } - if (!decimal.TryParse(parts[4], NumberStyles.Number, CsCulture, out decimal rate)) + if (!decimal.TryParse(parts[4].Trim(), NumberStyles.Number, CsCulture, out decimal rate)) { - throw new FormatException($"Invalid rate in CNB line: '{line}'."); + throw CreateFormatException(i + 1, line, "Invalid rate in CNB line."); + } + + if (amount <= 0) + { + throw CreateFormatException(i + 1, line, "Amount must be greater than zero."); + } + + if (rate <= 0) + { + throw CreateFormatException(i + 1, line, "Rate must be greater than zero."); } string code = parts[3].Trim().ToUpperInvariant(); yield return new ExchangeRate(new Currency(code), new Currency("CZK"), rate / amount); } } + + private static FormatException CreateFormatException(int lineNumber, string lineContent, string reason) + { + string preview = lineContent.Length <= MaxLinePreviewLength + ? lineContent + : string.Concat(lineContent.Take(MaxLinePreviewLength), "..."); + + return new FormatException($"{reason} Line {lineNumber}: '{preview}'"); + } } From 10decd084c8de106a10ded2c41c5605cecf735b1 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:38:01 +0200 Subject: [PATCH 05/20] feat(application): IExchangeRateProvider port --- jobs/Backend/Task/Application/IExchangeRateProvider.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 jobs/Backend/Task/Application/IExchangeRateProvider.cs diff --git a/jobs/Backend/Task/Application/IExchangeRateProvider.cs b/jobs/Backend/Task/Application/IExchangeRateProvider.cs new file mode 100644 index 0000000000..8e579abdf1 --- /dev/null +++ b/jobs/Backend/Task/Application/IExchangeRateProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Domain; + +namespace ExchangeRateUpdater.Application; + +public interface IExchangeRateProvider +{ + IEnumerable GetExchangeRates(IEnumerable currencies); +} From 895d0e14a9f469a19dc0afd645c05c4ad7a03e53 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:40:13 +0200 Subject: [PATCH 06/20] feat(infra): CnbOptions, CnbOptionsValidator and CacheSnapshot --- .../CnbOptionsValidatorTests.cs | 87 +++++++++++++++++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 +- .../Task/Infrastructure/CacheSnapshot.cs | 9 ++ .../Backend/Task/Infrastructure/CnbOptions.cs | 19 ++++ .../Infrastructure/CnbOptionsValidator.cs | 30 +++++++ 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 jobs/Backend/Task.Tests/Infrastructure/CnbOptionsValidatorTests.cs create mode 100644 jobs/Backend/Task/Infrastructure/CacheSnapshot.cs create mode 100644 jobs/Backend/Task/Infrastructure/CnbOptions.cs create mode 100644 jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs diff --git a/jobs/Backend/Task.Tests/Infrastructure/CnbOptionsValidatorTests.cs b/jobs/Backend/Task.Tests/Infrastructure/CnbOptionsValidatorTests.cs new file mode 100644 index 0000000000..9976afdd90 --- /dev/null +++ b/jobs/Backend/Task.Tests/Infrastructure/CnbOptionsValidatorTests.cs @@ -0,0 +1,87 @@ +using ExchangeRateUpdater.Infrastructure; +using Xunit; + +namespace Task.Tests.Infrastructure; + +public sealed class CnbOptionsValidatorTests +{ + private readonly CnbOptionsValidator _validator = new(); + + [Fact] + public void Validate_valid_http_url_returns_success() + { + CnbOptions options = new() + { + Url = "http://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt" + }; + + var result = _validator.Validate(name: null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_valid_https_url_returns_success() + { + CnbOptions options = new() + { + Url = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt" + }; + + var result = _validator.Validate(name: null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_relative_url_returns_failure() + { + CnbOptions options = new() + { + Url = "/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt" + }; + + var result = _validator.Validate(name: null, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void Validate_non_http_scheme_returns_failure() + { + CnbOptions options = new() + { + Url = "ftp://www.cnb.cz/path" + }; + + var result = _validator.Validate(name: null, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void Validate_empty_url_returns_failure() + { + CnbOptions options = new() + { + Url = "" + }; + + var result = _validator.Validate(name: null, options); + + Assert.False(result.Succeeded); + } + + [Fact] + public void Validate_malformed_url_returns_failure() + { + CnbOptions options = new() + { + Url = "http://" + }; + + var result = _validator.Validate(name: null, options); + + Assert.False(result.Succeeded); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 38170f35e0..24781f822c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,10 @@ - - + Exe net10.0 - \ No newline at end of file + + + + diff --git a/jobs/Backend/Task/Infrastructure/CacheSnapshot.cs b/jobs/Backend/Task/Infrastructure/CacheSnapshot.cs new file mode 100644 index 0000000000..d14ac53dce --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CacheSnapshot.cs @@ -0,0 +1,9 @@ +using System; +using ExchangeRateUpdater.Domain; + +namespace ExchangeRateUpdater.Infrastructure; + +public sealed record CacheSnapshot( + ExchangeRate[] Rates, + DateTimeOffset CachedAt, + DateTimeOffset? LastModified); diff --git a/jobs/Backend/Task/Infrastructure/CnbOptions.cs b/jobs/Backend/Task/Infrastructure/CnbOptions.cs new file mode 100644 index 0000000000..deee42c925 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CnbOptions.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Infrastructure; + +public sealed class CnbOptions +{ + public const string SectionName = "Cnb"; + + public string Url { get; set; } = string.Empty; + + [Range(1, int.MaxValue)] + public int TimeoutSeconds { get; set; } = 10; + + [Range(0, int.MaxValue)] + public int MaxRetries { get; set; } = 3; + + [Range(1, int.MaxValue)] + public int CacheTtlSeconds { get; set; } = 3600; +} diff --git a/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs b/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs new file mode 100644 index 0000000000..9d0ba19145 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure; + +public sealed class CnbOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string name, CnbOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrWhiteSpace(options.Url)) + { + return ValidateOptionsResult.Fail("Cnb:Url is required."); + } + + if (!Uri.TryCreate(options.Url, UriKind.Absolute, out Uri uri)) + { + return ValidateOptionsResult.Fail("Cnb:Url must be a valid absolute URI."); + } + + bool isHttpOrHttps = + string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + return isHttpOrHttps + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail("Cnb:Url must use http or https."); + } +} From e9a2b94bf0d6cf00b13aed43cce4c6db2d8af595 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:43:47 +0200 Subject: [PATCH 07/20] =?UTF-8?q?feat(infra):=20ExchangeRateProvider=20?= =?UTF-8?q?=E2=80=94=20guard=20clauses=20and=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExchangeRateProviderTests.cs | 152 ++++++++++++++++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 + .../Infrastructure/ExchangeRateProvider.cs | 129 +++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..21b8f13db1 --- /dev/null +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Task.Tests.Infrastructure; + +public sealed class ExchangeRateProviderTests +{ + [Fact] + public void GetExchangeRates_null_currencies_throws() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + Assert.Throws(() => provider.GetExchangeRates(null!)); + } + + [Fact] + public void GetExchangeRates_empty_currencies_returns_empty() + { + var factory = new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(_ => CreateOkResponse(DefaultPayload)))); + ExchangeRateProvider provider = CreateProvider(factory); + + ExchangeRate[] rates = provider.GetExchangeRates([]).ToArray(); + + Assert.Empty(rates); + Assert.Equal(0, factory.CreateClientCalls); + } + + [Fact] + public void GetExchangeRates_filters_only_requested_codes() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("USD"), new Currency("EUR")]).ToArray(); + + Assert.Equal(2, rates.Length); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "USD"); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "EUR"); + Assert.DoesNotContain(rates, rate => rate.SourceCurrency.Code == "AUD"); + } + + [Fact] + public void GetExchangeRates_ignores_unknown_currency() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("XYZ")]).ToArray(); + + Assert.Empty(rates); + } + + [Fact] + public void GetExchangeRates_case_insensitive_currency_codes() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("usd"), new Currency("eUr")]).ToArray(); + + Assert.Equal(2, rates.Length); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "USD"); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "EUR"); + } + + [Fact] + public void GetExchangeRates_without_czk_in_request_still_returns_rates() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + Assert.Equal("CZK", rate.TargetCurrency.Code); + Assert.Equal(22.640m, rate.Value); + } + + [Fact] + public void GetExchangeRates_czk_in_request_is_silently_ignored() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("CZK"), new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + } + + private static ExchangeRateProvider CreateProvider(Func onSend) + { + return CreateProvider(new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend)))); + } + + private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory) + { + return new ExchangeRateProvider( + factory, + Options.Create(new CnbOptions + { + Url = "https://example.test/cnb.txt", + CacheTtlSeconds = 3600 + }), + NullLogger.Instance); + } + + private static HttpResponseMessage CreateOkResponse(string content) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + } + + private const string DefaultPayload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14,123 + Eurozone|euro|1|EUR|24,560 + United States|dollar|1|USD|22,640 + """; + + private sealed class FakeHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public int CreateClientCalls { get; private set; } + + public HttpClient CreateClient(string name) + { + CreateClientCalls++; + return client; + } + } + + private sealed class DelegateHttpMessageHandler(Func onSend) : HttpMessageHandler + { + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + return onSend(request); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return System.Threading.Tasks.Task.FromResult(Send(request, cancellationToken)); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 24781f822c..0d35a13c87 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,6 +5,8 @@ + + diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs new file mode 100644 index 0000000000..31322c264c --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Domain; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure; + +public sealed class ExchangeRateProvider : IExchangeRateProvider +{ + private static readonly TimeSpan ZeroTtl = TimeSpan.Zero; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly CnbOptions _options; + private readonly ILogger _logger; + private readonly SemaphoreSlim _refreshSemaphore = new(1, 1); + private CacheSnapshot _snapshot; + + public ExchangeRateProvider( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + ArgumentNullException.ThrowIfNull(currencies); + + HashSet requestedCodes = NormalizeRequestedCodes(currencies); + if (requestedCodes.Count == 0) + { + return []; + } + + CacheSnapshot snapshot = Volatile.Read(ref _snapshot); + if (snapshot is not null && IsFresh(snapshot)) + { + return FilterRates(snapshot.Rates, requestedCodes); + } + + _refreshSemaphore.Wait(); + try + { + snapshot = Volatile.Read(ref _snapshot); + if (snapshot is not null && IsFresh(snapshot)) + { + return FilterRates(snapshot.Rates, requestedCodes); + } + + CacheSnapshot refreshedSnapshot = FetchSnapshot(); + Volatile.Write(ref _snapshot, refreshedSnapshot); + return FilterRates(refreshedSnapshot.Rates, requestedCodes); + } + finally + { + _refreshSemaphore.Release(); + } + } + + private CacheSnapshot FetchSnapshot() + { + HttpClient client = _httpClientFactory.CreateClient("cnb"); + using HttpRequestMessage request = new(HttpMethod.Get, _options.Url); + using HttpResponseMessage response = client.Send(request); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"CNB request failed with status code {(int)response.StatusCode} ({response.StatusCode}).", + inner: null, + response.StatusCode); + } + + string rawContent = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset? lastModified = response.Content.Headers.LastModified; + + _logger.LogDebug("Fetched {RatesCount} rates from CNB.", rates.Length); + return new CacheSnapshot(rates, now, lastModified); + } + + private HashSet NormalizeRequestedCodes(IEnumerable currencies) + { + HashSet requestedCodes = new(StringComparer.Ordinal); + foreach (Currency currency in currencies) + { + if (currency is null || string.IsNullOrWhiteSpace(currency.Code)) + { + continue; + } + + string normalizedCode = currency.Code.Trim().ToUpperInvariant(); + if (normalizedCode == "CZK") + { + continue; + } + + requestedCodes.Add(normalizedCode); + } + + return requestedCodes; + } + + private bool IsFresh(CacheSnapshot snapshot) + { + TimeSpan ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds); + if (ttl <= ZeroTtl) + { + return false; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + return now - snapshot.CachedAt < ttl; + } + + private static ExchangeRate[] FilterRates(IEnumerable rates, HashSet requestedCodes) + { + return rates.Where(rate => requestedCodes.Contains(rate.SourceCurrency.Code)).ToArray(); + } +} From 2c7f5da808b5201e643b95e7f700716d91401b4d Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:52:07 +0200 Subject: [PATCH 08/20] =?UTF-8?q?feat(infra):=20ExchangeRateProvider=20?= =?UTF-8?q?=E2=80=94=20HTTP=20semantics,=20concurrency=20and=20defensive?= =?UTF-8?q?=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExchangeRateProviderTests.cs | 164 +++++++++++++++++- .../Infrastructure/ExchangeRateProvider.cs | 36 +++- 2 files changed, 192 insertions(+), 8 deletions(-) diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index 21b8f13db1..2d214021a5 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -93,29 +94,184 @@ public void GetExchangeRates_czk_in_request_is_silently_ignored() Assert.Equal("USD", rate.SourceCurrency.Code); } + [Fact] + public void GetExchangeRates_http_error_throws() + { + ExchangeRateProvider provider = CreateProvider(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)); + + HttpRequestException ex = Assert.Throws( + () => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + + Assert.Equal(HttpStatusCode.BadGateway, ex.StatusCode); + } + + [Fact] + public void GetExchangeRates_304_with_cache_returns_cached() + { + DateTimeOffset expectedLastModified = new(2026, 04, 22, 10, 0, 0, TimeSpan.Zero); + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateOkResponse(DefaultPayload, expectedLastModified), + 2 => CreateNotModifiedResponse(), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 0); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + ExchangeRate[] refreshedRates = provider.GetExchangeRates([new Currency("EUR")]).ToArray(); + + ExchangeRate rate = Assert.Single(refreshedRates); + Assert.Equal("EUR", rate.SourceCurrency.Code); + Assert.Equal(2, requestCount); + Assert.Null(ifModifiedSinceValues[0]); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[1]); + } + + [Fact] + public void GetExchangeRates_304_without_cache_fallback_to_full_get() + { + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateNotModifiedResponse(), + 2 => CreateOkResponse(DefaultPayload), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 0); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + Assert.Equal(2, requestCount); + Assert.Null(ifModifiedSinceValues[0]); + Assert.Null(ifModifiedSinceValues[1]); + } + + [Fact] + public async global::System.Threading.Tasks.Task GetExchangeRates_concurrent_calls_only_one_http_request() + { + int requestCount = 0; + ExchangeRateProvider provider = CreateProvider( + _ => + { + Interlocked.Increment(ref requestCount); + Thread.Sleep(100); + return CreateOkResponse(DefaultPayload); + }); + + global::System.Threading.Tasks.Task task1 = + global::System.Threading.Tasks.Task.Run(() => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + global::System.Threading.Tasks.Task task2 = + global::System.Threading.Tasks.Task.Run(() => provider.GetExchangeRates([new Currency("EUR")]).ToArray()); + + await global::System.Threading.Tasks.Task.WhenAll(task1, task2); + + Assert.Equal(1, requestCount); + } + + [Fact] + public void GetExchangeRates_returns_defensive_copy_not_internal_reference() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] firstResult = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + firstResult[0] = new ExchangeRate(new Currency("BROKEN"), new Currency("CZK"), 0.01m); + + ExchangeRate[] secondResult = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(secondResult); + Assert.Equal("USD", rate.SourceCurrency.Code); + } + + [Fact] + public void GetExchangeRates_trims_currency_codes() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency(" usd "), new Currency(" eur\t")]).ToArray(); + + Assert.Equal(2, rates.Length); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "USD"); + Assert.Contains(rates, rate => rate.SourceCurrency.Code == "EUR"); + } + + [Fact] + public void GetExchangeRates_duplicate_requested_codes_are_deduplicated() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ExchangeRate[] rates = provider.GetExchangeRates( + [new Currency("USD"), new Currency(" usd "), new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + } + private static ExchangeRateProvider CreateProvider(Func onSend) { - return CreateProvider(new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend)))); + return CreateProvider(onSend, cacheTtlSeconds: 3600); + } + + private static ExchangeRateProvider CreateProvider( + Func onSend, + int cacheTtlSeconds) + { + return CreateProvider( + new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend))), + cacheTtlSeconds); } private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory) + { + return CreateProvider(factory, cacheTtlSeconds: 3600); + } + + private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory, int cacheTtlSeconds) { return new ExchangeRateProvider( factory, Options.Create(new CnbOptions { Url = "https://example.test/cnb.txt", - CacheTtlSeconds = 3600 + CacheTtlSeconds = cacheTtlSeconds }), NullLogger.Instance); } - private static HttpResponseMessage CreateOkResponse(string content) + private static HttpResponseMessage CreateOkResponse(string content, DateTimeOffset? lastModified = null) { - return new HttpResponseMessage(HttpStatusCode.OK) + HttpResponseMessage response = new(HttpStatusCode.OK) { Content = new StringContent(content) }; + + if (lastModified is not null) + { + response.Content.Headers.LastModified = lastModified; + } + + return response; + } + + private static HttpResponseMessage CreateNotModifiedResponse() + { + return new HttpResponseMessage(HttpStatusCode.NotModified); } private const string DefaultPayload = """ diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index 31322c264c..03c95653d5 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Threading; using ExchangeRateUpdater.Application; @@ -55,7 +56,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci return FilterRates(snapshot.Rates, requestedCodes); } - CacheSnapshot refreshedSnapshot = FetchSnapshot(); + CacheSnapshot refreshedSnapshot = FetchSnapshot(snapshot); Volatile.Write(ref _snapshot, refreshedSnapshot); return FilterRates(refreshedSnapshot.Rates, requestedCodes); } @@ -65,12 +66,28 @@ public IEnumerable GetExchangeRates(IEnumerable currenci } } - private CacheSnapshot FetchSnapshot() + private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) { HttpClient client = _httpClientFactory.CreateClient("cnb"); - using HttpRequestMessage request = new(HttpMethod.Get, _options.Url); - using HttpResponseMessage response = client.Send(request); + using HttpResponseMessage response = SendRequest(client, ifModifiedSince: currentSnapshot?.LastModified); + if (response.StatusCode == HttpStatusCode.NotModified) + { + if (currentSnapshot is not null) + { + _logger.LogDebug("CNB returned 304 Not Modified. Reusing cached snapshot."); + return currentSnapshot with { CachedAt = DateTimeOffset.UtcNow }; + } + + _logger.LogWarning("CNB returned 304 without an existing snapshot. Retrying with a full GET."); + using HttpResponseMessage fallbackResponse = SendRequest(client, ifModifiedSince: null); + return BuildSnapshotFromSuccessResponse(fallbackResponse); + } + + return BuildSnapshotFromSuccessResponse(response); + } + private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage response) + { if (!response.IsSuccessStatusCode) { throw new HttpRequestException( @@ -88,6 +105,17 @@ private CacheSnapshot FetchSnapshot() return new CacheSnapshot(rates, now, lastModified); } + private HttpResponseMessage SendRequest(HttpClient client, DateTimeOffset? ifModifiedSince) + { + using HttpRequestMessage request = new(HttpMethod.Get, _options.Url); + if (ifModifiedSince is not null) + { + request.Headers.IfModifiedSince = ifModifiedSince; + } + + return client.Send(request); + } + private HashSet NormalizeRequestedCodes(IEnumerable currencies) { HashSet requestedCodes = new(StringComparer.Ordinal); From 064df4f6ca810f5ccf1988501160bd7d68ead6ef Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:55:03 +0200 Subject: [PATCH 09/20] =?UTF-8?q?feat(infra):=20ExchangeRateProvider=20?= =?UTF-8?q?=E2=80=94=20TTL,=20retry=20invariants=20and=20snapshot=20preser?= =?UTF-8?q?vation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExchangeRateProviderTests.cs | 236 +++++++++++++++++- jobs/Backend/Task.Tests/Task.Tests.csproj | 1 + .../Infrastructure/ExchangeRateProvider.cs | 11 +- 3 files changed, 240 insertions(+), 8 deletions(-) diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index 2d214021a5..7cb8fa11ce 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -9,6 +9,7 @@ using ExchangeRateUpdater.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; using Xunit; namespace Task.Tests.Infrastructure; @@ -223,6 +224,209 @@ public void GetExchangeRates_duplicate_requested_codes_are_deduplicated() Assert.Equal("USD", rate.SourceCurrency.Code); } + [Fact] + public void GetExchangeRates_within_ttl_does_not_call_http() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + int requestCount = 0; + ExchangeRateProvider provider = CreateProvider( + _ => + { + requestCount++; + return CreateOkResponse(DefaultPayload); + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(30)); + _ = provider.GetExchangeRates([new Currency("EUR")]).ToArray(); + + Assert.Equal(1, requestCount); + } + + [Fact] + public void GetExchangeRates_after_ttl_expires_refetches() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + int requestCount = 0; + ExchangeRateProvider provider = CreateProvider( + _ => + { + requestCount++; + return CreateOkResponse(DefaultPayload); + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + _ = provider.GetExchangeRates([new Currency("EUR")]).ToArray(); + + Assert.Equal(2, requestCount); + } + + [Fact] + public void GetExchangeRates_parse_error_preserves_previous_snapshot() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + DateTimeOffset expectedLastModified = new(2026, 04, 22, 9, 0, 0, TimeSpan.Zero); + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateOkResponse(DefaultPayload, expectedLastModified), + 2 => CreateOkResponse("invalid payload"), + 3 => CreateNotModifiedResponse(), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + Assert.Throws(() => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + Assert.Equal(3, requestCount); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[1]); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[2]); + } + + [Fact] + public void GetExchangeRates_http_error_preserves_previous_snapshot() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + DateTimeOffset expectedLastModified = new(2026, 04, 22, 9, 0, 0, TimeSpan.Zero); + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateOkResponse(DefaultPayload, expectedLastModified), + 2 => new HttpResponseMessage(HttpStatusCode.BadGateway), + 3 => CreateNotModifiedResponse(), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + _ = Assert.Throws(() => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(rates); + Assert.Equal("USD", rate.SourceCurrency.Code); + Assert.Equal(3, requestCount); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[1]); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[2]); + } + + [Fact] + public void GetExchangeRates_refresh_failure_does_not_corrupt_existing_snapshot() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + DateTimeOffset expectedLastModified = new(2026, 04, 22, 9, 0, 0, TimeSpan.Zero); + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateOkResponse(DefaultPayload, expectedLastModified), + 2 => new HttpResponseMessage(HttpStatusCode.BadGateway), + 3 => CreateOkResponse(UpdatedPayload), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + _ = Assert.Throws(() => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + ExchangeRate[] refreshedRates = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + ExchangeRate rate = Assert.Single(refreshedRates); + Assert.Equal(23.000m, rate.Value); + Assert.Equal(3, requestCount); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[1]); + Assert.Equal(expectedLastModified, ifModifiedSinceValues[2]); + } + + [Fact] + public void GetExchangeRates_ttl_boundary_exact_match_is_treated_as_expired() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + int requestCount = 0; + ExchangeRateProvider provider = CreateProvider( + _ => + { + requestCount++; + return CreateOkResponse(DefaultPayload); + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(60)); + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + Assert.Equal(2, requestCount); + } + + [Fact] + public void GetExchangeRates_response_without_last_modified_does_not_inherit_previous_value() + { + FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 04, 22, 10, 0, 0, TimeSpan.Zero)); + DateTimeOffset firstLastModified = new(2026, 04, 22, 9, 0, 0, TimeSpan.Zero); + int requestCount = 0; + List ifModifiedSinceValues = []; + ExchangeRateProvider provider = CreateProvider( + request => + { + requestCount++; + ifModifiedSinceValues.Add(request.Headers.IfModifiedSince); + return requestCount switch + { + 1 => CreateOkResponse(DefaultPayload, firstLastModified), + 2 => CreateOkResponse(UpdatedPayload), + 3 => CreateOkResponse(UpdatedPayload), + _ => throw new InvalidOperationException("Unexpected request count.") + }; + }, + cacheTtlSeconds: 60, + timeProvider: timeProvider); + + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + timeProvider.Advance(TimeSpan.FromSeconds(61)); + _ = provider.GetExchangeRates([new Currency("USD")]).ToArray(); + + Assert.Equal(3, requestCount); + Assert.Null(ifModifiedSinceValues[0]); + Assert.Equal(firstLastModified, ifModifiedSinceValues[1]); + Assert.Null(ifModifiedSinceValues[2]); + } + private static ExchangeRateProvider CreateProvider(Func onSend) { return CreateProvider(onSend, cacheTtlSeconds: 3600); @@ -234,15 +438,30 @@ private static ExchangeRateProvider CreateProvider( { return CreateProvider( new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend))), - cacheTtlSeconds); + cacheTtlSeconds, + timeProvider: null); } private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory) { - return CreateProvider(factory, cacheTtlSeconds: 3600); + return CreateProvider(factory, cacheTtlSeconds: 3600, timeProvider: null); + } + + private static ExchangeRateProvider CreateProvider( + Func onSend, + int cacheTtlSeconds, + FakeTimeProvider timeProvider) + { + return CreateProvider( + new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend))), + cacheTtlSeconds, + timeProvider); } - private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory, int cacheTtlSeconds) + private static ExchangeRateProvider CreateProvider( + FakeHttpClientFactory factory, + int cacheTtlSeconds, + FakeTimeProvider? timeProvider) { return new ExchangeRateProvider( factory, @@ -251,7 +470,8 @@ private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory Url = "https://example.test/cnb.txt", CacheTtlSeconds = cacheTtlSeconds }), - NullLogger.Instance); + NullLogger.Instance, + timeProvider); } private static HttpResponseMessage CreateOkResponse(string content, DateTimeOffset? lastModified = null) @@ -282,6 +502,14 @@ private static HttpResponseMessage CreateNotModifiedResponse() United States|dollar|1|USD|22,640 """; + private const string UpdatedPayload = """ + 22.04.2026 #79 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14,500 + Eurozone|euro|1|EUR|24,800 + United States|dollar|1|USD|23,000 + """; + private sealed class FakeHttpClientFactory(HttpClient client) : IHttpClientFactory { public int CreateClientCalls { get; private set; } diff --git a/jobs/Backend/Task.Tests/Task.Tests.csproj b/jobs/Backend/Task.Tests/Task.Tests.csproj index 4c874a59b4..e7507d67ed 100644 --- a/jobs/Backend/Task.Tests/Task.Tests.csproj +++ b/jobs/Backend/Task.Tests/Task.Tests.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index 03c95653d5..b560d76b13 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -18,17 +18,20 @@ public sealed class ExchangeRateProvider : IExchangeRateProvider private readonly IHttpClientFactory _httpClientFactory; private readonly CnbOptions _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly SemaphoreSlim _refreshSemaphore = new(1, 1); private CacheSnapshot _snapshot; public ExchangeRateProvider( IHttpClientFactory httpClientFactory, IOptions options, - ILogger logger) + ILogger logger, + TimeProvider timeProvider = null) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } public IEnumerable GetExchangeRates(IEnumerable currencies) @@ -75,7 +78,7 @@ private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) if (currentSnapshot is not null) { _logger.LogDebug("CNB returned 304 Not Modified. Reusing cached snapshot."); - return currentSnapshot with { CachedAt = DateTimeOffset.UtcNow }; + return currentSnapshot with { CachedAt = _timeProvider.GetUtcNow() }; } _logger.LogWarning("CNB returned 304 without an existing snapshot. Retrying with a full GET."); @@ -98,7 +101,7 @@ private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage respo string rawContent = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); - DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset now = _timeProvider.GetUtcNow(); DateTimeOffset? lastModified = response.Content.Headers.LastModified; _logger.LogDebug("Fetched {RatesCount} rates from CNB.", rates.Length); @@ -146,7 +149,7 @@ private bool IsFresh(CacheSnapshot snapshot) return false; } - DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset now = _timeProvider.GetUtcNow(); return now - snapshot.CachedAt < ttl; } From a48044bc7fbe3c0d9ab78acd6186fcde4ce28d1e Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:57:04 +0200 Subject: [PATCH 10/20] feat(app): DI wiring, resilience pipeline and ValidateOnStart --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 3 + jobs/Backend/Task/Program.cs | 95 +++++++++++++++++++- jobs/Backend/Task/appsettings.json | 8 +- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 0d35a13c87..8feffd72b3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,8 +5,11 @@ + + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index b926d2b099..046b2b54ca 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,8 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Domain; +using ExchangeRateUpdater.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; + namespace ExchangeRateUpdater; public static class Program { - public static void Main(string[] args) + public static int Main(string[] args) + { + using IHost host = BuildHost(args); + ILogger logger = host.Services.GetRequiredService().CreateLogger("ExchangeRateUpdater"); + + try + { + IExchangeRateProvider provider = host.Services.GetRequiredService(); + IEnumerable currencies = BuildRequestedCurrencies(args); + IEnumerable rates = provider.GetExchangeRates(currencies); + + foreach (ExchangeRate rate in rates) + { + logger.LogInformation("{Rate}", rate); + } + + return 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to retrieve exchange rates from CNB."); + return 1; + } + } + + internal static IHost BuildHost(string[] args) { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(CnbOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + builder.Services.AddSingleton, CnbOptionsValidator>(); + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient("cnb") + .ConfigureHttpClient((serviceProvider, client) => + { + CnbOptions options = serviceProvider.GetRequiredService>().Value; + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + }) + .AddResilienceHandler( + "cnb-pipeline", + static (pipelineBuilder, context) => + { + CnbOptions options = context.ServiceProvider.GetRequiredService>().Value; + pipelineBuilder.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = options.MaxRetries, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = static args => + { + int statusCode = args.Outcome.Result is null ? 0 : (int)args.Outcome.Result.StatusCode; + bool shouldRetryStatus = statusCode == 408 || statusCode == 429 || statusCode >= 500; + bool shouldRetryException = args.Outcome.Exception is HttpRequestException or TimeoutException; + return ValueTask.FromResult(shouldRetryStatus || shouldRetryException); + } + }); + }); + + return builder.Build(); + } + + private static IEnumerable BuildRequestedCurrencies(string[] args) + { + if (args.Length > 0) + { + foreach (string arg in args) + { + yield return new Currency(arg); + } + + yield break; + } + + yield return new Currency("EUR"); + yield return new Currency("USD"); } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index 5bfc49ded2..aa862a9525 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,8 +1,8 @@ { "Cnb": { - "Url": "", - "TimeoutSeconds": "", - "MaxRetries": "", - "CacheTtlSeconds": "" + "Url": "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt", + "TimeoutSeconds": 10, + "MaxRetries": 3, + "CacheTtlSeconds": 3600 } } From 00de18defdde642e7f53a0444ea2f43971acebba Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 12:58:37 +0200 Subject: [PATCH 11/20] feat(infra): finalize sync GetExchangeRates behavior --- .../Infrastructure/ExchangeRateProviderTests.cs | 12 ++++++++++++ .../Task/Application/IExchangeRateProvider.cs | 4 ++++ .../Task/Infrastructure/ExchangeRateProvider.cs | 1 + 3 files changed, 17 insertions(+) diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index 7cb8fa11ce..57daf0fe1e 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -95,6 +95,18 @@ public void GetExchangeRates_czk_in_request_is_silently_ignored() Assert.Equal("USD", rate.SourceCurrency.Code); } + [Fact] + public void GetExchangeRates_only_czk_returns_empty_without_calling_http() + { + var factory = new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(_ => CreateOkResponse(DefaultPayload)))); + ExchangeRateProvider provider = CreateProvider(factory); + + ExchangeRate[] rates = provider.GetExchangeRates([new Currency("CZK"), new Currency(" czk ")]).ToArray(); + + Assert.Empty(rates); + Assert.Equal(0, factory.CreateClientCalls); + } + [Fact] public void GetExchangeRates_http_error_throws() { diff --git a/jobs/Backend/Task/Application/IExchangeRateProvider.cs b/jobs/Backend/Task/Application/IExchangeRateProvider.cs index 8e579abdf1..9d788ae380 100644 --- a/jobs/Backend/Task/Application/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/Application/IExchangeRateProvider.cs @@ -5,5 +5,9 @@ namespace ExchangeRateUpdater.Application; public interface IExchangeRateProvider { + /// + /// Returns the latest available rates for the requested source currencies against CZK. + /// This contract is intentionally synchronous (`sync-first`) in this iteration. + /// IEnumerable GetExchangeRates(IEnumerable currencies); } diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index b560d76b13..f07ecf5d6f 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -99,6 +99,7 @@ private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage respo response.StatusCode); } + // Keep the public contract sync-first while consuming HttpContent. string rawContent = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); DateTimeOffset now = _timeProvider.GetUtcNow(); From 34fb5eb83c2350861d3929cb0d2780dfec3b23b4 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:18:25 +0200 Subject: [PATCH 12/20] fix(config): make appsettings loading independent from cwd --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 6 ++++++ jobs/Backend/Task/Program.cs | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 8feffd72b3..c30119be3a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -12,4 +12,10 @@ + + + + PreserveNewest + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 046b2b54ca..c60125b806 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -43,7 +43,12 @@ public static int Main(string[] args) internal static IHost BuildHost(string[] args) { - HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + HostApplicationBuilderSettings settings = new() + { + Args = args, + ContentRootPath = AppContext.BaseDirectory + }; + HostApplicationBuilder builder = Host.CreateApplicationBuilder(settings); builder.Services .AddOptions() From e5394b94c82b7e8f08bcbf8ff0bfc940a8bbe5e8 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:19:08 +0200 Subject: [PATCH 13/20] refactor(domain): enforce currency and rate invariants --- .../Domain/DomainInvariantsTests.cs | 41 +++++++++++++++++++ jobs/Backend/Task/Domain/Currency.cs | 9 +++- jobs/Backend/Task/Domain/ExchangeRate.cs | 12 +++++- 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 jobs/Backend/Task.Tests/Domain/DomainInvariantsTests.cs diff --git a/jobs/Backend/Task.Tests/Domain/DomainInvariantsTests.cs b/jobs/Backend/Task.Tests/Domain/DomainInvariantsTests.cs new file mode 100644 index 0000000000..419a66a9d3 --- /dev/null +++ b/jobs/Backend/Task.Tests/Domain/DomainInvariantsTests.cs @@ -0,0 +1,41 @@ +using ExchangeRateUpdater.Domain; +using Xunit; + +namespace Task.Tests.Domain; + +public sealed class DomainInvariantsTests +{ + [Fact] + public void Currency_normalizes_code() + { + Currency currency = new(" usd "); + + Assert.Equal("USD", currency.Code); + } + + [Fact] + public void Currency_empty_or_whitespace_throws() + { + Assert.Throws(() => new Currency("")); + Assert.Throws(() => new Currency(" ")); + } + + [Fact] + public void ExchangeRate_requires_non_null_currencies() + { + Currency czk = new("CZK"); + + Assert.Throws(() => new ExchangeRate(null!, czk, 1m)); + Assert.Throws(() => new ExchangeRate(new Currency("USD"), null!, 1m)); + } + + [Fact] + public void ExchangeRate_requires_positive_value() + { + Currency usd = new("USD"); + Currency czk = new("CZK"); + + Assert.Throws(() => new ExchangeRate(usd, czk, 0m)); + Assert.Throws(() => new ExchangeRate(usd, czk, -1m)); + } +} diff --git a/jobs/Backend/Task/Domain/Currency.cs b/jobs/Backend/Task/Domain/Currency.cs index 5070e6deb2..3aca25dc74 100644 --- a/jobs/Backend/Task/Domain/Currency.cs +++ b/jobs/Backend/Task/Domain/Currency.cs @@ -1,10 +1,17 @@ +using System; + namespace ExchangeRateUpdater.Domain; public sealed class Currency { public Currency(string code) { - Code = code; + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be null, empty, or whitespace.", nameof(code)); + } + + Code = code.Trim().ToUpperInvariant(); } public string Code { get; } diff --git a/jobs/Backend/Task/Domain/ExchangeRate.cs b/jobs/Backend/Task/Domain/ExchangeRate.cs index e36191aaef..5af95ec974 100644 --- a/jobs/Backend/Task/Domain/ExchangeRate.cs +++ b/jobs/Backend/Task/Domain/ExchangeRate.cs @@ -1,11 +1,19 @@ +using System; + namespace ExchangeRateUpdater.Domain; public sealed class ExchangeRate { public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; + SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency)); + TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency)); + + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Exchange rate value must be greater than zero."); + } + Value = value; } From 597e9a20dbaf3a1f244c5cc73b9f0b41c3ca9075 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:20:15 +0200 Subject: [PATCH 14/20] refactor(provider): add async API with cancellation support --- .../Task/Application/IExchangeRateProvider.cs | 9 +++- .../Infrastructure/ExchangeRateProvider.cs | 42 +++++++++++++------ jobs/Backend/Task/Program.cs | 4 +- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/jobs/Backend/Task/Application/IExchangeRateProvider.cs b/jobs/Backend/Task/Application/IExchangeRateProvider.cs index 9d788ae380..4cd72f0180 100644 --- a/jobs/Backend/Task/Application/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/Application/IExchangeRateProvider.cs @@ -1,13 +1,18 @@ using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using ExchangeRateUpdater.Domain; namespace ExchangeRateUpdater.Application; public interface IExchangeRateProvider { + Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default); + /// - /// Returns the latest available rates for the requested source currencies against CZK. - /// This contract is intentionally synchronous (`sync-first`) in this iteration. + /// Synchronous wrapper preserved for compatibility with existing callers. /// IEnumerable GetExchangeRates(IEnumerable currencies); } diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index f07ecf5d6f..525632f5ba 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Http; using System.Threading; +using System.Threading.Tasks; using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Domain; using Microsoft.Extensions.Logging; @@ -35,8 +36,19 @@ public ExchangeRateProvider( } public IEnumerable GetExchangeRates(IEnumerable currencies) + { + return GetExchangeRatesAsync(currencies, CancellationToken.None) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(currencies); + cancellationToken.ThrowIfCancellationRequested(); HashSet requestedCodes = NormalizeRequestedCodes(currencies); if (requestedCodes.Count == 0) @@ -50,7 +62,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci return FilterRates(snapshot.Rates, requestedCodes); } - _refreshSemaphore.Wait(); + await _refreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { snapshot = Volatile.Read(ref _snapshot); @@ -59,7 +71,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci return FilterRates(snapshot.Rates, requestedCodes); } - CacheSnapshot refreshedSnapshot = FetchSnapshot(snapshot); + CacheSnapshot refreshedSnapshot = await FetchSnapshotAsync(snapshot, cancellationToken).ConfigureAwait(false); Volatile.Write(ref _snapshot, refreshedSnapshot); return FilterRates(refreshedSnapshot.Rates, requestedCodes); } @@ -69,10 +81,11 @@ public IEnumerable GetExchangeRates(IEnumerable currenci } } - private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) + private async Task FetchSnapshotAsync(CacheSnapshot currentSnapshot, CancellationToken cancellationToken) { HttpClient client = _httpClientFactory.CreateClient("cnb"); - using HttpResponseMessage response = SendRequest(client, ifModifiedSince: currentSnapshot?.LastModified); + using HttpResponseMessage response = + await SendRequestAsync(client, ifModifiedSince: currentSnapshot?.LastModified, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotModified) { if (currentSnapshot is not null) @@ -82,14 +95,17 @@ private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) } _logger.LogWarning("CNB returned 304 without an existing snapshot. Retrying with a full GET."); - using HttpResponseMessage fallbackResponse = SendRequest(client, ifModifiedSince: null); - return BuildSnapshotFromSuccessResponse(fallbackResponse); + using HttpResponseMessage fallbackResponse = + await SendRequestAsync(client, ifModifiedSince: null, cancellationToken).ConfigureAwait(false); + return await BuildSnapshotFromSuccessResponseAsync(fallbackResponse, cancellationToken).ConfigureAwait(false); } - return BuildSnapshotFromSuccessResponse(response); + return await BuildSnapshotFromSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); } - private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage response) + private async Task BuildSnapshotFromSuccessResponseAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) { if (!response.IsSuccessStatusCode) { @@ -99,8 +115,7 @@ private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage respo response.StatusCode); } - // Keep the public contract sync-first while consuming HttpContent. - string rawContent = response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + string rawContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); DateTimeOffset now = _timeProvider.GetUtcNow(); DateTimeOffset? lastModified = response.Content.Headers.LastModified; @@ -109,7 +124,10 @@ private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage respo return new CacheSnapshot(rates, now, lastModified); } - private HttpResponseMessage SendRequest(HttpClient client, DateTimeOffset? ifModifiedSince) + private Task SendRequestAsync( + HttpClient client, + DateTimeOffset? ifModifiedSince, + CancellationToken cancellationToken) { using HttpRequestMessage request = new(HttpMethod.Get, _options.Url); if (ifModifiedSince is not null) @@ -117,7 +135,7 @@ private HttpResponseMessage SendRequest(HttpClient client, DateTimeOffset? ifMod request.Headers.IfModifiedSince = ifModifiedSince; } - return client.Send(request); + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } private HashSet NormalizeRequestedCodes(IEnumerable currencies) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index c60125b806..630bd0d4f7 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -16,7 +16,7 @@ namespace ExchangeRateUpdater; public static class Program { - public static int Main(string[] args) + public static async Task Main(string[] args) { using IHost host = BuildHost(args); ILogger logger = host.Services.GetRequiredService().CreateLogger("ExchangeRateUpdater"); @@ -25,7 +25,7 @@ public static int Main(string[] args) { IExchangeRateProvider provider = host.Services.GetRequiredService(); IEnumerable currencies = BuildRequestedCurrencies(args); - IEnumerable rates = provider.GetExchangeRates(currencies); + IReadOnlyCollection rates = await provider.GetExchangeRatesAsync(currencies); foreach (ExchangeRate rate in rates) { From cb40b6de16eed8fb074123530b9254f713dd800e Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:25:03 +0200 Subject: [PATCH 15/20] refactor(provider): remove dead guard and redundant normalization ZeroTtl guard is unreachable given [Range(1, int.MaxValue)] on CacheTtlSeconds. Currency.Code is already uppercased and trimmed by the constructor, so the ToUpperInvariant()/Trim() calls in NormalizeRequestedCodes were redundant. Co-Authored-By: Claude Sonnet 4.6 --- .../Task/Infrastructure/ExchangeRateProvider.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index 525632f5ba..d23b8c76cf 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -14,8 +14,6 @@ namespace ExchangeRateUpdater.Infrastructure; public sealed class ExchangeRateProvider : IExchangeRateProvider { - private static readonly TimeSpan ZeroTtl = TimeSpan.Zero; - private readonly IHttpClientFactory _httpClientFactory; private readonly CnbOptions _options; private readonly ILogger _logger; @@ -148,13 +146,12 @@ private HashSet NormalizeRequestedCodes(IEnumerable currencies continue; } - string normalizedCode = currency.Code.Trim().ToUpperInvariant(); - if (normalizedCode == "CZK") + if (currency.Code == "CZK") { continue; } - requestedCodes.Add(normalizedCode); + requestedCodes.Add(currency.Code); } return requestedCodes; @@ -163,11 +160,6 @@ private HashSet NormalizeRequestedCodes(IEnumerable currencies private bool IsFresh(CacheSnapshot snapshot) { TimeSpan ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds); - if (ttl <= ZeroTtl) - { - return false; - } - DateTimeOffset now = _timeProvider.GetUtcNow(); return now - snapshot.CachedAt < ttl; } From 9996ed2b0e3352b587ab113236d65dffc2e5672d Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:25:33 +0200 Subject: [PATCH 16/20] feat(domain): support dot-decimal separator from CNB english endpoint The CNB txt feed uses comma decimals (cs-CZ) by default, but the English endpoint uses dots. TryParseCnbNumber falls back to InvariantCulture when the token contains a dot but no comma, making the parser endpoint-agnostic. Co-Authored-By: Claude Sonnet 4.6 --- .../Domain/CnbDailyTxtParserTests.cs | 19 ++++++++++++ jobs/Backend/Task/Domain/CnbDailyTxtParser.cs | 30 +++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs index 9884e4e248..c56f6feb0e 100644 --- a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs +++ b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs @@ -58,6 +58,25 @@ Another metadata line Assert.Equal(2.078m, rate.Value); } + [Fact] + public void Parse_accepts_dot_decimal_from_english_cnb_endpoint() + { + string payload = """ + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + Australia|dollar|1|AUD|14.795 + Japan|yen|100|JPY|15.320 + """; + + ExchangeRate[] rates = CnbDailyTxtParser.Parse(payload).ToArray(); + + Assert.Equal(2, rates.Length); + Assert.Equal("AUD", rates[0].SourceCurrency.Code); + Assert.Equal(14.795m, rates[0].Value); + Assert.Equal("JPY", rates[1].SourceCurrency.Code); + Assert.Equal(0.1532m, rates[1].Value); + } + [Fact] public void Parse_malformed_line_throws_with_line_number() { diff --git a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs index 19b1107d23..aca6ceadd8 100644 --- a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs +++ b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs @@ -10,6 +10,7 @@ public static class CnbDailyTxtParser private const string Header = "Country|Currency|Amount|Code|Rate"; private const int MaxLinePreviewLength = 100; private static readonly CultureInfo CsCulture = CultureInfo.GetCultureInfo("cs-CZ"); + private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; public static IEnumerable Parse(string rawContent) { @@ -40,12 +41,12 @@ public static IEnumerable Parse(string rawContent) throw CreateFormatException(i + 1, line, "Malformed CNB line."); } - if (!decimal.TryParse(parts[2].Trim(), NumberStyles.Number, CsCulture, out decimal amount)) + if (!TryParseCnbNumber(parts[2], out decimal amount)) { throw CreateFormatException(i + 1, line, "Invalid amount in CNB line."); } - if (!decimal.TryParse(parts[4].Trim(), NumberStyles.Number, CsCulture, out decimal rate)) + if (!TryParseCnbNumber(parts[4], out decimal rate)) { throw CreateFormatException(i + 1, line, "Invalid rate in CNB line."); } @@ -73,4 +74,29 @@ private static FormatException CreateFormatException(int lineNumber, string line return new FormatException($"{reason} Line {lineNumber}: '{preview}'"); } + + private static bool TryParseCnbNumber(string token, out decimal value) + { + value = 0m; + + string trimmedToken = token.Trim(); + if (trimmedToken.Length == 0) + { + return false; + } + + if (decimal.TryParse(trimmedToken, NumberStyles.Number, CsCulture, out value)) + { + return true; + } + + if (trimmedToken.Contains('.') && + !trimmedToken.Contains(',') && + decimal.TryParse(trimmedToken, NumberStyles.Number, InvariantCulture, out value)) + { + return true; + } + + return false; + } } From 31aa57304904ea62174c23a0287e46e47e0903cf Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:27:47 +0200 Subject: [PATCH 17/20] test(provider): replace Thread.Sleep with explicit sync in concurrency test ManualResetEventSlim guarantees task1 is holding the refresh semaphore before task2 starts, eliminating the timing dependency on a fixed sleep. Co-Authored-By: Claude Sonnet 4.6 --- .../Infrastructure/ExchangeRateProviderTests.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index 57daf0fe1e..a4561c98f5 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -179,20 +179,28 @@ public void GetExchangeRates_304_without_cache_fallback_to_full_get() [Fact] public async global::System.Threading.Tasks.Task GetExchangeRates_concurrent_calls_only_one_http_request() { + using ManualResetEventSlim handlerEntered = new(initialState: false); + using ManualResetEventSlim handlerRelease = new(initialState: false); int requestCount = 0; + ExchangeRateProvider provider = CreateProvider( _ => { Interlocked.Increment(ref requestCount); - Thread.Sleep(100); + handlerEntered.Set(); + handlerRelease.Wait(); return CreateOkResponse(DefaultPayload); }); global::System.Threading.Tasks.Task task1 = global::System.Threading.Tasks.Task.Run(() => provider.GetExchangeRates([new Currency("USD")]).ToArray()); + handlerEntered.Wait(); // task1 holds the refresh semaphore and is blocked in the handler + global::System.Threading.Tasks.Task task2 = global::System.Threading.Tasks.Task.Run(() => provider.GetExchangeRates([new Currency("EUR")]).ToArray()); + await global::System.Threading.Tasks.Task.Delay(20); // let task2 reach _refreshSemaphore.WaitAsync() + handlerRelease.Set(); await global::System.Threading.Tasks.Task.WhenAll(task1, task2); Assert.Equal(1, requestCount); From ee071cbf94ff52faa5463610e99bec500a82d7ce Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:31:55 +0200 Subject: [PATCH 18/20] refactor(provider): replace async wrapper with native sync implementation GetExchangeRates is now a genuine sync method: HttpClient.Send(), SemaphoreSlim.Wait(), and ReadAsStream()+StreamReader instead of their async counterparts. Removes the .GetAwaiter().GetResult() anti-pattern and the using-HttpRequestMessage-before-task-completes bug from SendRequest. IExchangeRateProvider exposes only the sync contract that DotNet.md requires. Co-Authored-By: Claude Sonnet 4.6 --- .../ExchangeRateProviderTests.cs | 2 +- .../Task/Application/IExchangeRateProvider.cs | 9 ---- .../Infrastructure/ExchangeRateProvider.cs | 46 +++++++------------ jobs/Backend/Task/Program.cs | 4 +- 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index a4561c98f5..710c2dc23b 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -198,7 +198,7 @@ public void GetExchangeRates_304_without_cache_fallback_to_full_get() global::System.Threading.Tasks.Task task2 = global::System.Threading.Tasks.Task.Run(() => provider.GetExchangeRates([new Currency("EUR")]).ToArray()); - await global::System.Threading.Tasks.Task.Delay(20); // let task2 reach _refreshSemaphore.WaitAsync() + await global::System.Threading.Tasks.Task.Delay(20); // let task2 reach _refreshSemaphore.Wait() handlerRelease.Set(); await global::System.Threading.Tasks.Task.WhenAll(task1, task2); diff --git a/jobs/Backend/Task/Application/IExchangeRateProvider.cs b/jobs/Backend/Task/Application/IExchangeRateProvider.cs index 4cd72f0180..8e579abdf1 100644 --- a/jobs/Backend/Task/Application/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/Application/IExchangeRateProvider.cs @@ -1,18 +1,9 @@ using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using ExchangeRateUpdater.Domain; namespace ExchangeRateUpdater.Application; public interface IExchangeRateProvider { - Task> GetExchangeRatesAsync( - IEnumerable currencies, - CancellationToken cancellationToken = default); - - /// - /// Synchronous wrapper preserved for compatibility with existing callers. - /// IEnumerable GetExchangeRates(IEnumerable currencies); } diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index d23b8c76cf..9a1a2f00b8 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Threading; -using System.Threading.Tasks; using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Domain; using Microsoft.Extensions.Logging; @@ -34,19 +34,8 @@ public ExchangeRateProvider( } public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return GetExchangeRatesAsync(currencies, CancellationToken.None) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - } - - public async Task> GetExchangeRatesAsync( - IEnumerable currencies, - CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(currencies); - cancellationToken.ThrowIfCancellationRequested(); HashSet requestedCodes = NormalizeRequestedCodes(currencies); if (requestedCodes.Count == 0) @@ -60,7 +49,7 @@ public async Task> GetExchangeRatesAsync( return FilterRates(snapshot.Rates, requestedCodes); } - await _refreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + _refreshSemaphore.Wait(); try { snapshot = Volatile.Read(ref _snapshot); @@ -69,7 +58,7 @@ public async Task> GetExchangeRatesAsync( return FilterRates(snapshot.Rates, requestedCodes); } - CacheSnapshot refreshedSnapshot = await FetchSnapshotAsync(snapshot, cancellationToken).ConfigureAwait(false); + CacheSnapshot refreshedSnapshot = FetchSnapshot(snapshot); Volatile.Write(ref _snapshot, refreshedSnapshot); return FilterRates(refreshedSnapshot.Rates, requestedCodes); } @@ -79,11 +68,11 @@ public async Task> GetExchangeRatesAsync( } } - private async Task FetchSnapshotAsync(CacheSnapshot currentSnapshot, CancellationToken cancellationToken) + private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) { HttpClient client = _httpClientFactory.CreateClient("cnb"); - using HttpResponseMessage response = - await SendRequestAsync(client, ifModifiedSince: currentSnapshot?.LastModified, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage response = SendRequest(client, ifModifiedSince: currentSnapshot?.LastModified); + if (response.StatusCode == HttpStatusCode.NotModified) { if (currentSnapshot is not null) @@ -93,17 +82,14 @@ private async Task FetchSnapshotAsync(CacheSnapshot currentSnapsh } _logger.LogWarning("CNB returned 304 without an existing snapshot. Retrying with a full GET."); - using HttpResponseMessage fallbackResponse = - await SendRequestAsync(client, ifModifiedSince: null, cancellationToken).ConfigureAwait(false); - return await BuildSnapshotFromSuccessResponseAsync(fallbackResponse, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage fallbackResponse = SendRequest(client, ifModifiedSince: null); + return BuildSnapshotFromSuccessResponse(fallbackResponse); } - return await BuildSnapshotFromSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); + return BuildSnapshotFromSuccessResponse(response); } - private async Task BuildSnapshotFromSuccessResponseAsync( - HttpResponseMessage response, - CancellationToken cancellationToken) + private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { @@ -113,7 +99,10 @@ private async Task BuildSnapshotFromSuccessResponseAsync( response.StatusCode); } - string rawContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using Stream stream = response.Content.ReadAsStream(); + using StreamReader reader = new(stream); + string rawContent = reader.ReadToEnd(); + ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); DateTimeOffset now = _timeProvider.GetUtcNow(); DateTimeOffset? lastModified = response.Content.Headers.LastModified; @@ -122,10 +111,7 @@ private async Task BuildSnapshotFromSuccessResponseAsync( return new CacheSnapshot(rates, now, lastModified); } - private Task SendRequestAsync( - HttpClient client, - DateTimeOffset? ifModifiedSince, - CancellationToken cancellationToken) + private HttpResponseMessage SendRequest(HttpClient client, DateTimeOffset? ifModifiedSince) { using HttpRequestMessage request = new(HttpMethod.Get, _options.Url); if (ifModifiedSince is not null) @@ -133,7 +119,7 @@ private Task SendRequestAsync( request.Headers.IfModifiedSince = ifModifiedSince; } - return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return client.Send(request, HttpCompletionOption.ResponseHeadersRead); } private HashSet NormalizeRequestedCodes(IEnumerable currencies) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 630bd0d4f7..c60125b806 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -16,7 +16,7 @@ namespace ExchangeRateUpdater; public static class Program { - public static async Task Main(string[] args) + public static int Main(string[] args) { using IHost host = BuildHost(args); ILogger logger = host.Services.GetRequiredService().CreateLogger("ExchangeRateUpdater"); @@ -25,7 +25,7 @@ public static async Task Main(string[] args) { IExchangeRateProvider provider = host.Services.GetRequiredService(); IEnumerable currencies = BuildRequestedCurrencies(args); - IReadOnlyCollection rates = await provider.GetExchangeRatesAsync(currencies); + IEnumerable rates = provider.GetExchangeRates(currencies); foreach (ExchangeRate rate in rates) { From f0ce22338a8f437aad1ed072333c1b264ecf1533 Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 13:37:09 +0200 Subject: [PATCH 19/20] fix: correct timeout exception type, dead code and stream ownership - OperationCanceledException instead of TimeoutException in ShouldHandle: HttpClient throws TaskCanceledException on timeout, not TimeoutException - Remove IsNullOrWhiteSpace(currency.Code): Currency constructor already guarantees Code is non-null and non-whitespace - StreamReader with leaveOpen: true so the enclosing using Stream owns disposal and the reader does not double-dispose it Co-Authored-By: Claude Sonnet 4.6 --- jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs | 4 ++-- jobs/Backend/Task/Program.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index 9a1a2f00b8..cce974b2c7 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -100,7 +100,7 @@ private CacheSnapshot BuildSnapshotFromSuccessResponse(HttpResponseMessage respo } using Stream stream = response.Content.ReadAsStream(); - using StreamReader reader = new(stream); + using StreamReader reader = new(stream, leaveOpen: true); string rawContent = reader.ReadToEnd(); ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); @@ -127,7 +127,7 @@ private HashSet NormalizeRequestedCodes(IEnumerable currencies HashSet requestedCodes = new(StringComparer.Ordinal); foreach (Currency currency in currencies) { - if (currency is null || string.IsNullOrWhiteSpace(currency.Code)) + if (currency is null) { continue; } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index c60125b806..a2a21c1bbd 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -79,7 +79,7 @@ internal static IHost BuildHost(string[] args) { int statusCode = args.Outcome.Result is null ? 0 : (int)args.Outcome.Result.StatusCode; bool shouldRetryStatus = statusCode == 408 || statusCode == 429 || statusCode >= 500; - bool shouldRetryException = args.Outcome.Exception is HttpRequestException or TimeoutException; + bool shouldRetryException = args.Outcome.Exception is HttpRequestException or OperationCanceledException; return ValueTask.FromResult(shouldRetryStatus || shouldRetryException); } }); From 401e9c249eb9aebf7d5faa169a255da3193bb40f Mon Sep 17 00:00:00 2001 From: davidvalero Date: Wed, 22 Apr 2026 16:50:10 +0200 Subject: [PATCH 20/20] fix: code quality, nullable annotations, observability and contract enforcement - Enable enable and fix all nullable warnings - Fix broken string truncation in CnbDailyTxtParser error messages - Throw ArgumentException on null items in currencies collection (was silently ignored) - Implement IDisposable on ExchangeRateProvider to dispose SemaphoreSlim - Add LogDebug for cache hits and log level config for clean output - Silence HttpClient and Polly info logs, surface Polly warnings Co-Authored-By: Claude Sonnet 4.6 --- .../Domain/CnbDailyTxtParserTests.cs | 16 ++++++++++++++ .../ExchangeRateProviderTests.cs | 22 +++++++++++++++++++ jobs/Backend/Task/Domain/CnbDailyTxtParser.cs | 2 +- jobs/Backend/Task/ExchangeRateUpdater.csproj | 1 + .../Infrastructure/CnbOptionsValidator.cs | 4 ++-- .../Infrastructure/ExchangeRateProvider.cs | 16 +++++++++----- jobs/Backend/Task/appsettings.json | 8 +++++++ 7 files changed, 60 insertions(+), 9 deletions(-) diff --git a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs index c56f6feb0e..97f81da498 100644 --- a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs +++ b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs @@ -150,4 +150,20 @@ public void Parse_rejects_zero_or_negative_rate() Assert.Contains("Rate must be greater than zero.", ex.Message); Assert.Contains("Line 3", ex.Message); } + + [Fact] + public void Parse_malformed_line_longer_than_100_chars_is_truncated_in_error() + { + string longCountryName = new('A', 101); + string payload = $""" + 22.04.2026 #78 + Country|Currency|Amount|Code|Rate + {longCountryName}|dollar|1|AUD + """; + + FormatException ex = Assert.Throws(() => CnbDailyTxtParser.Parse(payload).ToArray()); + + Assert.Contains("...", ex.Message); + Assert.DoesNotContain(longCountryName, ex.Message); + } } diff --git a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs index 710c2dc23b..33c4f574f5 100644 --- a/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -24,6 +24,28 @@ public void GetExchangeRates_null_currencies_throws() Assert.Throws(() => provider.GetExchangeRates(null!)); } + [Fact] + public void GetExchangeRates_null_item_in_currencies_throws() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ArgumentException ex = Assert.Throws( + () => provider.GetExchangeRates([null!, new Currency("USD")]).ToArray()); + + Assert.Equal("currencies", ex.ParamName); + } + + [Fact] + public void GetExchangeRates_collection_with_only_null_items_throws() + { + ExchangeRateProvider provider = CreateProvider(_ => CreateOkResponse(DefaultPayload)); + + ArgumentException ex = Assert.Throws( + () => provider.GetExchangeRates([null!]).ToArray()); + + Assert.Equal("currencies", ex.ParamName); + } + [Fact] public void GetExchangeRates_empty_currencies_returns_empty() { diff --git a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs index aca6ceadd8..7ca56d0986 100644 --- a/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs +++ b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs @@ -70,7 +70,7 @@ private static FormatException CreateFormatException(int lineNumber, string line { string preview = lineContent.Length <= MaxLinePreviewLength ? lineContent - : string.Concat(lineContent.Take(MaxLinePreviewLength), "..."); + : lineContent[..MaxLinePreviewLength] + "..."; return new FormatException($"{reason} Line {lineNumber}: '{preview}'"); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index c30119be3a..bbf792d79b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,6 +2,7 @@ Exe net10.0 + enable diff --git a/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs b/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs index 9d0ba19145..229ed328ac 100644 --- a/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs +++ b/jobs/Backend/Task/Infrastructure/CnbOptionsValidator.cs @@ -5,7 +5,7 @@ namespace ExchangeRateUpdater.Infrastructure; public sealed class CnbOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string name, CnbOptions options) + public ValidateOptionsResult Validate(string? name, CnbOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -14,7 +14,7 @@ public ValidateOptionsResult Validate(string name, CnbOptions options) return ValidateOptionsResult.Fail("Cnb:Url is required."); } - if (!Uri.TryCreate(options.Url, UriKind.Absolute, out Uri uri)) + if (!Uri.TryCreate(options.Url, UriKind.Absolute, out Uri? uri)) { return ValidateOptionsResult.Fail("Cnb:Url must be a valid absolute URI."); } diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs index cce974b2c7..ed1ac47b3e 100644 --- a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -12,20 +12,20 @@ namespace ExchangeRateUpdater.Infrastructure; -public sealed class ExchangeRateProvider : IExchangeRateProvider +public sealed class ExchangeRateProvider : IExchangeRateProvider, IDisposable { private readonly IHttpClientFactory _httpClientFactory; private readonly CnbOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly SemaphoreSlim _refreshSemaphore = new(1, 1); - private CacheSnapshot _snapshot; + private CacheSnapshot? _snapshot; public ExchangeRateProvider( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger, - TimeProvider timeProvider = null) + TimeProvider? timeProvider = null) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); @@ -43,9 +43,10 @@ public IEnumerable GetExchangeRates(IEnumerable currenci return []; } - CacheSnapshot snapshot = Volatile.Read(ref _snapshot); + CacheSnapshot? snapshot = Volatile.Read(ref _snapshot); if (snapshot is not null && IsFresh(snapshot)) { + _logger.LogDebug("Serving exchange rates from cache (cached at {CachedAt}).", snapshot.CachedAt); return FilterRates(snapshot.Rates, requestedCodes); } @@ -55,6 +56,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci snapshot = Volatile.Read(ref _snapshot); if (snapshot is not null && IsFresh(snapshot)) { + _logger.LogDebug("Serving exchange rates from cache (cached at {CachedAt}).", snapshot.CachedAt); return FilterRates(snapshot.Rates, requestedCodes); } @@ -68,7 +70,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci } } - private CacheSnapshot FetchSnapshot(CacheSnapshot currentSnapshot) + private CacheSnapshot FetchSnapshot(CacheSnapshot? currentSnapshot) { HttpClient client = _httpClientFactory.CreateClient("cnb"); using HttpResponseMessage response = SendRequest(client, ifModifiedSince: currentSnapshot?.LastModified); @@ -129,7 +131,7 @@ private HashSet NormalizeRequestedCodes(IEnumerable currencies { if (currency is null) { - continue; + throw new ArgumentException("Currencies collection must not contain null elements.", nameof(currencies)); } if (currency.Code == "CZK") @@ -154,4 +156,6 @@ private static ExchangeRate[] FilterRates(IEnumerable rates, HashS { return rates.Where(rate => requestedCodes.Contains(rate.SourceCurrency.Code)).ToArray(); } + + public void Dispose() => _refreshSemaphore.Dispose(); } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index aa862a9525..5f765d32e3 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,4 +1,12 @@ { + "Logging": { + "LogLevel": { + "Default": "Warning", + "ExchangeRateUpdater": "Information", + "System.Net.Http.HttpClient": "Warning", + "Polly": "Warning" + } + }, "Cnb": { "Url": "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt", "TimeoutSeconds": 10,