diff --git a/.gitignore b/.gitignore index fd35865456..faa5fb0b81 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ *.tgz *.sublime-* +.vs/ + node_modules bower_components npm-debug.log 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 index 89be84daff..db8a549dab 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,22 +1,124 @@ - -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 + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Console", "src\ExchangeRateUpdater.Console\ExchangeRateUpdater.Console.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.BusinessLogic", "src\ExchangeRateUpdater.BusinessLogic\ExchangeRateUpdater.BusinessLogic.csproj", "{741FA146-0B48-4A16-80F0-0A4D84A6AEA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Data", "src\ExchangeRateUpdater.Data\ExchangeRateUpdater.Data.csproj", "{2046544F-2178-4BB8-9E5A-904780231DB2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "tests\ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{02DCD6F6-F79D-460E-9943-A9E164F79452}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "src\ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{60D975FE-B57B-E1C4-6D3F-CF509327D85B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Client", "src\ExchangeRateUpdater.Client\ExchangeRateUpdater.Client.csproj", "{26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{101353D8-A63C-43AD-B04B-A2A5C7C02270}" + ProjectSection(SolutionItems) = preProject + README.MD = README.MD + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + 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}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.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 + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x64.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x64.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x86.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x86.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|Any CPU.Build.0 = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x64.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x64.Build.0 = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x86.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x86.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x64.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x86.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|Any CPU.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x64.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x64.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x86.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x86.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x64.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x64.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x86.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x86.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|Any CPU.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x64.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x64.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x64.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x64.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x86.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x86.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|Any CPU.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x64.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x64.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x86.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x86.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x64.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x64.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x86.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x86.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|Any CPU.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x64.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x64.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x86.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7B2695D6-D24C-4460-A58E-A10F08550CE0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2046544F-2178-4BB8-9E5A-904780231DB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {02DCD6F6-F79D-460E-9943-A9E164F79452} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {60D975FE-B57B-E1C4-6D3F-CF509327D85B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B62F2767-E00D-40E9-834A-79B58C8960FB} + EndGlobalSection +EndGlobal 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/README.MD b/jobs/Backend/Task/README.MD new file mode 100644 index 0000000000..0cbbbb9194 --- /dev/null +++ b/jobs/Backend/Task/README.MD @@ -0,0 +1,92 @@ +# Exchange Rate Provider + +This project implements a .NET solution for managing and retrieving echange rates from the Czech Natinal Bank. + +# Source for updating daily rates + +The daily rates are found at this location: +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/index.html?date=14.11.2025 + +And can be accessed directly as a csv plain text file at this location: +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt + +There is also an official official .NET library but is outdated so I went ahead and implemented my own provider with similar idea using csv parsing to split and extract the rates from the csv plain text file. +https://github.com/msigut/CNB.Exchange/tree/master + +# Structure +Split the solution into 5+1 different projects to keep it more organized + +src/ +- **ExchangeRateUpdater.Api** - Presentation/Application API Layer +- **ExchangeRateUpdate.Client** - This is the test client that uses the API + **ExchangeRateUpdate.Console** - Console only test of the solution +- **ExchangeRateUpdate.BusinessLogic** - Domain Layer +- **ExchangeRateUpdate.Data** - Infrastructure Layer + +test/ +- **ExchangeRateUpdate.UniTests** - Unit tests + +here is also where integration tests would be added eventually + +## Core Models + +### `ExchangeRate` +Represents a currency exchange rate with: +- `SourceCurrency`: The currency being converted from +- `TargetCurrency`: The currency being converted to +- `Value`: The exchange rate value + +### `Currency` +Represents a currency with its code (e.g., USD, EUR) + +## Getting Started + +### Prerequisites + +- .NET 7.0 or higher +- Visual Studio 2022 or Visual Studio Code + +### Observations + +#### API Configuration + +The API is set to run at `https://localhost:5001` (or as configured in `appsettings.json` and `launchSettings.json`) and has one single GET endpoint + +It also opens a swagger page at `https://localhost:5001/swagger/index.html` for easy testing and exploration of the API. + +#### Client Configuration + +Currently it doesn't have a setting where to look for the external service in appsettings.json but instead it expecting the API endpoint to be located at localhost:5001 in the javascript. Thus should be changed in the future so that it can be configured from appsettings.json. + +## Architecture + +The solution follows a layered architecture: + +``` +┌─────────────────────────────────────┐ +│ Presentation Layer │ +│ (Console, API, Client) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Business Logic Layer │ +│ (ExchangeRateManager, Services) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Data Access Layer │ +│ (Repositories, Database, Data) │ +└─────────────────────────────────────┘ +``` + +## License + +This is a Mews developer challenge project. + +## Contributing + +When contributing to this solution: +1. Ensure all unit tests pass +2. Follow C# coding standards +3. Add tests for new functionality +4. Update documentation as needed diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..b5be4dea66 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Api.Models; + +namespace ExchangeRateUpdater.Api.Controllers +{ + /// + /// API controller for exchange rate operations. + /// + [ApiController] + [Route("api/[controller]")] + public class ExchangeRatesController : ControllerBase + { + private readonly IExchangeRateManager _exchangeRateManager; + private readonly ILogger _logger; + + /// + /// Constructor for ExchangeRatesController. + /// + public ExchangeRatesController(IExchangeRateManager exchangeRateManager, ILogger logger) + { + _exchangeRateManager = exchangeRateManager ?? throw new ArgumentNullException(nameof(exchangeRateManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets exchange rates for specified currency codes. + /// + /// Comma-separated list of currency codes (e.g., "USD,EUR,JPY") + /// List of exchange rates from source currency to CZK + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetExchangeRates([FromQuery] string? currencies = null) + { + try + { + if (string.IsNullOrWhiteSpace(currencies)) + { + return BadRequest("Currencies parameter is required. Provide comma-separated currency codes."); + } + + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(c => new Currency(c.ToUpperInvariant())) + .ToList(); + + if (currencyList.Count == 0) + { + return BadRequest("At least one currency code is required."); + } + + // Always include CZK for consistency + if (!currencyList.Any(c => c.Code == "CZK")) + { + currencyList.Add(new Currency("CZK")); + } + + var rates = _exchangeRateManager.GetExchangeRates(currencyList); + + var result = rates + .Select(r => new ExchangeRateViewModel + { + SourceCurrencyCode = r.SourceCurrency.Code, + TargetCurrencyCode = r.TargetCurrency.Code, + Rate = r.Value + }) + .ToList(); + + return Ok(result); + } + catch (HttpRequestException httpEx) + { + _logger.LogError(httpEx, "HTTP error occurred while retrieving exchange rates for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Exchange rate service is temporarily unavailable." }); + } + catch (OperationCanceledException cancelEx) + { + _logger.LogWarning(cancelEx, "Exchange rate request was cancelled for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status408RequestTimeout, new { error = "Request timeout while retrieving exchange rates." }); + } + catch (ArgumentException argEx) + { + _logger.LogWarning(argEx, "Invalid argument provided for currencies: {Currencies}", currencies); + return BadRequest(new { error = "Invalid currency data." }); + } + catch (InvalidOperationException ioEx) + { + _logger.LogError(ioEx, "Invalid operation while retrieving exchange rates for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Failed to retrieve exchange rates." }); + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..b119816f1e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,17 @@ + + + net7.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs new file mode 100644 index 0000000000..d2a08dee85 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateUpdater.Api.Models +{ + /// + /// DTO for exchange rate information. + /// + public class ExchangeRateViewModel + { + /// + /// Source currency code (e.g., USD, EUR). + /// + public string SourceCurrencyCode { get; set; } = string.Empty; + + /// + /// Target currency code (typically CZK). + /// + public string TargetCurrencyCode { get; set; } = string.Empty; + + /// + /// Exchange rate (how many target currency units per one source currency unit). + /// + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..7dc9db05bd --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,65 @@ +using Microsoft.OpenApi.Models; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.Data; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllers(); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddLogging(l => l.AddConsole()); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + new ExchangeRateManager( + currencies => sp.GetRequiredService().GetExchangeRates(currencies) + ) +); + +// Add Swagger/OpenAPI +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Exchange Rate API", + Version = "v1", + Description = "API for retrieving Czech National Bank (CNB) exchange rates", + Contact = new OpenApiContact + { + Name = "Mews Systems" + } + }); + + // Include XML comments if available + var xmlFile = Path.Combine(AppContext.BaseDirectory, "ExchangeRateUpdater.Api.xml"); + if (File.Exists(xmlFile)) + { + c.IncludeXmlComments(xmlFile); + } +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Exchange Rate API V1"); +}); + +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..ec94e4cd7f --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "ExchangeRateUpdater.Api": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..7cfd94fe33 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "https://localhost:5001" +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs new file mode 100644 index 0000000000..81bc0b1e97 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs @@ -0,0 +1,32 @@ +using ExchangeRateUpdater.BusinessLogic.Models; +using System; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.BusinessLogic +{ + /// + /// Business logic service that manages and retrieves exchange rates. + /// Accepts a provider via dependency injection (wired at composition root in Console/API). + /// + public class ExchangeRateManager : IExchangeRateManager + { + private readonly Func, IEnumerable> _getExchangeRates; + + /// + /// Constructor accepting a function that retrieves exchange rates from the data layer. + /// This avoids circular dependency between BusinessLogic and Data projects. + /// + public ExchangeRateManager(Func, IEnumerable> getExchangeRates) + { + _getExchangeRates = getExchangeRates ?? throw new ArgumentNullException(nameof(getExchangeRates)); + } + + /// + /// Retrieves exchange rates for the specified currencies from the data layer provider. + /// + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + return _getExchangeRates(currencies); + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj new file mode 100644 index 0000000000..0fd66c5c9b --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj @@ -0,0 +1,7 @@ + + + net7.0 + enable + enable + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs new file mode 100644 index 0000000000..3e71456299 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs @@ -0,0 +1,17 @@ +using ExchangeRateUpdater.BusinessLogic.Models; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.BusinessLogic +{ + /// + /// Business logic service for managing and retrieving exchange rates. + /// + public interface IExchangeRateManager + { + /// + /// 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. + /// + IEnumerable GetExchangeRates(IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs similarity index 83% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs index f375776f25..74590a9a5d 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs @@ -1,20 +1,20 @@ -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; - } - } -} +namespace ExchangeRateUpdater.BusinessLogic.Models +{ + 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/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs similarity index 87% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs index 58c5bb10e0..abbc1f0384 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs @@ -1,23 +1,23 @@ -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}"; - } - } -} +namespace ExchangeRateUpdater.BusinessLogic.Models +{ + 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/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj new file mode 100644 index 0000000000..10cbea439a --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj @@ -0,0 +1,7 @@ + + + net7.0 + enable + enable + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs new file mode 100644 index 0000000000..c6ef8f838f --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json new file mode 100644 index 0000000000..45400c75fd --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ExchangeRateUpdater.Client": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57076;http://localhost:57077" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json new file mode 100644 index 0000000000..219e56b077 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "http://localhost:5002;https://localhost:5003" +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html new file mode 100644 index 0000000000..2287987253 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html @@ -0,0 +1,298 @@ + + + + + + Exchange Rate Viewer + + + +
+

Exchange Rate Viewer

+ +
+ Enter currency codes (e.g., USD, EUR, JPY) separated by commas to view their exchange rates to CZK +
+ +
+
+ + +
+ +
+ +
+
+
+ + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj new file mode 100644 index 0000000000..55b29d811e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -0,0 +1,19 @@ + + + Exe + net7.0 + enable + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs new file mode 100644 index 0000000000..c7d3484f36 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(s => + { + s.AddLogging(l => l.AddConsole()); + s.AddHttpClient(); + s.AddScoped(sp => + new ExchangeRateManager( + currencies => sp.GetRequiredService().GetExchangeRates(currencies) + ) + ); + }) + .Build(); + +var logger = host.Services.GetRequiredService>(); + +try +{ + var manager = host.Services.GetRequiredService(); + + var 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") + }; + + var rates = manager.GetExchangeRates(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + foreach (var rate in rates) + { + Console.WriteLine(rate.ToString()); + } + + return 0; +} +catch (Exception e) +{ + logger.LogError(e, "Could not retrieve exchange rates."); + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + return 1; +} +finally +{ + Console.WriteLine("Press Enter to exit."); + Console.ReadLine(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs new file mode 100644 index 0000000000..918ac8f078 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; +// Expose internal logic for unit testing +[assembly: InternalsVisibleTo("ExchangeRateUpdater.UnitTests")] diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs new file mode 100644 index 0000000000..9e3dd966e6 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data.Helpers; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Data +{ + /// + /// Exchange rate provider that fetches rates from CNB (Czech National Bank). + /// + public class ExchangeRateProvider : IExchangeRateProvider + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private const string DailyRatesUrl = "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt"; + + public ExchangeRateProvider(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + // Synchronous wrapper around async operation and I am now using ConfigureAwait(false) to prevent deadlock + var result = GetExchangeRatesAsync(currencies).ConfigureAwait(false).GetAwaiter().GetResult(); + return result; + } + + private async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + try + { + _logger.LogDebug("Downloading daily exchange rates from {Url}", DailyRatesUrl); + + using var res = await _httpClient.GetAsync(DailyRatesUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + res.EnsureSuccessStatusCode(); + await using var stream = await res.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var sr = new StreamReader(stream); + + var allText = await sr.ReadToEndAsync().ConfigureAwait(false); + + // Parser is now a static helper + return ParseHelper.ParseRates(allText, currencies); + } + catch (HttpRequestException httpEx) + { + _logger.LogError(httpEx, "Failed to download exchange rates from {Url}. HTTP error occurred.", DailyRatesUrl); + throw; + } + catch (IOException ioEx) + { + _logger.LogError(ioEx, "I/O error occurred while reading exchange rate data."); + throw; + } + catch (OperationCanceledException cancelEx) + { + _logger.LogWarning(cancelEx, "Exchange rate download was cancelled."); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj new file mode 100644 index 0000000000..afd1f555f5 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj @@ -0,0 +1,17 @@ + + + net7.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs new file mode 100644 index 0000000000..e1bb86c7f3 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs @@ -0,0 +1,128 @@ +using CsvHelper; +using CsvHelper.Configuration; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data.Models; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Data.Helpers +{ + /// + /// Helper class for parsing exchange rate data. + /// Marked as internal to discourage direct use; the public API is IExchangeRateProvider. + /// Tests access it via InternalsVisibleTo attribute. + /// + internal static class ParseHelper + { + /// + /// Parses exchange rates from the source content. + /// Internal method - only called from IExchangeRateProvider implementation and tests. + /// + internal static IEnumerable ParseRates(string content, IEnumerable currencies) + { + var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 3) + return Enumerable.Empty(); + + var codesInInput = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + var targetCurrencyCode = "CZK"; + + var result = new List(); + + try + { + // The CNB data format: + // Line 1: Date (e.g., "14 Nov 2025") + // Line 2: Reference number (e.g., "#222") + // Line 3: Header (e.g., "Country|Currency|Amount|Code|Rate") + // Line 4+: Data rows + + var csvLines = lines.Skip(2).ToList(); + + if (csvLines.Count == 0) + return Enumerable.Empty(); + + var csvContent = string.Join(Environment.NewLine, csvLines); + + using var reader = new StringReader(csvContent); + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "|", + HasHeaderRecord = true, + HeaderValidated = null, // Disable header validation + }; + using var csv = new CsvReader(reader, config); + + // Register the class map for proper field mapping + csv.Context.RegisterClassMap(); + + // Read all records - materialize the enumerable to ensure parsing happens + var records = csv.GetRecords().ToList(); + + foreach (var record in records) + { + try + { + // Skip records with invalid data + if (record?.Code == null || string.IsNullOrWhiteSpace(record.Code)) + continue; + + // Validate that both source currency and target currency (CZK) are requested + if (!codesInInput.Contains(record.Code) || !codesInInput.Contains(targetCurrencyCode)) + continue; + + // Calculate unit rate: rate / amount + var unitRate = record.Rate / record.Amount; + var sourceCurrency = new Currency(record.Code); + var targetCurrency = new Currency(targetCurrencyCode); + + result.Add(new ExchangeRate(sourceCurrency, targetCurrency, unitRate)); + } + catch (FormatException) + { + // Skip records with format issues (invalid data types) + continue; + } + catch (OverflowException) + { + // Skip records with numeric overflow + continue; + } + } + } + catch (ArgumentException) + { + // Invalid CSV content structure - return empty results + return Enumerable.Empty(); + } + catch (OperationCanceledException) + { + // Parsing was cancelled - return empty results + return Enumerable.Empty(); + } + + return result; + } + + /// + /// Class map for CsvHelper to properly map CSV columns to ExchangeRateRecord properties. + /// Maps by column index (0-based) instead of by header name for better flexibility. + /// + private sealed class ExchangeRateRecordMap : ClassMap + { + public ExchangeRateRecordMap() + { + // Map by index instead of header name for more flexibility + Map(m => m.Country).Index(0); + Map(m => m.Currency).Index(1); + Map(m => m.Amount).Index(2); + Map(m => m.Code).Index(3); + Map(m => m.Rate).Index(4); + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs new file mode 100644 index 0000000000..344057310e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.BusinessLogic.Models; + +namespace ExchangeRateUpdater.Data +{ + /// + /// Data layer provider interface for retrieving exchange rates from a source. + /// + public interface IExchangeRateProvider + { + /// + /// Retrieves exchange rates from the data source for the specified currencies. + /// + IEnumerable GetExchangeRates(IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs new file mode 100644 index 0000000000..81b5509961 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Data.Models +{ + /// + /// CSV record mapping for exchange rate data from CNB. + /// + class ExchangeRateRecord + { + public string Country { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } + public string Code { get; set; } + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..f2bdee508a --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,18 @@ + + + net7.0 + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs new file mode 100644 index 0000000000..f28f918494 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Data.Helpers; +using ExchangeRateUpdater.BusinessLogic.Models; +using Xunit; + +namespace ExchangeRateUpdater.UnitTests +{ + public class ParserTests + { + [Fact] + public void ParseRates_ReturnsRatesForRequestedCurrencies() + { + var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\nJapan|yen|100|JPY|13.511\n"; + var currencies = new[] { new Currency("USD"), new Currency("CZK"), new Currency("JPY") }; + + var rates = ParseHelper.ParseRates(content, currencies).ToList(); + + Assert.Equal(2, rates.Count); + + var usd = rates.Single(r => r.SourceCurrency.Code == "USD"); + Assert.Equal("CZK", usd.TargetCurrency.Code); + Assert.Equal(20.786m, usd.Value); + + var jpy = rates.Single(r => r.SourceCurrency.Code == "JPY"); + Assert.Equal("CZK", jpy.TargetCurrency.Code); + Assert.Equal(13.511m / 100m, jpy.Value); + } + + [Fact] + public void ParseRates_IgnoresCurrenciesNotRequested() + { + var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\n"; + var currencies = new[] { new Currency("JPY"), new Currency("CZK") }; + + var rates = ParseHelper.ParseRates(content, currencies); + + Assert.Empty(rates); + } + } +}