Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

71 changes: 71 additions & 0 deletions jobs/Backend/Task/CnbRatesParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace ExchangeRateUpdater
{
/// <summary>
/// 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.
/// </summary>
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;

/// <summary>
/// Parses CNB rates content and returns exchange rates for requested currencies.
/// </summary>
/// <param name="content">Raw CNB daily.txt content.</param>
/// <param name="requestedCodes">Set of currency codes to include (case-insensitive).</param>
/// <param name="targetCurrency">The target currency (CZK).</param>
/// <returns>Exchange rates matching requested currencies.</returns>
public static IEnumerable<ExchangeRate> Parse(string content, HashSet<string> 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<string> 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);
}
}
}

52 changes: 52 additions & 0 deletions jobs/Backend/Task/CnbRatesSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater
{
/// <summary>
/// Fetches exchange rates from the Czech National Bank daily fixing endpoint.
/// </summary>
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<CnbRatesSource> _logger;

public CnbRatesSource(string baseUrl, ILogger<CnbRatesSource> 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);
}
}
}
}
2 changes: 1 addition & 1 deletion jobs/Backend/Task/Currency.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater
{
public class Currency
{
Expand Down
77 changes: 77 additions & 0 deletions jobs/Backend/Task/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -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<ExchangeRate>`).

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
2 changes: 1 addition & 1 deletion jobs/Backend/Task/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater
{
public class ExchangeRate
{
Expand Down
32 changes: 30 additions & 2 deletions jobs/Backend/Task/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -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<ExchangeRateProvider> _logger;

public ExchangeRateProvider(IExchangeRatesSource ratesSource, ILogger<ExchangeRateProvider> logger)
{
_ratesSource = ratesSource ?? throw new ArgumentNullException(nameof(ratesSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <summary>
/// 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",
Expand All @@ -13,7 +27,21 @@ public class ExchangeRateProvider
/// </summary>
public IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies)
{
return Enumerable.Empty<ExchangeRate>();
var requestedCodes = new HashSet<string>(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;
}
}
}
91 changes: 91 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbRatesParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace ExchangeRateUpdater.Tests
{
/// <summary>
/// Tests for CnbRatesParser - focused on parsing behavior only.
/// </summary>
public class CnbRatesParserTests
{
private static HashSet<string> 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);
}
}
}
Loading