diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/Cnb/CnbRatesTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/Cnb/CnbRatesTests.cs new file mode 100644 index 0000000000..3a20d492f8 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/Cnb/CnbRatesTests.cs @@ -0,0 +1,22 @@ +using ExchangeRateUpdater.Cnb; + +namespace ExchangeRateUpdater.Tests.Cnb +{ + public class CnbRatesTests + { + + [Theory] + [InlineData(1, 32.2, 32.2)] + [InlineData(100, 32.2, 0.322)] + [InlineData(50, 32.2, 0.644)] + public void ShouldCalculateRealAmount(int amount, decimal rate, decimal expectedAmount) + { + var cnbRate = new CnbExchangeRate() + { + Amount = amount, + Rate = rate + }; + Assert.Equal(expectedAmount, cnbRate.RealRate); + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/CurrencyTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/CurrencyTests.cs new file mode 100644 index 0000000000..8051cc4690 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/CurrencyTests.cs @@ -0,0 +1,46 @@ +namespace ExchangeRateUpdater.Tests; + +public class CurrencyTests +{ + [Fact] + public void ShouldInitNewCurrency() + { + var curr = new Currency("USD"); + Assert.NotNull(curr); + Assert.Equal("USD", curr.Code); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData(" ")] + [InlineData(" ")] + public void ShouldThrowArgumentExceptionWhenCodeIsNullOrWhitespace(string code) + { + Assert.Throws(() => new Currency(code)); + } + + [Theory] + [InlineData("USDC")] + [InlineData("US")] + public void ShouldThrowIfInvalidLength(string code) + { + Assert.Throws(() => new Currency(code)); + } + + [Theory] + [InlineData("usd")] + [InlineData("Usd")] + public void ShouldThrowIfCaseMismatch(string code) + { + Assert.Throws(() => new Currency(code)); + } + + [Theory] + [InlineData("AB1")] + [InlineData("AR%")] + public void ShouldThrowIfNotAllLetters(string code) + { + Assert.Throws(() => new Currency(code)); + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..b35aacaa6f --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,78 @@ +using ExchangeRateUpdater.Cnb; +using Moq; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests + { + private readonly Mock _bankApiMock = new Mock(); + private readonly ExchangeRateProvider _exchangeRateProvider; + + public ExchangeRateProviderTests() + { + _exchangeRateProvider = new ExchangeRateProvider(_bankApiMock.Object); + } + + [Fact] + public async Task ShouldProvideOnlyCurrenciesSpecifiedInList() + { + ArrangeApiRates(); + + var currencies = new List { new Currency("USD"), new Currency("EUR") }; + var result = await _exchangeRateProvider.GetExchangeRates(currencies); + + var expected = new List() + { + ExchangeRate.FromCZK(new Currency("USD"), 2.0M), + ExchangeRate.FromCZK(new Currency("EUR"), 1.0M), + }; + Assert.Equal(expected, result); + } + + [Fact] + public async Task ShouldReturnEmptyListIfEmptyRequestedCurrencies() + { + ArrangeApiRates(); + + var currencies = Enumerable.Empty(); + + var result = await _exchangeRateProvider.GetExchangeRates(currencies); + Assert.Empty(result); + } + + [Fact] + public async Task ShouldThrowIfExpectedCurrenciesAreNull() + { + ArrangeApiRates(); + + IEnumerable currencies = null!; // whoopsie + var result = _exchangeRateProvider.GetExchangeRates(currencies); + await Assert.ThrowsAsync(() => result); + } + + private void ArrangeApiRates() + { + _bankApiMock.Setup(x => x.GetTodayExchangeRates()).ReturnsAsync(new List() + { + new CnbExchangeRate() + { + Amount = 1, + Rate = 2.0M, + CurrencyCode = "USD" + }, + new CnbExchangeRate() + { + Amount = 1, + Rate = 1.0M, + CurrencyCode = "EUR" + }, + new CnbExchangeRate() + { + Amount = 1, + Rate = 3.0M, + CurrencyCode = "AUD" + }, + }); + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..201c86bb0d --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Cnb/CnbApi.cs b/jobs/Backend/Task/Cnb/CnbApi.cs new file mode 100644 index 0000000000..72381741e3 --- /dev/null +++ b/jobs/Backend/Task/Cnb/CnbApi.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Cnb +{ + internal interface IBankApi + { + Task> GetTodayExchangeRates(); + } + + /// + /// Api for Česká národní banka + /// + /// + internal sealed class CnbApi : IBankApi + { + private readonly HttpClient _httpClient; + + public CnbApi(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetTodayExchangeRates() + { + const string exchangeRatesEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + var response = await _httpClient.GetFromJsonAsync(exchangeRatesEndpoint); + if (response == null) + { + throw new InvalidOperationException("Unable to get exchange rates. Response was null."); + } + return response.Rates; + } + } +} diff --git a/jobs/Backend/Task/Cnb/CnbRates.cs b/jobs/Backend/Task/Cnb/CnbRates.cs new file mode 100644 index 0000000000..7daf45f516 --- /dev/null +++ b/jobs/Backend/Task/Cnb/CnbRates.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Cnb +{ + internal class CnbExchangeRates + { + [JsonPropertyName("rates")] + [JsonInclude] + public IEnumerable Rates { get; private set; } = null!; // required and json don't work well + } + + internal sealed class CnbExchangeRate + { + [JsonPropertyName("currencyCode")] public string CurrencyCode { get; init; } = null!; // required and json don't work well + + [JsonPropertyName("amount")] public int Amount { get; init; } + + [JsonPropertyName("rate")] public decimal Rate { get; init; } + + public decimal RealRate => Rate / Amount; + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f25..ffaa3329b8 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,9 +1,29 @@ -namespace ExchangeRateUpdater +using System; +using System.Linq; + +namespace ExchangeRateUpdater { - public class Currency + internal sealed class Currency { + internal static readonly Currency Czk = new Currency("CZK"); + public Currency(string code) { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentNullException(nameof(code), + "code is null or empty. Provide a valid ISO 4217 currency code."); + } + + if (code.Length != 3) + { + throw new ArgumentException("code must be exactly 3 characters.", nameof(code)); + } + + if (!code.All(x => char.IsUpper(x) && char.IsLetter(x))) + { + throw new ArgumentException("code must be consist of all upper characters", nameof(code)); + } Code = code; } @@ -12,6 +32,20 @@ public Currency(string code) /// public string Code { get; } + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) return true; + if (obj is null) return false; + if (obj is not Currency other) return false; + + return Code == other.Code; + } + + public override int GetHashCode() + { + return Code.GetHashCode(); + } + public override string ToString() { return Code; diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e0..13972717fa 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,6 +1,8 @@ -namespace ExchangeRateUpdater +using System; + +namespace ExchangeRateUpdater { - public class ExchangeRate + internal sealed class ExchangeRate { public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { @@ -15,6 +17,32 @@ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal va public decimal Value { get; } + /// + /// Returns Exchange Rate with CZK as a source currency + /// + /// + /// + /// + public static ExchangeRate FromCZK(Currency targetCurrency, decimal value) + { + return new ExchangeRate(Currency.Czk, targetCurrency, value); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) return true; + if (obj is null) return false; + if (obj is not ExchangeRate other) return false; + + return Value == other.Value && SourceCurrency.Equals(other.SourceCurrency) && + TargetCurrency.Equals(other.TargetCurrency); + } + + public override int GetHashCode() + { + return HashCode.Combine(Value, SourceCurrency, TargetCurrency); + } + public override string ToString() { return $"{SourceCurrency}/{TargetCurrency}={Value}"; diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..303a3bac4b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,37 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; namespace ExchangeRateUpdater { - public class ExchangeRateProvider + internal sealed class ExchangeRateProvider { + private readonly IBankApi _bankApi; + + public ExchangeRateProvider(IBankApi bankApi) + { + _bankApi = bankApi; + } + /// /// 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. /// - public IEnumerable GetExchangeRates(IEnumerable currencies) + public async Task> GetExchangeRates(IEnumerable? currencies) { - return Enumerable.Empty(); + if (currencies == null) + { + throw new ArgumentNullException(nameof(currencies), "currencies is null"); + } + var cnbRates = await _bankApi.GetTodayExchangeRates(); + var selectedRates = cnbRates.Select(x => ExchangeRate.FromCZK(new Currency(x.CurrencyCode), x.RealRate)) + // TODO: seems should be filtered by source currency as well, but our source is always CZK I only need target currency then? + .Where(x => currencies.Contains(x.TargetCurrency)); + return selectedRates; } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..b889bb1fb8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,6 +3,13 @@ Exe net6.0 + enable - - \ No newline at end of file + + + + <_Parameter1>ExchangeRateUpdater.Tests + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..eeaa54c6c1 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 14.0.25123.0 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", "..\Task.Tests\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{FDD27E7D-8663-44F9-B247-610426632DA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -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 + {FDD27E7D-8663-44F9-B247-610426632DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDD27E7D-8663-44F9-B247-610426632DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDD27E7D-8663-44F9-B247-610426632DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDD27E7D-8663-44F9-B247-610426632DA6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..93b09ab63f 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,10 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] namespace ExchangeRateUpdater { - public static class Program + internal static class Program { private static IEnumerable currencies = new[] { @@ -19,12 +24,14 @@ 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 httpClient = new HttpClient(); + var cnbApi = new CnbApi(httpClient); + var provider = new ExchangeRateProvider(cnbApi); + var rates = await provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) diff --git a/jobs/Backend/Task/Readme.md b/jobs/Backend/Task/Readme.md new file mode 100644 index 0000000000..3f09688417 --- /dev/null +++ b/jobs/Backend/Task/Readme.md @@ -0,0 +1,16 @@ +# Hello! + + +## Prerequisites .net6, very high probability you already have it :) + +## Run +```bash +cd jobs/Backend/Task/ +dotnet run +``` + +## Run Test +```bash +cd jobs/Backend/Task/ +dotnet test +```