From d54f9b4bd219a0c508a335fb91248c966c81a18e Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 07:11:00 +0000 Subject: [PATCH 1/7] Upgrade project to .NET 8 and Add a test project --- jobs/Backend/Task/Currency.cs | 9 ++--- jobs/Backend/Task/ExchangeRate.cs | 15 +++----- .../ExchangeRateUpdater.Tests.csproj | 35 +++++++++++++++++++ .../ExchangeRateUpdater.Tests/UnitTest1.cs | 9 +++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++- jobs/Backend/Task/ExchangeRateUpdater.sln | 6 ++++ 6 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f25..f2de55079f 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,16 +1,11 @@ namespace ExchangeRateUpdater { - public class Currency + public class Currency(string code) { - public Currency(string code) - { - Code = code; - } - /// /// Three-letter ISO 4217 code of the currency. /// - public string Code { get; } + public string Code { get; } = code; public override string ToString() { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e0..fed9593d55 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,19 +1,12 @@ namespace ExchangeRateUpdater { - public class ExchangeRate + public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } + public Currency SourceCurrency { get; } = sourceCurrency; - public Currency TargetCurrency { get; } + public Currency TargetCurrency { get; } = targetCurrency; - public decimal Value { get; } + public decimal Value { get; } = value; public override string ToString() { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..b9563d76f1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs new file mode 100644 index 0000000000..4449e273d0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..e7874f6f97 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,9 @@ Exe - net6.0 + net8.0 + enable + enable \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..fcaacac2c6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{12D65648-13B2-42C9-831C-9C04F6386C0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,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 + {12D65648-13B2-42C9-831C-9C04F6386C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12D65648-13B2-42C9-831C-9C04F6386C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12D65648-13B2-42C9-831C-9C04F6386C0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12D65648-13B2-42C9-831C-9C04F6386C0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a0477d03f4779b27fddc88513da9050a34e55296 Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 07:55:32 +0000 Subject: [PATCH 2/7] Restructure project and make it more configurable --- .gitignore | 3 ++ jobs/Backend/Task/Common/Constants.cs | 7 +++ jobs/Backend/Task/Common/DateTimeSource.cs | 6 +++ jobs/Backend/Task/Common/IDateTimeSource.cs | 6 +++ .../Task/Configuration/ApiConfiguration.cs | 12 +++++ .../ExchangeRateConfiguration.cs | 6 +++ .../ExchangeRateUpdater.Tests.csproj | 50 ++++++++----------- .../ExchangeRateUpdater.Tests/UnitTest1.cs | 12 +++-- jobs/Backend/Task/ExchangeRateUpdater.csproj | 11 ++++ jobs/Backend/Task/ExchangeRateUpdater.sln | 17 ++++--- .../Extensions/ServiceCollectionExtensions.cs | 21 ++++++++ jobs/Backend/Task/Program.cs | 40 +++++++++------ jobs/Backend/Task/appsettings.json | 26 ++++++++++ 13 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 jobs/Backend/Task/Common/Constants.cs create mode 100644 jobs/Backend/Task/Common/DateTimeSource.cs create mode 100644 jobs/Backend/Task/Common/IDateTimeSource.cs create mode 100644 jobs/Backend/Task/Configuration/ApiConfiguration.cs create mode 100644 jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs create mode 100644 jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/.gitignore b/.gitignore index fd35865456..047fd6e82d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ node_modules bower_components npm-debug.log + +# .Net Backend Task +jobs/Backend/Task/.vs/* \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Constants.cs b/jobs/Backend/Task/Common/Constants.cs new file mode 100644 index 0000000000..c9f89cdb6b --- /dev/null +++ b/jobs/Backend/Task/Common/Constants.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Common; + +public static class Constants +{ + public const string ApiConfiguration = nameof(ApiConfiguration); + public const string ExchangeRateConfiguration = nameof(ExchangeRateConfiguration); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/DateTimeSource.cs b/jobs/Backend/Task/Common/DateTimeSource.cs new file mode 100644 index 0000000000..a96520c700 --- /dev/null +++ b/jobs/Backend/Task/Common/DateTimeSource.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Common; + +public class DateTimeSource : IDateTimeSource +{ + public DateTime UtcNow => DateTime.UtcNow; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/IDateTimeSource.cs b/jobs/Backend/Task/Common/IDateTimeSource.cs new file mode 100644 index 0000000000..6b5992fae2 --- /dev/null +++ b/jobs/Backend/Task/Common/IDateTimeSource.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Common; + +public interface IDateTimeSource +{ + DateTime UtcNow { get; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/ApiConfiguration.cs b/jobs/Backend/Task/Configuration/ApiConfiguration.cs new file mode 100644 index 0000000000..2bc2dc0f19 --- /dev/null +++ b/jobs/Backend/Task/Configuration/ApiConfiguration.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Configuration; + +public sealed record ApiConfiguration +{ + public string Name { get; init; } = string.Empty; + public string BaseUrl { get; init; } = string.Empty; + public string ExchangeRateEndpoint { get; init; } = string.Empty; + public string Language { get; init; } = "EN"; + public int RequestTimeoutInSeconds { get; init; } = 20; + public int RetryTimeOutInSeconds { get; init; } = 5; + public Dictionary DefaultRequestHeaders { get; init; } = []; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs b/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs new file mode 100644 index 0000000000..b71161008c --- /dev/null +++ b/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Configuration; + +public sealed record ExchangeRateConfiguration +{ + public IEnumerable CurrencyCodes { get; init; } = []; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index b9563d76f1..1bff6101f8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -1,35 +1,29 @@ - - net8.0 - enable - enable + + net8.0 + enable + enable - false - true - + false + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - - - - + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs index 4449e273d0..b89025013c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs @@ -1,9 +1,11 @@ -namespace ExchangeRateUpdater.Tests; - -public class UnitTest1 +namespace ExchangeRateUpdater.Tests { - [Fact] - public void Test1() + public class UnitTest1 { + [Fact] + public void Test1() + { + + } } } \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index e7874f6f97..c00c1a46ce 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -7,4 +7,15 @@ enable + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index fcaacac2c6..6f822ee5d9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,11 +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.14.36429.23 d17.14 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}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{12D65648-13B2-42C9-831C-9C04F6386C0D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{407DE2B0-9720-4A10-9519-58901BEFC692}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,12 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - {12D65648-13B2-42C9-831C-9C04F6386C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {12D65648-13B2-42C9-831C-9C04F6386C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {12D65648-13B2-42C9-831C-9C04F6386C0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {12D65648-13B2-42C9-831C-9C04F6386C0D}.Release|Any CPU.Build.0 = Release|Any CPU + {407DE2B0-9720-4A10-9519-58901BEFC692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {407DE2B0-9720-4A10-9519-58901BEFC692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {407DE2B0-9720-4A10-9519-58901BEFC692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {407DE2B0-9720-4A10-9519-58901BEFC692}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B43CDE54-9D19-4757-8785-2F0451ACD248} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f0fbc71a3b --- /dev/null +++ b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Extensions +{ + public static class ServiceCollectionExtensions + { + public static void AddServices(this IServiceCollection services, IConfiguration configuration) + { + var apiConfiguration = configuration + .GetSection(Constants.ApiConfiguration) + .Get(); + + services.AddSingleton(apiConfiguration!); + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..5bc6ed2266 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,29 +1,30 @@ -using System; -using System.Collections.Generic; -using System.Linq; + +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; namespace ExchangeRateUpdater { public static class Program { - private static IEnumerable currencies = new[] + public static void Main(string[] args) { - 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") - }; + var builder = Host.CreateApplicationBuilder(args); + builder.Services.AddServices(builder.Configuration); - public static void Main(string[] args) + var currencies = GetCurrencies(builder.Configuration); + GetExchangeRate(currencies); + } + + private static void GetExchangeRate( + IEnumerable currencies) { try { var provider = new ExchangeRateProvider(); + var rates = provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); @@ -39,5 +40,14 @@ public static void Main(string[] args) Console.ReadLine(); } + + private static IEnumerable GetCurrencies(IConfiguration configuration) + { + var section = configuration + .GetSection(Constants.ExchangeRateConfiguration) + .Get(); + + return section!.CurrencyCodes.Select(code => new Currency(code)); + } } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..33f50da82f --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,26 @@ +{ + "ApiConfiguration": { + "Name": "CNB API", + "BaseUrl": "https://api.cnb.cz/cnbapi/", + "ExchangeRateEndpoint": "exrates/daily", + "Language": "EN", + "RequestTimeoutInSeconds": 20, + "RetryTimeOutInSeconds": 5, + "DefaultRequestHeaders": { + "accept": "application/json" + } + }, + "ExchangeRateConfiguration": { + "CurrencyCodes": [ + "USD", + "EUR", + "CZK", + "JPY", + "KES", + "RUB", + "THB", + "TRY", + "XYZ" + ] + } +} \ No newline at end of file From f4581ba6369f8589c7775633908105296eab8f1e Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 10:05:02 +0000 Subject: [PATCH 3/7] Add retriable ApiClient, implement exchange rate services and core logic --- .../ExchangeRateUpdater.Tests.csproj | 1 + .../ExchangeRateUpdater.Tests/UnitTest1.cs | 0 jobs/Backend/Task/ExchangeRateProvider.cs | 19 ---- jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 +- jobs/Backend/Task/ExchangeRateUpdater.sln | 14 +-- .../Extensions/ServiceCollectionExtensions.cs | 96 ++++++++++++++++++- jobs/Backend/Task/Program.cs | 30 ++++-- jobs/Backend/Task/Services/CnbApiClient.cs | 43 +++++++++ .../Task/Services/ExchangeRateProvider.cs | 65 +++++++++++++ .../Task/Services/Interfaces/IApiClient.cs | 7 ++ .../Task/{ => Services/Models}/Currency.cs | 2 +- .../{ => Services/Models}/ExchangeRate.cs | 2 +- .../Models/External/CnbExchangeResponse.cs | 14 +++ 13 files changed, 257 insertions(+), 38 deletions(-) rename jobs/Backend/{Task => }/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj (93%) rename jobs/Backend/{Task => }/ExchangeRateUpdater.Tests/UnitTest1.cs (100%) delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Services/CnbApiClient.cs create mode 100644 jobs/Backend/Task/Services/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Services/Interfaces/IApiClient.cs rename jobs/Backend/Task/{ => Services/Models}/Currency.cs (85%) rename jobs/Backend/Task/{ => Services/Models}/ExchangeRate.cs (89%) create mode 100644 jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj similarity index 93% rename from jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj rename to jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 1bff6101f8..3171e0ec27 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -14,6 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.cs similarity index 100% rename from jobs/Backend/Task/ExchangeRateUpdater.Tests/UnitTest1.cs rename to jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.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 index c00c1a46ce..5f5eb33725 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -9,7 +9,7 @@ - + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 6f822ee5d9..e3ac174778 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36429.23 d17.14 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11012.119 d18.0 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}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{407DE2B0-9720-4A10-9519-58901BEFC692}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{F694A872-3E7C-42B6-B544-B5C5546875BC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,10 +17,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 - {407DE2B0-9720-4A10-9519-58901BEFC692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {407DE2B0-9720-4A10-9519-58901BEFC692}.Debug|Any CPU.Build.0 = Debug|Any CPU - {407DE2B0-9720-4A10-9519-58901BEFC692}.Release|Any CPU.ActiveCfg = Release|Any CPU - {407DE2B0-9720-4A10-9519-58901BEFC692}.Release|Any CPU.Build.0 = Release|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs index f0fbc71a3b..2dbe528a26 100644 --- a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs @@ -1,7 +1,14 @@ using ExchangeRateUpdater.Common; using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models.External; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; +using Polly.Timeout; namespace ExchangeRateUpdater.Extensions { @@ -13,9 +20,96 @@ public static void AddServices(this IServiceCollection services, IConfiguration .GetSection(Constants.ApiConfiguration) .Get(); - services.AddSingleton(apiConfiguration!); + var httpClientBuilder = services.AddHttpClient( + apiConfiguration!.Name, + o => + { + o.BaseAddress = new Uri(apiConfiguration.BaseUrl); + o.Timeout = TimeSpan.FromSeconds(apiConfiguration.RequestTimeoutInSeconds); + + if (apiConfiguration.DefaultRequestHeaders.Count == 0) + return; + + foreach (var entry in apiConfiguration.DefaultRequestHeaders) + { + o.DefaultRequestHeaders.Add(entry.Key, entry.Value); + } + }) + + .AddPolicyHandler((serviceProvider, _) => + CreateDefaultTransientRetryPolicy(serviceProvider, apiConfiguration.RetryTimeOutInSeconds)); + + services.AddSingleton(apiConfiguration); services.AddScoped(); services.AddScoped(); + httpClientBuilder.AddTypedClient, CnbApiClient>(); + } + + // Set up up to 3 retries with Polly with random jitter + private static IAsyncPolicy CreateDefaultTransientRetryPolicy( + IServiceProvider provider, + int retryTimeOut) + { + var jitter = new Random(); + var retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .Or() + .OrResult( + msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .OrResult(msg => msg?.Headers.RetryAfter != null) + .WaitAndRetryAsync( + 3, + (retryCount, response, _) => + response.Result?.Headers.RetryAfter?.Delta ?? + TimeSpan.FromSeconds( + Math.Pow(2, retryCount)) + + TimeSpan.FromMilliseconds( + jitter.Next(0, 100)), + (result, span, count, _) => + { + LogRetry(provider, result, span, count); + return Task.CompletedTask; + }); + + return Policy.WrapAsync(retryPolicy, Policy.TimeoutAsync(retryTimeOut)); + } + + // Log retry attempts + private static void LogRetry( + IServiceProvider provider, + DelegateResult response, + TimeSpan span, + int retryCount) + { + var logger = provider.GetService>(); + if (logger == null) + { + throw new NullReferenceException("Null reference for logger during retry"); + } + + var responseMsg = response.Result; + if (responseMsg is null) + { + logger.LogError( + response.Exception, + "Retry attempt [{RetryCount}] with delay of [{Time}]ms", + retryCount, + span.TotalMilliseconds); + } + else + { + logger.LogWarning( + "Retry attempt [{RetryCount}] with delay of [{Time}]ms [{@Response}]", + retryCount, + span.TotalMilliseconds, + new + { + responseMsg.StatusCode, + responseMsg.Content, + responseMsg.Headers, + responseMsg.RequestMessage?.RequestUri + }); + } } } } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 5bc6ed2266..5990d62273 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,9 +1,14 @@ - -using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Common; using ExchangeRateUpdater.Configuration; using ExchangeRateUpdater.Extensions; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -11,23 +16,32 @@ public static class Program { public static void Main(string[] args) { + var builder = Host.CreateApplicationBuilder(args); builder.Services.AddServices(builder.Configuration); + var serviceProvider = builder.Build().Services; var currencies = GetCurrencies(builder.Configuration); - GetExchangeRate(currencies); + GetExchangeRate(serviceProvider, currencies); } private static void GetExchangeRate( + IServiceProvider serviceProvider, IEnumerable currencies) { try { - var provider = new ExchangeRateProvider(); + var apiClient = serviceProvider.GetRequiredService>(); + var logger = serviceProvider.GetService>(); + var dateTimeSource = serviceProvider.GetService(); + + var provider = new ExchangeRateProvider( + apiClient, + logger!); - var rates = provider.GetExchangeRates(currencies); + var rates = provider.GetExchangeRates(currencies).Result; - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); @@ -40,10 +54,10 @@ private static void GetExchangeRate( Console.ReadLine(); } - + private static IEnumerable GetCurrencies(IConfiguration configuration) { - var section = configuration + var section = configuration .GetSection(Constants.ExchangeRateConfiguration) .Get(); diff --git a/jobs/Backend/Task/Services/CnbApiClient.cs b/jobs/Backend/Task/Services/CnbApiClient.cs new file mode 100644 index 0000000000..ba9109f0a1 --- /dev/null +++ b/jobs/Backend/Task/Services/CnbApiClient.cs @@ -0,0 +1,43 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; + +namespace ExchangeRateUpdater.Services +{ + public class CnbApiClient( + HttpClient httpClient, + ApiConfiguration configuration, + ILogger logger, + IDateTimeSource dateTimeSource) + : IApiClient + { + public async Task> GetExchangeRatesAsync() + { + var queryParams = new Dictionary + { + ["date"] = dateTimeSource.UtcNow.ToString("yyyy-MM-dd"), + ["lang"] = configuration.Language + }; + + var queryString = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + var uri = $"{configuration.ExchangeRateEndpoint}?{queryString}"; + + try + { + logger.LogInformation("Initiated exchange rate request to: [{Uri}]", uri); + + var response = await httpClient.GetFromJsonAsync(uri); + + return response?.Rates ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while retrieving exchange rates from [{Uri}]", uri); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..aa7c1618e6 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,65 @@ +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProvider( + IApiClient apiClient, + ILogger logger) + { + private const string TargetCurrencyCode = "CZK"; + + /// + /// Returns exchange rates among the specified currencies that are defined by the source. + /// Omits the currencies if they are not returned in the external API response. + /// + public async Task> GetExchangeRates(IEnumerable currencies) + { + ArgumentNullException.ThrowIfNull(currencies); + + var currencyCodes = currencies + .Select(c => c.Code) + .Where(code => + !string.IsNullOrWhiteSpace(code) && + !string.Equals(code, TargetCurrencyCode, StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (currencyCodes.Count == 0) + return []; + + var apiResponse = await apiClient.GetExchangeRatesAsync(); + var exchangeRates = FilterExchangeRates(apiResponse, currencyCodes); + + if (exchangeRates.Count < currencyCodes.Count) + { + var codesNotFound = currencyCodes.Except(exchangeRates.Keys); + logger.LogWarning("Unable to find rates for the following currencies: [{CodesNotFound}]", string.Join(", ", codesNotFound)); + } + + return exchangeRates.Values; + } + + private static Dictionary FilterExchangeRates(IEnumerable rates, HashSet currencyCodes) + { + if (rates is null || currencyCodes.Count == 0) + return []; + + // Add matching rates to a dictionary with currency code as the key + // and `ExchangeRate` as its value. To ensure consistent output, + // normalise currency rates return by the api so it's per 1 unit. + return rates + .Where(rate => + !string.IsNullOrWhiteSpace(rate.CurrencyCode) && + currencyCodes.Contains(rate.CurrencyCode)) + .ToDictionary( + rate => rate.CurrencyCode, + rate => new ExchangeRate( + new Currency(rate.CurrencyCode), + new Currency(TargetCurrencyCode), + rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount), + StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/jobs/Backend/Task/Services/Interfaces/IApiClient.cs b/jobs/Backend/Task/Services/Interfaces/IApiClient.cs new file mode 100644 index 0000000000..0d4fd601d7 --- /dev/null +++ b/jobs/Backend/Task/Services/Interfaces/IApiClient.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Services.Interfaces +{ + public interface IApiClient + { + Task> GetExchangeRatesAsync(); + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Services/Models/Currency.cs similarity index 85% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Services/Models/Currency.cs index f2de55079f..ade0b1d9b5 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Services/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Services.Models { public class Currency(string code) { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/Services/Models/ExchangeRate.cs similarity index 89% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/Services/Models/ExchangeRate.cs index fed9593d55..6fe9041cf2 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/Services/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Services.Models { public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { diff --git a/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs b/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs new file mode 100644 index 0000000000..7042b06227 --- /dev/null +++ b/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Services.Models.External +{ + public sealed record CnbExchangeResponse( + [property: JsonPropertyName("rates")] IEnumerable Rates + ); + + public sealed record CnbRate( + [property: JsonPropertyName("currencyCode")] string CurrencyCode, + [property: JsonPropertyName("rate")] decimal Rate, + [property: JsonPropertyName("amount")] int Amount + ); +} \ No newline at end of file From cf92af9e4a937bbbb748c346bf71e01e0a0f5402 Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 10:34:06 +0000 Subject: [PATCH 4/7] Add unit tests, disable uri redaction to log query parameters --- .../CnbApiClientTests.cs | 134 ++++++++++++++++++ .../ExchangeRateProviderTests.cs | 99 +++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 8 +- .../TestHelper/FakeHttpMessageHandler.cs | 71 ++++++++++ .../TestHelper/TestLogger.cs | 54 +++++++ .../ExchangeRateUpdater.Tests/UnitTest1.cs | 11 -- jobs/Backend/Task/ExchangeRateUpdater.csproj | 38 ++--- 7 files changed, 386 insertions(+), 29 deletions(-) create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs delete mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.cs diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs new file mode 100644 index 0000000000..c1a348ff59 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs @@ -0,0 +1,134 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Models.External; +using ExchangeRateUpdater.Tests.Services.TestHelper; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace ExchangeRateUpdater.Tests.Services; + +public class CnbApiClientTests +{ + private readonly HttpClient _httpClient; + private readonly FakeHttpMessageHandler _httpHandler; + private readonly ApiConfiguration _config; + private readonly TestLogger _logger; + private readonly IDateTimeSource _dateTimeSource; + + public CnbApiClientTests() + { + _httpHandler = new FakeHttpMessageHandler(); + _httpClient = new HttpClient(_httpHandler); + + _config = new ApiConfiguration + { + Language = "en", + ExchangeRateEndpoint = "https://api.example.com/rates" + }; + + _logger = new TestLogger(); + _dateTimeSource = A.Fake(); + A.CallTo(() => _dateTimeSource.UtcNow) + .Returns(new DateTime(2025, 10, 27)); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsRates_WhenResponseIsValid() + { + var expectedRates = new[] + { + new CnbRate ("USD", 1.0m, 1), + new CnbRate ("EUR", 0.9m, 1) + }; + + var response = new CnbExchangeResponse(expectedRates); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var result = await client.GetExchangeRatesAsync(); + + result.Should().BeEquivalentTo(expectedRates); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenRatesAreNull() + { + var response = new CnbExchangeResponse(null); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var result = await client.GetExchangeRatesAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsException_WhenHttpFails() + { + _httpHandler.SetException(new HttpRequestException("Network error")); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + Func act = async () => await client.GetExchangeRatesAsync(); + + await act.Should().ThrowAsync() + .WithMessage("Network error"); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } + + [Fact] + public async Task GetExchangeRatesAsync_LogsRequestUri() + { + var response = new CnbExchangeResponse([]); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + await client.GetExchangeRatesAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("https://api.example.com/rates") + && m.LogLevel == LogLevel.Information); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsJsonException_WhenResponseIsMalformed() + { + _httpHandler.SetRawResponse("not valid json"); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var act = client.GetExchangeRatesAsync; + + await act.Should().ThrowAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsTaskCanceledException_WhenRequestTimesOut() + { + _httpHandler.SetDelayedResponse(TimeSpan.FromSeconds(10)); + _httpClient.Timeout = TimeSpan.FromMilliseconds(100); // Force timeout + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var act = client.GetExchangeRatesAsync; + + await act.Should().ThrowAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..d41403980d --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,99 @@ +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using ExchangeRateUpdater.Tests.Services.TestHelper; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Tests.Services; + +public class ExchangeRateProviderTests +{ + private readonly IApiClient _apiClient; + private readonly TestLogger _logger; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _apiClient = A.Fake>(); + _logger = new TestLogger(); + _provider = new ExchangeRateProvider(_apiClient, _logger); + } + + [Fact] + public async Task GetExchangeRates_ShouldReturnRates_WhenCurrenciesAreValid() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR"), new Currency("CZK") }; + var apiRates = new List + { + new CnbRate("USD", 23.5m, 1), + new CnbRate("EUR", 25.0m, 10) + }; + + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .Returns(Task.FromResult>(apiRates)); + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(r => r.SourceCurrency.Code == "USD" && r.TargetCurrency.Code == "CZK" && r.Value == 23.5m); + result.Should().Contain(r => r.SourceCurrency.Code == "EUR" && r.TargetCurrency.Code == "CZK" && r.Value == 2.50m); + } + + [Fact] + public async Task GetExchangeRates_ShouldIgnoreTargetCurrency() + { + // Arrange + var currencies = new[] { new Currency("CZK") }; + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().BeEmpty(); + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetExchangeRates_ShouldLogWarning_WhenSomeRatesAreMissing() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("GBP") }; + var apiRates = new List + { + new CnbRate("USD", 23.5m, 1) + }; + + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .Returns(Task.FromResult>(apiRates)); + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainSingle(r => r.SourceCurrency.Code == "USD"); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("GBP") + && m.LogLevel == LogLevel.Warning); + } + + [Fact] + public async Task GetExchangeRates_ShouldThrow_WhenCurrenciesIsNull() + { + // Act + var act = async () => await _provider.GetExchangeRates(null); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .MustNotHaveHappened(); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 3171e0ec27..5093a03d7f 100644 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -14,7 +14,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -23,6 +25,10 @@ + + + + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs new file mode 100644 index 0000000000..19bf726688 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ExchangeRateUpdater.Tests.Services.TestHelper; + +public class FakeHttpMessageHandler : HttpMessageHandler +{ + private HttpResponseMessage _response; + private Exception _exception; + private TimeSpan _delay = TimeSpan.Zero; + private Func? _responseFactory; + + public void SetResponse(object content) + { + var json = JsonSerializer.Serialize(content); + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + public void SetException(Exception ex) + { + _exception = ex; + } + + public void SetRawResponse(string rawJson) + { + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(rawJson, Encoding.UTF8, "application/json") + }; + } + + public void SetDelayedResponse(TimeSpan delay) + { + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + _delay = delay; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (_responseFactory is not null) + { + var result = _responseFactory(); + if (result is Exception ex) + throw ex; + + var json = JsonSerializer.Serialize(result); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + if (_exception != null) + throw _exception; + + if (_delay > TimeSpan.Zero) + return Task.Delay(_delay, cancellationToken).ContinueWith(_ => _response, cancellationToken); + + return Task.FromResult(_response); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs new file mode 100644 index 0000000000..14ba0f9c90 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using System.Text.RegularExpressions; + +namespace ExchangeRateUpdater.Tests.Services.TestHelper +{ + public class TestLogger : ILogger + { + public List LogMessages { get; } + + public TestLogger() + { + LogMessages = new List(); + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) + { + LogMessages.Add(new LogMessage(logLevel, formatter(state, exception))); + } + + public void ClearLogs() + { + LogMessages.Clear(); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable BeginScope(TState state) + => default; + + public bool ContainsLog(LogLevel logLevel, string message) => + LogMessages.Any(m => m.LogLevel == logLevel && m.Message == message); + + public bool ContainsLogMatchingRegex(LogLevel logLevel, string regex) => + LogMessages.Any(m => m.LogLevel == logLevel && Regex.IsMatch(m.Message, regex)); + } + + public class LogMessage + { + public LogLevel LogLevel { get; } + public string Message { get; } + + public LogMessage(LogLevel logLevel, string message) + { + LogLevel = logLevel; + Message = message; + } + } +} + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.cs deleted file mode 100644 index b89025013c..0000000000 --- a/jobs/Backend/ExchangeRateUpdater.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ExchangeRateUpdater.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 5f5eb33725..204fa0ea94 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,21 +1,25 @@  - - Exe - net8.0 - enable - enable - + + Exe + net8.0 + enable + enable + + + + + + + + + + PreserveNewest + + + + + + - - - - - - - - PreserveNewest - - - \ No newline at end of file From d138c0b23ef2f069c4c63a9ef3f8b57d8784b7f1 Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 20:10:07 +0000 Subject: [PATCH 5/7] Add in-memory caching and quartz job for regular updates --- jobs/Backend/Readme.md | 105 +++++++++++++++++- jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 + .../Extensions/ServiceCollectionExtensions.cs | 28 ++++- .../Task/Jobs/ExchangeRateRefreshJob.cs | 38 +++++++ jobs/Backend/Task/Program.cs | 20 ++-- .../Task/Services/ExchangeRateCacheService.cs | 46 ++++++++ .../Task/Services/ExchangeRateProvider.cs | 18 ++- .../Interfaces/IExchangeRateCacheService.cs | 13 +++ .../Task/Services/Models/ExchangeRate.cs | 2 - jobs/Backend/Task/appsettings.json | 2 +- 10 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs create mode 100644 jobs/Backend/Task/Services/ExchangeRateCacheService.cs create mode 100644 jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index f2195e44dd..474d90ebef 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -1,6 +1,105 @@ # Mews backend developer task -We are focused on multiple backend frameworks at Mews. Depending on the job position you are applying for, you can choose among the following: +# 💱 Exchange Rate Updater +A .NET 8 console application that fetches and caches exchange rates from the Czech National Bank (CNB), using Quartz.NET for scheduled execution and Polly for resilient HTTP communication. A pragmatic approach combined with OOP and SOLID principles has been taken that particularly focuses on human-readable and intuitive code with modularity, reliability and ease of future extension. -* [.NET](DotNet.md) -* [Ruby on Rails](RoR.md) +## TLibraries and Dependencies +| Component | Purpose | +|------------------------|-----------------------------------------| +| .NET 8 | Core framework | +| HttpClientFactory | Typed API client | +| Polly | Retry and timeout policies | +| Quartz.NET | Job scheduling | +| MemoryCache | In-memory caching of exchange rates | +| Microsoft.Extensions | Hosting, Logging, Configuration | +--- + +## Main Features +- **CNB API integration** + - Typed HTTP client with configurable headers + - Normalizes rates per 1 unit +- **Caching** + - Avoids redundant API calls + - Skips invalid currencies + - Combines cached and fresh results +- **Modular Architecture** + - Hosted DI setup via `ServiceCollectionExtensions` + - Easily extendable to other exchange rate providers with generics +- **Structured Logging** + - Logs API calls, retries, missing currencies and job execution + +- **Quartz.NET Scheduled Jobs** + - Runs once immediately on startup to catch current exchange rates + - Runs daily at **14:30:30 CEST** (weekdays) + + The `ExchangeRateRefreshJob` runs on startup and at 14:30:30 CEST weekdays: + + ```csharp + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateRefreshTrigger") + .WithSchedule(CronScheduleBuilder + .CronSchedule("30 30 14 ? * MON-FRI") + .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Central Europe Standard Time")))); + ``` + +- **Retry Policy with Polly** + - Retries transient failures and timeouts + - Handles rate limits and `Retry-After` headers + - Adds jitter and exponential backoff + + ```csharp + .WaitAndRetryAsync( + 3, + retryCount => TimeSpan.FromSeconds(Math.Pow(2, retryCount)) + jitter, + onRetry: LogRetry); + ``` + +## Getting Started + +1. Clone the repo +2. [Configure `appsettings.json`](#configuration) with CNB API details and a list of ISO currency symbols +3. Run the app: + +```bash +dotnet run +``` + +You must be connected to the internet, you’ll be presented with: + +- Exchange rates printed to console +- Logs for API calls, retries, and job execution + + image + + +## Configuration + +Example `appsettings.json`: + +```json +{ + "ExchangeRateConfiguration": { + "CurrencyCodes": [ "USD", "EUR", "GBP" ] + }, + "ApiConfiguration": { + "Name": "CNB", + "BaseUrl": "https://api.cnb.cz", + "ExchangeRateEndpoint": "exrates/daily", + "Language": "EN", + "RequestTimeoutInSeconds": 20, + "RetryTimeOutInSeconds": 5, + "DefaultRequestHeaders": { + "Accept": "application/json" + } + } +} +``` + +## Potential Future Enhancements +- Add more tests, especially integration and end-to-end tests +- Add persistent caching, preferably distributed (e.g. Redis) +- Add new providers by implementing `IApiClient` +- Expose rates via REST or gRPC +- Add health checks or metrics +- Add CD pipeline for automated deployment \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 204fa0ea94..5c636d00dd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -8,8 +8,10 @@ + + diff --git a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs index 2dbe528a26..29ad1bedb2 100644 --- a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using ExchangeRateUpdater.Common; using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Jobs; using ExchangeRateUpdater.Services; using ExchangeRateUpdater.Services.Interfaces; using ExchangeRateUpdater.Services.Models.External; @@ -9,6 +10,7 @@ using Polly; using Polly.Extensions.Http; using Polly.Timeout; +using Quartz; namespace ExchangeRateUpdater.Extensions { @@ -39,10 +41,34 @@ public static void AddServices(this IServiceCollection services, IConfiguration .AddPolicyHandler((serviceProvider, _) => CreateDefaultTransientRetryPolicy(serviceProvider, apiConfiguration.RetryTimeOutInSeconds)); + services.AddQuartz(q => + { + var jobKey = new JobKey("ExchangeRateRefreshJob"); + + q.AddJob(opts => opts.WithIdentity(jobKey)); + + // Scheduled trigger (14:30:30 CEST weekdays), allowing a buffer of 30 seconds + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateRefreshTrigger") + .WithSchedule(CronScheduleBuilder + .CronSchedule("30 30 14 ? * MON-FRI") + .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Central Europe Standard Time")))); + + // Immediate trigger on startup + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateStartupTrigger") + .StartNow()); + }); + + services.AddMemoryCache(); + services.AddQuartzHostedService(); services.AddSingleton(apiConfiguration); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); httpClientBuilder.AddTypedClient, CnbApiClient>(); + services.AddScoped(); } // Set up up to 3 retries with Polly with random jitter diff --git a/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs b/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs new file mode 100644 index 0000000000..fa4616910f --- /dev/null +++ b/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs @@ -0,0 +1,38 @@ + +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace ExchangeRateUpdater.Jobs +{ + public class ExchangeRateRefreshJob( + IApiClient apiClient, + IExchangeRateCacheService cacheService, + IDateTimeSource dateTimeSource, + ILogger logger) : IJob + { + public async Task Execute(IJobExecutionContext context) + { + try + { + var rates = await apiClient.GetExchangeRatesAsync(); + var exchangeRates = rates + .Select(rate => new ExchangeRate( + new Currency(rate.CurrencyCode), + new Currency("CZK"), + rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount)); + + cacheService.SetRates(exchangeRates); + logger.LogInformation("Exchange rates succesfully refreshed at [{UTCTime}] UTC", dateTimeSource.UtcNow); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occured while trying to refresh exchange rates at [{UTCTime}] UTC", + dateTimeSource.UtcNow); + } + } + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 5990d62273..ef2adc1a09 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -5,6 +5,7 @@ using ExchangeRateUpdater.Services.Interfaces; using ExchangeRateUpdater.Services.Models; using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -14,18 +15,20 @@ namespace ExchangeRateUpdater { public static class Program { - public static void Main(string[] args) + public static async Task Main(string[] args) { var builder = Host.CreateApplicationBuilder(args); builder.Services.AddServices(builder.Configuration); - var serviceProvider = builder.Build().Services; + var app = builder.Build(); + var serviceProvider = app.Services; var currencies = GetCurrencies(builder.Configuration); - GetExchangeRate(serviceProvider, currencies); + await GetExchangeRate(serviceProvider, currencies); + await app.RunAsync(); } - private static void GetExchangeRate( + private static async Task GetExchangeRate( IServiceProvider serviceProvider, IEnumerable currencies) { @@ -34,14 +37,17 @@ private static void GetExchangeRate( var apiClient = serviceProvider.GetRequiredService>(); var logger = serviceProvider.GetService>(); var dateTimeSource = serviceProvider.GetService(); + var exchangeRateCache = serviceProvider.GetService(); var provider = new ExchangeRateProvider( apiClient, + exchangeRateCache!, + dateTimeSource!, logger!); - var rates = provider.GetExchangeRates(currencies).Result; + var rates = await provider.GetExchangeRates(currencies); - Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:"); + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates at {dateTimeSource?.UtcNow} UTC:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); @@ -51,8 +57,6 @@ private static void GetExchangeRate( { Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); } - - Console.ReadLine(); } private static IEnumerable GetCurrencies(IConfiguration configuration) diff --git a/jobs/Backend/Task/Services/ExchangeRateCacheService.cs b/jobs/Backend/Task/Services/ExchangeRateCacheService.cs new file mode 100644 index 0000000000..780e669ad9 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateCacheService.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateCacheService(IMemoryCache cache) : IExchangeRateCacheService + { + private const string InvalidCodesKey = "InvalidCodes"; + + public ICollection GetCachedRates(IEnumerable currencyCodes) + { + var results = new List(); + foreach (var code in currencyCodes) + { + var key = code.ToUpperInvariant(); + if (cache.TryGetValue(key, out ExchangeRate? rate) && rate is not null) + results.Add(rate); + } + return results; + } + + public void SetRates(IEnumerable rates) + { + foreach (var rate in rates) + { + var key = rate.SourceCurrency.Code.ToUpperInvariant(); + cache.Set(key, rate); + } + } + + public void UpdateInvalidCodes(IEnumerable codes) + { + var existing = GetInvalidCodes(); + foreach (var code in codes) + existing.Add(code); + + cache.Set(InvalidCodesKey, existing); + } + + public HashSet GetInvalidCodes() => + cache.TryGetValue>(InvalidCodesKey, out var codes) + ? codes + : []; + } +} diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs index aa7c1618e6..c614126622 100644 --- a/jobs/Backend/Task/Services/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -1,4 +1,5 @@ -using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Services.Interfaces; using ExchangeRateUpdater.Services.Models; using ExchangeRateUpdater.Services.Models.External; using Microsoft.Extensions.Logging; @@ -7,6 +8,8 @@ namespace ExchangeRateUpdater.Services { public class ExchangeRateProvider( IApiClient apiClient, + IExchangeRateCacheService cacheService, + IDateTimeSource dateTimeSource, ILogger logger) { private const string TargetCurrencyCode = "CZK"; @@ -29,6 +32,17 @@ public async Task> GetExchangeRates(IEnumerable r.SourceCurrency.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Check if any of the missing currency codes have already been cached as invalid. + // There's no need to call the API if all the remain missing codes are simply invalid. + var invalidCodes = cacheService.GetInvalidCodes(); + var missingCodes = currencyCodes.Except(cachedCodes).Except(invalidCodes).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (missingCodes.Count == 0) + return cachedRates; + var apiResponse = await apiClient.GetExchangeRatesAsync(); var exchangeRates = FilterExchangeRates(apiResponse, currencyCodes); @@ -38,7 +52,7 @@ public async Task> GetExchangeRates(IEnumerable FilterExchangeRates(IEnumerable rates, HashSet currencyCodes) diff --git a/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs b/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs new file mode 100644 index 0000000000..e19a903171 --- /dev/null +++ b/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs @@ -0,0 +1,13 @@ +using ExchangeRateUpdater.Services.Models; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Services.Interfaces +{ + public interface IExchangeRateCacheService + { + ICollection GetCachedRates(IEnumerable currencyCodes); + void SetRates(IEnumerable rates); + void UpdateInvalidCodes(IEnumerable codes); + HashSet GetInvalidCodes(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Services/Models/ExchangeRate.cs b/jobs/Backend/Task/Services/Models/ExchangeRate.cs index 6fe9041cf2..ca5b27da78 100644 --- a/jobs/Backend/Task/Services/Models/ExchangeRate.cs +++ b/jobs/Backend/Task/Services/Models/ExchangeRate.cs @@ -3,9 +3,7 @@ public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { public Currency SourceCurrency { get; } = sourceCurrency; - public Currency TargetCurrency { get; } = targetCurrency; - public decimal Value { get; } = value; public override string ToString() diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index 33f50da82f..a5698ae46f 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -7,7 +7,7 @@ "RequestTimeoutInSeconds": 20, "RetryTimeOutInSeconds": 5, "DefaultRequestHeaders": { - "accept": "application/json" + "Accept": "application/json" } }, "ExchangeRateConfiguration": { From e8f8d5b0d0e82eda903159535138bde2c28762db Mon Sep 17 00:00:00 2001 From: Bilal44 Date: Mon, 27 Oct 2025 20:47:09 +0000 Subject: [PATCH 6/7] Remove unused variables --- .../ExchangeRateProviderTests.cs | 4 +++- jobs/Backend/Task/Program.cs | 13 ++++++------- jobs/Backend/Task/Services/ExchangeRateProvider.cs | 5 ++--- .../Interfaces/IExchangeRateCacheService.cs | 2 -- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs index d41403980d..790e2a0024 100644 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -12,14 +12,16 @@ namespace ExchangeRateUpdater.Tests.Services; public class ExchangeRateProviderTests { private readonly IApiClient _apiClient; + private readonly IExchangeRateCacheService _cacheService; private readonly TestLogger _logger; private readonly ExchangeRateProvider _provider; public ExchangeRateProviderTests() { _apiClient = A.Fake>(); + _cacheService = A.Fake(); _logger = new TestLogger(); - _provider = new ExchangeRateProvider(_apiClient, _logger); + _provider = new ExchangeRateProvider(_apiClient, _cacheService, _logger); } [Fact] diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index ef2adc1a09..a5ad9a842c 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -35,19 +35,18 @@ private static async Task GetExchangeRate( try { var apiClient = serviceProvider.GetRequiredService>(); - var logger = serviceProvider.GetService>(); - var dateTimeSource = serviceProvider.GetService(); - var exchangeRateCache = serviceProvider.GetService(); + var exchangeRateCache = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + var dateTimeSource = serviceProvider.GetRequiredService(); var provider = new ExchangeRateProvider( apiClient, - exchangeRateCache!, - dateTimeSource!, - logger!); + exchangeRateCache, + logger); var rates = await provider.GetExchangeRates(currencies); - Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates at {dateTimeSource?.UtcNow} UTC:"); + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates at {dateTimeSource.UtcNow} UTC:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs index c614126622..6240ed89f6 100644 --- a/jobs/Backend/Task/Services/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -1,5 +1,4 @@ -using ExchangeRateUpdater.Common; -using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Interfaces; using ExchangeRateUpdater.Services.Models; using ExchangeRateUpdater.Services.Models.External; using Microsoft.Extensions.Logging; @@ -9,7 +8,6 @@ namespace ExchangeRateUpdater.Services public class ExchangeRateProvider( IApiClient apiClient, IExchangeRateCacheService cacheService, - IDateTimeSource dateTimeSource, ILogger logger) { private const string TargetCurrencyCode = "CZK"; @@ -49,6 +47,7 @@ public async Task> GetExchangeRates(IEnumerable Date: Mon, 27 Oct 2025 22:37:11 +0000 Subject: [PATCH 7/7] Use a `Set` instead of a `Dictionary` to store filtered rates --- jobs/Backend/Readme.md | 2 +- .../Task/Services/ExchangeRateProvider.cs | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index 474d90ebef..a7330fe381 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -102,4 +102,4 @@ Example `appsettings.json`: - Add new providers by implementing `IApiClient` - Expose rates via REST or gRPC - Add health checks or metrics -- Add CD pipeline for automated deployment \ No newline at end of file +- Add CI/CD pipeline for automated testing and deployment \ No newline at end of file diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs index 6240ed89f6..b8b1177ad6 100644 --- a/jobs/Backend/Task/Services/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -31,12 +31,12 @@ public async Task> GetExchangeRates(IEnumerable r.SourceCurrency.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + var cachedCodes = cachedRates.Select(r => r.SourceCurrency.Code); // Check if any of the missing currency codes have already been cached as invalid. // There's no need to call the API if all the remain missing codes are simply invalid. var invalidCodes = cacheService.GetInvalidCodes(); - var missingCodes = currencyCodes.Except(cachedCodes).Except(invalidCodes).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missingCodes = currencyCodes.Except(cachedCodes).Except(invalidCodes).ToHashSet(); if (missingCodes.Count == 0) return cachedRates; @@ -46,33 +46,31 @@ public async Task> GetExchangeRates(IEnumerable r.SourceCurrency.Code)); cacheService.UpdateInvalidCodes(codesNotFound); logger.LogWarning("Unable to find rates for the following currencies: [{CodesNotFound}]", string.Join(", ", codesNotFound)); } - return cachedRates.Concat(exchangeRates.Values).ToList(); + return cachedRates.Concat(exchangeRates).ToList(); } - private static Dictionary FilterExchangeRates(IEnumerable rates, HashSet currencyCodes) + private static HashSet FilterExchangeRates(IEnumerable rates, HashSet currencyCodes) { if (rates is null || currencyCodes.Count == 0) return []; - // Add matching rates to a dictionary with currency code as the key - // and `ExchangeRate` as its value. To ensure consistent output, - // normalise currency rates return by the api so it's per 1 unit. + // Filter rates with matching currency codes to a new collection. + // To ensure consistent output, normalise currency rates returned + // by the api so it's always per 1 unit. return rates .Where(rate => - !string.IsNullOrWhiteSpace(rate.CurrencyCode) && - currencyCodes.Contains(rate.CurrencyCode)) - .ToDictionary( - rate => rate.CurrencyCode, - rate => new ExchangeRate( - new Currency(rate.CurrencyCode), - new Currency(TargetCurrencyCode), - rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount), - StringComparer.OrdinalIgnoreCase); + currencyCodes.Contains(rate.CurrencyCode)) + .Select(rate => + new ExchangeRate( + new Currency(rate.CurrencyCode), + new Currency(TargetCurrencyCode), + rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount)) + .ToHashSet(); } } }