diff --git a/.gitignore b/.gitignore index fd3586545..43601dcb6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ node_modules bower_components npm-debug.log + +FileContentIndex/ +v17/ +.vs/ \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CnbToolsTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CnbToolsTests.cs new file mode 100644 index 000000000..7b3c2b5cb --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CnbToolsTests.cs @@ -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"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..760c6fbf1 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -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 BasicExchangeRates = new List + { + new ExchangeRate(Czk, Usd, 23.10m), + new ExchangeRate(Czk, Eur, 25.10m) + }; + + private static readonly IReadOnlyCollection ExchangeRatesWithOppositeDirection = new List + { + 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 _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())) + .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())) + .Returns(Task.FromResult>([])); + + 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)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..d26d60366 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/CnbCurrencyRateProvider.cs b/jobs/Backend/Task/CnbCurrencyRateProvider.cs new file mode 100644 index 000000000..5cf028a2d --- /dev/null +++ b/jobs/Backend/Task/CnbCurrencyRateProvider.cs @@ -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> 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; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/CnbTools.cs b/jobs/Backend/Task/CnbTools.cs new file mode 100644 index 000000000..4d488cc92 --- /dev/null +++ b/jobs/Backend/Task/CnbTools.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace ExchangeRateUpdater +{ + public static class CnbTools + { + /// + /// For a given text of CNB exchange rate file returns parsed exchange rates with respect to source currency. + /// + /// 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 + /// + /// + /// + /// + public static IEnumerable 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); + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f2..c7081f9ba 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -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; + } } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e..e93737eef 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -20,4 +20,4 @@ public override string ToString() return $"{SourceCurrency}/{TargetCurrency}={Value}"; } } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fb..0768365c6 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -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; + } + + // + // 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. + // + /// - /// 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 returns available exchange rates from underlying source. /// - public IEnumerable GetExchangeRates(IEnumerable currencies) + /// Currencies to get exchange rates for. + /// + /// Enumeration of available exchange rates. + public async IAsyncEnumerable GetExchangeRatesAsync(IEnumerable currencies, [EnumeratorCancellation] CancellationToken cancellationToken) { - return Enumerable.Empty(); + 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); } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..95c7cd2d9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,8 @@ net6.0 + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..310bb26e8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,9 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35013.160 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{D54030B8-B714-4E33-AE36-16A1D308BDEE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ 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 + {D54030B8-B714-4E33-AE36-16A1D308BDEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D54030B8-B714-4E33-AE36-16A1D308BDEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D54030B8-B714-4E33-AE36-16A1D308BDEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D54030B8-B714-4E33-AE36-16A1D308BDEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ICurrencyRateProvider.cs b/jobs/Backend/Task/ICurrencyRateProvider.cs new file mode 100644 index 000000000..fec1df143 --- /dev/null +++ b/jobs/Backend/Task/ICurrencyRateProvider.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + public interface ICurrencyRateProvider + { + /// + /// Retrieves all available exchange rates from source. + /// + /// + /// + Task> GetExchangeRatesAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..246c0496a 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace ExchangeRateUpdater { @@ -19,14 +21,17 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = CreateExchangeRateProvider(); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + var cancellationToken = default(CancellationToken); + + var rates = await provider.GetExchangeRatesAsync(currencies, cancellationToken).ToListAsync(cancellationToken); + + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); @@ -39,5 +44,13 @@ public static void Main(string[] args) Console.ReadLine(); } + + private static ExchangeRateProvider CreateExchangeRateProvider() + { + // DI and configuration setup would be here + const bool shouldIncludeOtherCurrencies = true; + var currencyRateProvider = new CnbCurrencyRateProvider(shouldIncludeOtherCurrencies); + return new ExchangeRateProvider(currencyRateProvider); + } } -} +} \ No newline at end of file