From b2a01f462b56793b9ce227e2bd5fe05cbf8e80f6 Mon Sep 17 00:00:00 2001 From: LeveretJZ Date: Mon, 10 Nov 2025 09:33:12 +0100 Subject: [PATCH] implemented ExchangeRateProvider for Daily Exchange Rates Loading --- jobs/Backend/Task/ExchangeRateProvider.cs | 19 --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 - jobs/Backend/Task/ExchangeRateUpdater.sln | 28 +++- .../Contracts/IDateTimeService.cs | 12 ++ .../Contracts/IExchangeRateLoader.cs | 12 ++ .../Contracts/IRateRefreshSchedule.cs | 8 + .../DataSources/Cnb/CnbExchangeRateLoader.cs | 94 +++++++++++ .../DataSources/Cnb/CnbRateConverter.cs | 29 ++++ .../DataSources/Cnb/Dto/CnbRate.cs | 28 ++++ .../DataSources/Cnb/Dto/CnbRateResponse.cs | 9 ++ .../DataSources/RateLoaderException.cs | 7 + .../DefaultRateRefreshScheduler.cs | 32 ++++ .../RefreshSchedule/NoRefreshScheduler.cs | 11 ++ .../RefreshSchedule/RefreshScheduleFactory.cs | 27 ++++ .../ExchangeRateProvider.cs | 106 +++++++++++++ .../ExchangeRateUpdater.csproj | 29 ++++ .../Infrastructure/Config/AppConfiguration.cs | 6 + .../Config/ExchangeRateLoaderConfig.cs | 8 + .../Config/RefreshScheduleConfig.cs | 7 + .../Infrastructure/Constants.cs | 6 + .../Infrastructure/DependencyExtensions.cs | 35 ++++ .../Infrastructure/HttpRequestHandler.cs | 20 +++ .../Infrastructure/IRefreshScheduleFactory.cs | 9 ++ .../Infrastructure/IRequestHandler.cs | 9 ++ .../Infrastructure/LocalDateTimeService.cs | 22 +++ .../Models}/Currency.cs | 8 +- .../Models}/ExchangeRate.cs | 2 +- .../Models/IsoCurrencyCode.cs | 34 ++++ .../Task/ExchangeRateUpdater/Program.cs | 53 +++++++ .../Task/ExchangeRateUpdater/appSettings.json | 9 ++ .../CnbExchangeRateLoaderTests.cs | 150 ++++++++++++++++++ .../DefaultRateRefreshSchedulerTests.cs | 49 ++++++ .../ExchangeRateProviderTests.cs | 132 +++++++++++++++ .../ExchangeRateUpdaterTests.csproj | 31 ++++ jobs/Backend/Task/Program.cs | 43 ----- 35 files changed, 1016 insertions(+), 76 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs rename jobs/Backend/Task/{ => ExchangeRateUpdater/Models}/Currency.cs (60%) rename jobs/Backend/Task/{ => ExchangeRateUpdater/Models}/ExchangeRate.cs (93%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/appSettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj delete mode 100644 jobs/Backend/Task/Program.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12b..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..b6c364128d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,18 +3,44 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.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 + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.Build.0 = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.Build.0 = Debug|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.Build.0 = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.ActiveCfg = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.Build.0 = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.ActiveCfg = Release|Any CPU + {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs new file mode 100644 index 0000000000..5c552f25fd --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs @@ -0,0 +1,12 @@ +using System; + +namespace ExchangeRateUpdater.Contracts; + +public interface IDateTimeService +{ + DateTime GetUtcNow(); + + DateTime GetNow(); + + DateTime GetToday(); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs new file mode 100644 index 0000000000..866b3225c8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Contracts; + +public interface IExchangeRateLoader +{ + IRateRefreshScheduler RateRefreshSchedule { get; } + Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime date); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs new file mode 100644 index 0000000000..18e2c3b97c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs @@ -0,0 +1,8 @@ +using System; + +namespace ExchangeRateUpdater.Contracts; + +public interface IRateRefreshScheduler +{ + DateTimeOffset GetNextRefreshTime(); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs new file mode 100644 index 0000000000..f86821372c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.DataSource.Cnb.Dto; +using ExchangeRateUpdater.DataSources; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Infrastructure.Config; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.DataSource.Cnb; + +internal class CnbExchangeRateLoader : IExchangeRateLoader +{ + private const string DailyExchangeRateUrlPath = "/cnbapi/exrates/daily?date={0}&lang=EN"; + private readonly IRequestHandler requestHandler; + private readonly CnbRateConverter converter; + private readonly ILogger logger; + private readonly IDateTimeService dateTimeService; + private readonly Uri baseApiUri; + private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + public CnbExchangeRateLoader(IRequestHandler requestHandler, + CnbRateConverter converter, + ILogger logger, + IRefreshScheduleFactory refreshScheduleFactory, + IDateTimeService dateTimeService, + [FromKeyedServices(Constants.CnbServiceKey)] ExchangeRateLoaderConfig config) + { + this.requestHandler = requestHandler; + this.converter = converter; + this.logger = logger; + this.dateTimeService = dateTimeService; + baseApiUri = new Uri(config.BaseApiUrl); + RateRefreshSchedule = refreshScheduleFactory.CreateRefreshSchedule(config.RefreshScheduleConfig); + logger.LogInformation("Initialized data source with URL {} and refresh schedule: {}", baseApiUri, RateRefreshSchedule); + } + + public IRateRefreshScheduler RateRefreshSchedule { get; } + + // discussable: can consider adding "retry" logic inside this method. In this case the method will need CancellationToken parameter to stop pending retries. + public async Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime date) + { + ArgumentNullException.ThrowIfNull(currencies); + + if (date >= dateTimeService.GetToday().AddDays(1)) + throw new ArgumentException("The date should not be in the future", nameof(date)); + + if (!currencies.Any()) + { + return []; + } + + try + { + var dateParam = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var requestUri = new Uri(baseApiUri, string.Format(DailyExchangeRateUrlPath, dateParam)).ToString(); + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + using var stream = await requestHandler.GetStreamAsync(requestUri); + + CnbRateResponse response = await JsonSerializer.DeserializeAsync(stream, serializerOptions); + + stopwatch.Stop(); // Further the elapsed time can be put into metrics + logger.LogInformation("Loaded {} rates in {} ms", response.Rates.Length, stopwatch.ElapsedMilliseconds); + + var requestedCurrencies = new HashSet(currencies); + var result = response.Rates + .Where(r => requestedCurrencies.Contains(r.CurrencyCode)) + .Select(converter.Convert) + .Where(r => r != null) + .ToList(); + + return result; + } + catch (HttpRequestException ex) + { + throw new RateLoaderException($"API responded with error status code: {ex.StatusCode}", ex); + } + catch (Exception ex) + { + throw new RateLoaderException("Unable load exchange rates from CNB", ex); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs new file mode 100644 index 0000000000..3bd0ba170c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs @@ -0,0 +1,29 @@ +using System; +using ExchangeRateUpdater.DataSource.Cnb.Dto; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.DataSource.Cnb; + +internal class CnbRateConverter +{ + private readonly ILogger logger; + + public CnbRateConverter( ILogger logger) + { + this.logger = logger; + } + + public ExchangeRate Convert(CnbRate rate) + { + IsoCurrencyCode sourceCurrency; + // poor man validation + if (!Enum.TryParse(rate.CurrencyCode, out sourceCurrency) || rate.Amount <= 0) + { + logger.LogWarning("Invalid rate for {} received from CNB, ignoring", rate.CurrencyCode); + return null; + } + + return new ExchangeRate(new Currency(sourceCurrency), new Currency(IsoCurrencyCode.CZK), rate.Rate / rate.Amount); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs new file mode 100644 index 0000000000..50ff69d935 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.DataSource.Cnb.Dto; + +internal sealed record CnbRate +{ + [JsonPropertyName("amount")] + public long Amount { get; init; } + + [JsonPropertyName("country")] + public string Country { get; init; } + + [JsonPropertyName("currency")] + public string Currency { get; init; } + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; init; } + + [JsonPropertyName("order")] + public int Order { get; init; } + + [JsonPropertyName("rate")] + public Decimal Rate { get; init; } + + [JsonPropertyName("validFor")] + public string ValidFor { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs new file mode 100644 index 0000000000..1a8531d15a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.DataSource.Cnb.Dto; + +internal sealed record CnbRateResponse +{ + [JsonPropertyName("rates")] + public CnbRate[] Rates { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs new file mode 100644 index 0000000000..2b81d5be94 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs @@ -0,0 +1,7 @@ +using System; + +namespace ExchangeRateUpdater.DataSources; + +public class RateLoaderException(string message, Exception inner) : Exception(message, inner) +{ +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs new file mode 100644 index 0000000000..aeadab60d8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs @@ -0,0 +1,32 @@ +using System; +using ExchangeRateUpdater.Contracts; + +namespace ExchangeRateUpdater.DataSources.RefreshSchedule; + +internal class DefaultRateRefreshScheduler : IRateRefreshScheduler +{ + private readonly TimeOnly time; + private readonly TimeZoneInfo timeZone; + private readonly IDateTimeService dateTimeService; + + public DefaultRateRefreshScheduler(TimeOnly time, TimeZoneInfo timeZone, IDateTimeService dateTimeService) + { + this.time = time; + this.timeZone = timeZone; + this.dateTimeService = dateTimeService; + } + + public DateTimeOffset GetNextRefreshTime() + { + var refreshTimeForCurrentDateInUtc = + TimeZoneInfo.ConvertTimeToUtc(new DateTime(DateOnly.FromDateTime(dateTimeService.GetToday()), time, DateTimeKind.Unspecified), timeZone); + + DateTime nextRefreshDate = dateTimeService.GetUtcNow() < refreshTimeForCurrentDateInUtc ? + refreshTimeForCurrentDateInUtc : + refreshTimeForCurrentDateInUtc.AddDays(1); + + return nextRefreshDate; + } + + public override string ToString() => $"{time:t} {timeZone.StandardName}"; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs new file mode 100644 index 0000000000..71f526959a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs @@ -0,0 +1,11 @@ +using System; +using ExchangeRateUpdater.Contracts; + +namespace ExchangeRateUpdater.DataSources.RefreshSchedule; + +internal sealed class NoRefreshScheduler : IRateRefreshScheduler +{ + public DateTimeOffset GetNextRefreshTime() => DateTimeOffset.MaxValue; + + public override string ToString() => "No refresh"; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs new file mode 100644 index 0000000000..66793f5ef9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs @@ -0,0 +1,27 @@ +using System; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Infrastructure.Config; + +namespace ExchangeRateUpdater.DataSources.RefreshSchedule; + +internal class RefreshScheduleFactory : IRefreshScheduleFactory +{ + private readonly IDateTimeService dateTimeService; + + public RefreshScheduleFactory(IDateTimeService dateTimeService) + { + this.dateTimeService = dateTimeService; + } + + public IRateRefreshScheduler CreateRefreshSchedule(RefreshScheduleConfig config) + { + return config == null + ? new NoRefreshScheduler() + : new DefaultRateRefreshScheduler( + TimeOnly.Parse(config.Time), + TimeZoneInfo.FindSystemTimeZoneById(config.TimeZone), + dateTimeService); + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs new file mode 100644 index 0000000000..62236a5a3f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater +{ + public class ExchangeRateProvider + { + private readonly ILogger logger; + private readonly IExchangeRateLoader loader; + private readonly IMemoryCache currencyCache; + private readonly IDateTimeService dateTimeService; + + public ExchangeRateProvider(IExchangeRateLoader loader, + ILogger logger, + IMemoryCache currencyCache, + IDateTimeService dateTimeService) + { + this.loader = loader; + this.logger = logger; + this.currencyCache = currencyCache; + this.dateTimeService = dateTimeService; + } + + /// + /// 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. + /// + /// + // discussable: do we need force manual cleaning cache during runtime? + public async Task> GetExchangeRates(IEnumerable currencies) + { + ArgumentNullException.ThrowIfNull(currencies); + + if (!currencies.Any()) return []; + + var ratesFromCache = GetRatesFromCache(currencies); + var currenciesFromCache = ratesFromCache.Select(r => r.SourceCurrency); + var currenciesToLoad = currencies.Except(currenciesFromCache).ToList(); + if (currenciesToLoad.Count == 0) + { + logger.LogInformation("All rates loaded from cache - no need to call Loader"); + return ratesFromCache; + } + + var ratesFromDataSource = await GetRatesFromDataSource(currenciesToLoad); + + PutRatesToCache(ratesFromDataSource); + + logger.LogInformation("Loaded {} rates from data source, {} rates from cache", ratesFromDataSource.Count, ratesFromCache.Count); + + return ratesFromDataSource.Union(ratesFromCache); + } + + private List GetRatesFromCache(IEnumerable currencies) + { + var ratesFromCache = new List(); + + foreach (var currency in currencies) + { + if (currencyCache.TryGetValue(currency.Code, out var exchangeRate)) + { + ratesFromCache.Add(exchangeRate); + } + } + + return ratesFromCache; + } + + // discussable: can handle exceptions and return empty result, or can re-throw exceptions, or can return a combined "Result" object containing result and optional exception + private async Task> GetRatesFromDataSource(IEnumerable currenciesToLoad) + { + List ratesFromDataSource; + + try + { + ratesFromDataSource = (await loader.GetExchangeRatesAsync(currenciesToLoad.Select(c => c.Code.ToString()), dateTimeService.GetNow())).ToList(); + } + catch (Exception e) + { + logger.LogError("Failed loading rates from data source: {}", e.Message); + ratesFromDataSource = []; + } + + return ratesFromDataSource; + } + + private void PutRatesToCache(IEnumerable ratesFromDataSource) + { + DateTimeOffset expiration = loader.RateRefreshSchedule.GetNextRefreshTime(); + + foreach (var loadedRate in ratesFromDataSource) + { + currencyCache.Set(loadedRate.SourceCurrency.Code, loadedRate, expiration); + logger.LogInformation("Cached rate {} with expiration: {}", loadedRate, expiration); + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 0000000000..4400107331 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + Always + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs new file mode 100644 index 0000000000..3dfae2acac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Infrastructure.Config; + +internal sealed record AppConfiguration +{ + public required ExchangeRateLoaderConfig CnbExchangeRateLoaderConfig { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs new file mode 100644 index 0000000000..c7051b4203 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Infrastructure.Config; + +internal record ExchangeRateLoaderConfig +{ + public required string BaseApiUrl { get; init; } + + public RefreshScheduleConfig RefreshScheduleConfig { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs new file mode 100644 index 0000000000..8a84052c3a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Infrastructure.Config; + +internal record RefreshScheduleConfig +{ + public required string Time { get; init; } + public required string TimeZone { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs new file mode 100644 index 0000000000..8f1572ffa8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Infrastructure; + +public static class Constants +{ + public const string CnbServiceKey = "cnb"; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs new file mode 100644 index 0000000000..6d7be4795e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs @@ -0,0 +1,35 @@ +using System.Net.Http; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.DataSource.Cnb; +using ExchangeRateUpdater.DataSources.RefreshSchedule; +using ExchangeRateUpdater.Infrastructure.Config; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure; + +public static class DependencyExtensions +{ + public static IServiceCollection AddExchangeRatesProvider(this IServiceCollection services) + { + services.AddLogging(builder => builder.AddConsole()); + services.AddMemoryCache(); + services.AddSingleton(); + + var config = new ConfigurationBuilder() + .AddJsonFile("appSettings.json") + .Build() + .Get(); // TODO: return default configuration if can't load from json; + services.AddKeyedSingleton(Constants.CnbServiceKey, config.CnbExchangeRateLoaderConfig); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs new file mode 100644 index 0000000000..5ee8e13ca4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs @@ -0,0 +1,20 @@ +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure; + +internal class HttpRequestHandler : IRequestHandler +{ + private HttpClient httpClient; + + public HttpRequestHandler(HttpClient httpClient) + { + this.httpClient = httpClient; + } + + public Task GetStreamAsync(string requestUri) + { + return httpClient.GetStreamAsync(requestUri); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs new file mode 100644 index 0000000000..fa3128aea1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Infrastructure.Config; + +namespace ExchangeRateUpdater.Infrastructure; + +internal interface IRefreshScheduleFactory +{ + IRateRefreshScheduler CreateRefreshSchedule(RefreshScheduleConfig config); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs new file mode 100644 index 0000000000..04e175d4af --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs @@ -0,0 +1,9 @@ +using System.IO; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure; + +internal interface IRequestHandler +{ + Task GetStreamAsync(string requestUri); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs new file mode 100644 index 0000000000..b66a7d3662 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs @@ -0,0 +1,22 @@ +using System; +using ExchangeRateUpdater.Contracts; + +namespace ExchangeRateUpdater.Infrastructure; + +internal class LocalDateTimeService : IDateTimeService +{ + public DateTime GetUtcNow() + { + return DateTime.UtcNow; + } + + public DateTime GetNow() + { + return DateTime.Now; + } + + public DateTime GetToday() + { + return DateTime.Today; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs similarity index 60% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs index f375776f25..fe80944103 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs @@ -1,8 +1,8 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { - public Currency(string code) + public Currency(IsoCurrencyCode code) { Code = code; } @@ -10,11 +10,11 @@ public Currency(string code) /// /// Three-letter ISO 4217 code of the currency. /// - public string Code { get; } + public IsoCurrencyCode Code { get; } public override string ToString() { - return Code; + return Code.ToString(); } } } diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs index 58c5bb10e0..2133586d44 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs new file mode 100644 index 0000000000..0bd5c51e73 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs @@ -0,0 +1,34 @@ +using System.Runtime.Serialization; + +namespace ExchangeRateUpdater.Models; + +/// +/// Three-letter ISO 4217 code of the currency. +/// +//[JsonConverter(typeof(StringEnumConverter))] ??? +public enum IsoCurrencyCode +{ + [EnumMember(Value = "USD")] + USD, + + [EnumMember(Value = "EUR")] + EUR, + + [EnumMember(Value = "CZK")] + CZK, + + [EnumMember(Value = "JPY")] + JPY, + + [EnumMember(Value = "KES")] + KES, + + [EnumMember(Value = "RUB")] + RUB, + + [EnumMember(Value = "THB")] + THB, + + [EnumMember(Value = "TRY")] + TRY +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs new file mode 100644 index 0000000000..f3c8d57ece --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater +{ + public static class Program + { + private static IEnumerable currencies = new[] + { + new Currency(IsoCurrencyCode.USD), + new Currency(IsoCurrencyCode.EUR), + new Currency(IsoCurrencyCode.CZK), + new Currency(IsoCurrencyCode.JPY), + new Currency(IsoCurrencyCode.KES), + new Currency(IsoCurrencyCode.RUB), + new Currency(IsoCurrencyCode.THB), + new Currency(IsoCurrencyCode.TRY) + }; + + public static async Task Main(string[] args) + { + IServiceProvider serviceProvider = GetConfiguredServiceProvider(); + + try + { + var provider = serviceProvider.GetRequiredService(); + var rates = await 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}'."); + } + } + + private static IServiceProvider GetConfiguredServiceProvider() + { + var services = new ServiceCollection(); + services.AddExchangeRatesProvider(); + return services.BuildServiceProvider(); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json new file mode 100644 index 0000000000..ae63d5a03d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json @@ -0,0 +1,9 @@ +{ + "CnbExchangeRateLoaderConfig": { + "BaseApiUrl": "https://api.cnb.cz", + "RefreshScheduleConfig": { + "Time": "14:30", + "TimeZone": "Central European Standard Time" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs new file mode 100644 index 0000000000..02b86c928d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using System.Text.Json; +using AutoFixture; +using AutoFixture.Xunit2; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.DataSource.Cnb; +using ExchangeRateUpdater.DataSource.Cnb.Dto; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Infrastructure.Config; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdaterTests; + +public class CnbExchangeRateLoaderTests +{ + private static readonly DateTime Today = new(2025, 11, 09); + + [Theory, LocalData] + internal async Task ThrowExceptionInCaseOfNullCurrenciesParam(CnbExchangeRateLoader loader) + { + await Assert.ThrowsAsync(() => loader.GetExchangeRatesAsync(null, Today)); + } + + [Theory, LocalData] + internal async Task ThrowExceptionIfDateIsInFuture(CnbExchangeRateLoader loader) + { + var tomorrow = Today.AddDays(1); + var ex = await Assert.ThrowsAsync(() => loader.GetExchangeRatesAsync([], tomorrow)); + + Assert.Equal("The date should not be in the future (Parameter 'date')", ex.Message); + } + + [Theory, LocalData] + internal async Task ReturnEmptyListIfNoCurrenciesWasRequested(CnbExchangeRateLoader loader) + { + var currencies = new string[0]; + var rates = await loader.GetExchangeRatesAsync(currencies, Today); + + Assert.Empty(rates); + } + + [Theory, LocalData] + internal void ThrowExceptionIfUriIsInvalid( + Mock requestHandlerMock, + Mock> loggerMock, + Mock factoryMock, + CnbRateConverter rateConverter, + Mock dateTimeServiceMock) + { + var incorrectUrl = "tratata"; + var config = new ExchangeRateLoaderConfig() { BaseApiUrl = incorrectUrl, RefreshScheduleConfig = null }; + + Assert.Throws( + () => new CnbExchangeRateLoader(requestHandlerMock.Object, rateConverter, loggerMock.Object, factoryMock.Object, dateTimeServiceMock.Object, config)); + } + + [Theory, LocalData] + internal async Task CanLoadRates( + Mock requestHandlerMock, + CnbExchangeRateLoader loader) + { + var response = new CnbRateResponse() + { + Rates = + [ + new CnbRate() { CurrencyCode = IsoCurrencyCode.EUR.ToString(), Amount = 1, Rate = 24.335M }, + new CnbRate() { CurrencyCode = IsoCurrencyCode.JPY.ToString(), Amount = 100, Rate = 14.11M }, + new CnbRate() { CurrencyCode = IsoCurrencyCode.TRY.ToString(), Amount = 100, Rate = 5.22M } + ] + }; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response))); + requestHandlerMock.Setup(h => h.GetStreamAsync(It.IsAny())).Returns(Task.FromResult(stream)); + + var result = (await loader.GetExchangeRatesAsync(["EUR", "JPY"], Today)).ToArray(); + + Assert.Equal(2, result.Length); + var eurRate = result.Single(r => r.SourceCurrency.Code == IsoCurrencyCode.EUR); + Assert.Equal(IsoCurrencyCode.EUR, eurRate.SourceCurrency.Code); + Assert.Equal(IsoCurrencyCode.CZK, eurRate.TargetCurrency.Code); + Assert.Equal(24.335M, eurRate.Value); + + var jpyRate = result.Single(r => r.SourceCurrency.Code == IsoCurrencyCode.JPY); + Assert.Equal(IsoCurrencyCode.JPY, jpyRate.SourceCurrency.Code); + Assert.Equal(IsoCurrencyCode.CZK, jpyRate.TargetCurrency.Code); + Assert.Equal(0.1411M, jpyRate.Value); + } + + [Theory, LocalData] + internal async Task DoNotReturnNonExistentCurrency( + Mock requestHandlerMock, + CnbExchangeRateLoader loader) + { + var response = new CnbRateResponse() + { + Rates = + [ + new CnbRate() { CurrencyCode = IsoCurrencyCode.EUR.ToString(), Amount = 1, Rate = 24.335M }, + new CnbRate() { CurrencyCode = "XYZ", Amount = 1, Rate = 15M } + ] + }; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response))); + requestHandlerMock.Setup(h => h.GetStreamAsync(It.IsAny())).Returns(Task.FromResult(stream)); + + var result = (await loader.GetExchangeRatesAsync(["XYZ", "EUR"], Today)).ToArray(); + + Assert.Single(result); + Assert.Equal(IsoCurrencyCode.EUR, result[0].SourceCurrency.Code); + Assert.Equal(IsoCurrencyCode.CZK, result[0].TargetCurrency.Code); + Assert.Equal(24.335M, result[0].Value); + } + + private class LocalDataAttribute : AutoDataAttribute + { + public LocalDataAttribute() : base(CreateFixture) { } + + private static IFixture CreateFixture() + { + var fixture = new Fixture(); + + var scheduleConfig = new RefreshScheduleConfig() { Time = "14:30", TimeZone = "Central European Standard Time" }; + var config = new ExchangeRateLoaderConfig() { BaseApiUrl = "https://api.tratata", RefreshScheduleConfig = scheduleConfig }; + + var loaderLoggerMock = new Mock>(); + var converterLoggerMock = new Mock>(); + + var requestHandlerMock = new Mock(); + + var refreshScheduleFactoryMock = new Mock(); + refreshScheduleFactoryMock.Setup(f => f.CreateRefreshSchedule(scheduleConfig)).Returns(new Mock().Object); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock.Setup(s => s.GetToday()).Returns(Today); + + var rateConverter = new CnbRateConverter(converterLoggerMock.Object); + + var loader = new CnbExchangeRateLoader(requestHandlerMock.Object, rateConverter, loaderLoggerMock.Object, refreshScheduleFactoryMock.Object, dateTimeServiceMock.Object, config); + + fixture.Inject(requestHandlerMock); + fixture.Inject(loaderLoggerMock); + fixture.Inject(refreshScheduleFactoryMock); + fixture.Inject(dateTimeServiceMock); + fixture.Inject(rateConverter); + fixture.Inject(loader); + + return fixture; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs new file mode 100644 index 0000000000..9c52e716b0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs @@ -0,0 +1,49 @@ +using AutoFixture.Xunit2; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.DataSources.RefreshSchedule; +using Moq; + +namespace ExchangeRateUpdaterTests; + +public class DefaultRateRefreshSchedulerTests +{ + [Theory, AutoData] + public void ReturnCurrentDateIfRefreshTimeHasNotPassed(Mock dateTimeServiceMock) + { + var utcNow = new DateTime(2025, 11, 9, 12, 45, 0); + var today = utcNow.Date; + + dateTimeServiceMock.Setup(s => s.GetUtcNow()).Returns(utcNow); + dateTimeServiceMock.Setup(s => s.GetToday()).Returns(today); + + var scheduler = new DefaultRateRefreshScheduler( + new TimeOnly(14, 30), + TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"), + dateTimeServiceMock.Object); + + var expectedRefreshTime = new DateTime(2025, 11, 9, 13, 30, 0); + var result = scheduler.GetNextRefreshTime(); + + Assert.Equal(expectedRefreshTime, result.DateTime); + } + + [Theory, AutoData] + public void ReturnNextDateIfRefreshTimeHasPassed(Mock dateTimeServiceMock) + { + var utcNow = new DateTime(2025, 11, 9, 13, 45, 0); + var today = utcNow.Date; + + dateTimeServiceMock.Setup(s => s.GetUtcNow()).Returns(utcNow); + dateTimeServiceMock.Setup(s => s.GetToday()).Returns(today); + + var scheduler = new DefaultRateRefreshScheduler( + new TimeOnly(14, 30), + TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"), + dateTimeServiceMock.Object); + + var expectedRefreshTime = new DateTime(2025, 11, 10, 13, 30, 0); + var result = scheduler.GetNextRefreshTime(); + + Assert.Equal(expectedRefreshTime, result.DateTime); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..05be02950d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs @@ -0,0 +1,132 @@ +using AutoFixture; +using AutoFixture.Xunit2; +using ExchangeRateUpdater; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdaterTests; + +public class ExchangeRateProviderTests +{ + [Theory, LocalData] + public async Task ThrowArgumentExceptionIfCurrenciesParamIsNull(ExchangeRateProvider provider) + { + await Assert.ThrowsAsync(() => provider.GetExchangeRates(null)); + } + + [Theory, LocalData] + public async Task ReturnEmptyListIfNoCurrenciesWasRequested(ExchangeRateProvider provider) + { + var rates = await provider.GetExchangeRates(Enumerable.Empty()); + + Assert.Empty(rates); + } + + [Theory, LocalData] + public async Task ReturnRatesFromCacheIfCacheHasIt(ExchangeRateProvider provider, Mock currencyCache, Mock loader) + { + var rate = new ExchangeRate(new Currency(IsoCurrencyCode.EUR), new Currency(IsoCurrencyCode.CZK), 24.335M); + + object? valueMock = rate; + currencyCache.Setup(c => c.TryGetValue(rate.SourceCurrency.Code, out valueMock)).Returns(true); + + var result = (await provider.GetExchangeRates([rate.SourceCurrency])).ToArray(); + + loader.Verify(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()), Times.Never); + + Assert.Single(result); + Assert.Equal(rate, result[0]); + } + + [Theory, LocalData] + public async Task ReturnRatesByLoaderIfCacheIsEmpty(ExchangeRateProvider provider, Mock currencyCache, Mock loader) + { + var rate = new ExchangeRate(new Currency(IsoCurrencyCode.JPY), new Currency(IsoCurrencyCode.CZK), 0.13745M); + + object? valueMock = rate; + currencyCache.Setup(c => c.TryGetValue(rate.SourceCurrency.Code, out valueMock)).Returns(false); + + loader + .Setup(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([rate]); + + var result = (await provider.GetExchangeRates([rate.SourceCurrency])).ToArray(); + + Assert.Single(result); + Assert.Equal(rate, result[0]); + } + + [Theory, LocalData] + public async Task ReturnRatesFromBothCacheAndLoader(ExchangeRateProvider provider, Mock currencyCache, Mock loader) + { + var cachedRate = new ExchangeRate(new Currency(IsoCurrencyCode.EUR), new Currency(IsoCurrencyCode.CZK), 24.335M); + + object? valueMock = cachedRate; + currencyCache.Setup(c => c.TryGetValue(cachedRate.SourceCurrency.Code, out valueMock)).Returns(true); + + var rateToLoad = new ExchangeRate(new Currency(IsoCurrencyCode.JPY), new Currency(IsoCurrencyCode.CZK), 0.13745M); + loader + .Setup(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([rateToLoad]); + + var result = (await provider.GetExchangeRates([cachedRate.SourceCurrency, rateToLoad.SourceCurrency])).ToArray(); + + Assert.Equal(2, result.Length); + Assert.Contains(cachedRate, result); + Assert.Contains(rateToLoad, result); + } + + [Theory, LocalData] + public async Task PutLoadedRatesToCache(ExchangeRateProvider provider, Mock currencyCache, Mock loader) + { + object? valueMock = null; + currencyCache.Setup(c => c.TryGetValue(It.IsAny(), out valueMock)).Returns(false); + + var loadedRates = new[] { + new ExchangeRate(new Currency(IsoCurrencyCode.EUR), new Currency(IsoCurrencyCode.CZK), 24.335M), + new ExchangeRate(new Currency(IsoCurrencyCode.JPY), new Currency(IsoCurrencyCode.CZK), 0.13745M) + }; + + loader + .Setup(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(loadedRates); + + var result = (await provider.GetExchangeRates(loadedRates.Select(r => r.SourceCurrency))).ToArray(); + + currencyCache.Verify(cache => cache.CreateEntry(IsoCurrencyCode.EUR), Times.Once); + currencyCache.Verify(cache => cache.CreateEntry(IsoCurrencyCode.JPY), Times.Once); + } + + private class LocalDataAttribute : AutoDataAttribute + { + public LocalDataAttribute() : base(CreateFixture) { } + + private static IFixture CreateFixture() + { + var fixture = new Fixture(); + + var loggerMock = new Mock>(); + + var loaderMock = new Mock(); + loaderMock.Setup(l => l.RateRefreshSchedule).Returns(new Mock().Object); + + var currencyCacheMock = new Mock(); + currencyCacheMock.Setup(c => c.CreateEntry(It.IsAny())).Returns(new Mock().Object); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock.Setup(s => s.GetNow()).Returns(DateTime.Now); + + var provider = new ExchangeRateProvider(loaderMock.Object, loggerMock.Object, currencyCacheMock.Object, dateTimeServiceMock.Object); + + fixture.Inject(loaderMock); + fixture.Inject(loggerMock); + fixture.Inject(currencyCacheMock); + fixture.Inject(provider); + + return fixture; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj new file mode 100644 index 0000000000..698dd4dc9c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + ExchangeRateUpdaterTests + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -}