From d0f0a580a20b6891acbeb590b2040f316c9a3196 Mon Sep 17 00:00:00 2001 From: David Martin Date: Mon, 9 Feb 2026 10:35:59 +0100 Subject: [PATCH 1/6] Visual Studio friendly .gitignore --- .gitignore | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) diff --git a/.gitignore b/.gitignore index fd35865456..0c8bd40584 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,432 @@ node_modules bower_components npm-debug.log + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp From 20d6e1902e1f5d57d13ebd5d677acd3570760558 Mon Sep 17 00:00:00 2001 From: David Martin Date: Tue, 10 Feb 2026 10:00:13 +0100 Subject: [PATCH 2/6] Initial commit --- jobs/Backend/Task/Currency.cs | 20 --- jobs/Backend/Task/ExchangeRate.cs | 23 --- jobs/Backend/Task/ExchangeRateUpdater.csproj | 21 ++- jobs/Backend/Task/ExchangeRateUpdater.sln | 13 +- jobs/Backend/Task/Models/Currency.cs | 11 ++ jobs/Backend/Task/Models/ExchangeRate.cs | 12 ++ jobs/Backend/Task/Program.cs | 46 +++++- .../Providers/CNBExchangeRateApiProvider.cs | 156 ++++++++++++++++++ .../IExchangeRateProvider.cs} | 16 +- jobs/Backend/Task/appsettings.json | 7 + 10 files changed, 258 insertions(+), 67 deletions(-) delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs create mode 100644 jobs/Backend/Task/Models/Currency.cs create mode 100644 jobs/Backend/Task/Models/ExchangeRate.cs create mode 100644 jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs rename jobs/Backend/Task/{ExchangeRateProvider.cs => Providers/IExchangeRateProvider.cs} (62%) create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..02fba9f5f9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,21 @@  - - Exe - net6.0 - + + Exe + net10.0 + enable + + + + + + + + + + + PreserveNewest + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..76027d194d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11415.280 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", "{30DD4A63-D42A-48E2-B0A3-1D07255842BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +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 + {30DD4A63-D42A-48E2-B0A3-1D07255842BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30DD4A63-D42A-48E2-B0A3-1D07255842BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30DD4A63-D42A-48E2-B0A3-1D07255842BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30DD4A63-D42A-48E2-B0A3-1D07255842BB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C6C84BA4-B867-4ADB-939A-AB1C5DEACD1F} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Models/Currency.cs b/jobs/Backend/Task/Models/Currency.cs new file mode 100644 index 0000000000..604549c2e1 --- /dev/null +++ b/jobs/Backend/Task/Models/Currency.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateUpdater.Models; + +public class Currency(string code) +{ + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } = code; + + public override string ToString() => Code; +} diff --git a/jobs/Backend/Task/Models/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs new file mode 100644 index 0000000000..c6883a8712 --- /dev/null +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Models; + +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() => $"{SourceCurrency}/{TargetCurrency}={Value}"; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..42ca52fa77 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,13 +1,20 @@ -using System; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Providers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading.Tasks; namespace ExchangeRateUpdater { public static class Program { - private static IEnumerable currencies = new[] - { + private static readonly IEnumerable currencies = + [ new Currency("USD"), new Currency("EUR"), new Currency("CZK"), @@ -17,20 +24,27 @@ public static class Program new Currency("THB"), new Currency("TRY"), new Currency("XYZ") - }; + ]; - public static void Main(string[] args) + public static async Task Main(string[] args) { try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + var services = new ServiceCollection(); + ConfigureServices(services, configuration); + var serviceProvider = services.BuildServiceProvider(); + + var provider = serviceProvider.GetRequiredService(); + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) - { Console.WriteLine(rate.ToString()); - } } catch (Exception e) { @@ -39,5 +53,19 @@ public static void Main(string[] args) Console.ReadLine(); } + + private static void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(configuration); + + services.AddHttpClient(); + services.AddTransient(); + + services.AddLogging(configure => + { + configure.AddConsole(); + configure.SetMinimumLevel(LogLevel.Debug); + }); + } } } diff --git a/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs new file mode 100644 index 0000000000..2104973cd0 --- /dev/null +++ b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs @@ -0,0 +1,156 @@ +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Providers +{ + public class CNBExchangeRateApiProvider : IExchangeRateProvider + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly AsyncRetryPolicy _retryPolicy; + + private readonly string _dailyRatesEndpoint; + private readonly string _targetCurrencyCode; + + + public CNBExchangeRateApiProvider(HttpClient httpClient, ILogger logger, IConfiguration configuration) + { + _httpClient = httpClient; + _logger = logger; + + var section = configuration.GetSection("CNBExchangeRateAPIProvider"); + var apiBaseUrl = section.GetValue("ApiBaseUrl") ?? throw new InvalidOperationException("ApiBaseUrl is not configured"); + _dailyRatesEndpoint = section.GetValue("DailyRatesEndpoint") ?? throw new InvalidOperationException("DailyRatesEndpoint is not configured"); + _targetCurrencyCode = section.GetValue("TargetCurrencyCode", "CZK")!; + + _httpClient.BaseAddress = new Uri(apiBaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(10); + + _retryPolicy = Policy.HandleResult(r => !r.IsSuccessStatusCode) + .Or() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, context) => + { + var error = outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString() ?? "Unknown"; + _logger.LogWarning("Retry {RetryCount} after {Delay}s due to: {Error}", retryCount, timespan.TotalSeconds, error); + }); + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + if (currencies == null || !currencies.Any()) + { + _logger.LogWarning("No currencies provided"); + return []; + } + + try + { + _logger.LogInformation("Fetching exchange rates from CNB API for {Count} currencies", currencies.Count()); + + var response = await FetchDataAsync(); + var rates = ParseApiResponse(response, currencies); + + _logger.LogInformation("Successfully retrieved {Count} exchange rates from CNB API", rates.Count()); + return rates; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to fetch exchange rates from CNB API"); + return []; + } + } + + private async Task FetchDataAsync() + { + var response = await _retryPolicy.ExecuteAsync(async () => + { + _logger.LogDebug("Calling CNB API: {Endpoint}", _dailyRatesEndpoint); + return await _httpClient.GetAsync(_dailyRatesEndpoint); + }); + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("Received response from CNB API: {Length} characters", content.Length); + return content; + } + + private List ParseApiResponse(string jsonContent, IEnumerable requestedCurrencies) + { + var rates = new List(); + + try + { + var apiResponse = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (apiResponse?.Rates == null) + { + _logger.LogWarning("Invalid API response: rates array is null or missing"); + return rates; + } + + _logger.LogDebug("Parsing {Count} rates from API response", apiResponse.Rates.Count); + + var requestedCodes = new HashSet( + requestedCurrencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase + ); + + rates = [.. apiResponse.Rates + .Where(r => !string.IsNullOrWhiteSpace(r.CurrencyCode)) + .Where(r => !string.Equals(r.CurrencyCode, _targetCurrencyCode, StringComparison.OrdinalIgnoreCase)) + .Where(r => r.CurrencyCode is not null && requestedCodes.Contains(r.CurrencyCode)) + .Select(r => + { + var normalizedRate = r.Rate / r.Amount; + _logger.LogDebug("Parsed rate: {Source}/{Target} = {Rate}", r.CurrencyCode, _targetCurrencyCode, normalizedRate); + return new ExchangeRate(new Currency(r.CurrencyCode!), new Currency(_targetCurrencyCode), normalizedRate); + })]; + + return rates; + } + catch (JsonException e) + { + _logger.LogError(e, "Failed to deserialize JSON response from CNB API"); + return rates; + } + catch (Exception e) + { + _logger.LogWarning(e, "Unexpected error parsing API response"); + return rates; + } + } + + #region DTOs + private class CnbApiResponse + { + public List Rates { get; set; } = new(); + } + + private class CnbRateData + { + [JsonPropertyName("currencyCode")] + public string? CurrencyCode { get; set; } + + [JsonPropertyName("amount")] + public int Amount { get; set; } = 1; + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } + #endregion + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/Providers/IExchangeRateProvider.cs similarity index 62% rename from jobs/Backend/Task/ExchangeRateProvider.cs rename to jobs/Backend/Task/Providers/IExchangeRateProvider.cs index 6f82a97fbe..b0a637d0bf 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Providers/IExchangeRateProvider.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; -using System.Linq; +using ExchangeRateUpdater.Models; +using System.Collections.Generic; +using System.Threading.Tasks; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Providers { - public class ExchangeRateProvider + public interface IExchangeRateProvider { /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined @@ -11,9 +12,6 @@ public class ExchangeRateProvider /// 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(); - } + Task> GetExchangeRatesAsync(IEnumerable currencies); } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..168f910472 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,7 @@ +{ + "CNBExchangeRateAPIProvider": { + "ApiBaseUrl": "https://api.cnb.cz", + "DailyRatesEndpoint": "/cnbapi/exrates/daily", + "TargetCurrencyCode": "CZK" + } +} From 374a6e8e6112a7a3eef35061d403244c41cb1b72 Mon Sep 17 00:00:00 2001 From: David Martin Date: Tue, 10 Feb 2026 10:00:33 +0100 Subject: [PATCH 3/6] Tests for CNBExchangeRateApiProvider --- .../CNBExchangeRateApiProviderTests.cs | 179 ++++++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 27 +++ 2 files changed, 206 insertions(+) create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/CNBExchangeRateApiProviderTests.cs create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CNBExchangeRateApiProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CNBExchangeRateApiProviderTests.cs new file mode 100644 index 0000000000..ab88c7a3b9 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CNBExchangeRateApiProviderTests.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Text.Json; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Providers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; + +namespace ExchangeRateUpdater.Tests.Providers; + +public class CNBExchangeRateApiProviderTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockHttpMessageHandler; + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + + public CNBExchangeRateApiProviderTests() + { + _mockLogger = new Mock>(); + _mockHttpMessageHandler = new Mock(); + _httpClient = new HttpClient(_mockHttpMessageHandler.Object); + + var settings = new Dictionary + { + ["CNBExchangeRateAPIProvider:ApiBaseUrl"] = "https://api.cnb.cz", + ["CNBExchangeRateAPIProvider:DailyRatesEndpoint"] = "/cnbapi/exrates/daily", + ["CNBExchangeRateAPIProvider:TargetCurrencyCode"] = "CZK" + }; + + _configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + } + + #region Auxiliary Methods + private CNBExchangeRateApiProvider CreateProvider() + => new(_httpClient, _mockLogger.Object, _configuration); + + private void SetupHttpResponse(HttpStatusCode statusCode, string content) + { + _mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(content) + }); + } + #endregion + + + [Fact] + public async Task GetExchangeRatesAsync_WithValidResponse_ReturnsExchangeRates() + { + var currencies = new List + { + new("USD"), + new("EUR") + }; + + var apiResponse = new + { + rates = new[] + { + new { currencyCode = "USD", amount = 1, rate = 20.367m }, + new { currencyCode = "EUR", amount = 1, rate = 24.220m }, + new { currencyCode = "JPY", amount = 100, rate = 13.044m } + } + }; + + SetupHttpResponse(HttpStatusCode.OK, JsonSerializer.Serialize(apiResponse)); + + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync(currencies); + + Assert.Equal(2, result.Count()); + Assert.Contains(result, r => r.SourceCurrency.Code == "USD" && r.Value == 20.367m); + Assert.Contains(result, r => r.SourceCurrency.Code == "EUR" && r.Value == 24.220m); + Assert.DoesNotContain(result, r => r.SourceCurrency.Code == "JPY"); + } + + [Fact] + public async Task GetExchangeRatesAsync_NormalizesAmountCorrectly() + { + var currencies = new List { new("JPY") }; + + var apiResponse = new + { + rates = new[] + { + new { currencyCode = "JPY", amount = 100, rate = 13.044m } + } + }; + + SetupHttpResponse(HttpStatusCode.OK, JsonSerializer.Serialize(apiResponse)); + + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync(currencies); + + var rate = Assert.Single(result); + Assert.Equal(0.13044m, rate.Value); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithNoCurrencies_ReturnsEmpty() + { + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync([]); + + Assert.Empty(result); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithHttpError_ReturnsEmptyAndLogsError() + { + SetupHttpResponse(HttpStatusCode.InternalServerError, "Server error"); + + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync([new Currency("USD")]); + + Assert.Empty(result); + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to fetch exchange rates from CNB API")), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithInvalidJson_ReturnsEmpty() + { + SetupHttpResponse(HttpStatusCode.OK, "invalid json {{{"); + + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync([new Currency("USD")]); + + Assert.Empty(result); + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to deserialize JSON response from CNB API")), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + + [Fact] + public async Task GetExchangeRatesAsync_IgnoresTargetCurrency() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + + var apiResponse = new + { + rates = new[] + { + new { currencyCode = "USD", amount = 1, rate = 20.367m }, + new { currencyCode = "CZK", amount = 1, rate = 1.0m } + } + }; + + SetupHttpResponse(HttpStatusCode.OK, JsonSerializer.Serialize(apiResponse)); + + var provider = CreateProvider(); + var result = await provider.GetExchangeRatesAsync(currencies); + + var rate = Assert.Single(result); + Assert.Equal("USD", rate.SourceCurrency.Code); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..0ea1ee0521 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From d2cc03b8fa6a2af09abed6e8f92fdc20b4960aaf Mon Sep 17 00:00:00 2001 From: David Martin Date: Tue, 10 Feb 2026 10:27:07 +0100 Subject: [PATCH 4/6] improve appsettings.json use --- jobs/Backend/Task/Program.cs | 4 +++- .../Task/Providers/CNBExchangeRateApiProvider.cs | 6 ++++-- jobs/Backend/Task/appsettings.json | 12 +++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 42ca52fa77..ceda6e200f 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -30,11 +30,13 @@ public static async Task Main(string[] args) { try { + //Configuration var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); + //Dependency Injection var services = new ServiceCollection(); ConfigureServices(services, configuration); var serviceProvider = services.BuildServiceProvider(); @@ -64,7 +66,7 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio services.AddLogging(configure => { configure.AddConsole(); - configure.SetMinimumLevel(LogLevel.Debug); + configure.AddConfiguration(configuration.GetSection("Logging")); }); } } diff --git a/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs index 2104973cd0..23b5b13b7d 100644 --- a/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs @@ -32,14 +32,16 @@ public CNBExchangeRateApiProvider(HttpClient httpClient, ILogger("ApiBaseUrl") ?? throw new InvalidOperationException("ApiBaseUrl is not configured"); _dailyRatesEndpoint = section.GetValue("DailyRatesEndpoint") ?? throw new InvalidOperationException("DailyRatesEndpoint is not configured"); _targetCurrencyCode = section.GetValue("TargetCurrencyCode", "CZK")!; + var timeoutSeconds = section.GetValue("TimeoutSeconds", 30); + var retryCount = section.GetValue("RetryCount", 3); _httpClient.BaseAddress = new Uri(apiBaseUrl); - _httpClient.Timeout = TimeSpan.FromSeconds(10); + _httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); _retryPolicy = Policy.HandleResult(r => !r.IsSuccessStatusCode) .Or() .WaitAndRetryAsync( - retryCount: 3, + retryCount: retryCount, sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), onRetry: (outcome, timespan, retryCount, context) => { diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index 168f910472..674b10c885 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,7 +1,17 @@ { + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "System": "Warning", + "ExchangeRateUpdater": "Information" + } + }, "CNBExchangeRateAPIProvider": { "ApiBaseUrl": "https://api.cnb.cz", "DailyRatesEndpoint": "/cnbapi/exrates/daily", - "TargetCurrencyCode": "CZK" + "TargetCurrencyCode": "CZK", + "TimeoutSeconds": 10, + "RetryCount": 3 } } From 3573336f5a1a7c619f006474de7d92cb7c9894b8 Mon Sep 17 00:00:00 2001 From: DavidMartin112 Date: Tue, 10 Feb 2026 11:26:20 +0100 Subject: [PATCH 5/6] Avoid division by 0 while Parsing response --- jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs index 23b5b13b7d..8e3c00b80e 100644 --- a/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/Providers/CNBExchangeRateApiProvider.cs @@ -115,6 +115,7 @@ private List ParseApiResponse(string jsonContent, IEnumerable !string.IsNullOrWhiteSpace(r.CurrencyCode)) .Where(r => !string.Equals(r.CurrencyCode, _targetCurrencyCode, StringComparison.OrdinalIgnoreCase)) .Where(r => r.CurrencyCode is not null && requestedCodes.Contains(r.CurrencyCode)) + .Where(r => r.Amount != 0) .Select(r => { var normalizedRate = r.Rate / r.Amount; From fb7c9c87ad49647229450206afb70a70b7160455 Mon Sep 17 00:00:00 2001 From: DavidMartin112 Date: Tue, 10 Feb 2026 11:52:02 +0100 Subject: [PATCH 6/6] THOUGHTS.md --- jobs/Backend/Task/THOUGHTS.md | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 jobs/Backend/Task/THOUGHTS.md diff --git a/jobs/Backend/Task/THOUGHTS.md b/jobs/Backend/Task/THOUGHTS.md new file mode 100644 index 0000000000..f5a77aa3fc --- /dev/null +++ b/jobs/Backend/Task/THOUGHTS.md @@ -0,0 +1,121 @@ +# David's Notes: Exchange Rate Provider Implementation +## Overview +**My personal Philosophy** is that: Above all design and complexities, write **READABLE & UNDERSTANDABLE** code for others and and my future self. + +The code is almost ready for production with minor enhancements (health checks, caching...). It balances simplicity with robustness, avoiding over-engineering while maintaining standards. + +--- + +## Decisions + +### 1. API Integration Over Text File Parsing + +**Decision**: Use CNB's JSON API (`https://api.cnb.cz/cnbapi/exrates/daily`) instead of parsing their text file format. + +**Logic**: +- **Reliability**: APIs have versioning and backward compatibility guarantees; text formats can change without notice +- **Structured Data**: JSON deserializes directly into strongly-typed objects, eliminating parsing errors +- **Future-Proofing**: If CNB deprecates the text format, the API will likely receive advance notice + +**Trade-offs**: +- Text files might be faster (no HTTP overhead) +- But APIs win on reliability and maintainability in production + +--- + +### 2. Dependency Injection Pattern + +**Implementation**: +```csharp +services.AddHttpClient(); +services.AddTransient(); +``` + +**Logic**: +- **Testability**: Allows mocking `HttpClient` and `ILogger` in unit tests +- **Flexibility**: Easy to swap implementations (e.g., add a database-backed provider, caching...) +- **Configuration Injection**: `IConfiguration` can be injected and tested with in-memory providers + +--- + +### 3. Resilience: Polly Retry Policy + +**Logic**: +- **Transient Failures**: Network hiccups, temporary API outages, rate limiting +- **Exponential Backoff**: 2s, 4s, 8s delays prevent overwhelming a struggling service +- **Production Reality**: External APIs fail; + +--- + +### 4. Configuration Externalization + +**Implementation**: `appsettings.json` with strongly-typed configuration access. + +**Logic**: +- **Environment Parity**: Different configs for Dev/Test/Prod without code changes +- **Security**: Secrets can be replaced with environment variables or Azure Key Vault + +--- + +### 5. Logging Strategy + +**Logic**: +- **Operations**: Info logs show system health; errors trigger alerts +- **Debugging**: Debug logs help troubleshoot production issues without redeploying + +--- + +## Testing Strategy + +### Why Unit Tests? + +**Coverage**: +- Happy path (valid API response) +- Edge cases (empty input, normalization) +- Error scenarios (HTTP errors, invalid JSON) +- Behavioral testing (ignores target currency, filters requested currencies) + +### Why Mock HttpClient? + +Testing against a real API is: +- **Unreliable**: API might be down +- **Non-Deterministic**: Results change daily + +Mocking gives: +- **Speed**: Millisecond execution +- **Reliability**: 100% deterministic +- **Control**: Test error scenarios without breaking things + +--- + +## What Would I Add for a Real Production System? + +This is a simple but reliable version of an exchange rate provider. For a real production system with more time, I may add: +1. Health Checks +2. Caching +3. Observability (Metrics & Tracing) + +--- + +## Technology Choices + +### Why .NET 10? +- **Latest LTS**: Long-term support, latest performance improvements +- **Performance**: .NET continues to improve runtime performance + +### Why Polly? +- **Standard**: De facto resilience library for .NET + +### Why System.Text.Json? +- **Performance**: Faster than Newtonsoft.Json +- **Built-in**: Built-in to .NET, actively maintained + +--- + +## Conclusion + +This implementation demonstrates: +- **Clean Architecture**: Separation of concerns, dependency injection +- **Resilience**: Retry logic, graceful degradation +- **Testability**: Mocked dependencies, comprehensive test coverage +- **Production Mindset**: Logging, configuration, error handling \ No newline at end of file