diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} 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.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12b..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daff..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -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}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/ExchangeRateUpdater.slnx b/jobs/Backend/Task/ExchangeRateUpdater.slnx new file mode 100644 index 0000000000..5a4e224552 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/docs/readme.md b/jobs/Backend/Task/docs/readme.md new file mode 100644 index 0000000000..2967902d90 --- /dev/null +++ b/jobs/Backend/Task/docs/readme.md @@ -0,0 +1,179 @@ +# Exchange Rate Updater – Notes & Considerations + +> **TL;DR** +> A clean, immutable, and test-focused solution that fetches and parses daily CNB exchange rates, emphasizing correctness, validation, and separation of concerns + +
+ + +### _How to Run The Application_ + +```bash +# navigate to src/Application folder +cd src/Application + +# restore dependencies +dotnet restore + +# build the solution +dotnet build + +# run unit tests +dotnet test + +# run the application +dotnet run --project ExchangeRateUpdater.Application.csproj +``` + +
+
+ + +## 1. Problem Statement + +_See Challenge Instructions here: [Mews Backend .NET Challenge Instructions](https://github.com/MewsSystems/developers/blob/master/jobs/Backend/DotNet.md)_ + + +The goal of this challenge is to fetch daily foreign exchange rates published by the Czech National Bank (CNB), parse the returned data, and expose the rates in a clean, domain-driven model. + +The CNB endpoint returns exchange rates as a plain-text, pipe-separated file containing: +- A header with the publication date and working day number +- A column definition row +- One row per currency exchange rate + +The solution is expected to: +- Retrieve the data over HTTP +- Parse and validate the response format +- Convert the raw data into strongly typed domain objects +- Handle invalid or unexpected input gracefully + +
+ +## 2. Assumptions +The following assumptions were made to keep the solution focused and explicit: + +- The CNB response format is stable and follows the structure found in the [sample_data.txt](sample_data.txt) +- All exchange rate values are positive +- The application aims to display the convertion of 1 unit of given currency. Example: 1 EUR -> CZK +- The target currency is implicitly CZK, as implied by the [CNB source](https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) +- The full response fits comfortably in memory +- This is a read-only, batch-style operation (no persistence required) + +
+ +## 3. Approach +The solution is structured around a clear separation of concerns: + +_It uses IoC and DI to allow new implementations to be added without breaking existing ones_ + +1. **HTTP Client Layer** + - Responsible for retrieving raw data from the CNB endpoint + - Configured with timeouts, retries, and resilience policies + +2. **Parsing Layer** + - Converts the pipe-separated text response into structured models + - Validates headers, column definitions, and data rows and fails early in case the contract ever changes + - Produces deterministic parsing results or meaningful exceptions + +3. **Domain Layer** + - Models currencies and exchange rates as immutable value objects + - Enforces basic domain invariants (e.g. positive exchange rates) + +4. **Application Layer** + - Orchestrates the flow between client, parser, and domain + - Exposes a clean API for consuming exchange rate data + +The design favors clarity and correctness over premature optimization. + +
+ +## 4. Design Decisions + +### Immutability +- Domain entities and DTOs are implemented as immutable types +- Value-based equality is used where identity is defined purely by data +- This reduces side effects and simplifies reasoning and testing + +### Separation of Concerns +- Parsing logic is isolated from HTTP and orchestration logic +- Domain models are independent of infrastructure concerns +- This makes individual components easier to test and evolve + +### Explicit Validation +- The parser validates headers, column definitions, and row structure +- Failures are detected early and reported with clear exception messages + +### Resilience +- HTTP calls are protected using retry and circuit breaker policies +- Transient failures are handled without leaking infrastructure concerns into the domain + +
+ +## 5. Immutability & Modeling +The following modeling strategy was applied: + +- **Records** + - Domain value objects such as `Currency` and `ExchangeRate` + - DTOs and parsing result models + - Configuration objects loaded from application settings + +- **Classes** + - Services, clients, and parsers + - Components that encapsulate behavior or orchestration + +This distinction reflects the difference between *data* and *behavior* in the system. + +
+ +## 6. Edge Cases & Error Handling +The solution explicitly handles: + +- Empty or whitespace-only responses +- Missing or malformed headers +- Unexpected column definitions +- Invalid numeric values +- Incomplete or malformed data rows + +When errors occur, the system: +- Fails fast during parsing +- Throws domain-specific exceptions with descriptive messages +- Avoids silently ignoring invalid data + +
+ +## 7. Testing Strategy +The testing approach focuses on correctness and determinism: + +- Unit tests for domain entities and invariants +- Comprehensive parser tests covering: + - Valid inputs + - Invalid formats + - Edge cases and error scenarios +- Tests are isolated, fast, and do not rely on external systems + +Unit tests for higher-level application behavior and Integration tests were intentionally omitted due to scope. + +
+ +## 8. Limitations +Known limitations include: + +- No caching of results between runs +- No dynamically parsing program args as currencies input +- No persistence layer +- No localization or formatting concerns addressed +- No retry backoff customization exposed via configuration + +These were accepted trade-offs given the scope of the challenge. + +
+ +## 9. Possible Improvements +With additional time, the following enhancements could be made: + +- Add integration tests with a mocked HTTP server +- Implement better Domain Exceptions for proper retries / logging +- Implement greceful failures for malformed Exchange Rates +- Consider adding caching +- Support additional rate providers via a common abstraction +- Expose richer observability (metrics, structured logs) \ No newline at end of file diff --git a/jobs/Backend/Task/docs/sample_data.txt b/jobs/Backend/Task/docs/sample_data.txt new file mode 100644 index 0000000000..d6f580d4cb --- /dev/null +++ b/jobs/Backend/Task/docs/sample_data.txt @@ -0,0 +1,33 @@ +23 Dec 2025 #248 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|13.818 +Brazil|real|1|BRL|3.694 +Bulgaria|lev|1|BGN|12.434 +Canada|dollar|1|CAD|15.064 +China|renminbi|1|CNY|2.936 +Denmark|krone|1|DKK|3.256 +EMU|euro|1|EUR|24.320 +Hongkong|dollar|1|HKD|2.652 +Hungary|forint|100|HUF|6.218 +Iceland|krona|100|ISK|16.432 +IMF|SDR|1|XDR|28.206 +India|rupee|100|INR|23.043 +Indonesia|rupiah|1000|IDR|1.230 +Israel|new shekel|1|ILS|6.452 +Japan|yen|100|JPY|13.226 +Malaysia|ringgit|1|MYR|5.077 +Mexico|peso|1|MXN|1.150 +New Zealand|dollar|1|NZD|12.049 +Norway|krone|1|NOK|2.051 +Philippines|peso|100|PHP|35.072 +Poland|zloty|1|PLN|5.747 +Romania|leu|1|RON|4.778 +Singapore|dollar|1|SGD|16.051 +South Africa|rand|1|ZAR|1.237 +South Korea|won|100|KRW|1.393 +Sweden|krona|1|SEK|2.247 +Switzerland|franc|1|CHF|26.193 +Thailand|baht|100|THB|66.364 +Turkey|lira|100|TRY|48.183 +United Kingdom|pound|1|GBP|27.861 +USA|dollar|1|USD|20.631 \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/ExchangeRateApp.cs b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs new file mode 100644 index 0000000000..66f3c32820 --- /dev/null +++ b/jobs/Backend/Task/src/Application/ExchangeRateApp.cs @@ -0,0 +1,38 @@ +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Application; + +public class ExchangeRateApp(IExchangeRateProvider provider, ILogger logger) +{ + public async Task RunAsync(IEnumerable currencies) + { + try + { + var rates = await provider.GetExchangeRatesAsync(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + + foreach (var rate in rates) + { + Console.WriteLine(rate); + } + + logger.LogInformation( + "Application completed successfully. Retrieved {Count} exchange rates", + rates.Count() + ); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Network error while retrieving exchange rates"); + Console.WriteLine("Unable to retrieve exchange rates due to network error. Please check your connection."); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error while retrieving exchange rates"); + Console.WriteLine("Unable to retrieve exchange rates at this time. Please try again later."); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj new file mode 100644 index 0000000000..b0b8f22fef --- /dev/null +++ b/jobs/Backend/Task/src/Application/ExchangeRateUpdater.Application.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + + + + + + + + + + PreserveNewest + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/Application/Program.cs b/jobs/Backend/Task/src/Application/Program.cs new file mode 100644 index 0000000000..ebce75ae35 --- /dev/null +++ b/jobs/Backend/Task/src/Application/Program.cs @@ -0,0 +1,81 @@ +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace ExchangeRateUpdater.Application; + +public static class Program +{ + private static IEnumerable _currencies = + [ + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + ]; + + public static async Task Main(string[] args) + { + ConfigureLogging(); + + try + { + Log.Information("Starting Exchange Rate Updater Application"); + + var host = CreateHostBuilder(args).Build(); + + using var scope = host.Services.CreateScope(); + var app = scope.ServiceProvider.GetRequiredService(); + await app.RunAsync(_currencies); + + return 0; + } + catch (Exception ex) + { + Log.Fatal(ex, "Application terminated unexpectedly"); + Console.WriteLine($"Could not retrieve exchange rates: '{ex.Message}'."); + return 1; + } + finally + { + Log.CloseAndFlush(); + } + } + + private static void ConfigureLogging() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseSerilog() + .UseDefaultServiceProvider(options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + } + ) + .ConfigureServices((context, services) => + { + services + .UseCzechNationalBankProvider(context); + + services.AddTransient(); + } + ); + +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Application/appsettings.json b/jobs/Backend/Task/src/Application/appsettings.json new file mode 100644 index 0000000000..fe1c0141a0 --- /dev/null +++ b/jobs/Backend/Task/src/Application/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + }, + "CnbProvider": { + "BaseUrl": "https://www.cnb.cz", + "DailyRatesPath": "/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 10, + "DurationOfCircuitBreakSeconds": 30, + "RetryCount": 3, + "UserAgent": "Chrome/120.0.0.0 Safari/537.36" + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Entities/Currency.cs b/jobs/Backend/Task/src/Domain/Entities/Currency.cs new file mode 100644 index 0000000000..fc0e244608 --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Entities/Currency.cs @@ -0,0 +1,26 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +/// +/// Represents a currency using its ISO 4217 code. +/// +public record Currency +{ + public string Code { get; } + + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be empty", nameof(code)); + } + + if (code.Length != 3) + { + throw new ArgumentException("Currency code must be 3 characters", nameof(code)); + } + + Code = code.ToUpperInvariant(); + } + + public override string ToString() => Code; +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs new file mode 100644 index 0000000000..933f1870fc --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Entities/ExchangeRate.cs @@ -0,0 +1,46 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +/// +/// Represents an exchange rate between two currencies. +/// +/// +/// The value expresses how much of the target currency equals one unit of the source currency. +/// +public record ExchangeRate +{ + public Currency SourceCurrency { get; } + + public Currency TargetCurrency { get; } + + public decimal Value { get; } + + /// + /// Creates a new exchange rate. + /// + /// The base currency. + /// The currency being quoted. + /// The exchange rate value. Must be greater than zero. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is less than or equal to zero. + /// + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + ArgumentNullException.ThrowIfNull(sourceCurrency, nameof(sourceCurrency)); + ArgumentNullException.ThrowIfNull(targetCurrency, nameof(targetCurrency)); + if (value <= 0) + { + throw new ArgumentException("Exchange rate value must be positive", nameof(value)); + } + + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + } + + public override string ToString() => + $"{SourceCurrency}/{TargetCurrency} = {Value}"; + +} diff --git a/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj new file mode 100644 index 0000000000..d1c2dc5118 --- /dev/null +++ b/jobs/Backend/Task/src/Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + enable + + + diff --git a/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..f668048fba --- /dev/null +++ b/jobs/Backend/Task/src/Domain/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,22 @@ +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.Domain.Interfaces; + +/// +/// Provides exchange rates from an external source. +/// +public interface IExchangeRateProvider +{ + /// + /// Retrieves the latest available exchange rates. + /// + /// + /// A collection of exchange rates indexed by currency code. + /// + /// + /// Thrown when exchange rates cannot be retrieved. + /// + Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs new file mode 100644 index 0000000000..f2f2ececa2 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/CzechNationalBankHttpClient.cs @@ -0,0 +1,65 @@ +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; + +/// +/// HTTP client for fetching Czech National Bank exchange rate data. +/// +sealed internal class CzechNationalBankHttpClient : ICzechNationalBankClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ProviderOptions _options; + + public CzechNationalBankHttpClient( + HttpClient httpClient, + ProviderOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options; + _logger = logger; + + _httpClient.BaseAddress = new Uri(_options.BaseUrl); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_options.UserAgent); + } + + public async Task GetDailyRatesAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "Fetching daily exchange rates from CNB: {Url}", + new Uri(_httpClient.BaseAddress!, _options.DailyRatesPath) + ); + + try + { + var response = await _httpClient.GetAsync(_options.DailyRatesPath, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogInformation( + "Successfully retrieved exchange rates data ({Size} bytes)", + content.Length + ); + + _logger.LogDebug("Response content preview: {Preview}", + content.Length > 200 ? content.Substring(0, 200) + "..." : content + ); + + return content; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed while fetching exchange rates from CNB"); + throw; + } + catch (TaskCanceledException ex) + { + _logger.LogError(ex, "Request timed out while fetching exchange rates from CNB"); + throw; + } + } +} diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs new file mode 100644 index 0000000000..78bc15c381 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Clients/ICzechNationalBankClient.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; + +public interface ICzechNationalBankClient +{ + Task GetDailyRatesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs new file mode 100644 index 0000000000..11a92c87ba --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/PollyPolicies.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; + +internal sealed class PollyPolicies( + ProviderOptions options, + ILogger logger) +{ + public IAsyncPolicy RetryPolicy => + HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: options.RetryCount, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, _) => + { + logger.LogWarning( + "Retry {RetryCount} after {Delay}s due to {Reason}", + retryCount, + timespan.TotalSeconds, + outcome.Exception?.Message + ?? outcome.Result.StatusCode.ToString()); + }); + + public IAsyncPolicy CircuitBreakerPolicy => + HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: options.RetryCount, + durationOfBreak: TimeSpan.FromSeconds(options.DurationOfCircuitBreakSeconds), + onBreak: (_, duration) => + { + logger.LogError("Circuit breaker opened for {Duration}s", duration.TotalSeconds); + }, + onReset: () => + { + logger.LogInformation("Circuit breaker reset"); + }); + + public IAsyncPolicy TimeoutPolicy => + Policy.TimeoutAsync( + TimeSpan.FromSeconds(options.TimeoutSeconds)); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs new file mode 100644 index 0000000000..4694caf3a4 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Configuration/ProviderOptions.cs @@ -0,0 +1,48 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; + +/// +/// Configuration options for the Czech National Bank exchange rate provider. +/// +internal record ProviderOptions +{ + public static string ConfigurationSectionName => "CnbProvider"; + public required string BaseUrl { get; set; } = string.Empty; + public required string DailyRatesPath { get; set; } = string.Empty; + public required int TimeoutSeconds { get; set; } = 10; + public required int DurationOfCircuitBreakSeconds { get; set; } = 30; + public required int RetryCount { get; set; } = 3; + public required string UserAgent { get; set; } = string.Empty; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + throw new InvalidOperationException("BaseUrl is required"); + } + + if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _)) + { + throw new InvalidOperationException("BaseUrl must be a valid URL"); + } + + if (string.IsNullOrWhiteSpace(DailyRatesPath)) + { + throw new InvalidOperationException($"{nameof(DailyRatesPath)} is required"); + } + + if (TimeoutSeconds <= 0) + { + throw new InvalidOperationException($"{nameof(TimeoutSeconds)} must be positive"); + } + + if (DurationOfCircuitBreakSeconds <= 0) + { + throw new InvalidOperationException($"{nameof(DurationOfCircuitBreakSeconds)} must be positive"); + } + + if (RetryCount < 0) + { + throw new InvalidOperationException($"{nameof(RetryCount)} cannot be negative"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs new file mode 100644 index 0000000000..67c65c721b --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Exceptions/CzechNationalBankParsingException.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; + +/// +/// Represents errors that occur while parsing CNB exchange rate responses. +/// +public class CzechNationalBankParsingException(string message) : Exception(message) +{ } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs new file mode 100644 index 0000000000..586e4f9183 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateProvider.cs @@ -0,0 +1,110 @@ +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; + +/// +/// Exchange rate provider backed by the Czech National Bank daily rates feed. +/// +/// +/// This provider fetches and parses daily exchange rates published by the CNB. +/// +internal class ExchangeRateProvider : IExchangeRateProvider +{ + private static readonly Currency _targetCurrency = new("CZK"); + + private readonly ICzechNationalBankClient _client; + private readonly IDailyExchangeRatesResponseParser _parser; + private readonly ILogger _logger; + + public ExchangeRateProvider( + ICzechNationalBankClient client, + IDailyExchangeRatesResponseParser parser, + ILogger logger) + { + _client = client; + _parser = parser; + _logger = logger; + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + var currencyList = currencies.ToList(); + _logger.LogInformation( + "Fetching exchange rates for {Count} currencies: {Currencies}", + currencyList.Count, + string.Join(", ", currencyList.Select(c => c.Code)) + ); + + try + { + var rawData = await _client.GetDailyRatesAsync(cancellationToken); + var data = _parser.Parse(rawData); + + var requestedCodes = new HashSet( + currencyList.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase + ); + + var exchangeRates = data + .ExchangeRates + .Where(model => requestedCodes.Contains(model.Code)) + .Select(DomainExchangeRate) + .ToList(); + + _logger.LogInformation( + "Successfully retrieved {Count} exchange rates out of {Requested} requested", + exchangeRates.Count, + currencyList.Count + ); + + LogNotFoundCurrencies(currencyList, exchangeRates); + + return exchangeRates; + } + catch (CzechNationalBankParsingException ex) + { + _logger.LogError(ex, "Failed to parse exchange rates from Czech National Bank response"); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve exchange rates"); + throw; + } + } + + private void LogNotFoundCurrencies(List currencyList, List exchangeRates) + { + var foundCodes = new HashSet( + exchangeRates.Select(rate => rate.SourceCurrency.Code), + StringComparer.OrdinalIgnoreCase + ); + + var missingCurrencies = currencyList + .Where(rate => !foundCodes.Contains(rate.Code)) + .Select(rate => rate.Code) + .ToList(); + + if (missingCurrencies.Any()) + { + _logger.LogWarning( + "Could not find exchange rates for currencies: {Currencies}", + string.Join(", ", missingCurrencies) + ); + } + } + + private static ExchangeRate DomainExchangeRate(Models.ExchangeRate model) => + new( + sourceCurrency: new Currency(model.Code), + targetCurrency: _targetCurrency, + value: model.Rate / model.Amount + ); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj new file mode 100644 index 0000000000..7b76e29bb1 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/ExchangeRateUpdater.Providers.CzechNationalBank.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + <_Parameter1>ExchangeRateUpdater.UnitTests + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs new file mode 100644 index 0000000000..68a8130e58 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/DailyExchangeRatesResponse.cs @@ -0,0 +1,13 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; + +/// +/// Represents the complete daily exchange rate data from CNB. +/// +/// The date of the exchange rates. +/// Sequential number of the publication. Represents the number of working day according to Czech bank holidays +/// Collection of exchange rate records. +public record DailyExchangeRatesResponse( + DateOnly Date, + int Sequence, + IReadOnlyList ExchangeRates +); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs new file mode 100644 index 0000000000..8a0ceaae34 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Models/ExchangeRate.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; + +/// +/// Represents a single exchange rate record from the CNB daily file. +/// +/// Country name. +/// Currency name. +/// The amount of foreign currency (typically 1, 100, or 1000). +/// Three-letter ISO 4217 currency code. +/// Exchange rate to CZK for the specified amount. +public record ExchangeRate( + string Country, + string CurrencyName, + int Amount, + string Code, + decimal Rate +); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs new file mode 100644 index 0000000000..720143e27e --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/IDailyExchangeRatesResponseParser.cs @@ -0,0 +1,20 @@ +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; + +/// +/// Parses a raw daily exchange rates response into a structured model. +/// +public interface IDailyExchangeRatesResponseParser +{ + /// + /// Parses the raw response content. + /// + /// Raw response body returned by the provider. + /// The parsed daily exchange rates. + /// + /// Thrown when the response format is invalid or unsupported. + /// + DailyExchangeRatesResponse Parse(string rawData); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs new file mode 100644 index 0000000000..8ffb13414c --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedDailyExchangeResponseParser.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Models; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; + +/// +/// Parses exchange rate data returned by the Czech National Bank daily rates endpoint. +/// +/// +/// Expected format: +/// +/// 23 Dec 2025 #248 +/// Country|Currency|Amount|Code|Rate +/// Australia|dollar|1|AUD|13.818 +/// +/// +internal sealed class PipeSeparatedDailyExchangeResponseParser : IDailyExchangeRatesResponseParser +{ + private static readonly char[] _newLineCharacters = ['\r', '\n']; + private const int _expectedColumnCount = 5; + private const int _minimumLineCount = 3; + private const int _expectedHeaderPartsCount = 2; + private const string _expectedHeaderColumns = "Country|Currency|Amount|Code|Rate"; + public DailyExchangeRatesResponse Parse(string rawData) + { + if (string.IsNullOrWhiteSpace(rawData)) + { + throw new CzechNationalBankParsingException("Raw data is empty, null or white space."); + } + + var contents = GetContents(rawData); + + var header = contents[0]; + var headerParts = GetHeaderParts(header); + var exchangeDate = GetExchangeDate(headerParts); + var exchangeSequence = GetExchangeSequence(headerParts); + + ValidateColumnNames(contents[1]); + + return new DailyExchangeRatesResponse + ( + Date: exchangeDate, + Sequence: exchangeSequence, + ExchangeRates: contents[2..] + .Select(ParseRecord) + .ToArray() + ); + } + + private static string[] GetContents(string rawData) + { + var contents = rawData.Split(_newLineCharacters, StringSplitOptions.RemoveEmptyEntries); + if (contents.Length < _minimumLineCount) + { + throw new CzechNationalBankParsingException($"Response does not contain enough lines. Lines found: {contents.Length}."); + } + + return contents; + } + + private static string[] GetHeaderParts(string header) + { + var headerParts = header.Split('#', StringSplitOptions.TrimEntries); + + if (headerParts.Length != _expectedHeaderPartsCount) + { + throw new CzechNationalBankParsingException($"Header is not in expected format. Header value: '{header}'."); + } + + return headerParts; + } + + private static DateOnly GetExchangeDate(string[] headerParts) + { + var value = headerParts[0]; + if (!DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + throw new CzechNationalBankParsingException($"Header date is not in expected format. Value: '{value}'."); + } + + return date; + } + + private static int GetExchangeSequence(string[] headerParts) + { + var value = headerParts[1]; + if (!int.TryParse(value, out var sequence)) + { + throw new CzechNationalBankParsingException($"Header sequence is not in expected format. Value: '{value}'."); + } + + return sequence; + } + + private static void ValidateColumnNames(string columnNames) + { + if (!string.Equals(columnNames.Trim(), _expectedHeaderColumns, StringComparison.OrdinalIgnoreCase)) + { + throw new CzechNationalBankParsingException($"Column names are not in expected format. Value: '{columnNames}'."); + } + } + + private static ExchangeRate ParseRecord(string line) + { + var parts = line.Split('|', StringSplitOptions.TrimEntries); + + if (parts.Length != _expectedColumnCount) + { + throw new CzechNationalBankParsingException($"Invalid record format: '{line}'. Expected {_expectedColumnCount} pipe-separated columns."); + } + + var amountPart = parts[2]; + if (!int.TryParse(amountPart, out var amount)) + { + throw new CzechNationalBankParsingException($"Invalid amount: '{amountPart}'"); + } + + var ratePart = parts[4]; + if (!decimal.TryParse(ratePart, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate)) + { + throw new CzechNationalBankParsingException($"Invalid rate: '{ratePart}'"); + } + + return new ExchangeRate( + Country: parts[0], + CurrencyName: parts[1], + Amount: amount, + Code: parts[3], + Rate: rate + ); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs new file mode 100644 index 0000000000..de48174f84 --- /dev/null +++ b/jobs/Backend/Task/src/Infrastructure/Providers/CzechNationalBank/Registration.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Configuration; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Clients; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank; + + +public static class Registration +{ + public static IServiceCollection UseCzechNationalBankProvider( + this IServiceCollection services, + HostBuilderContext context) + { + services + .AddOptions() + .Bind(context.Configuration.GetSection(ProviderOptions.ConfigurationSectionName)) + .Validate(opts => + { + opts.Validate(); + return true; + } + ); + + services.AddSingleton(sp => sp.GetRequiredService>().Value); + + services + .AddSingleton() + .AddScoped(); + + services + .AddSingleton() + .AddHttpClient() + .AddPolicyHandler((sp, _) => sp.GetRequiredService().RetryPolicy) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().CircuitBreakerPolicy) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().TimeoutPolicy); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs new file mode 100644 index 0000000000..15e4151a62 --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/CurrencyTests.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.UnitTests; + +public class CurrencyTests +{ + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsEmpty() + { + Assert.Throws(() => new Currency(string.Empty)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsWhitespace() + { + Assert.Throws(() => new Currency(" ")); + } + + [Fact] + public void Constructor_ShouldThrow_WhenCodeIsNotThreeCharacters() + { + Assert.Throws(() => new Currency("US")); + Assert.Throws(() => new Currency("USDA")); + } + + [Fact] + public void Constructor_ShouldSetCode_ToUpperInvariant() + { + var currency = new Currency("usd"); + Assert.Equal("USD", currency.Code); + } + + [Fact] + public void TwoCurrencies_WithSameCode_ShouldBeEqual() + { + var currency1 = new Currency("USD"); + var currency2 = new Currency("USD"); + + Assert.Equal(currency1, currency2); + Assert.True(currency1 == currency2); + } + + [Fact] + public void Currency_ShouldBeCaseInsensitive() + { + var currency1 = new Currency("USD"); + var currency2 = new Currency("usd"); + + Assert.Equal(currency1, currency2); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs new file mode 100644 index 0000000000..f6fea0807e --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/Domain/Entities/ExchangeRateTests.cs @@ -0,0 +1,48 @@ + +using ExchangeRateUpdater.Domain.Entities; + +namespace ExchangeRateUpdater.UnitTests; + +public class ExchangeRateTests +{ + [Fact] + public void Constructor_ShouldThrow_WhenSourceCurrencyIsNull() + { + var targetCurrency = new Currency("USD"); + var value = 1; + + Assert.Throws(() => new ExchangeRate(null, targetCurrency, value)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenTargetCurrencyIsNull() + { + var sourceCurrency = new Currency("USD"); + var value = 1; + + Assert.Throws(() => new ExchangeRate(sourceCurrency, null, value)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_ShouldThrow_WhenValueIsNotPositive(int value) + { + var sourceCurrency = new Currency("USD"); + var targetCurrency = new Currency("EUR"); + + Assert.Throws(() => new ExchangeRate(sourceCurrency, targetCurrency, value)); + } + + [Fact] + public void ToString_ShouldReturnCorrectFormat() + { + var sourceCurrency = new Currency("USD"); + var targetCurrency = new Currency("EUR"); + var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, 1.2m); + + var result = exchangeRate.ToString(); + + Assert.Equal("USD/EUR = 1.2", result); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..18b01414aa --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs new file mode 100644 index 0000000000..7e8da2f55b --- /dev/null +++ b/jobs/Backend/Task/tests/UnitTests/Infrastructure/Providers/CzechNationalBank/Parsers/PipeSeparatedResponseParserTests.cs @@ -0,0 +1,204 @@ +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Exceptions; +using ExchangeRateUpdater.Infrastructure.Providers.CzechNationalBank.Parsers; + +namespace ExchangeRateUpdater.UnitTests; + +public class PipeSeparatedResponseParserTests +{ + private readonly PipeSeparatedDailyExchangeResponseParser _sut = new(); + private readonly DateOnly _testDate = new(2025, 12, 23); + private const int _testSequence = 248; + private const string _validHeader = "23 Dec 2025 #248"; + private const string _validColumnNames = "Country|Currency|Amount|Code|Rate"; + + private static string RawData(params string[] parts) + => string.Join(Environment.NewLine, parts); + + [Fact] + public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsNull() + { + AssertThrowsWithMessage(() => _sut.Parse(null), "Raw data is empty, null or white space."); + } + + [Theory] + [InlineData("")] // Empty string + [InlineData(" ")] // Whitespace only + [InlineData("\t")] // Tab character + [InlineData("\n")] // Newline character + [InlineData("\r\n")] // Carriage return and newline + public void Parse_ShouldThrowCnbParsingException_WhenRawDataIsEmptyOrWhitespace(string input) + { + AssertThrowsWithMessage(() => _sut.Parse(input), "Raw data is empty, null or white space."); + } + + [Theory] + [InlineData("invalidheader\n anything \n anything")] // No hash symbolon header + [InlineData("Multiple##hash##symbols\n anything \n anything")] // More than one hash symbol + [InlineData("##StartsWithHash\n anything \n anything")] // Hash at the beginning + public void Parse_ShouldThrowCnbParsingException_WhenHeaderIsInvalid(string input) + { + AssertThrowsWithMessage(() => _sut.Parse(input), "Header is not in expected format."); + } + + + public static IEnumerable GetInvalidColumnFormats() + { + // Wrong column names + yield return new object[] { RawData(_validHeader, "WrongColumn1|WrongColumn2|WrongColumn3|WrongColumn4|WrongColumn5", "anything") }; + // Comma separator instead of pipe + yield return new object[] { RawData(_validHeader, "Country,Currency,Amount,Code,Rate", "anything") }; + // Too many columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code|Rate|ExtraColumn", "anything") }; + // Too few columns + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount", "anything") }; + // Missing Rate column + yield return new object[] { RawData(_validHeader, "Country|Currency|Amount|Code", "anything") }; + } + + [Theory] + [MemberData(nameof(GetInvalidColumnFormats))] + public void Parse_ShouldThrowCnbParsingException_WhenColumnNamesAreInvalid(string input) + { + AssertThrowsWithMessage(() => _sut.Parse(input), "Column names are not in expected format."); + } + + [Fact] + public void Parse_ShouldThrowCnbParsingException_WhenRecordIsMalformed() + { + var malformedData = RawData( + _validHeader, + _validColumnNames, + "||" // missing columns + ); + + AssertThrowsWithMessage(() => _sut.Parse(malformedData), "Invalid record format"); + } + + [Fact] + public void Parse_ShouldThrow_WhenAmountIsNotNumeric() + { + var data = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|X|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Invalid amount"); + } + + [Fact] + public void Parse_ShouldThrow_WhenRateIsNotNumeric() + { + var data = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|1|AUD|abc" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Invalid rate"); + } + + [Fact] + public void Parse_ShouldThrow_WhenHeaderDateIsInvalid() + { + var data = RawData( + "99 Dec 99999 #248", // wrong format + _validColumnNames, + "Australia|dollar|1|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Header date is not in expected format."); + } + + [Fact] + public void Parse_ShouldThrow_WhenHeaderSequenceIsInvalid() + { + var data = RawData( + "23 Dec 2025 #ABC", + _validColumnNames, + "Australia|dollar|1|AUD|13.818" + ); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Header sequence is not in expected format."); + } + + [Fact] + public void Parse_ShouldThrow_WhenNoRecordsAreProvided() + { + var data = RawData(_validHeader, _validColumnNames); + + AssertThrowsWithMessage(() => _sut.Parse(data), "Response does not contain enough lines."); + } + + [Fact] + public void Parse_ShouldReturnExchangeRates_WhenDataIsValid() + { + var validData = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|1|AUD|13.818", + "Brazil|real|1|BRL|3.694", + "Canada|dollar|1|CAD|15.064" + ); + + var actual = _sut.Parse(validData); + + Assert.NotNull(actual); + Assert.Equal(_testDate, actual.Date); + Assert.Equal(_testSequence, actual.Sequence); + + Assert.Equal(3, actual.ExchangeRates.Count); + + Assert.Collection(actual.ExchangeRates, + result => + { + Assert.Equal("Australia", result.Country); + Assert.Equal("dollar", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("AUD", result.Code); + Assert.Equal(13.818m, result.Rate); + }, + result => + { + Assert.Equal("Brazil", result.Country); + Assert.Equal("real", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("BRL", result.Code); + Assert.Equal(3.694m, result.Rate); + }, + result => + { + Assert.Equal("Canada", result.Country); + Assert.Equal("dollar", result.CurrencyName); + Assert.Equal(1, result.Amount); + Assert.Equal("CAD", result.Code); + Assert.Equal(15.064m, result.Rate); + } + ); + } + + [Fact] + public void Parse_ShouldIgnoreExtraWhitespace_WhenDataHasTrailingNewlines() + { + var dataWithExtraWhitespace = RawData( + _validHeader, + _validColumnNames, + "Australia|dollar|1|AUD|13.818", + "", + "" + ); + + var actual = _sut.Parse(dataWithExtraWhitespace); + + Assert.NotNull(actual); + Assert.Equal(_testDate, actual.Date); + Assert.Equal(_testSequence, actual.Sequence); + Assert.Single(actual.ExchangeRates); + } + + private static void AssertThrowsWithMessage(Action act, string expectedMessage) + { + var ex = Assert.Throws(act); + Assert.Contains(expectedMessage, ex.Message); + } +} \ No newline at end of file