diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..8787fc0ef4 --- /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: 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Test + run: dotnet test --verbosity normal + diff --git a/jobs/Backend/Task/CnbRatesParser.cs b/jobs/Backend/Task/CnbRatesParser.cs new file mode 100644 index 0000000000..d75e7b2c01 --- /dev/null +++ b/jobs/Backend/Task/CnbRatesParser.cs @@ -0,0 +1,71 @@ +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) + { + if (string.IsNullOrWhiteSpace(content)) + yield break; + + var lines = content.Split(['\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..e6c49bb3bf --- /dev/null +++ b/jobs/Backend/Task/CnbRatesSource.cs @@ -0,0 +1,52 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +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; + private readonly ILogger _logger; + + 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/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/DECISIONS.md b/jobs/Backend/Task/DECISIONS.md new file mode 100644 index 0000000000..7146c5b076 --- /dev/null +++ b/jobs/Backend/Task/DECISIONS.md @@ -0,0 +1,77 @@ +# Decision Notes + +## Framework + +- Chose **.NET 10** as a current, supported runtime. +- Kept the implementation simple — no AOT, trimming, or advanced resilience patterns. + +## Data Source + +- **Czech National Bank (CNB) daily fixing** — official, public, no API key, plain-text format. +- Alternatives (ECB, Fixer.io) add complexity (XML, auth, rate limits). + +## Architecture Notes + +### 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. + +## Not Implemented + +- 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/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 { diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..41063d6ce9 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,10 +1,24 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private const string TargetCurrencyCode = "CZK"; + private static readonly Currency TargetCurrency = new(TargetCurrencyCode); + + private readonly IExchangeRatesSource _ratesSource; + private readonly ILogger _logger; + + public ExchangeRateProvider(IExchangeRatesSource ratesSource, ILogger logger) + { + _ratesSource = ratesSource ?? throw new ArgumentNullException(nameof(ratesSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// /// 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 +27,21 @@ 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)) + { + _logger.LogDebug("CZK not requested; returning empty result set"); + return []; + } + + // Future improvement: add caching here to avoid repeated HTTP calls for the same day's rates. + var content = _ratesSource.GetLatestRatesContent(); + 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/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..4732eba80d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,84 @@ +using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; +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), + NullLogger.Instance); + 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), + NullLogger.Instance); + 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), + NullLogger.Instance); + 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(""), + NullLogger.Instance); + 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..bc79ab7663 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.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.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..b4e5e5c474 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,28 @@ Exe - net6.0 + net10.0 + enable - \ No newline at end of file + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..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 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..255deee682 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,11 +1,16 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { public static class Program { + private const string DefaultCnbBaseUrl = "https://www.cnb.cz"; + private static IEnumerable currencies = new[] { new Currency("USD"), @@ -19,25 +24,62 @@ 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) + .AddEnvironmentVariables() + .Build(); + + var cnbBaseUrl = configuration["CnbApi:BaseUrl"] ?? DefaultCnbBaseUrl; + + var services = new ServiceCollection(); + + 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 = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = serviceProvider.GetRequiredService(); + var rates = provider.GetExchangeRates(currencies).ToList(); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + logger.LogDebug("Retrieved {Count} exchange rates", rates.Count); + + 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/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 0000000000..77bb91c3eb --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,39 @@ +# 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. + +## 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 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## How to Run + +```bash +./run.sh # default (summary output) +./run.sh -v # verbose (debug logs) +``` + +## How to Test + +```bash +./test.sh +``` + +## Configuration + +| Method | Key | Default | +|--------|-----|---------| +| appsettings.json | `CnbApi:BaseUrl` | `https://www.cnb.cz` | +| Environment variable | `CnbApi__BaseUrl` | — | + +## Design Decisions + +See [DECISIONS.md](DECISIONS.md). diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..ad4b0e81e8 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +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 new file mode 100755 index 0000000000..ebfa5aea44 --- /dev/null +++ b/jobs/Backend/Task/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e + +if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then + env Logging__LogLevel__ExchangeRateUpdater=Debug \ + dotnet run --project ExchangeRateUpdater.csproj +else + dotnet run --project ExchangeRateUpdater.csproj +fi diff --git a/jobs/Backend/Task/test.sh b/jobs/Backend/Task/test.sh new file mode 100755 index 0000000000..e3a31e785e --- /dev/null +++ b/jobs/Backend/Task/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e + +dotnet test