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
65 changes: 62 additions & 3 deletions jobs/Backend/Readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,65 @@
# Mews backend developer task

We are focused on multiple backend frameworks at Mews. Depending on the job position you are applying for, you can choose among the following:
## What This Application Does

* [.NET](DotNet.md)
* [Ruby on Rails](RoR.md)
This is a .NET 10.0 console application that fetches current exchange rates from the Czech National Bank (CNB) and displays them in CZK-to-foreign-currency format.

The application:
- Fetches daily exchange rate data from CNB's public API (pipe-separated text format)
- Parses the data and converts rates to CZK/XXX format (1 CZK = X foreign currency units)
- Returns only explicitly defined rates (no calculated or inverse rates)
- Silently ignores currencies not available from CNB

## Design Decisions

**Data Source**: CNB daily exchange rates API (`https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt`)
- Official source, pipe-separated format, updated daily
- Provides rates as: Amount units of foreign currency = Rate CZK

**Architecture**:
- Dependency injection for loose coupling and testability
- Separation of concerns: HTTP client, parser, and provider
- Polly retry policy for transient HTTP failures (3 retries with 2-second delays)
- Configuration-based settings for timeouts and retry behavior

**Error Handling**:
- HTTP errors are logged and propagated with clear messages
- Missing currencies are silently ignored per requirements
- Timeout errors are explicitly handled

## Possible Improvements

**Caching**: Add in-memory caching with time-based expiration (CNB updates daily at 2:15 PM CET)

**Rate Conversion**: Support inverse rate calculation (e.g., USD/CZK from CZK/USD) if business requirements change

**Data Source Fallback**: Add secondary data source in case CNB API is unavailable

**Observability**: Add structured logging with correlation IDs for better troubleshooting

## Running the .NET Exchange Rate Updater

### Prerequisites
- .NET 10.0 SDK

### Build and Run the Application

```bash
# Build the solution
dotnet build Task/ExchangeRateUpdater.sln

# Run the application
dotnet run --project Task/ExchangeRateUpdater.csproj
```

The application will fetch current exchange rates from the Czech National Bank and display them in the console.

### Run Unit Tests

```bash
# Run all tests
dotnet test Task.Tests/ExchangeRateUpdater.Tests.csproj

# Run tests with detailed output
dotnet test Task.Tests/ExchangeRateUpdater.Tests.csproj --verbosity normal
```
272 changes: 272 additions & 0 deletions jobs/Backend/Task.Tests/ExchangeRateProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
using FluentAssertions;
using Moq;
using ExchangeRateUpdater;
using ExchangeRateUpdater.HttpClients;
using ExchangeRateUpdater.Parsers;
using ExchangeRateUpdater.Parsers.Models;
using ExchangeRateUpdater.Tests.TestData;

namespace ExchangeRateUpdater.Tests
{
public class ExchangeRateProviderTests
{
private readonly Mock<ICnbHttpClient> _mockHttpClient;
private readonly Mock<ICnbDataParser> _mockParser;
private readonly ExchangeRateProvider _provider;

public ExchangeRateProviderTests()
{
_mockHttpClient = new Mock<ICnbHttpClient>();
_mockParser = new Mock<ICnbDataParser>();
_provider = new ExchangeRateProvider(_mockHttpClient.Object, _mockParser.Object);
}

[Fact]
public async Task GetExchangeRatesAsync_ValidData_ReturnsFilteredRates()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD(),
CnbTestDataBuilder.EUR(),
CnbTestDataBuilder.JPY(),
CnbTestDataBuilder.GBP()
};

var currencies = new[]
{
new Currency("USD"),
new Currency("EUR"),
new Currency("JPY")
};

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);
var rates = result.ToList();

// Assert
rates.Should().HaveCount(3);
rates.Should().Contain(r => r.TargetCurrency.Code == "USD");
rates.Should().Contain(r => r.TargetCurrency.Code == "EUR");
rates.Should().Contain(r => r.TargetCurrency.Code == "JPY");
rates.Should().NotContain(r => r.TargetCurrency.Code == "GBP");
}

[Fact]
public async Task GetExchangeRatesAsync_CorrectlyNormalizesRates()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD(),
CnbTestDataBuilder.JPY(),
CnbTestDataBuilder.IDR()
};

var currencies = new[]
{
new Currency("USD"),
new Currency("JPY"),
new Currency("IDR")
};

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);
var rates = result.ToList();

// Assert
var usdRate = rates.First(r => r.TargetCurrency.Code == "USD");
usdRate.Value.Should().BeApproximately(1m / 20.774m, 0.000001m);

var jpyRate = rates.First(r => r.TargetCurrency.Code == "JPY");
jpyRate.Value.Should().BeApproximately(100m / 13.278m, 0.000001m);

var idrRate = rates.First(r => r.TargetCurrency.Code == "IDR");
idrRate.Value.Should().BeApproximately(1000m / 1.238m, 0.000001m);
}

[Fact]
public async Task GetExchangeRatesAsync_AllRatesHaveCzkAsSource()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD(),
CnbTestDataBuilder.EUR()
};

var currencies = new[]
{
new Currency("USD"),
new Currency("EUR")
};

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);
var rates = result.ToList();

// Assert
rates.Should().AllSatisfy(r =>
{
r.SourceCurrency.Code.Should().Be("CZK");
});
}

[Fact]
public async Task GetExchangeRatesAsync_CaseInsensitiveCurrencyMatching()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD()
};

var currencies = new[]
{
new Currency("usd"), // lowercase
new Currency("USD"), // uppercase
new Currency("Usd") // mixed case
};

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);
var rates = result.ToList();

// Assert - Should return USD only once despite multiple case variations
rates.Should().HaveCount(1);
rates[0].TargetCurrency.Code.Should().Be("USD");
}

[Fact]
public async Task GetExchangeRatesAsync_MissingCurrency_SilentlyIgnored()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD(),
CnbTestDataBuilder.EUR()
};

var currencies = new[]
{
new Currency("USD"),
new Currency("XYZ"), // Doesn't exist in parsed data
new Currency("KES") // Doesn't exist in parsed data
};

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);
var rates = result.ToList();

// Assert - Should only return USD, silently ignoring XYZ and KES
rates.Should().HaveCount(1);
rates[0].TargetCurrency.Code.Should().Be("USD");
}

[Fact]
public async Task GetExchangeRatesAsync_EmptyRequestedCurrencies_ReturnsEmpty()
{
// Build mock data
var rawData = "mocked CNB data";
var parsedData = new List<CnbExchangeRateData>
{
CnbTestDataBuilder.USD()
};

var currencies = Array.Empty<Currency>();

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ReturnsAsync(rawData);

_mockParser
.Setup(x => x.Parse(rawData))
.Returns(parsedData);

// Call provider
var result = await _provider.GetExchangeRatesAsync(currencies);

// Assert
result.Should().BeEmpty();
}

[Fact]
public async Task GetExchangeRatesAsync_HttpClientThrows_PropagatesException()
{
// Build mock data
var currencies = new[] { new Currency("USD") };

_mockHttpClient
.Setup(x => x.GetExchangeRatesAsync())
.ThrowsAsync(new InvalidOperationException("Network error"));

// Call provider & Assert
await _provider.Invoking(p => p.GetExchangeRatesAsync(currencies))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Network error");
}

[Fact]
public void Constructor_NullHttpClient_ThrowsArgumentNullException()
{
// Call provider & Assert
var act = () => new ExchangeRateProvider(null!, _mockParser.Object);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("httpClient");
}

[Fact]
public void Constructor_NullParser_ThrowsArgumentNullException()
{
// Call provider & Assert
var act = () => new ExchangeRateProvider(_mockHttpClient.Object, null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("parser");
}
}
}
33 changes: 33 additions & 0 deletions jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../Task/ExchangeRateUpdater.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<None Update="TestData\*.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Loading