Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BE Task: ExchangeRateProvider implemented with CnbCurrencyRateProvider as default source #650

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@
node_modules
bower_components
npm-debug.log

FileContentIndex/
v17/
.vs/
47 changes: 47 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/CnbToolsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using FluentAssertions;

namespace ExchangeRateUpdater.Tests
{
public class CnbToolsTests
{

private const string CorrectCnbFileText = """
01 Sep 2024
Country|Currency|Amount|Code|Rate
USA|Dollar|1|USD|23.10
""";

private const string CnbFileTextWithInvalidLines = """
01 Sep 2024
Country|Currency|Amount|Code|Rate
USA|Dollar|1|USD|23.10
This is a comment from CNB
Europe|Euro|1|EUR|25.10
""";

[Fact]
public void EmptyInputText_ShouldBeSuccessfullyParsedAndReturnEmpty()
{
var exchangeRates = CnbTools.ParseExchangeRates(new Currency("CZK"), "").ToList();
exchangeRates.Should().HaveCount(0);
}

[Fact]
public void CorrectInputText_ShouldBeSuccessfullyParsed()
{
var exchangeRates = CnbTools.ParseExchangeRates(new Currency("CZK"), CorrectCnbFileText).ToList();
exchangeRates.Should().HaveCount(1);
exchangeRates[0].TargetCurrency.Code.Should().Be("USD");
exchangeRates[0].Value.Should().BeApproximately(23.10m, 0.0001m);
}

[Fact]
public void CorrectInputTextWithIncorrectLines_ShouldOmitIncorrectLinesAndSucceed()
{
var exchangeRates = CnbTools.ParseExchangeRates(new Currency("CZK"), CnbFileTextWithInvalidLines).ToList();
exchangeRates.Should().HaveCount(2);
exchangeRates.Should().Contain(x => x.TargetCurrency.Code == "EUR");
exchangeRates.Should().Contain(x => x.TargetCurrency.Code == "USD");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using FluentAssertions;
using Moq;

namespace ExchangeRateUpdater.Tests
{
public class ExchangeRateProviderTests
{
private static readonly Currency Czk = new Currency("CZK");
private static readonly Currency Eur = new Currency("EUR");
private static readonly Currency Usd = new Currency("USD");

private static readonly IReadOnlyCollection<ExchangeRate> BasicExchangeRates = new List<ExchangeRate>
{
new ExchangeRate(Czk, Usd, 23.10m),
new ExchangeRate(Czk, Eur, 25.10m)
};

private static readonly IReadOnlyCollection<ExchangeRate> ExchangeRatesWithOppositeDirection = new List<ExchangeRate>
{
new ExchangeRate(Czk, Usd, 23.10m),
new ExchangeRate(Czk, Eur, 25.10m),
new ExchangeRate(Usd, Czk, 1 / 23.10m),
new ExchangeRate(Eur, Czk, 1 / 25.10m)
};

private readonly Mock<ICurrencyRateProvider> _currencyRateProviderMock = new();
private readonly ExchangeRateProvider _provider;

public ExchangeRateProviderTests()
{
_provider = new ExchangeRateProvider(_currencyRateProviderMock.Object);
}

[Fact]
public async Task ForTwoCurrenciesAndAvailableExchangeRate_ShouldReturnCorrectExchangeRate()
{
_currencyRateProviderMock
.Setup(c => c.GetExchangeRatesAsync(It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(BasicExchangeRates));

var rates = await _provider
.GetExchangeRatesAsync([Czk, Eur], default)
.ToListAsync();

var expectedRate =
BasicExchangeRates.Single(x => x.SourceCurrency.Equals(Czk) && x.TargetCurrency.Equals(Eur));

// expecting correct output for two given currencies
rates.Should().ContainSingle(x => x.SourceCurrency.Equals(Czk) && x.TargetCurrency.Equals(Eur));
var rate = rates.Single(x => x.SourceCurrency.Equals(Czk) && x.TargetCurrency.Equals(Eur));
rate.Value.Should().BeApproximately(expectedRate.Value, 0.0001m);

// expecting not to contain currency we didn't ask for
rates.Should().NotContain(x => x.SourceCurrency.Equals(Czk) && x.TargetCurrency.Equals(Usd));
// expecting not to contain opposite conversion direction when it is not contained in source data
rates.Should().NotContain(x => x.SourceCurrency.Equals(Eur) && x.TargetCurrency.Equals(Czk));
}

[Fact]
public async Task ForTwoCurrenciesAndEmptySource_ShouldReturnEmptyExchangeRates()
{
_currencyRateProviderMock
.Setup(c => c.GetExchangeRatesAsync(It.IsAny<CancellationToken>()))
.Returns(Task.FromResult<IReadOnlyCollection<ExchangeRate>>([]));

var rates = await _provider.GetExchangeRatesAsync([Czk, Eur], default).ToListAsync();

rates.Should().BeEmpty();
}

[Fact]
public async Task ForUnknownCurrency_ShouldNotCauseExceptionAndBeIgnored()
{
_currencyRateProviderMock
.Setup(c => c.GetExchangeRatesAsync(default))
.Returns(Task.FromResult(BasicExchangeRates));

var rates = await _provider.GetExchangeRatesAsync([Czk, new Currency("XYZ")], default).ToListAsync();
rates.Should().BeEmpty();
}

[Fact]
public async Task BidirectionalRatesInSource_RatesForBothDirectionsShouldBeReturned()
{
_currencyRateProviderMock
.Setup(c => c.GetExchangeRatesAsync(default))
.Returns(Task.FromResult(ExchangeRatesWithOppositeDirection));

var rates = await _provider.GetExchangeRatesAsync([Czk, Usd], default).ToListAsync();

rates.Should().HaveCount(2);
rates.Should().Contain(x => x.SourceCurrency.Equals(Czk) && x.TargetCurrency.Equals(Usd));
rates.Should().Contain(x => x.SourceCurrency.Equals(Usd) && x.TargetCurrency.Equals(Czk));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>

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

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

</Project>
41 changes: 41 additions & 0 deletions jobs/Backend/Task/CnbCurrencyRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace ExchangeRateUpdater
{
public class CnbCurrencyRateProvider : ICurrencyRateProvider
{
private readonly bool _shouldIncludeOtherCurrencies;

private const string RegularCurrencyExchangeRatesUrl =
"https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt";
private const string OtherCurrencyExchangeRatesUrl =
"https://www.cnb.cz/en/financial-markets/foreign-exchange-market/fx-rates-of-other-currencies/fx-rates-of-other-currencies/fx_rates.txt";

private static readonly HttpClient _httpClient = new();

private readonly Currency _sourceCurrency = new("CZK");

public CnbCurrencyRateProvider(bool shouldIncludeOtherCurrencies)
{
_shouldIncludeOtherCurrencies = shouldIncludeOtherCurrencies;
}

public async Task<IReadOnlyCollection<ExchangeRate>> GetExchangeRatesAsync(CancellationToken cancellationToken)
{
var regularCurrenciesResponse = await _httpClient.GetStringAsync(RegularCurrencyExchangeRatesUrl, cancellationToken);
var resultList = CnbTools.ParseExchangeRates(_sourceCurrency, regularCurrenciesResponse).ToList();

if (_shouldIncludeOtherCurrencies)
{
var otherCurrenciesResponse = await _httpClient.GetStringAsync(OtherCurrencyExchangeRatesUrl, cancellationToken);
resultList.AddRange(CnbTools.ParseExchangeRates(_sourceCurrency, otherCurrenciesResponse));
}

return resultList;
}
}
}
45 changes: 45 additions & 0 deletions jobs/Backend/Task/CnbTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;

namespace ExchangeRateUpdater
{
public static class CnbTools
{
/// <summary>
/// For a given text of CNB exchange rate file returns parsed exchange rates with respect to source currency.
/// <para />
/// Format of a CNB currency file should be CSV file with date row, header row and '|' as separator.
/// Columns are: Country|Currency|Amount|Code|Rate
/// </summary>
/// <param name="sourceCurrency"></param>
/// <param name="exchangeRateText"></param>
/// <returns></returns>
public static IEnumerable<ExchangeRate> ParseExchangeRates(Currency sourceCurrency, string exchangeRateText)
{
ArgumentNullException.ThrowIfNull(sourceCurrency, nameof(sourceCurrency));
ArgumentNullException.ThrowIfNull(exchangeRateText, nameof(exchangeRateText));

using var reader = new StringReader(exchangeRateText);

reader.ReadLine();
reader.ReadLine();

string? line = null;
while ((line = reader.ReadLine()) != null)
{
var columns = line.Split('|');
if (columns.Length != 5)
continue; // invalid or comment line

if (!int.TryParse(columns[2], out var amount)
|| !decimal.TryParse(columns[4], NumberStyles.Any, CultureInfo.InvariantCulture, out var rate))
continue; // line with invalid values

var targetCurrency = new Currency(columns[3]);
yield return new ExchangeRate(sourceCurrency, targetCurrency, rate / amount);
}
}
}
}
17 changes: 16 additions & 1 deletion jobs/Backend/Task/Currency.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,20 @@ public override string ToString()
{
return Code;
}

public override bool Equals(object obj)
{
return obj is Currency other && Equals(other);
}

private bool Equals(Currency other)
{
return Code == other.Code;
}

public override int GetHashCode()
{
return Code != null ? Code.GetHashCode() : 0;
}
}
}
}
2 changes: 1 addition & 1 deletion jobs/Backend/Task/ExchangeRate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ public override string ToString()
return $"{SourceCurrency}/{TargetCurrency}={Value}";
}
}
}
}
51 changes: 44 additions & 7 deletions jobs/Backend/Task/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;

namespace ExchangeRateUpdater
{
public class ExchangeRateProvider
{
private readonly ICurrencyRateProvider _currencyRateProvider;

public ExchangeRateProvider(ICurrencyRateProvider currencyRateProvider)
{
_currencyRateProvider = currencyRateProvider;
}

// <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",
// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
// some of the currencies, ignore them.
// </summary>

/// <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",
/// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
/// some of the currencies, ignore them.
/// For all combinations of currencies specified by <paramref name="currencies"/> returns available exchange rates from underlying source.
/// </summary>
public IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies)
/// <param name="currencies">Currencies to get exchange rates for.</param>
/// <param name="cancellationToken"></param>
/// <returns>Enumeration of available exchange rates.</returns>
public async IAsyncEnumerable<ExchangeRate> GetExchangeRatesAsync(IEnumerable<Currency> currencies, [EnumeratorCancellation] CancellationToken cancellationToken)
{
return Enumerable.Empty<ExchangeRate>();
var currencySet = currencies.ToHashSet();

var exchangeRates = await _currencyRateProvider.GetExchangeRatesAsync(cancellationToken);
var exchangeRatesMap =
exchangeRates.ToDictionary(x => new CurrencyCombination(x.SourceCurrency, x.TargetCurrency));

foreach (var sourceCurrency in currencySet)
{
foreach (var targetCurrency in currencySet)
{
if (sourceCurrency.Equals(targetCurrency))
continue;

var combination = new CurrencyCombination(sourceCurrency, targetCurrency);
if (exchangeRatesMap.TryGetValue(combination, out var exchangeRate))
{
yield return exchangeRate;
}
}
}
}

private record struct CurrencyCombination(Currency SourceCurrency, Currency TargetCurrency);
}
}
}
4 changes: 4 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>

</Project>
Loading