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
25 changes: 25 additions & 0 deletions jobs/Backend/Task/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
63 changes: 63 additions & 0 deletions jobs/Backend/Task/DEVJOURNAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Mews backend developer task

The task of implementing an ExchangeRateProvider for Czech National Bank seems relatively simple on the surface but has some gotchas and is open-ended enough which gives me some good oportunities to think of and apply various dotnet best practices and design patterns. At the same time this also bears the risk of over-engineering.


### Functional Requirements
The main functional requirement is well defined with a clear example in Program.cs

- The 'solution' should return current exchange rates for given currency codes that CNB provides data and ONLY those. Exclude unsupported or missing currencies.
- The source of the exchange rate must be CNB
- The TargetCurrency we will have exchange rates for is CZK
- Our Result model should have SourceCurrency, TargetCurrency, Value as minimum
- The cnb.cz source has the exchange rates from SourceCurrency (USD,POUND etc) to target currency (CZK)
and we do not need to calculate and return the opposite (CZK to USD)
- The cnb.cz source SourceCurrency sometimes has amount 1 (USD,POUND etc) and sometimes has amount 100 (TRY,THB). For our return model we will simplify and base every exchange rate to 1 SourceCurrency.

- Result Model
```
- `SourceCurrency`
- `TargetCurrency` (CZK)
- `Value` (rate value normalized per 1 unit)
- `ValidFor` (the CNB bulletin’s date)
```


### Non-Functional Requirements

For non-functional requirements I will have to take some liberties and make some assumptions on the basis of the solution being complete enough for a "production environment".

- Avoid fetching from CNB on every request. I will need to come up with some caching strategy

- Resilience. Retry transient errors (HTTP 5xx, 429) with backoff (e.g., via Polly). Optional: fall back to TXT/XML source if the API is unavailable.

- Maintainability & Extensibility. I will try to make good use of patterns like strategy+factory combo which seems like a good match for the task and setup the project for future extensibility. I need to split application logic from any 'client'(Api, Console app) and setup the project structure accordingly. I will try to aim for 100% Unit Testing. I need to have good logging coverage accross the application.

- Api. I will try to expose the ExchangeRateProvider via an API and deploy it all the way to "production".

- For a real-world deployment, the solution should be containerized and deployable via CI/CD pipeline (GitHub Actions or Azure DevOps), though this may be beyond the scope of the exercise.

## Gotchas & Assumptions

- I need to identify the best source possible from Czech National Bank. The task emphasizes on finding data source and extracting the data. After investigating it seems that CNB offers multiple ways to access the data

- Those are
- API (https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates/dailyUsingGET_1)
- Text file (https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt)
- XML (https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml)


By far the simplest and more standard/best practice way seems to be using their API. The text file is also relatevly very simple and can be parsed easily with a package like csvhelper which I have used extensively. A possible implementation could have both ways to create some resiliency and fall back in case of API (and all retries) failing. Thats is something to consider but for now I will use the banks API.

- The solution should be able to balance handling load but also making sure to not serve stale data.

This could be potentially critical. On one hand I wouldnt like the idea of requesting the API or downloading file for new data for every request. On the other hand I wouldnt like to serve stale data. I will need some caching strategy. At the bank website it states

"Exchange rates of commonly traded currencies are declared every working day after 2.30 p.m. and are valid for the current working day and, where relevant, the following Saturday, Sunday or public holiday (for example, an exchange rate declared on Tuesday 23 December is valid for Tuesday 23 December, the public holidays 24–26 December, and Saturday 27 December and Sunday 28 December)."

But... what does "after 2:30 pm" means,when excactly, after 2:30 can be 3,4,6pm?

I will make the assumption that the data are available sometime between 2:31pm-3:31pm Prague Time and refresh cache every 5 minutes starting at 2:31pm until 3:31 pm. After that I will assume the data are stable and refresh cache every hour. I will reduce to 12 hours on weekends and ignore holidays for siplicity. In case of failing to get fresh data I will allow some cached data to be served.


- Unsupported currencies: If a requested currency is not present in the CNB publication or is not a currency code it is silently ignored (not returned as an error).
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
using ExchangeRateApi.Controllers;
using ExchangeRateApi.Models;
using ExchangeRateProviders.Core;
using ExchangeRateProviders.Core.Model;
using NSubstitute;
using NUnit.Framework;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Mvc;
using ExchangeRateApi.Tests.TestHelpers;

namespace ExchangeRateApi.Tests.Controllers;

[TestFixture]
public class ExchangeRateControllerTests
{
private IExchangeRateService _exchangeRateService = null!;
private ILogger<ExchangeRateController> _logger = null!;
private ExchangeRateController _controller = null!;

private static readonly CancellationToken TestToken = CancellationToken.None;

[SetUp]
public void SetUp()
{
_exchangeRateService = Substitute.For<IExchangeRateService>();
_logger = Substitute.For<ILogger<ExchangeRateController>>();
_controller = new ExchangeRateController(_exchangeRateService, _logger);
}

[Test]
public async Task GetExchangeRates_ValidRequest_ReturnsOk()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>{"USD","EUR"}, TargetCurrency = "CZK" };
var rates = new List<ExchangeRate>
{
new(new Currency("USD"), new Currency("CZK"), 22.5m),
new(new Currency("EUR"), new Currency("CZK"), 24.0m)
};
_exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()).Returns(rates);

// Act
var result = await _controller.GetExchangeRates(request, TestToken);

// Assert
Assert.Multiple(() =>
{
Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
var response = (ExchangeRateResponse)((OkObjectResult)result.Result!).Value!;
Assert.That(response.Rates, Has.Count.EqualTo(2));
Assert.That(response.TargetCurrency, Is.EqualTo("CZK"));
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Received request for exchange rates");
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Successfully retrieved");
});
}

[Test]
public async Task GetExchangeRates_LowercaseCode_ReturnsBadRequest()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>{"usd"}, TargetCurrency = "CZK" }; // invalid casing

// Act
var result = await _controller.GetExchangeRates(request, TestToken);

// Assert
Assert.Multiple(() =>
{
Assert.That(result.Result, Is.TypeOf<BadRequestObjectResult>());
var bad = (BadRequestObjectResult)result.Result!;
Assert.That(bad.Value, Is.InstanceOf<ErrorResponse>());
Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("uppercase"));
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Warning, "Validation failed for request");
});
}

[Test]
public void GetExchangeRates_NoCodes_ThrowsArgumentException()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>(), TargetCurrency = "CZK" };

// Act & Assert
var ex = Assert.ThrowsAsync<ArgumentException>(async () => await _controller.GetExchangeRates(request, TestToken));
Assert.Multiple(() =>
{
Assert.That(ex!.Message, Does.Contain("At least one currency code"));
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Warning, "empty currency codes");
});
}

[Test]
public async Task GetExchangeRates_DefaultTargetCurrency_WhenNull()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>{"USD"}, TargetCurrency = null };
var rates = new List<ExchangeRate>{ new(new Currency("USD"), new Currency("CZK"), 22.5m)};
_exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()).Returns(rates);

// Act
var result = await _controller.GetExchangeRates(request, TestToken);

// Assert
Assert.Multiple(() =>
{
var response = (ExchangeRateResponse)((OkObjectResult)result.Result!).Value!;
Assert.That(response.TargetCurrency, Is.EqualTo("CZK"));
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Received request for exchange rates");
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Successfully retrieved");
});
}

[Test]
public void GetExchangeRates_InvalidOperation_Propagates()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>{"USD"}, TargetCurrency = "XXX" };
_exchangeRateService.When(s => s.GetExchangeRatesAsync("XXX", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()))
.Do(_ => throw new InvalidOperationException("no provider"));

// Act & Assert
Assert.ThrowsAsync<InvalidOperationException>(async () => await _controller.GetExchangeRates(request, TestToken));
}

[Test]
public void GetExchangeRates_UnexpectedException_BubblesUp()
{
// Arrange
var request = new ExchangeRateRequest { CurrencyCodes = new List<string>{"USD"}, TargetCurrency = "CZK" };
_exchangeRateService.When(s => s.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()))
.Do(_ => throw new Exception("boom"));

// Act & Assert
var ex = Assert.ThrowsAsync<Exception>(async () => await _controller.GetExchangeRates(request, TestToken));
Assert.That(ex!.Message, Is.EqualTo("boom"));
}

// GET endpoint tests
[Test]
public async Task GetExchangeRatesQuery_Valid_ReturnsOk()
{
// Arrange
var rates = new List<ExchangeRate>{ new(new Currency("USD"), new Currency("CZK"), 22.5m)};
_exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()).Returns(rates);

// Act
var result = await _controller.GetExchangeRatesQuery("USD", "CZK", TestToken);

// Assert
Assert.Multiple(() =>
{
Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Received request for exchange rates");
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Successfully retrieved");
});
}

[Test]
public async Task GetExchangeRatesQuery_InvalidFormat_ReturnsBadRequest()
{
// Arrange / Act
var result = await _controller.GetExchangeRatesQuery("USDX,EUR", "CZK", TestToken);

// Assert
Assert.Multiple(() =>
{
Assert.That(result.Result, Is.TypeOf<BadRequestObjectResult>());
var bad = (BadRequestObjectResult)result.Result!;
Assert.That(bad.Value, Is.InstanceOf<ErrorResponse>());
Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("format"));
});
}

[Test]
public async Task GetExchangeRatesQuery_TooLong_ReturnsBadRequest()
{
// Arrange
var longString = new string('A', 1001);
// Act
var result = await _controller.GetExchangeRatesQuery(longString, "CZK", TestToken);

// Assert
Assert.Multiple(() =>
{
Assert.That(result.Result, Is.TypeOf<BadRequestObjectResult>());
var bad = (BadRequestObjectResult)result.Result!;
Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("too long"));
});
}

[Test]
public async Task GetExchangeRatesQuery_ManyCodes_ReturnsOk()
{
// Arrange - validate controller accepts more than 10 (limit removed in refactor)
var codes = string.Join(',', new [] { "USD","EUR","JPY","GBP","CHF","AUD","CAD","NZD","CNY","INR","PLN" });
var rates = new List<ExchangeRate>{ new(new Currency("USD"), new Currency("CZK"), 22.5m)};
_exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()).Returns(rates);

// Act
var result = await _controller.GetExchangeRatesQuery(codes, "CZK", TestToken);
Assert.That(result.Result, Is.TypeOf<OkObjectResult>());
}

[Test]
public async Task GetExchangeRatesQuery_DefaultTarget_WhenNull()
{
// Arrange
var rates = new List<ExchangeRate>{ new(new Currency("USD"), new Currency("CZK"), 22.5m)};
_exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any<IEnumerable<Currency>>(), Arg.Any<CancellationToken>()).Returns(rates);

// Act
var result = await _controller.GetExchangeRatesQuery("USD", null, TestToken);

// Assert
Assert.Multiple(() =>
{
var ok = (OkObjectResult)result.Result!;
var response = (ExchangeRateResponse)ok.Value!;
Assert.That(response.TargetCurrency, Is.EqualTo("CZK"));
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Received request for exchange rates");
_logger.VerifyLogContaining<ExchangeRateController>(1, LogLevel.Information, "Successfully retrieved");
});
}

[Test]
public void GetAvailableProviders_ReturnsOkWithProviderList()
{
// Arrange / Act
var result = _controller.GetAvailableProviders();

// Assert
Assert.Multiple(() =>
{
var ok = result.Result as OkObjectResult;
Assert.That(ok, Is.Not.Null);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.6" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ExchangeRateApi\ExchangeRateApi.csproj" />
<ProjectReference Include="..\ExchangeRateProviders\ExchangeRateProviders.csproj" />
</ItemGroup>

</Project>
Loading