diff --git a/.gitignore b/.gitignore index fd35865456..264efba310 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ node_modules bower_components npm-debug.log + +.vs/ +CopilotIndices*/ \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/MapToExchangeRatesTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/MapToExchangeRatesTests.cs new file mode 100644 index 0000000000..5506acd93a --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/MapToExchangeRatesTests.cs @@ -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)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)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)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)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() + .WithMessage("*duplicate*"); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/ParseDailyRatesTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/ParseDailyRatesTests.cs new file mode 100644 index 0000000000..53a852cccd --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CnbExchangeRateProviderTests/ParseDailyRatesTests.cs @@ -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)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(); + } + + [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() + .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() + .WithMessage("*rate*"); + } + + [Fact] + public void Parse_EmptyContent_Throws() + { + Action act = () => InvokeParse(""); + + act.Should().Throw(); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..bc5b51798e --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs b/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs new file mode 100644 index 0000000000..8c927eb747 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/jobs/Backend/Readme_Solution.md b/jobs/Backend/Readme_Solution.md new file mode 100644 index 0000000000..77daceedd0 --- /dev/null +++ b/jobs/Backend/Readme_Solution.md @@ -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 \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e0..24611ed08c 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,4 +1,6 @@ -namespace ExchangeRateUpdater +using System.Globalization; + +namespace ExchangeRateUpdater { public class ExchangeRate { @@ -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)}"; } } } diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// 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) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..78d917cc9f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -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 @@ -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 diff --git a/jobs/Backend/Task/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..b5f0effaa9 --- /dev/null +++ b/jobs/Backend/Task/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Interfaces +{ + /// + /// Defines a contract for retrieving exchange rates for the specified currencies from an external data source. + /// + public interface IExchangeRateProvider + { + /// + /// Retrieves exchange rates for the given list of currencies. + /// + /// + /// + /// The list of currencies for which exchange rates should be retrieved. + /// Only currencies available in the underlying data source will be returned. + /// + /// + /// + /// A collection of exchange rates for the currencies that are available in the underlying data source. + /// Currencies that are not provided by the source are excluded from the result. + /// + IEnumerable GetExchangeRates(IEnumerable currencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..729ec252a4 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,4 +1,6 @@ -using System; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Providers; +using System; using System.Collections.Generic; using System.Linq; @@ -21,9 +23,10 @@ public static class Program public static void Main(string[] args) { + IExchangeRateProvider provider = new CnbExchangeRateProvider(); + try { - var provider = new ExchangeRateProvider(); var rates = provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); diff --git a/jobs/Backend/Task/Providers/CnbExchangeRateProvider.cs b/jobs/Backend/Task/Providers/CnbExchangeRateProvider.cs new file mode 100644 index 0000000000..48df9dee57 --- /dev/null +++ b/jobs/Backend/Task/Providers/CnbExchangeRateProvider.cs @@ -0,0 +1,220 @@ +using ExchangeRateUpdater.Interfaces; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Providers +{ + public class CnbExchangeRateProvider : IExchangeRateProvider + { + private const string DailyRatesUrl = + "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/" + + "central-bank-exchange-rate-fixing/daily.txt"; + + private static readonly HttpClient HttpClient = new HttpClient(); + + /// + /// 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) + { + if (currencies == null) + throw new ArgumentNullException(nameof(currencies)); + + Log("Downloading daily rates..."); + var raw = DownloadDailyRatesAsync().GetAwaiter().GetResult(); + Log("Download completed."); + + var rows = ParseDailyRates(raw); + Log($"Parsed {rows.Count} CNB rows."); + + var result = MapToExchangeRates(rows, currencies); + Log($"Mapped {result.Count()} exchange rates."); + + return result; + } + + + /// + /// Downloads the daily exchange rates TXT file from the CNB public endpoint. + /// + /// + /// Thrown when the CNB endpoint is unavailable or returns an invalid response. + /// + private async Task DownloadDailyRatesAsync() + { + try + { + using var response = await HttpClient.GetAsync(DailyRatesUrl); + + response.EnsureSuccessStatusCode(); + + string content = await response.Content.ReadAsStringAsync(); + + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidOperationException("Received empty response from CNB endpoint."); + + return content; + } + catch (Exception ex) + { + throw new HttpRequestException("Failed to download daily exchange rates from CNB.", ex); + } + } + + /// + /// Parses the raw CNB daily rates TXT content into a collection of CNB rate rows. + /// + /// Raw TXT response returned by the CNB endpoint. + /// Parsed list of CNB rate rows. + /// + /// Thrown when the response does not match the expected CNB TXT format. + /// + private IReadOnlyList ParseDailyRates(string rawContent) + { + if (string.IsNullOrWhiteSpace(rawContent)) + throw new FormatException("CNB daily rates response is empty."); + + var lines = rawContent + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + // Expect at least: 1 line with date, 1 header line, and at least 1 data line + if (lines.Length < 3) + throw new FormatException("CNB daily rates response does not contain expected header and data lines."); + + // line[0] = date metadata, line[1] = header, rest = data + var dataLines = lines.Skip(2); + + var result = new List(); + + foreach (var line in dataLines) + { + var parts = line.Split('|'); + + if (parts.Length != 5) + throw new FormatException($"Unexpected CNB daily rates line format: '{line}'."); + + // parts[0] = Country (ignored) + // parts[1] = Currency name (ignored) + // parts[2] = Amount + // parts[3] = Code + // parts[4] = Rate + + if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var amount)) + throw new FormatException($"Invalid amount value in CNB daily rates line: '{line}'."); + + if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + throw new FormatException($"Invalid rate value in CNB daily rates line: '{line}'."); + + var code = parts[3].Trim(); + + if (string.IsNullOrEmpty(code)) + throw new FormatException($"Missing currency code in CNB daily rates line: '{line}'."); + + result.Add(new CnbRateRow(code, amount, rate)); + } + + return result; + } + + /// + /// Maps parsed CNB rate rows to domain exchange rates for the requested currencies. + /// + /// + /// Parsed CNB daily rate rows. + /// + /// Currencies for which exchange rates should be returned. + /// Only currencies available in the CNB data source are included. + /// + /// + /// Collection of exchange rates where source currency is the requested currency and target currency is CZK. + /// + private IEnumerable MapToExchangeRates( + IReadOnlyList rows, + IEnumerable requestedCurrencies) + { + if (rows == null) + throw new ArgumentNullException(nameof(rows)); + + if (requestedCurrencies == null) + throw new ArgumentNullException(nameof(requestedCurrencies)); + + // Normalize requested currencies by code (case-insensitive) + var requestedByCode = requestedCurrencies + .Where(c => c != null && !string.IsNullOrWhiteSpace(c.Code)) + .GroupBy(c => c.Code.ToUpperInvariant()) + .ToDictionary(g => g.Key, g => g.First()); + + if (requestedByCode.Count == 0) + return Enumerable.Empty(); + + // Index CNB rows by currency code and detect duplicates + var duplicateCodes = rows + .GroupBy(r => r.Code.ToUpperInvariant()) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateCodes.Count > 0) + { + throw new FormatException( + $"CNB daily rates contain duplicate entries for currency codes: {string.Join(", ", duplicateCodes)}."); + } + + var rowsByCode = rows.ToDictionary(r => r.Code.ToUpperInvariant()); + + var result = new List(); + + // Target currency is always CZK for CNB rates + var targetCurrency = new Currency("CZK"); + + foreach (var (codeKey, sourceCurrency) in requestedByCode) + { + if (!rowsByCode.TryGetValue(codeKey, out var row)) + { + // CNB does not provide rate for this currency – we skip it. + continue; + } + + if (row.Amount <= 0) + throw new FormatException($"Invalid amount '{row.Amount}' for currency '{row.Code}' in CNB data."); + + var valuePerUnit = row.Rate / row.Amount; + + result.Add(new ExchangeRate( + sourceCurrency, + targetCurrency, + valuePerUnit)); + } + + return result; + } + + private void Log(string message) + { +#if DEBUG + Console.WriteLine($"[CNB] {message}"); +#endif + } + + private sealed class CnbRateRow + { + public CnbRateRow(string code, int amount, decimal rate) + { + Code = code; + Amount = amount; + Rate = rate; + } + + public string Code { get; } + public int Amount { get; } + public decimal Rate { get; } + } + } +}