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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
node_modules
bower_components
npm-debug.log

.vs/
CopilotIndices*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using ExchangeRateUpdater.Providers;
using FluentAssertions;
using System.Reflection;

namespace ExchangeRateUpdater.Tests.CnbExchangeRateProviderTests
{
public class MapToExchangeRatesTests
{
private readonly object _provider;
private readonly MethodInfo _parseMethod;
private readonly MethodInfo _mapMethod;

public MapToExchangeRatesTests()
{
var type = typeof(CnbExchangeRateProvider);
_provider = Activator.CreateInstance(type)!;

_parseMethod = type.GetMethod("ParseDailyRates", BindingFlags.NonPublic | BindingFlags.Instance)!;
_mapMethod = type.GetMethod("MapToExchangeRates", BindingFlags.NonPublic | BindingFlags.Instance)!;
}

private object InvokeParse(string raw)
{
try
{
return _parseMethod.Invoke(_provider, new object[] { raw })!;
}
catch (TargetInvocationException ex)
{
throw ex.InnerException ?? ex;
}
}

private object InvokeMap(object rows, params Currency[] currencies)
{
try
{
return _mapMethod.Invoke(_provider, new object[] { rows, currencies })!;
}
catch (TargetInvocationException ex)
{
throw ex.InnerException ?? ex;
}
}

[Fact]
public void Map_ValidRow_ReturnsNormalizedRate()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
Japan|yen|100|JPY|15.0";

var rows = InvokeParse(txt);

var result = (System.Collections.Generic.IEnumerable<ExchangeRate>)InvokeMap(rows,
new Currency("JPY"));

var rate = result.Single();

rate.Value.Should().Be(0.15m);
}

[Fact]
public void Map_RequestMissingCurrency_ReturnsEmpty()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|1|USD|20.0";

var rows = InvokeParse(txt);

var result = (IEnumerable<ExchangeRate>)InvokeMap(rows,
new Currency("EUR"));

result.Should().BeEmpty();
}

[Fact]
public void Map_MultipleRequestedSameCode_NoDuplicateRatesReturned()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|1|USD|20.0";

var rows = InvokeParse(txt);

var result = (IEnumerable<ExchangeRate>)InvokeMap(rows,
new Currency("USD"), new Currency("USD"));

result.Should().HaveCount(1);
}

[Fact]
public void Map_RequestedCurrenciesIsEmpty_ReturnsEmpty()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|1|USD|20.0";

var rows = InvokeParse(txt);

var result = (IEnumerable<ExchangeRate>)InvokeMap(rows);

result.Should().BeEmpty();
}

[Fact]
public void Map_DuplicateCurrencyCodesInRows_Throws()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|1|USD|20.0
United States|dollar|1|USD|21.0";

var rows = InvokeParse(txt);

Action act = () => InvokeMap(rows, new Currency("USD"));

act.Should().Throw<FormatException>()
.WithMessage("*duplicate*");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using ExchangeRateUpdater.Providers;
using FluentAssertions;
using System.Reflection;


namespace ExchangeRateUpdater.Tests.CnbExchangeRateProviderTests
{
public class ParseDailyRatesTests
{
private readonly object _provider;
private readonly MethodInfo _parseMethod;

public ParseDailyRatesTests()
{
var type = typeof(CnbExchangeRateProvider);
_provider = Activator.CreateInstance(type)!;

_parseMethod = type.GetMethod("ParseDailyRates", BindingFlags.NonPublic | BindingFlags.Instance)!;
}

private object InvokeParse(string raw)
{
try
{
return _parseMethod.Invoke(_provider, new object[] { raw })!;
}
catch (TargetInvocationException ex)
{
throw ex.InnerException ?? ex;
}
}

[Fact]
public void Parse_ValidContent_ReturnsRows()
{
// Arrange
var txt = @"03 Jan 2025 #1
Country|Currency|Amount|Code|Rate
USA|dollar|1|USD|20.796";

// Act
var result = InvokeParse(txt);

// Assert
var rows = (System.Collections.Generic.IReadOnlyList<object>)result;
rows.Should().HaveCount(1);
}

[Fact]
public void Parse_InvalidColumnCount_Throws()
{
var txt = @"03 Jan 2025 #1
H1|H2|H3
Bad|Row|Only3Cols";

Action act = () => InvokeParse(txt);

act.Should().Throw<FormatException>();
}

[Fact]
public void Parse_InvalidAmount_Throws()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|XYZ|USD|20.5";

Action act = () => InvokeParse(txt);

act.Should().Throw<FormatException>()
.WithMessage("*amount*");
}

[Fact]
public void Parse_InvalidRate_Throws()
{
var txt = @"03 Jan 2025 #1
C|C|C|C|C
USA|dollar|1|USD|BAD";

Action act = () => InvokeParse(txt);

act.Should().Throw<FormatException>()
.WithMessage("*rate*");
}

[Fact]
public void Parse_EmptyContent_Throws()
{
Action act = () => InvokeParse("");

act.Should().Throw<FormatException>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

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

</Project>
1 change: 1 addition & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
45 changes: 45 additions & 0 deletions jobs/Backend/Readme_Solution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Solution Overview

This project implements an exchange rate provider for the Czech National Bank (CNB).
The provider downloads the daily `daily.txt` file from the official CNB endpoint, parses it, and maps the data to the domain `ExchangeRate` model.

## How it works

1. The provider downloads the TXT file using `HttpClient` (async internally).
2. The file is parsed into an internal `CnbRateRow` model (`Amount`, `Code`, `Rate`).
3. Only currencies requested by the caller and available in the CNB data source are returned.
4. CNB publishes rates in the format "foreign currency - CZK". The provider converts the CNB rate to “per one unit” using: value = Rate / Amount
5. The provider does not create synthetic or reverse exchange rates.

## Design Notes

- The public API remains synchronous to match the original task skeleton. Internal download logic uses async for correctness.
- The `CnbRateRow` model is kept private because it represents a CNB-specific TXT structure and is not part of the domain model.
- Duplicate currency codes in the CNB data are validated to ensure data quality.
- The target currency is always CZK.
- Minimal debug logging is implemented directly inside the provider. This keeps the code simple for a small console application.
In a real production service, structured logging (ILogger) would be used.

## Running

dotnet build
dotnet run



## Unit Tests

A few unit tests were added to check the key logic of the CNB provider:

- parsing the `daily.txt` file
- handling invalid or unexpected input
- mapping CNB rows to `ExchangeRate`
- preventing duplicate currency codes

Only the internal parsing/mapping is tested — the HTTP download is intentionally not covered to keep things simple.

Tests use **xUnit** + **FluentAssertions**.

### Running the tests

dotnet test
6 changes: 4 additions & 2 deletions jobs/Backend/Task/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ExchangeRateUpdater
using System.Globalization;

namespace ExchangeRateUpdater
{
public class ExchangeRate
{
Expand All @@ -17,7 +19,7 @@ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal va

public override string ToString()
{
return $"{SourceCurrency}/{TargetCurrency}={Value}";
return $"{SourceCurrency}/{TargetCurrency}={Value.ToString(CultureInfo.InvariantCulture)}";
}
}
}
19 changes: 0 additions & 19 deletions jobs/Backend/Task/ExchangeRateProvider.cs

This file was deleted.

13 changes: 11 additions & 2 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
# Visual Studio Version 18
VisualStudioVersion = 18.3.11218.70 d18.3
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", "{A5AB45FE-FA68-4A98-86F4-DF890B2D589D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -15,8 +17,15 @@ 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
{A5AB45FE-FA68-4A98-86F4-DF890B2D589D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5AB45FE-FA68-4A98-86F4-DF890B2D589D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5AB45FE-FA68-4A98-86F4-DF890B2D589D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5AB45FE-FA68-4A98-86F4-DF890B2D589D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F4DA4DC8-CEF4-4CDD-B5B5-736DBEAF50D5}
EndGlobalSection
EndGlobal
Loading