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
21 changes: 21 additions & 0 deletions .cursor/rules/collaboration-and-docs.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
description: Collaboration style, planning docs, and engineering decision log for this project
alwaysApply: true
---

# Collaboration and documentation

## How to work with the maintainer

- **Step by step:** Discuss and implement **one step at a time** unless the user explicitly asks to batch or skip ahead.
- **No surprise decisions:** Do **not** choose libraries, frameworks, or structural changes (e.g. new classes, folders, or public APIs) on the maintainer’s behalf. Present **options and tradeoffs** and wait for an explicit choice when it matters.
- **No unsolicited code:** Do **not** write or edit application code unless the maintainer **explicitly asks** you to. It is fine to explain approaches, sketch snippets in chat when helpful, and review or suggest changes in prose—but avoid applying patches or creating/editing source files until asked.
- **`Program.cs` (current):** The maintainer is wiring [`jobs/Backend/Task/Program.cs`](jobs/Backend/Task/Program.cs) themselves (composition root, `IExchangeRateSource` + `ExchangeRateProvider`). Do **not** change that file unless they explicitly request it.

## Where documentation lives

- **Task / implementation plan:** The versioned plan for this backend task is [`jobs/Backend/docs/PLAN.md`](jobs/Backend/docs/PLAN.md). When the plan changes, **update that file** so it stays the single source of truth in the repo.
- **Engineering decisions:** Record substantive choices (libraries, patterns, whether to add a type or folder, sync vs async, etc.) in [`jobs/Backend/docs/DECISIONS.md`](jobs/Backend/docs/DECISIONS.md), using the same lightweight sections as existing entries (Context / Decision / Why / Consequences).
- **Test case matrix:** When adding, renaming, removing, or materially changing a test method, update [`jobs/Backend/docs/TEST_CASES.md`](jobs/Backend/docs/TEST_CASES.md) so the documented test matrices stay aligned with the test suite.

Cursor may also store a generated plan under `.cursor/plans/`; treat **`PLAN.md` in the repo** as the authoritative plan for git and reviews unless the user says otherwise.
90 changes: 90 additions & 0 deletions jobs/Backend/SOLUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Backend Solution Guide - Nicolas Darriulat

Hi there! This guide is the starting point for reading the implemented .NET backend task. The original assignment files are still present, this file explains what was built, where the supporting documents live, and how to run the solution locally.

Although the assignment is small, I intentionally used a production-oriented structure to demonstrate how I approach maintainable code: clear responsibilities, explicit dependencies, documented decisions, and focused tests.

I also documented every decision, and created multiple documents to explain my thought process. I used Cursor while coding this project, so you may also find a .mdc file which tells which were the collaboration rules agreed with Cursor's AI Agents.

**Note:** This PR is squashed into a few clean commits on top of the latest upstream `master`. The work was developed incrementally, but the final branch was rebuilt to keep the review focused on the backend solution instead of unrelated fork-history differences.

## What This Implements

The solution implements an `ExchangeRateProvider` backed by the Czech National Bank daily exchange-rate feed. The concrete CNB source downloads the published text document, parses it into normalized `ExchangeRate` objects, and the provider filters those rates for requested source currencies.

CNB publishes rates as foreign currency against CZK. For example, requesting `USD` returns the source-provided `USD/CZK` rate; the caller does not need to request `CZK` separately.

## Where To Start

1. `[DotNet.md](DotNet.md)` - the original .NET assignment.
2. `[SOLUTION.md](SOLUTION.md)` - this guide and local setup instructions.
3. `[docs/PLAN.md](docs/PLAN.md)` - the implementation plan and high-level flow.
4. `[docs/DECISIONS.md](docs/DECISIONS.md)` - the engineering decisions and tradeoffs.
5. `[docs/TEST_CASES.md](docs/TEST_CASES.md)` - the documented test matrix.
6. `[.cursor/rules/collaboration-and-docs.mdc](../../.cursor/rules/collaboration-and-docs.mdc)` - the collaboration rules agreed with Cursor's AI agents.

## Document Map


| Document | Purpose |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `[Readme.md](Readme.md)` | Backend task index that links to the available backend exercises. |
| `[SOLUTION.md](SOLUTION.md)` | Reader-oriented guide for this implementation and local commands. |
| `[DotNet.md](DotNet.md)` | Original task description for the .NET implementation. |
| `[docs/PLAN.md](docs/PLAN.md)` | Current implementation plan, flow, checklist, and scope notes. |
| `[docs/DECISIONS.md](docs/DECISIONS.md)` | Decision log explaining the main design choices and consequences. |
| `[docs/TEST_CASES.md](docs/TEST_CASES.md)` | Test coverage matrix for provider, filtering, and CNB source behavior. |
| `[.cursor/rules/collaboration-and-docs.mdc](../../.cursor/rules/collaboration-and-docs.mdc)` | Collaboration rules agreed with Cursor's AI agents while building this project. |


## Code Structure


| Path | Role |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `[Task/ExchangeRateProvider.cs](Task/ExchangeRateProvider.cs)` | Orchestrates source retrieval, filtering, and return of exchange rates. |
| `[Task/IExchangeRateSource.cs](Task/IExchangeRateSource.cs)` | Source abstraction returning parsed exchange-rate objects. |
| `[Task/CnbExchangeRateSource.cs](Task/CnbExchangeRateSource.cs)` | Fetches and parses the CNB daily exchange-rate text document. |
| `[Task/Currency.cs](Task/Currency.cs)` | Currency value object with case-insensitive code equality. |
| `[Task/ExchangeRate.cs](Task/ExchangeRate.cs)` | Exchange-rate model with source currency, target currency, and value. |
| `[Task/Program.cs](Task/Program.cs)` | Console composition root: configuration, DI, HTTP client, and provider execution. |
| `[Task.Tests](Task.Tests)` | xUnit tests for provider behavior, filtering, and CNB source parsing. |


## Run Locally

Required tooling:

- .NET SDK 10.0 or newer, because both projects target `net10.0`.
- Internet access for the first NuGet restore and for running the console app against the live CNB endpoint.

From the repository root:

```bash
dotnet restore "jobs/Backend/Task/ExchangeRateUpdater.csproj"
dotnet run --project "jobs/Backend/Task/ExchangeRateUpdater.csproj"
```

`dotnet run` also performs restore/build automatically, but running `dotnet restore` explicitly makes missing SDK or package issues easier to see.

## Run Tests

From the repository root:

```bash
dotnet test "jobs/Backend/Task.Tests/Task.Tests.csproj"
```

The tests use xUnit. Provider tests use fake `IExchangeRateSource` implementations, while CNB source tests use stubbed HTTP responses so they do not depend on the live CNB website.

## Behavior Notes

- CNB rows are parsed as foreign currency to CZK, e.g. `USD/CZK`.
- Requested currencies are interpreted as requested source currencies.
- Unknown requested currencies are ignored.
- The provider does not synthesize inverse rates such as `CZK/USD`.
- CNB amounts greater than one, such as `100 JPY`, are normalized to a per-unit `ExchangeRate.Value`.

## Production Notes

The main production-oriented choices are documented in `[docs/DECISIONS.md](docs/DECISIONS.md)`: `HttpClient` creation through `IHttpClientFactory`, configuration through `appsettings.json` with startup validation, configurable CNB retries/timeouts through `Microsoft.Extensions.Http.Resilience`, source-specific parsing inside `CnbExchangeRateSource`, and a deliberate sync-over-async boundary to preserve the assignment-facing provider API.
150 changes: 150 additions & 0 deletions jobs/Backend/Task.Tests/CnbExchangeRateSourceIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using ExchangeRateUpdater;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;

public class CnbExchangeRateSourceIntegrationTests
{
private const string TestUrl = "https://example.test/denni_kurz.txt";

/// <summary>
/// Matrix of CNB daily document shapes (as returned by HTTP) and expected parsed rates.
/// Filtering by requested currencies happens in <see cref="ExchangeRateProvider"/>; the source returns all parsed rows.
/// </summary>
public static IEnumerable<object[]> CnbDocumentScenarios()
{
const string header = "07 May 2026 #87\nCountry|Currency|Amount|Code|Rate\n";

yield return new object[]
{
"single_row_amount_one_dot_decimal",
header + "USA|dollar|1|USD|20.648\n",
new (string Src, string Tgt, decimal Value)[] { ("USD", "CZK", 20.648m) },
};

yield return new object[]
{
"amount_100_normalises_to_per_unit",
header + "Japan|yen|100|JPY|13.204\n",
new (string Src, string Tgt, decimal Value)[] { ("JPY", "CZK", 0.13204m) },
};

yield return new object[]
{
"multiple_rows",
header
+ "USA|dollar|1|USD|20.648\n"
+ "EMU|euro|1|EUR|24.305\n",
new (string Src, string Tgt, decimal Value)[]
{
("USD", "CZK", 20.648m),
("EUR", "CZK", 24.305m),
},
};

yield return new object[]
{
"comma_decimal_czech_style",
header + "USA|dollar|1|USD|20,648\n",
new (string Src, string Tgt, decimal Value)[] { ("USD", "CZK", 20.648m) },
};

yield return new object[]
{
"malformed_pipe_row_skipped_valid_rows_kept",
header
+ "not|enough|columns\n"
+ "USA|dollar|1|USD|1\n"
+ "||||\n",
new (string Src, string Tgt, decimal Value)[] { ("USD", "CZK", 1m) },
};

yield return new object[]
{
"empty_body",
"",
System.Array.Empty<(string Src, string Tgt, decimal Value)>(),
};

yield return new object[]
{
"headers_only_no_data_rows",
header.TrimEnd(),
System.Array.Empty<(string Src, string Tgt, decimal Value)>(),
};

yield return new object[]
{
"whitespace_trimmed_in_fields",
header + " USA | dollar | 1 | USD | 10 \n",
new (string Src, string Tgt, decimal Value)[] { ("USD", "CZK", 10m) },
};
}

[Theory]
[MemberData(nameof(CnbDocumentScenarios))]
public async Task GetExchangeRates_parses_http_body_as_cnb_daily_document(
string scenario,
string httpBody,
(string Src, string Tgt, decimal Value)[] expected)
{
_ = scenario;
using var handler = new StubHttpMessageHandler(HttpStatusCode.OK, httpBody);
using var httpClient = new HttpClient(handler);
var options = Options.Create(new CnbOptions { DailyKurzUrl = TestUrl });
var source = new CnbExchangeRateSource(httpClient, options, NullLogger<CnbExchangeRateSource>.Instance);

var rates = (await source.GetExchangeRates()).ToList();

Assert.Equal(expected.Length, rates.Count);
foreach (var exp in expected)
{
Assert.Contains(
rates,
r => r.SourceCurrency.Code == exp.Src
&& r.TargetCurrency.Code == exp.Tgt
&& r.Value == exp.Value);
}
}

[Fact]
public async Task GetExchangeRates_when_http_fails_throws()
{
using var handler = new StubHttpMessageHandler(HttpStatusCode.NotFound, body: "");
using var httpClient = new HttpClient(handler);
var options = Options.Create(new CnbOptions { DailyKurzUrl = TestUrl });
var source = new CnbExchangeRateSource(httpClient, options, NullLogger<CnbExchangeRateSource>.Instance);

await Assert.ThrowsAsync<HttpRequestException>(
() => source.GetExchangeRates());
}

private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _body;

public StubHttpMessageHandler(HttpStatusCode statusCode, string body)
{
_statusCode = statusCode;
_body = body;
}

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_body),
};
return Task.FromResult(response);
}
}
}
91 changes: 91 additions & 0 deletions jobs/Backend/Task.Tests/ExchangeRateProviderFilteringTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using ExchangeRateUpdater;
using System.Threading.Tasks;
using Xunit;

public class ExchangeRateProviderFilteringTests
{
[Fact]
public void GetFilteredRates_returns_rates_for_requested_source_currency()
{
var usd = new Currency("USD");
var czk = new Currency("CZK");
var eur = new Currency("EUR");
var cnbRates = new List<ExchangeRate>
{
new ExchangeRate(usd, czk, 25.0m),
new ExchangeRate(eur, czk, 24.0m),
};

var filteredRates = InvokeGetFilteredRates(cnbRates, new List<Currency> { usd }).ToList();

Assert.Single(filteredRates);
Assert.Equal("USD", filteredRates[0].SourceCurrency.Code);
Assert.Equal("CZK", filteredRates[0].TargetCurrency.Code);
}

[Fact]
public void GetFilteredRates_ignores_unknown_requested_currencies()
{
var usd = new Currency("USD");
var cnbRates = new List<ExchangeRate>
{
new ExchangeRate(usd, new Currency("CZK"), 25.0m),
};

var filteredRates = InvokeGetFilteredRates(cnbRates, new List<Currency> { new Currency("XYZ") });

Assert.Empty(filteredRates);
}

[Fact]
public void GetFilteredRates_does_not_create_inverse_pairs()
{
var usd = new Currency("USD");
var czk = new Currency("CZK");
var cnbRates = new List<ExchangeRate>
{
new ExchangeRate(usd, czk, 25.0m),
};

var filteredRates = InvokeGetFilteredRates(cnbRates, new List<Currency> { usd }).ToList();

Assert.Single(filteredRates);
Assert.DoesNotContain(filteredRates, r => r.SourceCurrency.Code == "CZK" && r.TargetCurrency.Code == "USD");
}

private static IEnumerable<ExchangeRate> InvokeGetFilteredRates(
IEnumerable<ExchangeRate> cnbRates,
IEnumerable<Currency> currencies)
{
var provider = new ExchangeRateProvider(new FakeExchangeRateSource());
var method = typeof(ExchangeRateProvider).GetMethod("GetFilteredRates", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);

var result = method!.Invoke(provider, new object[] { cnbRates, currencies });
Assert.NotNull(result);
return (IEnumerable<ExchangeRate>)result!;
}

private sealed class FakeExchangeRateSource : IExchangeRateSource
{
private readonly IReadOnlyList<ExchangeRate> _rates;

public FakeExchangeRateSource()
: this(Enumerable.Empty<ExchangeRate>())
{
}

public FakeExchangeRateSource(IEnumerable<ExchangeRate> rates)
{
_rates = rates.ToList();
}

public Task<IEnumerable<ExchangeRate>> GetExchangeRates()
{
return Task.FromResult<IEnumerable<ExchangeRate>>(_rates);
}
}
}
Loading