From 8fa3182a615f7bc4d3e42b24f66f74bfe07ee84b Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:18:25 +0100 Subject: [PATCH 01/11] Implement CNB exchange rate fetching and parsing --- jobs/Backend/Task/CnbRatesParser.cs | 68 ++++++++++++++++++++ jobs/Backend/Task/CnbRatesSource.cs | 47 ++++++++++++++ jobs/Backend/Task/ExchangeRateProvider.cs | 21 +++++- jobs/Backend/Task/ExchangeRateUpdater.csproj | 21 +++++- jobs/Backend/Task/IExchangeRatesSource.cs | 15 +++++ jobs/Backend/Task/Program.cs | 21 +++++- jobs/Backend/Task/appsettings.json | 7 ++ 7 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 jobs/Backend/Task/CnbRatesParser.cs create mode 100644 jobs/Backend/Task/CnbRatesSource.cs create mode 100644 jobs/Backend/Task/IExchangeRatesSource.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task/CnbRatesParser.cs b/jobs/Backend/Task/CnbRatesParser.cs new file mode 100644 index 0000000000..fb50e3c425 --- /dev/null +++ b/jobs/Backend/Task/CnbRatesParser.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace ExchangeRateUpdater +{ + /// + /// Parses CNB daily exchange rate content. + /// Format: Plain text, pipe-delimited (Country|Currency|Amount|Code|Rate). + /// First line = date, second line = header, remaining lines = rates vs CZK. + /// + public static class CnbRatesParser + { + private const int HeaderLinesToSkip = 2; + private const int ExpectedColumnCount = 5; + + // Column indices for: Country|Currency|Amount|Code|Rate + private const int AmountColumn = 2; + private const int CodeColumn = 3; + private const int RateColumn = 4; + + /// + /// Parses CNB rates content and returns exchange rates for requested currencies. + /// + /// Raw CNB daily.txt content. + /// Set of currency codes to include (case-insensitive). + /// The target currency (CZK). + /// Exchange rates matching requested currencies. + public static IEnumerable Parse(string content, HashSet requestedCodes, Currency targetCurrency) + { + var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines.Skip(HeaderLinesToSkip)) + { + var rate = TryParseRateLine(line, requestedCodes, targetCurrency); + if (rate is not null) + yield return rate; + } + } + + private static ExchangeRate? TryParseRateLine(string line, HashSet requestedCodes, Currency targetCurrency) + { + var columns = line.Split('|'); + if (columns.Length != ExpectedColumnCount) + return null; + + var code = columns[CodeColumn].Trim(); + if (!requestedCodes.Contains(code)) + return null; + + if (!TryParseDecimal(columns[AmountColumn], out var amount) || amount <= 0) + return null; + + if (!TryParseDecimal(columns[RateColumn], out var rate)) + return null; + + return new ExchangeRate(new Currency(code), targetCurrency, rate / amount); + } + + private static bool TryParseDecimal(string value, out decimal result) + { + var normalized = value.Trim().Replace(',', '.'); + return decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out result); + } + } +} + diff --git a/jobs/Backend/Task/CnbRatesSource.cs b/jobs/Backend/Task/CnbRatesSource.cs new file mode 100644 index 0000000000..45d6d27968 --- /dev/null +++ b/jobs/Backend/Task/CnbRatesSource.cs @@ -0,0 +1,47 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + /// + /// Fetches exchange rates from the Czech National Bank daily fixing endpoint. + /// + public class CnbRatesSource : IExchangeRatesSource + { + private const string CnbDailyRatesPath = + "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; + + private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; + + private readonly string _ratesUrl; + + public CnbRatesSource(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ArgumentException("Base URL cannot be null or empty.", nameof(baseUrl)); + + _ratesUrl = baseUrl.TrimEnd('/') + CnbDailyRatesPath; + } + + public string GetLatestRatesContent() + { + try + { + return HttpClient.GetStringAsync(_ratesUrl).GetAwaiter().GetResult(); + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException( + $"Failed to fetch exchange rates from {_ratesUrl}: {ex.Message}", ex); + } + catch (TaskCanceledException ex) + { + throw new InvalidOperationException( + $"Request timed out while fetching exchange rates from {_ratesUrl}", ex); + } + } + } +} + + diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..3661cbd924 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,10 +1,20 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private const string TargetCurrencyCode = "CZK"; + + private readonly IExchangeRatesSource _ratesSource; + + public ExchangeRateProvider(IExchangeRatesSource ratesSource) + { + _ratesSource = ratesSource ?? throw new ArgumentNullException(nameof(ratesSource)); + } + /// /// 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", @@ -13,7 +23,14 @@ public class ExchangeRateProvider /// public IEnumerable GetExchangeRates(IEnumerable currencies) { - return Enumerable.Empty(); + var requestedCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + if (!requestedCodes.Contains(TargetCurrencyCode)) + return Enumerable.Empty(); + + // Future improvement: add caching here to avoid repeated HTTP calls for the same day's rates. + var content = _ratesSource.GetLatestRatesContent(); + return CnbRatesParser.Parse(content, requestedCodes, new Currency(TargetCurrencyCode)); } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..0f56109192 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,6 +3,25 @@ Exe net6.0 + enable - \ No newline at end of file + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/jobs/Backend/Task/IExchangeRatesSource.cs b/jobs/Backend/Task/IExchangeRatesSource.cs new file mode 100644 index 0000000000..f73786f71c --- /dev/null +++ b/jobs/Backend/Task/IExchangeRatesSource.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater +{ + /// + /// Abstraction for fetching raw exchange rate data from a source. + /// + public interface IExchangeRatesSource + { + /// + /// Fetches the latest exchange rates content as a string. + /// + string GetLatestRatesContent(); + } +} + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..2ae63f16b7 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,11 +1,15 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace ExchangeRateUpdater { public static class Program { + private const string DefaultCnbBaseUrl = "https://www.cnb.cz"; + private static IEnumerable currencies = new[] { new Currency("USD"), @@ -21,9 +25,22 @@ public static class Program public static void Main(string[] args) { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + var cnbBaseUrl = configuration["CnbApi:BaseUrl"] ?? DefaultCnbBaseUrl; + + var services = new ServiceCollection(); + services.AddSingleton(_ => new CnbRatesSource(cnbBaseUrl)); + services.AddTransient(); + + using var serviceProvider = services.BuildServiceProvider(); + try { - var provider = new ExchangeRateProvider(); + var provider = serviceProvider.GetRequiredService(); var rates = provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..b98a600444 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,7 @@ +{ + "CnbApi": { + "BaseUrl": "https://www.cnb.cz" + } +} + + From af894eee8efbd5eb505561968332f58e0f1e0210 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:19:06 +0100 Subject: [PATCH 02/11] Add unit tests for parser and provider --- .../CnbRatesParserTests.cs | 91 +++++++++++++++++++ .../ExchangeRateProviderTests.cs | 75 +++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 24 +++++ .../FakeRatesSource.cs | 20 ++++ jobs/Backend/Task/ExchangeRateUpdater.sln | 6 ++ 5 files changed, 216 insertions(+) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbRatesParserTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/FakeRatesSource.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbRatesParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbRatesParserTests.cs new file mode 100644 index 0000000000..cb0a4217ec --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbRatesParserTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace ExchangeRateUpdater.Tests +{ + /// + /// Tests for CnbRatesParser - focused on parsing behavior only. + /// + public class CnbRatesParserTests + { + private static HashSet AllCodes(params string[] codes) => + new(codes, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void Parse_ValidPayload_ParsesAndNormalizesRates() + { + // Arrange + const string payload = @"14 Jan 2026 #9 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|23.456 +Japan|yen|100|JPY|15.48"; + + // Act + var rates = CnbRatesParser.Parse(payload, AllCodes("USD", "JPY"), new Currency("CZK")).ToList(); + + // Assert + Assert.Equal(2, rates.Count); + + var usd = rates.Single(r => r.SourceCurrency.Code == "USD"); + Assert.Equal(23.456m, usd.Value); // amount=1, no normalization + + var jpy = rates.Single(r => r.SourceCurrency.Code == "JPY"); + Assert.Equal(0.1548m, jpy.Value); // 15.48 / 100 = 0.1548 + } + + [Fact] + public void Parse_SkipsMalformedLinesWithoutFailing() + { + // Arrange: various malformed lines mixed with valid ones + const string payload = @"14 Jan 2026 #9 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|23.456 +Bad|Line|Missing|Columns +Japan|yen|INVALID|JPY|15.48 +Eurozone|euro|1|EUR|"; + + // Act + var rates = CnbRatesParser.Parse(payload, AllCodes("USD", "JPY", "EUR"), new Currency("CZK")).ToList(); + + // Assert: only USD parses (JPY has invalid amount, EUR has empty rate) + Assert.Single(rates); + Assert.Equal("USD", rates[0].SourceCurrency.Code); + } + + [Fact] + public void Parse_HandlesCommaAsDecimalSeparator() + { + // Arrange + const string payload = @"14 Jan 2026 #9 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|23,456"; + + // Act + var rates = CnbRatesParser.Parse(payload, AllCodes("USD"), new Currency("CZK")).ToList(); + + // Assert + Assert.Single(rates); + Assert.Equal(23.456m, rates[0].Value); + } + + [Fact] + public void Parse_FiltersToRequestedCodes() + { + // Arrange + const string payload = @"14 Jan 2026 #9 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|23.456 +Eurozone|euro|1|EUR|25.123 +Japan|yen|100|JPY|15.48"; + + // Act: only request USD + var rates = CnbRatesParser.Parse(payload, AllCodes("USD"), new Currency("CZK")).ToList(); + + // Assert + Assert.Single(rates); + Assert.Equal("USD", rates[0].SourceCurrency.Code); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..c7fa101fd3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,75 @@ +using System.Linq; +using Xunit; + +namespace ExchangeRateUpdater.Tests +{ + /// + /// Tests for ExchangeRateProvider - focused on orchestration rules only. + /// + public class ExchangeRateProviderTests + { + private const string ValidCnbContent = @"14 Jan 2026 #9 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|23.456 +Eurozone|euro|1|EUR|25.123"; + + [Fact] + public void GetExchangeRates_WhenCzkNotRequested_ReturnsEmpty() + { + // Arrange: CZK is the target currency and must be in the request + var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; // No CZK + + // Act + var rates = provider.GetExchangeRates(currencies); + + // Assert + Assert.Empty(rates); + } + + [Fact] + public void GetExchangeRates_WhenCzkRequested_ReturnsMatchingRates() + { + // Arrange + var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + + // Act + var rates = provider.GetExchangeRates(currencies).ToList(); + + // Assert + Assert.Single(rates); + Assert.Equal("USD", rates[0].SourceCurrency.Code); + Assert.Equal("CZK", rates[0].TargetCurrency.Code); + } + + [Fact] + public void GetExchangeRates_IsCaseInsensitiveForCurrencyCodes() + { + // Arrange: provider builds HashSet with OrdinalIgnoreCase + var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var currencies = new[] { new Currency("usd"), new Currency("czk") }; // lowercase + + // Act + var rates = provider.GetExchangeRates(currencies).ToList(); + + // Assert + Assert.Single(rates); + Assert.Equal("USD", rates[0].SourceCurrency.Code); + } + + [Fact] + public void GetExchangeRates_ReturnsEmptyWhenSourceIsEmpty() + { + // Arrange + var provider = new ExchangeRateProvider(new FakeRatesSource("")); + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + + // Act + var rates = provider.GetExchangeRates(currencies); + + // Assert + Assert.Empty(rates); + } + } +} 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..f21940aaac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/FakeRatesSource.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/FakeRatesSource.cs new file mode 100644 index 0000000000..8bfee9ab4b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/FakeRatesSource.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateUpdater.Tests +{ + /// + /// Fake implementation of IExchangeRatesSource for testing. + /// Returns the content provided at construction time. + /// + public class FakeRatesSource : IExchangeRatesSource + { + private readonly string _content; + + public FakeRatesSource(string content) + { + _content = content; + } + + public string GetLatestRatesContent() => _content; + } +} + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..2a396c677e 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", "{B3E7A1F2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}" +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 + {B3E7A1F2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3E7A1F2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3E7A1F2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3E7A1F2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 7169bfa6c040a637a6672c6eead818c196d6e3be Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:19:30 +0100 Subject: [PATCH 03/11] Add documentation and helper scripts --- jobs/Backend/Task/DECISIONS.md | 74 ++++++++++++++++++++++++++++++ jobs/Backend/Task/README.md | 82 ++++++++++++++++++++++++++++++++++ jobs/Backend/Task/run.sh | 5 +++ jobs/Backend/Task/test.sh | 5 +++ 4 files changed, 166 insertions(+) create mode 100644 jobs/Backend/Task/DECISIONS.md create mode 100644 jobs/Backend/Task/README.md create mode 100755 jobs/Backend/Task/run.sh create mode 100755 jobs/Backend/Task/test.sh diff --git a/jobs/Backend/Task/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md new file mode 100644 index 0000000000..3a72e57389 --- /dev/null +++ b/jobs/Backend/Task/DECISIONS.md @@ -0,0 +1,74 @@ +# Decision Notes -- Exchange Rate Provider + +## 1. Data Source Choice + +- **Czech National Bank (CNB) daily fixing** selected as the authoritative source. +- Reasons: + - Official, publicly available, no API key required. + - Simple plain-text format (pipe-delimited), easy to parse. + - Reliable uptime; data updates once daily at ~14:30 CET. + - Suitable for a read-only task with no write/mutation requirements. +- Alternative sources (ECB, Fixer.io, Open Exchange Rates) were considered but add complexity (XML parsing, authentication, rate limits). + +## 2. Exchange Rate Rules + +- **Only source-defined rates are returned** -- if CNB publishes `EUR -> CZK`, we return it as-is. +- **No inverse rates**: `CZK -> EUR` is NOT computed as `1 / rate`. +- **No cross rates**: `USD -> EUR` is NOT derived via `USD -> CZK -> EUR`. +- Rationale: + - Calculated rates introduce rounding errors and deviate from official values. + - The assignment explicitly requires returning only what the source provides. + - Keeps the provider predictable and auditable. + +## 3. Error Handling & Reliability + +- Network errors (`HttpRequestException`) and timeouts (`TaskCanceledException`) are caught and wrapped into `InvalidOperationException` with the URL for diagnostics. +- Exceptions bubble up to the caller (`Program.Main`) which handles them with a user-friendly message. +- Rationale: + - For a console app, fail-fast is appropriate -- no silent failures. + - Retries and circuit breakers would add complexity beyond the scope of a take-home task. + +## 4. Concurrency & Performance + +- **Expected usage**: single-threaded console app, called once per run. +- **No async public API**: `GetExchangeRates` is synchronous. + - Sync-over-async (`GetAwaiter().GetResult()`) is acceptable here -- no UI thread, no request pool to block. +- **What would change in production**: + - Expose `async Task> GetExchangeRatesAsync()`. + - Inject `HttpClient` via DI (or `IHttpClientFactory`) for testability and lifecycle management. + - Consider parallel fetches if multiple sources are added. + +## 5. Caching (Not Implemented) + +- **Intentionally omitted** to keep the solution minimal and stateless. +- **Where to add**: wrap or replace `GetLatestRatesContent()` with a caching layer. +- **Possible strategies**: + - In-memory cache with TTL (e.g., `MemoryCache`, 1-hour expiry). + - Cache key: URL or date string (`yyyy-MM-dd`) since CNB updates daily. + - For distributed systems: Redis with sliding expiration. +- **Trade-off**: caching adds state; for a one-shot console app, the overhead isn't justified. + +## 6. Extensibility + +If this were to evolve into a production service: + +| Concern | Approach | +|---------|----------| +| Async API | Add `GetExchangeRatesAsync` alongside sync version | +| Testability | Inject `HttpClient` or `IExchangeRateSource` interface | +| Multiple sources | Strategy pattern; aggregate or fallback between CNB, ECB, etc. | +| Background refresh | Hosted service polling on schedule, populating shared cache | +| Distributed cache | Redis/Memcached with pub/sub invalidation | + +--- + +## Out of Scope + +The following were deliberately not implemented: + +- Integration tests hitting the real CNB endpoint. +- Retry policies (Polly or similar). +- Logging / structured telemetry. +- Rate limiting or circuit breaker. +- Distributed caching. +- Historical rates (CNB supports date parameter; not required here). diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 0000000000..3e7fb64aab --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,82 @@ +# Exchange Rate Updater + +A .NET 6 console application that fetches daily foreign currency exchange rates from the Czech National Bank (CNB). It filters rates by a configurable list of currencies and outputs them to the console in a human-readable format. + +## Behavior Rules + +- **Source-defined rates only**: Returns only rates published by CNB (e.g., `EUR -> CZK`). +- **No inverse rates**: `CZK -> EUR` is NOT computed as `1 / rate`. +- **No cross rates**: `USD -> EUR` is NOT derived via `USD -> CZK -> EUR`. +- **CZK required**: CZK must be in the requested currency list (it is the target currency for all rates). +- **Missing currencies ignored**: Unknown or unavailable currency codes are silently skipped. + +## Prerequisites + +- [.NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) + +## How to Run + +```bash +./run.sh +``` + +Or manually: + +```bash +dotnet run --project ExchangeRateUpdater.csproj +``` + +## How to Test + +```bash +./test.sh +``` + +Or manually: + +```bash +dotnet test +``` + +## Configuration + +The CNB API base URL can be configured via: + +| Method | Key / Variable | Example | +|--------|----------------|---------| +| appsettings.json | `CnbApi:BaseUrl` | `"https://www.cnb.cz"` | +| Environment variable | `CnbApi__BaseUrl` | `export CnbApi__BaseUrl=https://www.cnb.cz` | + +If neither is set, defaults to `https://www.cnb.cz`. + +## Architecture + +``` +Program.cs + | + +-- DI wiring (ServiceCollection) + | + v +ExchangeRateProvider (orchestration) + | + +-- IExchangeRatesSource (interface) + | | + | +-- CnbRatesSource (HTTP fetch) + | + +-- CnbRatesParser (static, parsing logic) +``` + +| Component | Responsibility | +|-----------|----------------| +| `CnbRatesSource` | Fetches raw text from CNB daily.txt endpoint | +| `CnbRatesParser` | Parses pipe-delimited text into `ExchangeRate` objects | +| `ExchangeRateProvider` | Orchestrates fetch + parse, filters by requested currencies | +| `Program.cs` | Configures DI, resolves provider, outputs results | + +**Tests** are split into: +- `ExchangeRateProviderTests` -- orchestration logic (uses fake source) +- `CnbRatesParserTests` -- parsing logic (pure functions) + +## Design Decisions + +See [DECISIONS.md](DECISIONS.md) for detailed rationale on data source choice, error handling, caching trade-offs, and extensibility considerations. diff --git a/jobs/Backend/Task/run.sh b/jobs/Backend/Task/run.sh new file mode 100755 index 0000000000..b8bc7228d4 --- /dev/null +++ b/jobs/Backend/Task/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +echo "Running ExchangeRateUpdater..." +dotnet run --project ExchangeRateUpdater.csproj diff --git a/jobs/Backend/Task/test.sh b/jobs/Backend/Task/test.sh new file mode 100755 index 0000000000..6dc4c5c34d --- /dev/null +++ b/jobs/Backend/Task/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +echo "Running tests..." +dotnet test From 33caf46ea1ada2ddf55babef58ae511ffb844062 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:20:16 +0100 Subject: [PATCH 04/11] Fix encoding: remove BOM from legacy files --- jobs/Backend/Task/Currency.cs | 2 +- jobs/Backend/Task/ExchangeRate.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f25..0f63327ce3 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e0..cdb0572463 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater { public class ExchangeRate { From 2d239a4ebab17c15cb0a58b85aea626834b54b78 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:31:49 +0100 Subject: [PATCH 05/11] Self review: minimal safe-guard fixes --- jobs/Backend/Task/CnbRatesParser.cs | 3 +++ jobs/Backend/Task/ExchangeRateProvider.cs | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/CnbRatesParser.cs b/jobs/Backend/Task/CnbRatesParser.cs index fb50e3c425..6d6beccb1f 100644 --- a/jobs/Backend/Task/CnbRatesParser.cs +++ b/jobs/Backend/Task/CnbRatesParser.cs @@ -29,6 +29,9 @@ public static class CnbRatesParser /// Exchange rates matching requested currencies. public static IEnumerable Parse(string content, HashSet requestedCodes, Currency targetCurrency) { + if (string.IsNullOrWhiteSpace(content)) + yield break; + var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines.Skip(HeaderLinesToSkip)) diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 3661cbd924..211e384d7d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -7,6 +7,7 @@ namespace ExchangeRateUpdater public class ExchangeRateProvider { private const string TargetCurrencyCode = "CZK"; + private static readonly Currency TargetCurrency = new(TargetCurrencyCode); private readonly IExchangeRatesSource _ratesSource; @@ -23,6 +24,9 @@ public ExchangeRateProvider(IExchangeRatesSource ratesSource) /// public IEnumerable GetExchangeRates(IEnumerable currencies) { + if (currencies is null) + return Enumerable.Empty(); + var requestedCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); if (!requestedCodes.Contains(TargetCurrencyCode)) @@ -30,7 +34,7 @@ public IEnumerable GetExchangeRates(IEnumerable currenci // Future improvement: add caching here to avoid repeated HTTP calls for the same day's rates. var content = _ratesSource.GetLatestRatesContent(); - return CnbRatesParser.Parse(content, requestedCodes, new Currency(TargetCurrencyCode)); + return CnbRatesParser.Parse(content, requestedCodes, TargetCurrency); } } } From f2fa03db77797b84970a4a8369ac996e8fb42f38 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:55:07 +0100 Subject: [PATCH 06/11] Polish: add verbose and non-verbose logging --- jobs/Backend/Task/CnbRatesSource.cs | 13 ++++-- jobs/Backend/Task/ExchangeRateProvider.cs | 14 ++++++- .../ExchangeRateProviderTests.cs | 17 ++++++-- .../ExchangeRateUpdater.Tests.csproj | 1 + jobs/Backend/Task/ExchangeRateUpdater.csproj | 2 + jobs/Backend/Task/Program.cs | 41 +++++++++++++++---- jobs/Backend/Task/appsettings.json | 11 ++++- jobs/Backend/Task/run.sh | 8 +++- 8 files changed, 85 insertions(+), 22 deletions(-) diff --git a/jobs/Backend/Task/CnbRatesSource.cs b/jobs/Backend/Task/CnbRatesSource.cs index 45d6d27968..e6c49bb3bf 100644 --- a/jobs/Backend/Task/CnbRatesSource.cs +++ b/jobs/Backend/Task/CnbRatesSource.cs @@ -1,6 +1,7 @@ using System; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -9,39 +10,43 @@ namespace ExchangeRateUpdater /// public class CnbRatesSource : IExchangeRatesSource { - private const string CnbDailyRatesPath = + private const string CnbDailyRatesPath = "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"; private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; private readonly string _ratesUrl; + private readonly ILogger _logger; - public CnbRatesSource(string baseUrl) + public CnbRatesSource(string baseUrl, ILogger logger) { if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("Base URL cannot be null or empty.", nameof(baseUrl)); _ratesUrl = baseUrl.TrimEnd('/') + CnbDailyRatesPath; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public string GetLatestRatesContent() { + _logger.LogDebug("Fetching exchange rates from CNB: {Url}", _ratesUrl); + try { return HttpClient.GetStringAsync(_ratesUrl).GetAwaiter().GetResult(); } catch (HttpRequestException ex) { + _logger.LogWarning(ex, "HTTP request failed for {Url}", _ratesUrl); throw new InvalidOperationException( $"Failed to fetch exchange rates from {_ratesUrl}: {ex.Message}", ex); } catch (TaskCanceledException ex) { + _logger.LogWarning(ex, "Request timed out for {Url}", _ratesUrl); throw new InvalidOperationException( $"Request timed out while fetching exchange rates from {_ratesUrl}", ex); } } } } - - diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 211e384d7d..9215689474 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -10,10 +11,12 @@ public class ExchangeRateProvider private static readonly Currency TargetCurrency = new(TargetCurrencyCode); private readonly IExchangeRatesSource _ratesSource; + private readonly ILogger _logger; - public ExchangeRateProvider(IExchangeRatesSource ratesSource) + public ExchangeRateProvider(IExchangeRatesSource ratesSource, ILogger logger) { _ratesSource = ratesSource ?? throw new ArgumentNullException(nameof(ratesSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -30,11 +33,18 @@ public IEnumerable GetExchangeRates(IEnumerable currenci var requestedCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); if (!requestedCodes.Contains(TargetCurrencyCode)) + { + _logger.LogDebug("CZK not requested; returning empty result set"); return Enumerable.Empty(); + } // Future improvement: add caching here to avoid repeated HTTP calls for the same day's rates. var content = _ratesSource.GetLatestRatesContent(); - return CnbRatesParser.Parse(content, requestedCodes, TargetCurrency); + var rates = CnbRatesParser.Parse(content, requestedCodes, TargetCurrency).ToList(); + + _logger.LogDebug("Returning {Count} rates for requested currencies", rates.Count); + + return rates; } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs index c7fa101fd3..4732eba80d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace ExchangeRateUpdater.Tests @@ -17,7 +18,9 @@ public class ExchangeRateProviderTests public void GetExchangeRates_WhenCzkNotRequested_ReturnsEmpty() { // Arrange: CZK is the target currency and must be in the request - var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var provider = new ExchangeRateProvider( + new FakeRatesSource(ValidCnbContent), + NullLogger.Instance); var currencies = new[] { new Currency("USD"), new Currency("EUR") }; // No CZK // Act @@ -31,7 +34,9 @@ public void GetExchangeRates_WhenCzkNotRequested_ReturnsEmpty() public void GetExchangeRates_WhenCzkRequested_ReturnsMatchingRates() { // Arrange - var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var provider = new ExchangeRateProvider( + new FakeRatesSource(ValidCnbContent), + NullLogger.Instance); var currencies = new[] { new Currency("USD"), new Currency("CZK") }; // Act @@ -47,7 +52,9 @@ public void GetExchangeRates_WhenCzkRequested_ReturnsMatchingRates() public void GetExchangeRates_IsCaseInsensitiveForCurrencyCodes() { // Arrange: provider builds HashSet with OrdinalIgnoreCase - var provider = new ExchangeRateProvider(new FakeRatesSource(ValidCnbContent)); + var provider = new ExchangeRateProvider( + new FakeRatesSource(ValidCnbContent), + NullLogger.Instance); var currencies = new[] { new Currency("usd"), new Currency("czk") }; // lowercase // Act @@ -62,7 +69,9 @@ public void GetExchangeRates_IsCaseInsensitiveForCurrencyCodes() public void GetExchangeRates_ReturnsEmptyWhenSourceIsEmpty() { // Arrange - var provider = new ExchangeRateProvider(new FakeRatesSource("")); + var provider = new ExchangeRateProvider( + new FakeRatesSource(""), + NullLogger.Instance); var currencies = new[] { new Currency("USD"), new Currency("CZK") }; // Act diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index f21940aaac..41704fb25b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 0f56109192..08053d0e3e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -16,6 +16,8 @@ + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 2ae63f16b7..255deee682 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -23,7 +24,7 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static int Main(string[] args) { var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) @@ -33,28 +34,52 @@ public static void Main(string[] args) var cnbBaseUrl = configuration["CnbApi:BaseUrl"] ?? DefaultCnbBaseUrl; var services = new ServiceCollection(); - services.AddSingleton(_ => new CnbRatesSource(cnbBaseUrl)); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.IncludeScopes = false; + }); + builder.AddConfiguration(configuration.GetSection("Logging")); + }); + + services.AddSingleton(sp => + new CnbRatesSource(cnbBaseUrl, sp.GetRequiredService>())); services.AddTransient(); using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger("ExchangeRateUpdater"); + var summaryLogger = loggerFactory.CreateLogger("ExchangeRateUpdater.Summary"); + + logger.LogDebug("Starting ExchangeRateUpdater"); + logger.LogDebug("CNB base URL: {BaseUrl}", cnbBaseUrl); + logger.LogDebug("Requested currencies: {Currencies}", string.Join(", ", currencies.Select(c => c.Code))); + try { var provider = serviceProvider.GetRequiredService(); - var rates = provider.GetExchangeRates(currencies); + var rates = provider.GetExchangeRates(currencies).ToList(); + + logger.LogDebug("Retrieved {Count} exchange rates", rates.Count); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + summaryLogger.LogInformation("Successfully retrieved {Count} exchange rates:", rates.Count); foreach (var rate in rates) { - Console.WriteLine(rate.ToString()); + summaryLogger.LogInformation("{Rate}", rate.ToString()); } + + return 0; } catch (Exception e) { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + logger.LogError(e, "Failed to retrieve exchange rates"); + return 1; } - - Console.ReadLine(); } } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index b98a600444..ad4b0e81e8 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,7 +1,14 @@ { "CnbApi": { "BaseUrl": "https://www.cnb.cz" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "System": "Warning", + "ExchangeRateUpdater": "Warning", + "ExchangeRateUpdater.Summary": "Information" + } } } - - diff --git a/jobs/Backend/Task/run.sh b/jobs/Backend/Task/run.sh index b8bc7228d4..ebfa5aea44 100755 --- a/jobs/Backend/Task/run.sh +++ b/jobs/Backend/Task/run.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash set -e -echo "Running ExchangeRateUpdater..." -dotnet run --project ExchangeRateUpdater.csproj +if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then + env Logging__LogLevel__ExchangeRateUpdater=Debug \ + dotnet run --project ExchangeRateUpdater.csproj +else + dotnet run --project ExchangeRateUpdater.csproj +fi From 0ed361cbfd71a5614cb8efdbfff7bc239c3e710c Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 11:57:14 +0100 Subject: [PATCH 07/11] CI: add yaml script to run tests --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..44e3d3f170 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: jobs/Backend/Task + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Test + run: dotnet test --verbosity normal + From 3ef5faff0b8ad741eddd7a40738d2c62cd5d60a8 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 12:14:09 +0100 Subject: [PATCH 08/11] SDK: update to .NET 10 --- .github/workflows/ci.yml | 2 +- jobs/Backend/Task/CnbRatesParser.cs | 2 +- jobs/Backend/Task/DECISIONS.md | 7 +++++++ .../ExchangeRateUpdater.Tests.csproj | 12 +++++------- jobs/Backend/Task/ExchangeRateUpdater.csproj | 12 ++++++------ jobs/Backend/Task/global.json | 7 +++++++ jobs/Backend/Task/run.sh | 5 +++++ jobs/Backend/Task/test.sh | 5 +++++ 8 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 jobs/Backend/Task/global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44e3d3f170..8787fc0ef4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/jobs/Backend/Task/CnbRatesParser.cs b/jobs/Backend/Task/CnbRatesParser.cs index 6d6beccb1f..d75e7b2c01 100644 --- a/jobs/Backend/Task/CnbRatesParser.cs +++ b/jobs/Backend/Task/CnbRatesParser.cs @@ -32,7 +32,7 @@ public static IEnumerable Parse(string content, HashSet re if (string.IsNullOrWhiteSpace(content)) yield break; - var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var lines = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines.Skip(HeaderLinesToSkip)) { diff --git a/jobs/Backend/Task/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md index 3a72e57389..e9ee480487 100644 --- a/jobs/Backend/Task/DECISIONS.md +++ b/jobs/Backend/Task/DECISIONS.md @@ -1,5 +1,12 @@ # Decision Notes -- Exchange Rate Provider +## 0. Framework Version (.NET 10 LTS) + +- The assignment allows choosing any .NET family tech/packages. +- Chose **.NET 10 (LTS)** to avoid legacy runtime and stay on a supported, long-lived baseline. +- This choice is not for performance optimization or new complexity; it's a maintainability/support choice. +- We intentionally keep the implementation simple and avoid advanced runtime-specific optimizations (AOT, trimming, heavy resilience policies) because the scope is a small console app. + ## 1. Data Source Choice - **Czech National Bank (CNB) daily fixing** selected as the authoritative source. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 41704fb25b..bc79ab7663 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -1,16 +1,16 @@ - net6.0 + net10.0 enable false - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -21,5 +21,3 @@ - - diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 08053d0e3e..b4e5e5c474 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 enable @@ -13,11 +13,11 @@ - - - - - + + + + + diff --git a/jobs/Backend/Task/global.json b/jobs/Backend/Task/global.json new file mode 100644 index 0000000000..6b97fb4d57 --- /dev/null +++ b/jobs/Backend/Task/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.102", + "rollForward": "latestMinor" + } +} + diff --git a/jobs/Backend/Task/run.sh b/jobs/Backend/Task/run.sh index ebfa5aea44..af14c5f16a 100755 --- a/jobs/Backend/Task/run.sh +++ b/jobs/Backend/Task/run.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -e +# Use .NET 10 SDK if available in home directory +if [[ -d "$HOME/.dotnet" ]]; then + export PATH="$HOME/.dotnet:$PATH" +fi + if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then env Logging__LogLevel__ExchangeRateUpdater=Debug \ dotnet run --project ExchangeRateUpdater.csproj diff --git a/jobs/Backend/Task/test.sh b/jobs/Backend/Task/test.sh index 6dc4c5c34d..f0b22794ec 100755 --- a/jobs/Backend/Task/test.sh +++ b/jobs/Backend/Task/test.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash set -e +# Use .NET 10 SDK if available in home directory +if [[ -d "$HOME/.dotnet" ]]; then + export PATH="$HOME/.dotnet:$PATH" +fi + echo "Running tests..." dotnet test From a905d24a958ebcd6109ad75c462556690070882e Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 12:22:34 +0100 Subject: [PATCH 09/11] Self-review: cleanup docs --- jobs/Backend/Task/DECISIONS.md | 4 ++-- jobs/Backend/Task/README.md | 30 +++--------------------------- jobs/Backend/Task/run.sh | 5 ----- jobs/Backend/Task/test.sh | 6 ------ 4 files changed, 5 insertions(+), 40 deletions(-) diff --git a/jobs/Backend/Task/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md index e9ee480487..2db81992a5 100644 --- a/jobs/Backend/Task/DECISIONS.md +++ b/jobs/Backend/Task/DECISIONS.md @@ -3,7 +3,7 @@ ## 0. Framework Version (.NET 10 LTS) - The assignment allows choosing any .NET family tech/packages. -- Chose **.NET 10 (LTS)** to avoid legacy runtime and stay on a supported, long-lived baseline. +- Chose **.NET 10 (LTS)** to use a supported LTS baseline; .NET 6 is EOL. - This choice is not for performance optimization or new complexity; it's a maintainability/support choice. - We intentionally keep the implementation simple and avoid advanced runtime-specific optimizations (AOT, trimming, heavy resilience policies) because the scope is a small console app. @@ -13,7 +13,7 @@ - Reasons: - Official, publicly available, no API key required. - Simple plain-text format (pipe-delimited), easy to parse. - - Reliable uptime; data updates once daily at ~14:30 CET. + - Reliable uptime; data updates daily. - Suitable for a read-only task with no write/mutation requirements. - Alternative sources (ECB, Fixer.io, Open Exchange Rates) were considered but add complexity (XML parsing, authentication, rate limits). diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 3e7fb64aab..1d1c3ada1d 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -1,6 +1,6 @@ # Exchange Rate Updater -A .NET 6 console application that fetches daily foreign currency exchange rates from the Czech National Bank (CNB). It filters rates by a configurable list of currencies and outputs them to the console in a human-readable format. +A .NET 10 console application that fetches daily foreign currency exchange rates from the Czech National Bank (CNB). It filters rates by a configurable list of currencies and outputs them to the console in a human-readable format. ## Behavior Rules @@ -12,7 +12,7 @@ A .NET 6 console application that fetches daily foreign currency exchange rates ## Prerequisites -- [.NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (pinned via `global.json`) ## How to Run @@ -51,31 +51,7 @@ If neither is set, defaults to `https://www.cnb.cz`. ## Architecture -``` -Program.cs - | - +-- DI wiring (ServiceCollection) - | - v -ExchangeRateProvider (orchestration) - | - +-- IExchangeRatesSource (interface) - | | - | +-- CnbRatesSource (HTTP fetch) - | - +-- CnbRatesParser (static, parsing logic) -``` - -| Component | Responsibility | -|-----------|----------------| -| `CnbRatesSource` | Fetches raw text from CNB daily.txt endpoint | -| `CnbRatesParser` | Parses pipe-delimited text into `ExchangeRate` objects | -| `ExchangeRateProvider` | Orchestrates fetch + parse, filters by requested currencies | -| `Program.cs` | Configures DI, resolves provider, outputs results | - -**Tests** are split into: -- `ExchangeRateProviderTests` -- orchestration logic (uses fake source) -- `CnbRatesParserTests` -- parsing logic (pure functions) +`Program.cs` wires DI and logging. `ExchangeRateProvider` orchestrates fetch (via `IExchangeRatesSource`) and parse (via `CnbRatesParser`). The `IExchangeRatesSource` abstraction enables unit testing with a fake implementation. ## Design Decisions diff --git a/jobs/Backend/Task/run.sh b/jobs/Backend/Task/run.sh index af14c5f16a..ebfa5aea44 100755 --- a/jobs/Backend/Task/run.sh +++ b/jobs/Backend/Task/run.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash set -e -# Use .NET 10 SDK if available in home directory -if [[ -d "$HOME/.dotnet" ]]; then - export PATH="$HOME/.dotnet:$PATH" -fi - if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then env Logging__LogLevel__ExchangeRateUpdater=Debug \ dotnet run --project ExchangeRateUpdater.csproj diff --git a/jobs/Backend/Task/test.sh b/jobs/Backend/Task/test.sh index f0b22794ec..e3a31e785e 100755 --- a/jobs/Backend/Task/test.sh +++ b/jobs/Backend/Task/test.sh @@ -1,10 +1,4 @@ #!/usr/bin/env bash set -e -# Use .NET 10 SDK if available in home directory -if [[ -d "$HOME/.dotnet" ]]; then - export PATH="$HOME/.dotnet:$PATH" -fi - -echo "Running tests..." dotnet test From ca384f80e2b07c7187d0046cc356b6ebe58dda2c Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 12:26:32 +0100 Subject: [PATCH 10/11] Self-review: shorten docs --- jobs/Backend/Task/DECISIONS.md | 90 ++++++++-------------------------- jobs/Backend/Task/README.md | 37 ++++---------- jobs/Backend/Task/global.json | 7 --- 3 files changed, 30 insertions(+), 104 deletions(-) delete mode 100644 jobs/Backend/Task/global.json diff --git a/jobs/Backend/Task/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md index 2db81992a5..98eba7d45d 100644 --- a/jobs/Backend/Task/DECISIONS.md +++ b/jobs/Backend/Task/DECISIONS.md @@ -1,81 +1,33 @@ -# Decision Notes -- Exchange Rate Provider +# Decision Notes -## 0. Framework Version (.NET 10 LTS) +## Framework -- The assignment allows choosing any .NET family tech/packages. -- Chose **.NET 10 (LTS)** to use a supported LTS baseline; .NET 6 is EOL. -- This choice is not for performance optimization or new complexity; it's a maintainability/support choice. -- We intentionally keep the implementation simple and avoid advanced runtime-specific optimizations (AOT, trimming, heavy resilience policies) because the scope is a small console app. +- Chose **.NET 10** as a current, supported runtime. +- Kept the implementation simple — no AOT, trimming, or advanced resilience patterns. -## 1. Data Source Choice +## Data Source -- **Czech National Bank (CNB) daily fixing** selected as the authoritative source. -- Reasons: - - Official, publicly available, no API key required. - - Simple plain-text format (pipe-delimited), easy to parse. - - Reliable uptime; data updates daily. - - Suitable for a read-only task with no write/mutation requirements. -- Alternative sources (ECB, Fixer.io, Open Exchange Rates) were considered but add complexity (XML parsing, authentication, rate limits). +- **Czech National Bank (CNB) daily fixing** — official, public, no API key, plain-text format. +- Alternatives (ECB, Fixer.io) add complexity (XML, auth, rate limits). -## 2. Exchange Rate Rules +## Exchange Rate Rules -- **Only source-defined rates are returned** -- if CNB publishes `EUR -> CZK`, we return it as-is. -- **No inverse rates**: `CZK -> EUR` is NOT computed as `1 / rate`. -- **No cross rates**: `USD -> EUR` is NOT derived via `USD -> CZK -> EUR`. -- Rationale: - - Calculated rates introduce rounding errors and deviate from official values. - - The assignment explicitly requires returning only what the source provides. - - Keeps the provider predictable and auditable. +- Only source-defined rates are returned (no inverse/cross rates computed). +- Rationale: calculated rates introduce rounding errors; assignment requires source-only. -## 3. Error Handling & Reliability +## Error Handling -- Network errors (`HttpRequestException`) and timeouts (`TaskCanceledException`) are caught and wrapped into `InvalidOperationException` with the URL for diagnostics. -- Exceptions bubble up to the caller (`Program.Main`) which handles them with a user-friendly message. -- Rationale: - - For a console app, fail-fast is appropriate -- no silent failures. - - Retries and circuit breakers would add complexity beyond the scope of a take-home task. +- Network errors and timeouts throw `InvalidOperationException` with URL for diagnostics. +- Fail-fast is appropriate for a console app; retries/circuit breakers omitted. -## 4. Concurrency & Performance +## Sync vs Async -- **Expected usage**: single-threaded console app, called once per run. -- **No async public API**: `GetExchangeRates` is synchronous. - - Sync-over-async (`GetAwaiter().GetResult()`) is acceptable here -- no UI thread, no request pool to block. -- **What would change in production**: - - Expose `async Task> GetExchangeRatesAsync()`. - - Inject `HttpClient` via DI (or `IHttpClientFactory`) for testability and lifecycle management. - - Consider parallel fetches if multiple sources are added. +- `GetExchangeRates` is synchronous (sync-over-async via `GetAwaiter().GetResult()`). +- Acceptable for a single-threaded console app with no request pool. -## 5. Caching (Not Implemented) +## Not Implemented -- **Intentionally omitted** to keep the solution minimal and stateless. -- **Where to add**: wrap or replace `GetLatestRatesContent()` with a caching layer. -- **Possible strategies**: - - In-memory cache with TTL (e.g., `MemoryCache`, 1-hour expiry). - - Cache key: URL or date string (`yyyy-MM-dd`) since CNB updates daily. - - For distributed systems: Redis with sliding expiration. -- **Trade-off**: caching adds state; for a one-shot console app, the overhead isn't justified. - -## 6. Extensibility - -If this were to evolve into a production service: - -| Concern | Approach | -|---------|----------| -| Async API | Add `GetExchangeRatesAsync` alongside sync version | -| Testability | Inject `HttpClient` or `IExchangeRateSource` interface | -| Multiple sources | Strategy pattern; aggregate or fallback between CNB, ECB, etc. | -| Background refresh | Hosted service polling on schedule, populating shared cache | -| Distributed cache | Redis/Memcached with pub/sub invalidation | - ---- - -## Out of Scope - -The following were deliberately not implemented: - -- Integration tests hitting the real CNB endpoint. -- Retry policies (Polly or similar). -- Logging / structured telemetry. -- Rate limiting or circuit breaker. -- Distributed caching. -- Historical rates (CNB supports date parameter; not required here). +- Caching (stateless is simpler for a one-shot app) +- Retry policies / circuit breakers +- Integration tests against live CNB endpoint +- Historical rates diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 1d1c3ada1d..77bb91c3eb 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -1,6 +1,6 @@ # Exchange Rate Updater -A .NET 10 console application that fetches daily foreign currency exchange rates from the Czech National Bank (CNB). It filters rates by a configurable list of currencies and outputs them to the console in a human-readable format. +A .NET 10 console application that fetches daily foreign currency exchange rates from the Czech National Bank (CNB). It filters rates by a configurable list of currencies and outputs them to the console. ## Behavior Rules @@ -12,18 +12,13 @@ A .NET 10 console application that fetches daily foreign currency exchange rates ## Prerequisites -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (pinned via `global.json`) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) ## How to Run ```bash -./run.sh -``` - -Or manually: - -```bash -dotnet run --project ExchangeRateUpdater.csproj +./run.sh # default (summary output) +./run.sh -v # verbose (debug logs) ``` ## How to Test @@ -32,27 +27,13 @@ dotnet run --project ExchangeRateUpdater.csproj ./test.sh ``` -Or manually: - -```bash -dotnet test -``` - ## Configuration -The CNB API base URL can be configured via: - -| Method | Key / Variable | Example | -|--------|----------------|---------| -| appsettings.json | `CnbApi:BaseUrl` | `"https://www.cnb.cz"` | -| Environment variable | `CnbApi__BaseUrl` | `export CnbApi__BaseUrl=https://www.cnb.cz` | - -If neither is set, defaults to `https://www.cnb.cz`. - -## Architecture - -`Program.cs` wires DI and logging. `ExchangeRateProvider` orchestrates fetch (via `IExchangeRatesSource`) and parse (via `CnbRatesParser`). The `IExchangeRatesSource` abstraction enables unit testing with a fake implementation. +| Method | Key | Default | +|--------|-----|---------| +| appsettings.json | `CnbApi:BaseUrl` | `https://www.cnb.cz` | +| Environment variable | `CnbApi__BaseUrl` | — | ## Design Decisions -See [DECISIONS.md](DECISIONS.md) for detailed rationale on data source choice, error handling, caching trade-offs, and extensibility considerations. +See [DECISIONS.md](DECISIONS.md). diff --git a/jobs/Backend/Task/global.json b/jobs/Backend/Task/global.json deleted file mode 100644 index 6b97fb4d57..0000000000 --- a/jobs/Backend/Task/global.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sdk": { - "version": "10.0.102", - "rollForward": "latestMinor" - } -} - From 62939a0982335450e276de980e3efe99507898d0 Mon Sep 17 00:00:00 2001 From: Roman Kulakov Date: Thu, 15 Jan 2026 13:01:45 +0100 Subject: [PATCH 11/11] Self-review: add some description to files --- jobs/Backend/Task/DECISIONS.md | 60 ++++++++++++++++++++--- jobs/Backend/Task/ExchangeRateProvider.cs | 5 +- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/jobs/Backend/Task/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md index 98eba7d45d..7146c5b076 100644 --- a/jobs/Backend/Task/DECISIONS.md +++ b/jobs/Backend/Task/DECISIONS.md @@ -10,21 +10,65 @@ - **Czech National Bank (CNB) daily fixing** — official, public, no API key, plain-text format. - Alternatives (ECB, Fixer.io) add complexity (XML, auth, rate limits). -## Exchange Rate Rules +## Architecture Notes -- Only source-defined rates are returned (no inverse/cross rates computed). -- Rationale: calculated rates introduce rounding errors; assignment requires source-only. +### Separation: fetch vs parse vs orchestration + +The solution is split into three responsibilities: + +- **`CnbRatesSource`**: fetches raw text from CNB (I/O, network concerns). +- **`CnbRatesParser`**: parses raw text into domain objects (pure logic). +- **`ExchangeRateProvider`**: orchestrates the flow and applies the task rules (filtering, CZK requirement). + +This keeps parsing testable without HTTP and keeps the provider focused on business rules. + +### Why `IExchangeRatesSource` exists + +`IExchangeRatesSource` abstracts the external dependency (HTTP fetch). + +Benefits: +- Unit tests can supply a fake source (`FakeRatesSource`) without network calls. +- The provider stays deterministic and easy to test (no flakiness, no timeouts). +- If the data source changes (CNB format endpoint, alternative provider), the provider logic stays the same. + +### Why the parser is `static` + +`CnbRatesParser` is a pure, stateless function. Making it static: +- communicates "no state, no side effects" +- avoids unnecessary DI wiring +- keeps unit tests focused and simple + +If in the future parsing becomes configurable (different formats/sources), it can be converted to an injected service. + +### Why the provider requires `CZK` in the request + +CNB daily fixing publishes rates **against CZK** (e.g., `USD -> CZK`), not arbitrary pairs. + +Requiring CZK in the input: +- makes it explicit that CZK is the target currency for returned results +- avoids returning confusing partial results when users ask for pairs CNB cannot provide + +### Why no inverse/cross rates are computed + +The assignment explicitly requires returning only rates defined by the source. + +Computing inverse rates (`CZK -> USD`) or cross rates (`USD -> EUR`) would introduce derived values and rounding differences vs official CNB fixing. + +### Sync-over-async choice + +The console app is intended to run once and exit. Using a synchronous API simplifies usage (`GetExchangeRates(...)` returning `IEnumerable`). + +The HTTP call uses `GetAwaiter().GetResult()` which is acceptable here because: +- there is no UI thread / request context +- the process is short-lived + +In a long-running service, I would expose `async` and use `IHttpClientFactory`. ## Error Handling - Network errors and timeouts throw `InvalidOperationException` with URL for diagnostics. - Fail-fast is appropriate for a console app; retries/circuit breakers omitted. -## Sync vs Async - -- `GetExchangeRates` is synchronous (sync-over-async via `GetAwaiter().GetResult()`). -- Acceptable for a single-threaded console app with no request pool. - ## Not Implemented - Caching (stateless is simpler for a one-shot app) diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 9215689474..41063d6ce9 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -27,15 +27,12 @@ public ExchangeRateProvider(IExchangeRatesSource ratesSource, ILogger public IEnumerable GetExchangeRates(IEnumerable currencies) { - if (currencies is null) - return Enumerable.Empty(); - var requestedCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); if (!requestedCodes.Contains(TargetCurrencyCode)) { _logger.LogDebug("CZK not requested; returning empty result set"); - return Enumerable.Empty(); + return []; } // Future improvement: add caching here to avoid repeated HTTP calls for the same day's rates.