diff --git a/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs new file mode 100644 index 0000000000..97f81da498 --- /dev/null +++ b/jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs @@ -0,0 +1,169 @@ +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); + } + + [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() + { + 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); + } + + [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/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.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.Tests/Infrastructure/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..33c4f574f5 --- /dev/null +++ b/jobs/Backend/Task.Tests/Infrastructure/ExchangeRateProviderTests.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 Microsoft.Extensions.Time.Testing; +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_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() + { + 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); + } + + [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() + { + 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() + { + using ManualResetEventSlim handlerEntered = new(initialState: false); + using ManualResetEventSlim handlerRelease = new(initialState: false); + int requestCount = 0; + + ExchangeRateProvider provider = CreateProvider( + _ => + { + Interlocked.Increment(ref requestCount); + 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.Wait() + + handlerRelease.Set(); + 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); + } + + [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); + } + + private static ExchangeRateProvider CreateProvider( + Func onSend, + int cacheTtlSeconds) + { + return CreateProvider( + new FakeHttpClientFactory(new HttpClient(new DelegateHttpMessageHandler(onSend))), + cacheTtlSeconds, + timeProvider: null); + } + + private static ExchangeRateProvider CreateProvider(FakeHttpClientFactory factory) + { + 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, + FakeTimeProvider? timeProvider) + { + return new ExchangeRateProvider( + factory, + Options.Create(new CnbOptions + { + Url = "https://example.test/cnb.txt", + CacheTtlSeconds = cacheTtlSeconds + }), + NullLogger.Instance, + timeProvider); + } + + private static HttpResponseMessage CreateOkResponse(string content, DateTimeOffset? lastModified = null) + { + 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 = """ + 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 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; } + + 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.Tests/Task.Tests.csproj b/jobs/Backend/Task.Tests/Task.Tests.csproj new file mode 100644 index 0000000000..e7507d67ed --- /dev/null +++ b/jobs/Backend/Task.Tests/Task.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + 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); +} 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/CnbDailyTxtParser.cs b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs new file mode 100644 index 0000000000..7ca56d0986 --- /dev/null +++ b/jobs/Backend/Task/Domain/CnbDailyTxtParser.cs @@ -0,0 +1,102 @@ +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"); + private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; + + public static IEnumerable Parse(string rawContent) + { + ArgumentNullException.ThrowIfNull(rawContent); + + string[] lines = rawContent + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + int headerIndex = Array.FindIndex(lines, line => line.Trim() == Header); + if (headerIndex < 0) + { + throw new FormatException("Missing CNB header line."); + } + + for (int i = headerIndex + 1; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] parts = line.Split('|'); + if (parts.Length != 5) + { + throw CreateFormatException(i + 1, line, "Malformed CNB line."); + } + + if (!TryParseCnbNumber(parts[2], out decimal amount)) + { + throw CreateFormatException(i + 1, line, "Invalid amount in CNB line."); + } + + if (!TryParseCnbNumber(parts[4], out decimal rate)) + { + 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 + : lineContent[..MaxLinePreviewLength] + "..."; + + 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; + } +} diff --git a/jobs/Backend/Task/Domain/Currency.cs b/jobs/Backend/Task/Domain/Currency.cs new file mode 100644 index 0000000000..3aca25dc74 --- /dev/null +++ b/jobs/Backend/Task/Domain/Currency.cs @@ -0,0 +1,23 @@ +using System; + +namespace ExchangeRateUpdater.Domain; + +public sealed class Currency +{ + public Currency(string 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; } + + 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..5af95ec974 --- /dev/null +++ b/jobs/Backend/Task/Domain/ExchangeRate.cs @@ -0,0 +1,30 @@ +using System; + +namespace ExchangeRateUpdater.Domain; + +public sealed class ExchangeRate +{ + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency)); + TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency)); + + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Exchange rate value must be greater than zero."); + } + + 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.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..bbf792d79b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,22 @@ - - + Exe - net6.0 + net10.0 + enable - \ No newline at end of file + + + + + + + + + + + + PreserveNewest + + + 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/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..229ed328ac --- /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."); + } +} diff --git a/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs new file mode 100644 index 0000000000..ed1ac47b3e --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/ExchangeRateProvider.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +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, 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; + + public ExchangeRateProvider( + IHttpClientFactory httpClientFactory, + IOptions options, + 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) + { + 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)) + { + _logger.LogDebug("Serving exchange rates from cache (cached at {CachedAt}).", snapshot.CachedAt); + return FilterRates(snapshot.Rates, requestedCodes); + } + + _refreshSemaphore.Wait(); + try + { + 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); + } + + CacheSnapshot refreshedSnapshot = FetchSnapshot(snapshot); + Volatile.Write(ref _snapshot, refreshedSnapshot); + return FilterRates(refreshedSnapshot.Rates, requestedCodes); + } + finally + { + _refreshSemaphore.Release(); + } + } + + private CacheSnapshot FetchSnapshot(CacheSnapshot? currentSnapshot) + { + HttpClient client = _httpClientFactory.CreateClient("cnb"); + 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 = _timeProvider.GetUtcNow() }; + } + + _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( + $"CNB request failed with status code {(int)response.StatusCode} ({response.StatusCode}).", + inner: null, + response.StatusCode); + } + + using Stream stream = response.Content.ReadAsStream(); + using StreamReader reader = new(stream, leaveOpen: true); + string rawContent = reader.ReadToEnd(); + + ExchangeRate[] rates = CnbDailyTxtParser.Parse(rawContent).ToArray(); + DateTimeOffset now = _timeProvider.GetUtcNow(); + DateTimeOffset? lastModified = response.Content.Headers.LastModified; + + _logger.LogDebug("Fetched {RatesCount} rates from CNB.", rates.Length); + 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, HttpCompletionOption.ResponseHeadersRead); + } + + private HashSet NormalizeRequestedCodes(IEnumerable currencies) + { + HashSet requestedCodes = new(StringComparer.Ordinal); + foreach (Currency currency in currencies) + { + if (currency is null) + { + throw new ArgumentException("Currencies collection must not contain null elements.", nameof(currencies)); + } + + if (currency.Code == "CZK") + { + continue; + } + + requestedCodes.Add(currency.Code); + } + + return requestedCodes; + } + + private bool IsFresh(CacheSnapshot snapshot) + { + TimeSpan ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds); + DateTimeOffset now = _timeProvider.GetUtcNow(); + return now - snapshot.CachedAt < ttl; + } + + private static ExchangeRate[] FilterRates(IEnumerable rates, HashSet requestedCodes) + { + return rates.Where(rate => requestedCodes.Contains(rate.SourceCurrency.Code)).ToArray(); + } + + public void Dispose() => _refreshSemaphore.Dispose(); +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..a2a21c1bbd 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,43 +1,106 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; +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 +namespace ExchangeRateUpdater; + +public static class Program { - public static class Program + public static int 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") - }; + using IHost host = BuildHost(args); + ILogger logger = host.Services.GetRequiredService().CreateLogger("ExchangeRateUpdater"); - public static void Main(string[] args) + try { - try + IExchangeRateProvider provider = host.Services.GetRequiredService(); + IEnumerable currencies = BuildRequestedCurrencies(args); + IEnumerable rates = provider.GetExchangeRates(currencies); + + foreach (ExchangeRate rate in rates) { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + logger.LogInformation("{Rate}", rate); + } + + return 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to retrieve exchange rates from CNB."); + return 1; + } + } - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) + internal static IHost BuildHost(string[] args) + { + HostApplicationBuilderSettings settings = new() + { + Args = args, + ContentRootPath = AppContext.BaseDirectory + }; + HostApplicationBuilder builder = Host.CreateApplicationBuilder(settings); + + 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) => { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) + 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 OperationCanceledException; + return ValueTask.FromResult(shouldRetryStatus || shouldRetryException); + } + }); + }); + + return builder.Build(); + } + + private static IEnumerable BuildRequestedCurrencies(string[] args) + { + if (args.Length > 0) + { + foreach (string arg in args) { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + yield return new Currency(arg); } - Console.ReadLine(); + 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 new file mode 100644 index 0000000000..5f765d32e3 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,16 @@ +{ + "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, + "MaxRetries": 3, + "CacheTtlSeconds": 3600 + } +}