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