diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/ExchangeRateUpdater.sln similarity index 59% rename from jobs/Backend/Task/ExchangeRateUpdater.sln rename to jobs/Backend/ExchangeRateUpdater.sln index 89be84daf..20f160a48 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/ExchangeRateUpdater.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "Task/ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "Tests\ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{64D000B5-8127-4D22-8C9B-68280A728B97}" 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 + {64D000B5-8127-4D22-8C9B-68280A728B97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64D000B5-8127-4D22-8C9B-68280A728B97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64D000B5-8127-4D22-8C9B-68280A728B97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64D000B5-8127-4D22-8C9B-68280A728B97}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f2..224ac8a4b 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,4 +1,6 @@ -namespace ExchangeRateUpdater +using System; + +namespace ExchangeRateUpdater { public class Currency { @@ -16,5 +18,11 @@ public override string ToString() { return Code; } + + public override bool Equals(object? obj) => Equals(obj as Currency); + + public override int GetHashCode() => Code.GetHashCode(StringComparison.OrdinalIgnoreCase); + + private bool Equals(Currency? other) => other != null && Code == other.Code; } } diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fb..1a18d4d09 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,124 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; namespace ExchangeRateUpdater { - public class ExchangeRateProvider + public class ExchangeRateProvider : IExchangeRateProvider { + private const string Headers = "Country|Currency|Amount|Code|Rate"; + private const int IdxCurrency = 3; + private const int IdxAmount = 2; + private const int IdxRate = 4; + + private static readonly Currency ExchangeProviderCurrency = new("CZK"); + + // Ideally this would come from HttpClientFactory rather than be created like this + private readonly HttpClient _httpClient; + private readonly IOptions _settings; + + public ExchangeRateProvider(HttpClient httpClient, IOptions settings) + { + _httpClient = httpClient; + _settings = settings; + } + /// /// 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> GetExchangeRatesAsync(IReadOnlySet currencies, CancellationToken cancellationToken = default) + { + // https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml returns an XML file + // https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt returns a txt file + // I used the TXT version, it is a bit less verbose wwand I assume the structure would not change often + // so we can save importing a library to parse simple text + // XML would make it more resistent to format changes in case they moved fields or added new ones + if (!currencies.Any()) return new List(); + + // The rates are updated daily, only during working days, we could introduce some caching to prevent re-requesting the same data + var response = await _httpClient.GetAsync(_settings.Value.BankUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + return await ParseExchangeRatesAsync(currencies, content); + } + + // this code could be moved to an helper method to help with testing + // I left it here because it simple enough and makes the class self contained + private async Task> ParseExchangeRatesAsync(IReadOnlySet currencies, string content) { - return Enumerable.Empty(); + using var reader = new StringReader(content); + + // skip the line with date + string? line = await reader.ReadLineAsync(); + + // header line + line = await reader.ReadLineAsync(); + if (line == null) + { + throw new InvalidOperationException("Missing header line"); + } + + if (!line.Equals(Headers, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid header line. Expected: '{Headers}' Got: '{line}'"); + } + + var result = new List(); + while ((line = await reader.ReadLineAsync()) != null) + { + var rate = ParseExchangeRateLine(currencies, line); + if (rate != null) + { + result.Add(rate); + } + } + + return result; + } + + private ExchangeRate? ParseExchangeRateLine(IReadOnlySet currencies, string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return null; + } + + var parts = line.Split('|'); + if (parts.Length != 5) + { + throw new InvalidOperationException($"Invalid number of parts on line: {line}"); + } + + var currency = new Currency(parts[IdxCurrency]); + if (!currencies.Contains(currency)) + { + return null; + } + + var amountString = parts[IdxAmount]; + var rateString = parts[IdxRate]; + + if (!decimal.TryParse(rateString, out var rate)) + { + throw new InvalidOperationException($"Unable to parse rate for line: {line}"); + } + + if (!int.TryParse(amountString, out var amount)) + { + throw new InvalidOperationException($"Unable to parse amount for line: {line}"); + } + + return new ExchangeRate(currency, ExchangeProviderCurrency, rate / amount); } } } diff --git a/jobs/Backend/Task/ExchangeRateProviderSettings.cs b/jobs/Backend/Task/ExchangeRateProviderSettings.cs new file mode 100644 index 000000000..ec8da2cc8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviderSettings.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater; + +public class ExchangeRateProviderSettings +{ + public string BankUrl { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..c3ba45cc4 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,6 +3,20 @@ Exe net6.0 + enable + true + + + PreserveNewest + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/HostHelper.cs b/jobs/Backend/Task/HostHelper.cs new file mode 100644 index 000000000..7a271bd15 --- /dev/null +++ b/jobs/Backend/Task/HostHelper.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateUpdater; + +public static class HostHelper +{ + public static ServiceProvider CreateServiceProvider() + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: false, reloadOnChange: true).Build(); + + var services = new ServiceCollection(); + services.AddOptions().Bind(configuration.GetSection(nameof(ExchangeRateProviderSettings))); + services.AddHttpClient(); + return services.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs new file mode 100644 index 000000000..ffbca9718 --- /dev/null +++ b/jobs/Backend/Task/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater; + +public interface IExchangeRateProvider +{ + public Task> GetExchangeRatesAsync(IReadOnlySet currencies, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..d7dbd4bd6 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; -using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace ExchangeRateUpdater { public static class Program { - private static IEnumerable currencies = new[] + // I changed it from IEnumerable to HashSet because we do not need lazy iteration + // and the number of possible currencies is small, so there is no memory issue. + // Also HashSet is faster for searches + private static readonly HashSet Currencies = new() { new Currency("USD"), new Currency("EUR"), @@ -19,14 +23,15 @@ 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); + await using var serviceProvider = HostHelper.CreateServiceProvider(); + var provider = serviceProvider.GetRequiredService(); + var rates = await provider.GetExchangeRatesAsync(Currencies); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..ad5522377 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,5 @@ +{ + "ExchangeRateProviderSettings": { + "BankUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt" + } +} \ No newline at end of file diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.cs b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.cs new file mode 100644 index 000000000..88b9234d9 --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace ExchangeRateUpdaterTests; + +public class ExchangeRateUpdaterTests +{ + private const string UrlEndpoint = "/GetRates"; + private WireMockServer? _server; + private ExchangeRateProviderSettings? _settings; + private HttpClient? _httpClient; + private ExchangeRateProvider _provider; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _server = WireMockServer.Start(); + _settings = new ExchangeRateProviderSettings { BankUrl = $"{_server.Url}{UrlEndpoint}" }; + _httpClient = new HttpClient(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _server?.Stop(); + } + + [SetUp] + public void Setup() + { + _server!.Reset(); + _provider = new ExchangeRateProvider(_httpClient!, Options.Create(_settings!)); + } + + [Test] + public async Task GetExchangeRatesAsync_WhenPassingAnEmptySetOfCurrencies_ShouldReturnEmptyResult() + { + // Actions + var result = await _provider.GetExchangeRatesAsync(new HashSet()); + + // Post-Conditions + Assert.That(result, Is.Empty); + AssertNoServerRequestsWereSent(); + } + + [Test] + public async Task GetExchangeRatesAsync_WhenPassingValidSetOfCurrencies_ShouldReturnRates() + { + // Pre-Conditions + GetRatesWithSuccess(GetDefaultResponseBody()); + var targetConcurrency = new Currency("CZK"); + var expectedResult = new List + { + new(new Currency("EUR"), targetConcurrency, 24.645m), + new(new Currency("JPY"), targetConcurrency, 0.14282m), + new(new Currency("THB"), targetConcurrency, 0.65269m), + new(new Currency("TRY"), targetConcurrency, 0.52843m), + new(new Currency("USD"), targetConcurrency, 21.248m), + }; + + // Actions + var result = await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList()); + + // Post-Conditions + AssertResultIsAsExpected(result, expectedResult); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public async Task GetExchangeRatesAsync_WhenNoMatchingCurrencies_ReturnEmptyResult() + { + // Pre-Conditions + GetRatesWithSuccess(GetDefaultResponseBody()); + var currencies = new HashSet + { + new("CZK"), + new("KES"), + new("RUB"), + new("XYZ") + }; + + // Actions + var result = await _provider.GetExchangeRatesAsync(currencies); + + // Post-Conditions + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(0)); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public void GetExchangeRatesAsync_WhenEndpointNotFound_ThrowException() + { + // Pre-Conditions + GetRatesWithNotFound(); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public void GetExchangeRatesAsync_WhenMissingHeader_ThrowException() + { + // Pre-Conditions + var responseBody = "16 Jul 2025 #136"; + + GetRatesWithSuccess(responseBody); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.Message, Is.EqualTo("Missing header line")); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public void GetExchangeRatesAsync_WhenInvalidHeader_ThrowException() + { + // Pre-Conditions + var responseBody = @"16 Jul 2025 #136 +Currency|Country|Amount|Code|Rate +dollar|Australia|1|AUD|13.850"; + + GetRatesWithSuccess(responseBody); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.Message, Is.EqualTo("Invalid header line. Expected: 'Country|Currency|Amount|Code|Rate' Got: 'Currency|Country|Amount|Code|Rate'")); + AssertServerRequestsWereSentOnce(); + } + + [TestCase("Australia|1|AUD|13.850")] + [TestCase("Australia|dollar|1|AUD|13.850|ExtraValue")] + public void GetExchangeRatesAsync_WhenLineHasWrongFormat_ThrowException(string line) + { + // Pre-Conditions + var responseBody = @$"16 Jul 2025 #136 +Country|Currency|Amount|Code|Rate +{line}"; + + GetRatesWithSuccess(responseBody); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.Message, Is.EqualTo($"Invalid number of parts on line: {line}")); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public void GetExchangeRatesAsync_WhenRateNotANumber_ThrowException() + { + // Pre-Conditions + var responseBody = @"16 Jul 2025 #136 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|nan"; + + GetRatesWithSuccess(responseBody); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.Message, Is.EqualTo("Unable to parse rate for line: USA|dollar|1|USD|nan")); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public void GetExchangeRatesAsync_WhenAmountNotANumber_ThrowException() + { + // Pre-Conditions + var responseBody = @"16 Jul 2025 #136 +Country|Currency|Amount|Code|Rate +USA|dollar|nan|USD|123"; + + GetRatesWithSuccess(responseBody); + + // Action + var exception = Assert.ThrowsAsync(async () => await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList())); + + // Post-Conditions + Assert.That(exception!.Message, Is.EqualTo("Unable to parse amount for line: USA|dollar|nan|USD|123")); + AssertServerRequestsWereSentOnce(); + } + + [Test] + public async Task GetExchangeRatesAsync_WhenReturningMultipleRatesForSameCurrency_ExpectMultipleResults() + { + // Pre-Conditions + var responseBody = @"16 Jul 2025 #136 +Country|Currency|Amount|Code|Rate +USA|dollar|1|USD|100 +USA|dollar|2|USD|100"; + GetRatesWithSuccess(responseBody); + var targetConcurrency = new Currency("CZK"); + var expectedResult = new List + { + new(new Currency("USD"), targetConcurrency, 100m), + new(new Currency("USD"), targetConcurrency, 50m), + }; + + // Action + var result = await _provider.GetExchangeRatesAsync(GetDefaultCurrencyList()); + + // Post-Conditions + AssertResultIsAsExpected(result, expectedResult); + AssertServerRequestsWereSentOnce(); + } + + private static string GetDefaultResponseBody() + { + return @"16 Jul 2025 #136 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|13.850 +Brazil|real|1|BRL|3.818 +Bulgaria|lev|1|BGN|12.602 +Canada|dollar|1|CAD|15.479 +China|renminbi|1|CNY|2.959 +Denmark|krone|1|DKK|3.302 +EMU|euro|1|EUR|24.645 +Hongkong|dollar|1|HKD|2.707 +Hungary|forint|100|HUF|6.162 +Iceland|krona|100|ISK|17.331 +IMF|SDR|1|XDR|29.067 +India|rupee|100|INR|24.711 +Indonesia|rupiah|1000|IDR|1.306 +Israel|new shekel|1|ILS|6.328 +Japan|yen|100|JPY|14.282 +Malaysia|ringgit|1|MYR|5.006 +Mexico|peso|1|MXN|1.129 +New Zealand|dollar|1|NZD|12.618 +Norway|krone|1|NOK|2.062 +Philippines|peso|100|PHP|37.215 +Poland|zloty|1|PLN|5.787 +Romania|leu|1|RON|4.858 +Singapore|dollar|1|SGD|16.531 +South Africa|rand|1|ZAR|1.186 +South Korea|won|100|KRW|1.527 +Sweden|krona|1|SEK|2.177 +Switzerland|franc|1|CHF|26.420 +Thailand|baht|100|THB|65.269 +Turkey|lira|100|TRY|52.843 +United Kingdom|pound|1|GBP|28.460 +USA|dollar|1|USD|21.248 +"; + } + + private static HashSet GetDefaultCurrencyList() + { + return new HashSet + { + new("USD"), + new("EUR"), + new("CZK"), + new("JPY"), + new("KES"), + new("RUB"), + new("THB"), + new("TRY"), + new("XYZ") + }; + } + + private void GetRatesWithSuccess(string bodyContent) + { + var serverResponse = Response.Create().WithSuccess().WithBody(bodyContent); + var serverRequest = Request.Create().WithPath(UrlEndpoint).UsingMethod(HttpMethod.Get.ToString()); + _server!.Given(serverRequest).RespondWith(serverResponse); + } + + private void GetRatesWithNotFound() + { + var serverResponse = Response.Create().WithNotFound(); + var serverRequest = Request.Create().WithPath(UrlEndpoint).UsingMethod(HttpMethod.Get.ToString()); + _server!.Given(serverRequest).RespondWith(serverResponse); + } + + private void AssertNoServerRequestsWereSent() + { + Assert.That(_server!.LogEntries.Count, Is.EqualTo(0)); + } + + private void AssertServerRequestsWereSentOnce() + { + Assert.That(_server!.LogEntries.Count, Is.EqualTo(1)); + } + + private void AssertResultIsAsExpected(IReadOnlyList result, IReadOnlyList expectedResult) + { + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(expectedResult.Count)); + for (var i = 0; i < result.Count; i++) + { + Assert.That(result[i].ToString(), Is.EqualTo(expectedResult[i].ToString())); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj new file mode 100644 index 000000000..975c0cd53 --- /dev/null +++ b/jobs/Backend/Tests/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + enable + + + + + + + + + + + + + + +