From eceec9ae46382332910b4ae3cad7f247da7bb529 Mon Sep 17 00:00:00 2001 From: pedroVL Date: Tue, 29 Jul 2025 17:17:40 +0200 Subject: [PATCH 1/8] API working initial commit --- jobs/Backend/Task/Currency.cs | 20 ----- jobs/Backend/Task/ExchangeRate.cs | 23 ------ jobs/Backend/Task/ExchangeRateProvider.cs | 19 ----- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 +- jobs/Backend/Task/Program.cs | 43 ---------- jobs/Backend/Task/appsettings.json | 43 ++++++++++ .../src/Controllers/ExchageRateController.cs | 61 ++++++++++++++ .../ExternalExchangeRateApiException.cs | 6 ++ .../Task/src/Exceptions/ParsingException.cs | 6 ++ .../src/Interfaces/IExchangeRateParser.cs | 9 +++ .../src/Interfaces/IExchangeRateProvider.cs | 10 +++ .../Task/src/Interfaces/IParserFactory.cs | 6 ++ jobs/Backend/Task/src/Models/Currency.cs | 20 +++++ jobs/Backend/Task/src/Models/ExchangeRate.cs | 23 ++++++ .../Task/src/Models/ExchangeRateResponse.cs | 8 ++ .../Task/src/Models/ExchangeRateSource.cs | 8 ++ jobs/Backend/Task/src/Parsers/CnbXmlParser.cs | 35 ++++++++ .../Backend/Task/src/Parsers/ParserFactory.cs | 16 ++++ jobs/Backend/Task/src/Program.cs | 26 ++++++ .../Task/src/Services/ExchangeRateProvider.cs | 80 +++++++++++++++++++ .../Services/ExchangeRateSourceResolver.cs | 37 +++++++++ 21 files changed, 396 insertions(+), 107 deletions(-) delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/appsettings.json create mode 100644 jobs/Backend/Task/src/Controllers/ExchageRateController.cs create mode 100644 jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs create mode 100644 jobs/Backend/Task/src/Exceptions/ParsingException.cs create mode 100644 jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs create mode 100644 jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/Interfaces/IParserFactory.cs create mode 100644 jobs/Backend/Task/src/Models/Currency.cs create mode 100644 jobs/Backend/Task/src/Models/ExchangeRate.cs create mode 100644 jobs/Backend/Task/src/Models/ExchangeRateResponse.cs create mode 100644 jobs/Backend/Task/src/Models/ExchangeRateSource.cs create mode 100644 jobs/Backend/Task/src/Parsers/CnbXmlParser.cs create mode 100644 jobs/Backend/Task/src/Parsers/ParserFactory.cs create mode 100644 jobs/Backend/Task/src/Program.cs create mode 100644 jobs/Backend/Task/src/Services/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f2..000000000 --- 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 58c5bb10e..000000000 --- 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 6f82a97fb..000000000 --- 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 index 2fc654a12..4ef3b5823 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,8 @@ - + Exe - net6.0 + net8.0 \ 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 379a69b1f..000000000 --- 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/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..c8a2cfee7 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,43 @@ +{ + "ExchangeRateSources": [ + { + "BaseCurrency": "CZK", + "Url": "https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml", + "ParserType": "CnbXmlParser" + } + ], + "AllowedCurrencies": [ + "AUD", + "CZK", + "BRL", + "BGN", + "CNY", + "DKK", + "EUR", + "PHP", + "HKD", + "INR", + "IDR", + "ISK", + "ILS", + "JPY", + "ZAR", + "CAD", + "KRW", + "HUF", + "MYR", + "MXN", + "XDR", + "NOK", + "NZD", + "PLN", + "RON", + "SGD", + "SEK", + "CHF", + "THB", + "TRY", + "USD", + "GBP" + ] +} diff --git a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs new file mode 100644 index 000000000..77bf87263 --- /dev/null +++ b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Controllers; + +[ApiController] +[Route("ExchangeRate")] +public class ExchageRateController : ControllerBase +{ + private readonly IExchangeRateProvider _provider; + + public ExchageRateController(IExchangeRateProvider provider) + { + _provider = provider; + } + + [HttpGet] + public async Task>> GetExchangeRates( + [FromQuery] string currencies, [FromQuery] string baseCurrency = "CZK") + { + if (string.IsNullOrWhiteSpace(currencies)) + { + return BadRequest("Currency codes cannot be empty."); + } + + if (string.IsNullOrWhiteSpace(baseCurrency)) + { + return BadRequest("Base currency cannot be empty."); + } + + var currencyObjects = currencies.Split(',') + .Select(c => new Currency(c.Trim().ToUpper())).ToList(); + + try + { + var exchangeRates = await _provider.GetExchangeRates(currencyObjects, new Currency(baseCurrency)); + + return Ok(exchangeRates.Select(er => new ExchangeRateResponse + { + SourceCurrency = er.SourceCurrency.Code, + TargetCurrency = er.TargetCurrency.Code, + ExchangeRate = er.Value + })); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (ExternalExchangeRateApiException ex) + { + // Log the exception if logging is configured + return StatusCode(503, $"Service Unavailable: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs b/jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs new file mode 100644 index 000000000..e8a42f46e --- /dev/null +++ b/jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs @@ -0,0 +1,6 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions; + +public class ExternalExchangeRateApiException(string message, Exception innerException) + : Exception(message, innerException); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Exceptions/ParsingException.cs b/jobs/Backend/Task/src/Exceptions/ParsingException.cs new file mode 100644 index 000000000..8c772c169 --- /dev/null +++ b/jobs/Backend/Task/src/Exceptions/ParsingException.cs @@ -0,0 +1,6 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions; + +public class ParsingException(string message, Exception innerException) + : Exception(message, innerException); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs new file mode 100644 index 000000000..ffc115071 --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateParser +{ + IEnumerable Parse(string data, Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..136dee7dc --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRates(List currencies, Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IParserFactory.cs b/jobs/Backend/Task/src/Interfaces/IParserFactory.cs new file mode 100644 index 000000000..0b23e4204 --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IParserFactory.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Interfaces; + +public interface IParserFactory +{ + IExchangeRateParser CreateParser(string parserType); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/Currency.cs b/jobs/Backend/Task/src/Models/Currency.cs new file mode 100644 index 000000000..5cd9dcd77 --- /dev/null +++ b/jobs/Backend/Task/src/Models/Currency.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateUpdater.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/src/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Models/ExchangeRate.cs new file mode 100644 index 000000000..44f33c2cc --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRate.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateUpdater.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/Models/ExchangeRateResponse.cs b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs new file mode 100644 index 000000000..3f16b5721 --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateResponse +{ + public string SourceCurrency { get; set; } + public string TargetCurrency { get; set; } + public decimal ExchangeRate { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSource.cs b/jobs/Backend/Task/src/Models/ExchangeRateSource.cs new file mode 100644 index 000000000..2b4e0c493 --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateSource.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateSource +{ + public string BaseCurrency { get; set; } + public string Url { get; set; } + public string ParserType { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs new file mode 100644 index 000000000..f1ff5b393 --- /dev/null +++ b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Parsers; + +public class CnbXmlParser : IExchangeRateParser +{ + public IEnumerable Parse(string data, Currency baseCurrency) + { + try + { + var dataDoc = XDocument.Parse(data); + var rates = dataDoc.Descendants("radek") + .Select(currency => new ExchangeRate( + new Currency(currency.Attribute("kod")?.Value), + baseCurrency, + // make sure the comma is used as a decimal delimiter + decimal.Parse(currency.Attribute("kurz")?.Value!, CultureInfo.GetCultureInfo("cs-CZ")) / + int.Parse(currency.Attribute("mnozstvi")?.Value!) + )); + return rates; + + } + catch (Exception ex) + { + throw new ParsingException("Failed to parse CNB exchange rates", ex); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Parsers/ParserFactory.cs b/jobs/Backend/Task/src/Parsers/ParserFactory.cs new file mode 100644 index 000000000..538c4fe90 --- /dev/null +++ b/jobs/Backend/Task/src/Parsers/ParserFactory.cs @@ -0,0 +1,16 @@ +using ExchangeRateUpdater.Interfaces; +using System; + +namespace ExchangeRateUpdater.Parsers; + +public class ParserFactory : IParserFactory +{ + public IExchangeRateParser CreateParser(string parserType) + { + return parserType switch + { + "CnbXmlParser" => new CnbXmlParser(), + _ => throw new InvalidOperationException($"Unknown parser type: {parserType}"), + }; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs new file mode 100644 index 000000000..f2220a1ec --- /dev/null +++ b/jobs/Backend/Task/src/Program.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Parsers; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); + +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +var app = builder.Build(); + +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..dcf10fb00 --- /dev/null +++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Services; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using System.Net.Http; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateUpdater.Services; + +public class ExchangeRateProvider : IExchangeRateProvider +{ + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + private readonly ExchangeRateSourceResolver _sourceResolver; + private readonly List _allowedCurrencies; + + public ExchangeRateProvider(IMemoryCache cache, HttpClient httpClient, ExchangeRateSourceResolver sourceResolver, IConfiguration configuration) + { + _cache = cache; + _httpClient = httpClient; + _sourceResolver = sourceResolver; + _allowedCurrencies = configuration.GetSection("AllowedCurrencies").Get>(); + } + + public async Task> GetExchangeRates(List currencies, Currency baseCurrency) + { + ValidateCurrencies(currencies, baseCurrency); + + var cacheKey = $"ExchangeRates_{baseCurrency.Code}"; + + if (!_cache.TryGetValue(cacheKey, out List rates)) + { + ExchangeRateSource source; + IExchangeRateParser parser; + try + { + (source, parser) = _sourceResolver.ResolveSourceAndParser(baseCurrency); + } + + catch (InvalidOperationException ex) + { + throw new ExternalExchangeRateApiException(ex.Message, ex); + } + try + { + var response = await _httpClient.GetStringAsync(source.Url); + rates = parser.Parse(response, baseCurrency).ToList(); + } + catch (HttpRequestException ex) + { + throw new ExternalExchangeRateApiException($"Failed to retrieve exchange rates from {source.Url}", ex); + } + + _cache.Set(cacheKey, rates, TimeSpan.FromMinutes(5)); + } + + var requestedCodes = new HashSet(currencies.Select(c => c.Code)); + return rates.Where(r => requestedCodes.Contains(r.SourceCurrency.Code)); + } + + private void ValidateCurrencies(List currencies, Currency baseCurrency) + { + if (!_allowedCurrencies.Contains(baseCurrency.Code.ToUpper())) + { + throw new ArgumentException($"Base currency '{baseCurrency}' is not allowed."); + } + + var unallowedCurrencies = currencies.Where(c => !_allowedCurrencies.Contains(c.Code.ToUpper())).ToList(); + if (unallowedCurrencies.Any()) + { + throw new ArgumentException($"The following currencies are not allowed: {string.Join(", ", unallowedCurrencies.Select(c => c.Code))}"); + } + } +} + diff --git a/jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs b/jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs new file mode 100644 index 000000000..0162f5670 --- /dev/null +++ b/jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Services; + +public class ExchangeRateSourceResolver +{ + private readonly IConfiguration _configuration; + private readonly IParserFactory _parserFactory; + + public ExchangeRateSourceResolver(IConfiguration configuration, IParserFactory parserFactory) + { + _configuration = configuration; + _parserFactory = parserFactory; + } + + public (ExchangeRateSource Source, IExchangeRateParser Parser) ResolveSourceAndParser(Currency baseCurrency) + { + var sources = _configuration.GetSection("ExchangeRateSources").Get>(); + + var source = sources + .FirstOrDefault(s => s.BaseCurrency.Equals(baseCurrency.Code, StringComparison.OrdinalIgnoreCase)); + + if (source == null) + { + throw new InvalidOperationException($"No exchange rate source found for base currency {baseCurrency.Code}"); + } + + var parser = _parserFactory.CreateParser(source.ParserType); + + return (source, parser); + } +} \ No newline at end of file From d9c16c70265c1b249581a7e1b84200d7e8d74f0d Mon Sep 17 00:00:00 2001 From: pedroVL Date: Tue, 29 Jul 2025 17:32:45 +0200 Subject: [PATCH 2/8] added readme --- jobs/Backend/Task/README.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 jobs/Backend/Task/README.md diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..c5906778c --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,6 @@ +# Proposed API solution +This API is created with the goal of being extensible. It allows to get the current exchange rates from the CNB, and makes them available through the API. +This could be extended adding new providers with new parsers. +The appsettings.json should include the allowed sources set by base currency and allowed currencies. + +It will have only one GET endpoint "ExchangeRate" that will take a mandatory parameter "currencies" and an optional parameter "baseCurrency" which is currently defaulting to "CZK" as it's the only one implemented at the moment \ No newline at end of file From 9bb8b558409e806d169ca8332c99f375f5223e91 Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 10:32:30 +0200 Subject: [PATCH 3/8] AddedTests --- .../CnbXmlParserTests.cs | 46 ++++++ .../ExchangeRateControllerTests.cs | 147 ++++++++++++++++++ .../ExchangeRateProviderTests.cs | 137 ++++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 27 ++++ .../ExchangeRateUpdater.Tests/TestData.cs | 57 +++++++ .../appsettings.test.json | 16 ++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 11 +- jobs/Backend/Task/ExchangeRateUpdater.sln | 9 ++ .../src/Controllers/ExchageRateController.cs | 21 +-- ...ception.cs => ExchangeRateApiException.cs} | 2 +- .../src/Interfaces/IExchangeRateParser.cs | 2 +- .../IExchangeRateSettingsResolver.cs | 8 + jobs/Backend/Task/src/Models/ExchangeRate.cs | 14 +- .../Task/src/Models/ExchangeRateSettings.cs | 9 ++ ...geRateSource.cs => ExchangeRateSources.cs} | 2 +- jobs/Backend/Task/src/Parsers/CnbXmlParser.cs | 13 +- .../Backend/Task/src/Parsers/ParserFactory.cs | 4 +- jobs/Backend/Task/src/Program.cs | 9 +- .../Task/src/Services/ExchangeRateProvider.cs | 28 ++-- ...ver.cs => ExchangeRateSettingsResolver.cs} | 15 +- 20 files changed, 519 insertions(+), 58 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json rename jobs/Backend/Task/src/Exceptions/{ExternalExchangeRateApiException.cs => ExchangeRateApiException.cs} (52%) create mode 100644 jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs create mode 100644 jobs/Backend/Task/src/Models/ExchangeRateSettings.cs rename jobs/Backend/Task/src/Models/{ExchangeRateSource.cs => ExchangeRateSources.cs} (83%) rename jobs/Backend/Task/src/Services/{ExchangeRateSourceResolver.cs => ExchangeRateSettingsResolver.cs} (59%) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs new file mode 100644 index 000000000..b1490ab75 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs @@ -0,0 +1,46 @@ + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Exceptions; +using System.Linq; + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class CnbXmlParserTests +{ + [Test] + public void Parse_ValidXml_ReturnsCorrectExchangeRates() + { + // Arrange + var parser = new CnbXmlParser(); + var baseCurrency = new Currency("CZK"); + + // Act + var rates = parser.Parse(TestData.CnbExchangeRateXml, baseCurrency).ToList(); + + // Assert + + Assert.That(rates.Count, Is.EqualTo(31)); + + var audRate = rates.FirstOrDefault(r => r.TargetCurrency.Code == "AUD"); + Assert.That(audRate.SourceCurrency.Code, Is.EqualTo("CZK")); + Assert.That(audRate.Value, Is.EqualTo(13.862m)); + } + [Test] + public void Parse_IvalidXml_ReturnsCorrectExchangeRates() + { + // Arrange + var parser = new CnbXmlParser(); + var baseCurrency = new Currency("CZK"); + + // Act + // Assert + Assert.Throws(() => parser.Parse(TestData.InvalidCnbExchangeRateXml, baseCurrency)); + + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs new file mode 100644 index 000000000..5935f0c8b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs @@ -0,0 +1,147 @@ +using NUnit.Framework; +using Moq; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; +using Moq.Protected; +using System; +using System.Net.Http; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Controllers; +using ExchangeRateUpdater.Parsers; + + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class ExchangeRateControllerTests +{ + private ExchangeRateController _controller; + private IConfiguration _configuration; + private Mock _mockCache; + private ExchangeRateSettingsResolver _settingsResolver; + private ExchangeRateProvider _provider; + + [SetUp] + public void Setup() + { + _configuration = new ConfigurationBuilder() + .SetBasePath(TestContext.CurrentContext.TestDirectory) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + _mockCache = new Mock(); + + + _settingsResolver = new ExchangeRateSettingsResolver(_configuration); + _mockCache.Setup(m => m.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns(false); + _mockCache.Setup(m => m.CreateEntry(It.IsAny())) + .Returns(Mock.Of()); + + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(TestData.CnbExchangeRateXml), + }); + + var httpClient = new HttpClient(mockHttpMessageHandler.Object); + + _provider = new ExchangeRateProvider(_mockCache.Object, httpClient, _settingsResolver, _configuration); + _controller = new ExchangeRateController(_provider); + } + + [Test] + public async Task GetExchangeRates_ValidCurrencies_ReturnsOkWithRates() + { + // Arrange + var currencies = "EUR,USD"; + var baseCurrency = "CZK"; + var expectedRates = new List + { + new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 24.610m), + new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.331m) + }; + + // Act + var result = await _controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var returnedRates = okResult.Value as IEnumerable; + Assert.That(returnedRates, Is.Not.Null); + Assert.That(returnedRates.Count(), Is.EqualTo(expectedRates.Count)); + } + + [Test] + public async Task GetExchangeRates_EmptyCurrencies_ReturnsBadRequest() + { + // Arrange + var currencies = ""; + var baseCurrency = "CZK"; + + // Act + var result = await _controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.That(badRequestResult, Is.Not.Null); + Assert.That(badRequestResult.Value, Is.EqualTo("Currency codes cannot be empty.")); + } + + [Test] + public async Task GetExchangeRates_ArgumentExceptionFromProvider_ReturnsBadRequest() + { + // Arrange + var currency = "TEST"; + var baseCurrency = "CZK"; + var errorMessage = $"The following currencies are not allowed: {currency}"; + + + // Act + var result = await _controller.GetExchangeRates(currency, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.That(badRequestResult, Is.Not.Null); + Assert.That(badRequestResult.Value, Is.EqualTo(errorMessage)); + } + + [Test] + public async Task GetExchangeRates_ExchangeRateApiExceptionFromProvider_ReturnsServiceUnavailable() + { + // Arrange + var currencies = "EUR"; + var baseCurrency = "CZK"; + + + var httpClient = new HttpClient(); + + + var provider = new ExchangeRateProvider( _mockCache.Object, httpClient, _settingsResolver, _configuration); + var controller = new ExchangeRateController(provider); + + // Act + var result = await controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var objectResult = result.Result as ObjectResult; + Assert.That(objectResult, Is.Not.Null); + Assert.That(objectResult.StatusCode, Is.GreaterThan(299)); + Assert.That(objectResult.Value, Contains.Substring($"Service Unavailable: Failed to retrieve exchange rates from ")); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..a9ddb743e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,137 @@ +using NUnit.Framework; +using Moq; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; +using Moq.Protected; +using System; +using System.Net.Http; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Parsers; + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class ExchangeRateProviderTests +{ + private Mock _mockCache; + private IConfiguration _configuration; + private ExchangeRateSettingsResolver _settingsResolver; + private ExchangeRateProvider _provider; + + [SetUp] + public void Setup() + { + _mockCache = new Mock(); + + _configuration = new ConfigurationBuilder() + .SetBasePath(TestContext.CurrentContext.TestDirectory) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + _settingsResolver = new ExchangeRateSettingsResolver(_configuration); + _mockCache.Setup(m => m.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns(false); + _mockCache.Setup(m => m.CreateEntry(It.IsAny())) + .Returns(Mock.Of()); + + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(TestData.CnbExchangeRateXml), + }); + + var httpClient = new HttpClient(mockHttpMessageHandler.Object); + + _provider = new ExchangeRateProvider(_mockCache.Object, httpClient, _settingsResolver, _configuration); + } + + [Test] + public async Task GetExchangeRates_ValidCurrencies_ReturnsRates() + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency("CZK"); + var expectedRates = new List + { + new ExchangeRate(baseCurrency, new Currency("EUR"), 24.610m), + new ExchangeRate(baseCurrency, new Currency("USD"), 21.331m) + }; + + // Act + var result = (await _provider.GetExchangeRates(currencies, baseCurrency)).ToList(); + + // Assert + Assert.That(result, Is.EquivalentTo(expectedRates).Using(new ExchangeRateComparer())); + } + + + [TestCase("TEST")] + [TestCase("")] + public void GetExchangeRates_InvalidBaseCurrency_ThrowsArgumentException(string currencyCode) + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency(currencyCode); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + [Test] + public void GetExchangeRates_NotImplementedBaseCurrency_ThrowsApiException() + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency("USD"); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + + [Test] + public void GetExchangeRates_InvalidTargetCurrency_ThrowsArgumentException() + { + // Arrange + var currencies = TestData.InvalidCurrenciesForTest; + var baseCurrency = new Currency("CZK"); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + [Test] + public void GetExchangeRates_EmptyTargetCurrencies_ThrowsArgumentException() + { + // Arrange + var currencies = TestData.InvalidCurrenciesForTest; + var baseCurrency = new Currency("CZK"); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates([], baseCurrency)); + } + + private class ExchangeRateComparer : IEqualityComparer + { + public bool Equals(ExchangeRate x, ExchangeRate y) + { + return x.SourceCurrency.Code == y.SourceCurrency.Code && + x.TargetCurrency.Code == y.TargetCurrency.Code && + x.Value == y.Value; + } + + public int GetHashCode(ExchangeRate obj) + { + return HashCode.Combine(obj.SourceCurrency.Code, obj.TargetCurrency.Code, obj.Value); + } + } +} + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..5c4af88e5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + Always + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs new file mode 100644 index 000000000..1fe91408e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Tests; + +public static class TestData +{ + public const string CnbExchangeRateXml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + public const string InvalidCnbExchangeRateXml = """ + + + + + + + """; + + + public static readonly List ValidCurrenciesForTest = [new Currency("USD"), new Currency("EUR")]; + public static readonly List InvalidCurrenciesForTest = [new Currency("TEST")]; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json b/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json new file mode 100644 index 000000000..42435009b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json @@ -0,0 +1,16 @@ +{ + "ExchangeRateSources": [ + { + "BaseCurrency": "CZK", + "Url": "https://thiswebsitedefinetelydoesntexists12345", + "ParserType": "CnbXmlParser" + } + ], + "AllowedCurrencies": [ + "CZK", + "USD", + "EUR", + "GBP", + "AUD" + ] +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 4ef3b5823..27744cceb 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -4,5 +4,14 @@ Exe net8.0 - + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..438116d70 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{22B0A12A-EC5B-4152-9F31-43F5332B984C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ProjectDependencies) = postSolution + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.0 = {7B2695D6-D24C-4460-A58E-A10F08550CE0} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs index 77bf87263..cf5cd99a4 100644 --- a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs +++ b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs @@ -11,11 +11,11 @@ namespace ExchangeRateUpdater.Controllers; [ApiController] [Route("ExchangeRate")] -public class ExchageRateController : ControllerBase +public class ExchangeRateController : ControllerBase { private readonly IExchangeRateProvider _provider; - public ExchageRateController(IExchangeRateProvider provider) + public ExchangeRateController(IExchangeRateProvider provider) { _provider = provider; } @@ -28,12 +28,7 @@ public async Task>> GetExchangeRa { return BadRequest("Currency codes cannot be empty."); } - - if (string.IsNullOrWhiteSpace(baseCurrency)) - { - return BadRequest("Base currency cannot be empty."); - } - + var currencyObjects = currencies.Split(',') .Select(c => new Currency(c.Trim().ToUpper())).ToList(); @@ -41,20 +36,14 @@ public async Task>> GetExchangeRa { var exchangeRates = await _provider.GetExchangeRates(currencyObjects, new Currency(baseCurrency)); - return Ok(exchangeRates.Select(er => new ExchangeRateResponse - { - SourceCurrency = er.SourceCurrency.Code, - TargetCurrency = er.TargetCurrency.Code, - ExchangeRate = er.Value - })); + return Ok(ExchangeRate.GetResponse(exchangeRates)); } catch (ArgumentException ex) { return BadRequest(ex.Message); } - catch (ExternalExchangeRateApiException ex) + catch (ExchangeRateApiException ex) { - // Log the exception if logging is configured return StatusCode(503, $"Service Unavailable: {ex.Message}"); } } diff --git a/jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs b/jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs similarity index 52% rename from jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs rename to jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs index e8a42f46e..0cf5304ad 100644 --- a/jobs/Backend/Task/src/Exceptions/ExternalExchangeRateApiException.cs +++ b/jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs @@ -2,5 +2,5 @@ namespace ExchangeRateUpdater.Exceptions; -public class ExternalExchangeRateApiException(string message, Exception innerException) +public class ExchangeRateApiException(string message, Exception innerException) : Exception(message, innerException); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs index ffc115071..b5ef39f05 100644 --- a/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs @@ -5,5 +5,5 @@ namespace ExchangeRateUpdater.Interfaces; public interface IExchangeRateParser { - IEnumerable Parse(string data, Currency baseCurrency); + List Parse(string data, Currency baseCurrency); } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs new file mode 100644 index 000000000..6925c77b9 --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateSettingsResolver +{ + ExchangeRateSettings ResolveSourceSettings(Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Models/ExchangeRate.cs index 44f33c2cc..2a0b91a4f 100644 --- a/jobs/Backend/Task/src/Models/ExchangeRate.cs +++ b/jobs/Backend/Task/src/Models/ExchangeRate.cs @@ -1,4 +1,6 @@ -namespace ExchangeRateUpdater.Models; +using System.Collections.Generic; +using System.Linq; +namespace ExchangeRateUpdater.Models; public class ExchangeRate { @@ -19,5 +21,15 @@ public override string ToString() { return $"{SourceCurrency}/{TargetCurrency}={Value}"; } + + public static IEnumerable GetResponse(IEnumerable rates) + { + return rates.Select(er => new ExchangeRateResponse + { + SourceCurrency = er.SourceCurrency.Code, + TargetCurrency = er.TargetCurrency.Code, + ExchangeRate = er.Value + }); + } } diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs b/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs new file mode 100644 index 000000000..f859f3ca2 --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateSettings(string url, IExchangeRateParser parser) +{ + public string Url { get; } = url; + public IExchangeRateParser Parser { get; } = parser; +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSource.cs b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs similarity index 83% rename from jobs/Backend/Task/src/Models/ExchangeRateSource.cs rename to jobs/Backend/Task/src/Models/ExchangeRateSources.cs index 2b4e0c493..bf1a39eb2 100644 --- a/jobs/Backend/Task/src/Models/ExchangeRateSource.cs +++ b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs @@ -1,6 +1,6 @@ namespace ExchangeRateUpdater.Models; -public class ExchangeRateSource +public class ExchangeRateSources { public string BaseCurrency { get; set; } public string Url { get; set; } diff --git a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs index f1ff5b393..33bd5486d 100644 --- a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs +++ b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs @@ -11,21 +11,18 @@ namespace ExchangeRateUpdater.Parsers; public class CnbXmlParser : IExchangeRateParser { - public IEnumerable Parse(string data, Currency baseCurrency) + public List Parse(string data, Currency baseCurrency) { try { var dataDoc = XDocument.Parse(data); - var rates = dataDoc.Descendants("radek") - .Select(currency => new ExchangeRate( - new Currency(currency.Attribute("kod")?.Value), + return dataDoc.Descendants("radek").Select(currency => new ExchangeRate( baseCurrency, + new Currency(currency.Attribute("kod")!.Value), // make sure the comma is used as a decimal delimiter decimal.Parse(currency.Attribute("kurz")?.Value!, CultureInfo.GetCultureInfo("cs-CZ")) / - int.Parse(currency.Attribute("mnozstvi")?.Value!) - )); - return rates; - + int.Parse(currency.Attribute("mnozstvi")!.Value) + )).ToList(); } catch (Exception ex) { diff --git a/jobs/Backend/Task/src/Parsers/ParserFactory.cs b/jobs/Backend/Task/src/Parsers/ParserFactory.cs index 538c4fe90..3899f182c 100644 --- a/jobs/Backend/Task/src/Parsers/ParserFactory.cs +++ b/jobs/Backend/Task/src/Parsers/ParserFactory.cs @@ -3,9 +3,9 @@ namespace ExchangeRateUpdater.Parsers; -public class ParserFactory : IParserFactory +public static class ParserFactory { - public IExchangeRateParser CreateParser(string parserType) + public static IExchangeRateParser CreateParser(string parserType) { return parserType switch { diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs index f2220a1ec..fb7bcc59d 100644 --- a/jobs/Backend/Task/src/Program.cs +++ b/jobs/Backend/Task/src/Program.cs @@ -1,9 +1,7 @@ -using System.IO; -using System.Reflection; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; + using ExchangeRateUpdater.Interfaces; using ExchangeRateUpdater.Services; using ExchangeRateUpdater.Parsers; @@ -12,8 +10,7 @@ builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddControllers(); diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs index dcf10fb00..3fa9cf1fb 100644 --- a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs @@ -4,7 +4,6 @@ using ExchangeRateUpdater.Interfaces; using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Exceptions; -using ExchangeRateUpdater.Services; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using System.Net.Http; @@ -16,14 +15,14 @@ public class ExchangeRateProvider : IExchangeRateProvider { private readonly IMemoryCache _cache; private readonly HttpClient _httpClient; - private readonly ExchangeRateSourceResolver _sourceResolver; + private readonly IExchangeRateSettingsResolver _settingsResolver; private readonly List _allowedCurrencies; - public ExchangeRateProvider(IMemoryCache cache, HttpClient httpClient, ExchangeRateSourceResolver sourceResolver, IConfiguration configuration) + public ExchangeRateProvider(IMemoryCache cache, HttpClient httpClient, IExchangeRateSettingsResolver settingsResolver, IConfiguration configuration) { _cache = cache; _httpClient = httpClient; - _sourceResolver = sourceResolver; + _settingsResolver = settingsResolver; _allowedCurrencies = configuration.GetSection("AllowedCurrencies").Get>(); } @@ -35,32 +34,30 @@ public async Task> GetExchangeRates(List cur if (!_cache.TryGetValue(cacheKey, out List rates)) { - ExchangeRateSource source; - IExchangeRateParser parser; + ExchangeRateSettings settings; try { - (source, parser) = _sourceResolver.ResolveSourceAndParser(baseCurrency); + settings = _settingsResolver.ResolveSourceSettings(baseCurrency); } catch (InvalidOperationException ex) { - throw new ExternalExchangeRateApiException(ex.Message, ex); + throw new ExchangeRateApiException(ex.Message, ex); } try { - var response = await _httpClient.GetStringAsync(source.Url); - rates = parser.Parse(response, baseCurrency).ToList(); + var response = await _httpClient.GetStringAsync(settings.Url); + rates = settings.Parser.Parse(response, baseCurrency).ToList(); } catch (HttpRequestException ex) { - throw new ExternalExchangeRateApiException($"Failed to retrieve exchange rates from {source.Url}", ex); + throw new ExchangeRateApiException($"Failed to retrieve exchange rates from {settings.Url}", ex); } - _cache.Set(cacheKey, rates, TimeSpan.FromMinutes(5)); } var requestedCodes = new HashSet(currencies.Select(c => c.Code)); - return rates.Where(r => requestedCodes.Contains(r.SourceCurrency.Code)); + return rates.Where(r => requestedCodes.Contains(r.TargetCurrency.Code)); } private void ValidateCurrencies(List currencies, Currency baseCurrency) @@ -70,6 +67,11 @@ private void ValidateCurrencies(List currencies, Currency baseCurrency throw new ArgumentException($"Base currency '{baseCurrency}' is not allowed."); } + if (!currencies.Any()) + { + throw new ArgumentException("Target currencies cannot be empty."); + } + var unallowedCurrencies = currencies.Where(c => !_allowedCurrencies.Contains(c.Code.ToUpper())).ToList(); if (unallowedCurrencies.Any()) { diff --git a/jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs similarity index 59% rename from jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs rename to jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs index 0162f5670..7b5b87daf 100644 --- a/jobs/Backend/Task/src/Services/ExchangeRateSourceResolver.cs +++ b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs @@ -4,23 +4,22 @@ using Microsoft.Extensions.Configuration; using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Parsers; namespace ExchangeRateUpdater.Services; -public class ExchangeRateSourceResolver +public class ExchangeRateSettingsResolver : IExchangeRateSettingsResolver { private readonly IConfiguration _configuration; - private readonly IParserFactory _parserFactory; - public ExchangeRateSourceResolver(IConfiguration configuration, IParserFactory parserFactory) + public ExchangeRateSettingsResolver(IConfiguration configuration) { _configuration = configuration; - _parserFactory = parserFactory; } - public (ExchangeRateSource Source, IExchangeRateParser Parser) ResolveSourceAndParser(Currency baseCurrency) + public ExchangeRateSettings ResolveSourceSettings(Currency baseCurrency) { - var sources = _configuration.GetSection("ExchangeRateSources").Get>(); + var sources = _configuration.GetSection("ExchangeRateSources").Get>(); var source = sources .FirstOrDefault(s => s.BaseCurrency.Equals(baseCurrency.Code, StringComparison.OrdinalIgnoreCase)); @@ -30,8 +29,8 @@ public ExchangeRateSourceResolver(IConfiguration configuration, IParserFactory p throw new InvalidOperationException($"No exchange rate source found for base currency {baseCurrency.Code}"); } - var parser = _parserFactory.CreateParser(source.ParserType); + var parser = ParserFactory.CreateParser(source.ParserType); - return (source, parser); + return new ExchangeRateSettings(source.Url, parser); } } \ No newline at end of file From 800ee9f4da6795c9e4f6d83a71ccb4d0e511914d Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 11:07:41 +0200 Subject: [PATCH 4/8] Test refactor --- .../ExchangeRateControllerTests.cs | 37 +++++++++++++------ .../ExchangeRateProviderTests.cs | 20 ++-------- .../ExchangeRateUpdater.Tests/TestData.cs | 2 +- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs index 5935f0c8b..c6c4106bb 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs @@ -16,6 +16,7 @@ using ExchangeRateUpdater.Exceptions; using ExchangeRateUpdater.Controllers; using ExchangeRateUpdater.Parsers; +using System.Net; namespace ExchangeRateUpdater.Tests; @@ -65,11 +66,11 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsOkWithRates() { // Arrange var currencies = "EUR,USD"; - var baseCurrency = "CZK"; - var expectedRates = new List + var baseCurrency = TestData.BaseCurrency; + var expectedRates = new List { - new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 24.610m), - new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.331m) + new () { SourceCurrency = baseCurrency, TargetCurrency = "EUR", ExchangeRate = 24.610m }, + new () { SourceCurrency = baseCurrency, TargetCurrency = "USD", ExchangeRate = 21.331m} }; // Act @@ -81,7 +82,10 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsOkWithRates() Assert.That(okResult, Is.Not.Null); var returnedRates = okResult.Value as IEnumerable; Assert.That(returnedRates, Is.Not.Null); - Assert.That(returnedRates.Count(), Is.EqualTo(expectedRates.Count)); + Assert.That(returnedRates.ToList(), Is.EquivalentTo(expectedRates) + .Using((ExchangeRateResponse x, ExchangeRateResponse y) => x.SourceCurrency == y.SourceCurrency && + x.TargetCurrency == y.TargetCurrency && + x.ExchangeRate == y.ExchangeRate)); } [Test] @@ -89,7 +93,7 @@ public async Task GetExchangeRates_EmptyCurrencies_ReturnsBadRequest() { // Arrange var currencies = ""; - var baseCurrency = "CZK"; + var baseCurrency = TestData.BaseCurrency; // Act var result = await _controller.GetExchangeRates(currencies, baseCurrency); @@ -106,7 +110,7 @@ public async Task GetExchangeRates_ArgumentExceptionFromProvider_ReturnsBadReque { // Arrange var currency = "TEST"; - var baseCurrency = "CZK"; + var baseCurrency = TestData.BaseCurrency; var errorMessage = $"The following currencies are not allowed: {currency}"; @@ -120,16 +124,25 @@ public async Task GetExchangeRates_ArgumentExceptionFromProvider_ReturnsBadReque Assert.That(badRequestResult.Value, Is.EqualTo(errorMessage)); } - [Test] - public async Task GetExchangeRates_ExchangeRateApiExceptionFromProvider_ReturnsServiceUnavailable() + [TestCase(HttpStatusCode.ServiceUnavailable)] + [TestCase(HttpStatusCode.InternalServerError)] + [TestCase(HttpStatusCode.BadRequest)] + + public async Task GetExchangeRates_ExchangeRateApiExceptionFromProvider_ReturnsServiceUnavailable(HttpStatusCode statusCode) { // Arrange var currencies = "EUR"; - var baseCurrency = "CZK"; - + var baseCurrency = TestData.BaseCurrency; - var httpClient = new HttpClient(); + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + }); + var httpClient = new HttpClient(mockHttpMessageHandler.Object); var provider = new ExchangeRateProvider( _mockCache.Object, httpClient, _settingsResolver, _configuration); var controller = new ExchangeRateController(provider); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs index a9ddb743e..a968800da 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -71,7 +71,10 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsRates() var result = (await _provider.GetExchangeRates(currencies, baseCurrency)).ToList(); // Assert - Assert.That(result, Is.EquivalentTo(expectedRates).Using(new ExchangeRateComparer())); + Assert.That(result, Is.EquivalentTo(expectedRates) + .Using((ExchangeRate x, ExchangeRate y) => x.SourceCurrency.Code == y.SourceCurrency.Code && + x.TargetCurrency.Code == y.TargetCurrency.Code && + x.Value == y.Value)); } @@ -117,21 +120,6 @@ public void GetExchangeRates_EmptyTargetCurrencies_ThrowsArgumentException() // Act & Assert Assert.ThrowsAsync(() => _provider.GetExchangeRates([], baseCurrency)); } - - private class ExchangeRateComparer : IEqualityComparer - { - public bool Equals(ExchangeRate x, ExchangeRate y) - { - return x.SourceCurrency.Code == y.SourceCurrency.Code && - x.TargetCurrency.Code == y.TargetCurrency.Code && - x.Value == y.Value; - } - - public int GetHashCode(ExchangeRate obj) - { - return HashCode.Combine(obj.SourceCurrency.Code, obj.TargetCurrency.Code, obj.Value); - } - } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs index 1fe91408e..40120235b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs @@ -51,7 +51,7 @@ public static class TestData """; - + public static string BaseCurrency = "CZK"; public static readonly List ValidCurrenciesForTest = [new Currency("USD"), new Currency("EUR")]; public static readonly List InvalidCurrenciesForTest = [new Currency("TEST")]; } \ No newline at end of file From 67187465a1be5910b38d52b249f6e12e7b5bf5e6 Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 11:25:08 +0200 Subject: [PATCH 5/8] Added swagger --- jobs/Backend/Task/src/Program.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs index fb7bcc59d..729803f33 100644 --- a/jobs/Backend/Task/src/Program.cs +++ b/jobs/Backend/Task/src/Program.cs @@ -4,7 +4,7 @@ using ExchangeRateUpdater.Interfaces; using ExchangeRateUpdater.Services; -using ExchangeRateUpdater.Parsers; +using Microsoft.OpenApi; var builder = WebApplication.CreateBuilder(args); @@ -15,9 +15,15 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); var app = builder.Build(); +app.UseSwagger(c => +{ + c.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0; +}); +app.UseSwaggerUI(); app.MapControllers(); app.Run(); \ No newline at end of file From 6d697e030cd0d2eaf148443fc72b959217c859cc Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 11:48:01 +0200 Subject: [PATCH 6/8] cleanup and update readme --- .../CnbXmlParserTests.cs | 13 +++----- .../ExchangeRateControllerTests.cs | 32 ++++++++----------- .../ExchangeRateProviderTests.cs | 31 ++++++++---------- jobs/Backend/Task/README.md | 17 +++++++++- .../src/Controllers/ExchageRateController.cs | 6 ++-- .../Task/src/Interfaces/IParserFactory.cs | 6 ---- .../Task/src/Models/ExchangeRateResponse.cs | 6 ++-- .../Task/src/Models/ExchangeRateSources.cs | 6 ++-- jobs/Backend/Task/src/Parsers/CnbXmlParser.cs | 2 +- .../Backend/Task/src/Parsers/ParserFactory.cs | 2 +- jobs/Backend/Task/src/Program.cs | 8 ++--- .../Task/src/Services/ExchangeRateProvider.cs | 10 +++--- .../Services/ExchangeRateSettingsResolver.cs | 4 +-- 13 files changed, 70 insertions(+), 73 deletions(-) delete mode 100644 jobs/Backend/Task/src/Interfaces/IParserFactory.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs index b1490ab75..d1057e124 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs @@ -1,11 +1,8 @@ - -using System; -using System.Collections.Generic; -using NUnit.Framework; -using ExchangeRateUpdater.Parsers; -using ExchangeRateUpdater.Models; -using ExchangeRateUpdater.Exceptions; using System.Linq; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using NUnit.Framework; namespace ExchangeRateUpdater.Tests; @@ -31,7 +28,7 @@ public void Parse_ValidXml_ReturnsCorrectExchangeRates() Assert.That(audRate.Value, Is.EqualTo(13.862m)); } [Test] - public void Parse_IvalidXml_ReturnsCorrectExchangeRates() + public void Parse_InvalidXml_ReturnsCorrectExchangeRates() { // Arrange var parser = new CnbXmlParser(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs index c6c4106bb..e9bfa51e1 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs @@ -1,23 +1,17 @@ -using NUnit.Framework; -using Moq; -using ExchangeRateUpdater.Services; -using ExchangeRateUpdater.Interfaces; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Controllers; using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Threading; -using System.Linq; +using Moq; using Moq.Protected; -using System; -using System.Net.Http; -using ExchangeRateUpdater.Exceptions; -using ExchangeRateUpdater.Controllers; -using ExchangeRateUpdater.Parsers; -using System.Net; - +using NUnit.Framework; namespace ExchangeRateUpdater.Tests; @@ -51,7 +45,7 @@ public void Setup() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage { - StatusCode = System.Net.HttpStatusCode.OK, + StatusCode = HttpStatusCode.OK, Content = new StringContent(TestData.CnbExchangeRateXml), }); @@ -82,8 +76,8 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsOkWithRates() Assert.That(okResult, Is.Not.Null); var returnedRates = okResult.Value as IEnumerable; Assert.That(returnedRates, Is.Not.Null); - Assert.That(returnedRates.ToList(), Is.EquivalentTo(expectedRates) - .Using((ExchangeRateResponse x, ExchangeRateResponse y) => x.SourceCurrency == y.SourceCurrency && + Assert.That(returnedRates, Is.EquivalentTo(expectedRates) + .Using((x, y) => x.SourceCurrency == y.SourceCurrency && x.TargetCurrency == y.TargetCurrency && x.ExchangeRate == y.ExchangeRate)); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs index a968800da..032e2551f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -1,19 +1,17 @@ -using NUnit.Framework; -using Moq; -using ExchangeRateUpdater.Services; -using ExchangeRateUpdater.Interfaces; -using ExchangeRateUpdater.Models; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; +using System; using System.Collections.Generic; -using System.Threading.Tasks; -using System.Threading; using System.Linq; -using Moq.Protected; -using System; using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using ExchangeRateUpdater.Exceptions; -using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using Moq.Protected; +using NUnit.Framework; namespace ExchangeRateUpdater.Tests; @@ -60,7 +58,7 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsRates() { // Arrange var currencies = TestData.ValidCurrenciesForTest; - var baseCurrency = new Currency("CZK"); + var baseCurrency = new Currency(TestData.BaseCurrency); var expectedRates = new List { new ExchangeRate(baseCurrency, new Currency("EUR"), 24.610m), @@ -72,7 +70,7 @@ public async Task GetExchangeRates_ValidCurrencies_ReturnsRates() // Assert Assert.That(result, Is.EquivalentTo(expectedRates) - .Using((ExchangeRate x, ExchangeRate y) => x.SourceCurrency.Code == y.SourceCurrency.Code && + .Using((x, y) => x.SourceCurrency.Code == y.SourceCurrency.Code && x.TargetCurrency.Code == y.TargetCurrency.Code && x.Value == y.Value)); } @@ -105,7 +103,7 @@ public void GetExchangeRates_InvalidTargetCurrency_ThrowsArgumentException() { // Arrange var currencies = TestData.InvalidCurrenciesForTest; - var baseCurrency = new Currency("CZK"); + var baseCurrency = new Currency(TestData.BaseCurrency); // Act & Assert Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); @@ -114,8 +112,7 @@ public void GetExchangeRates_InvalidTargetCurrency_ThrowsArgumentException() public void GetExchangeRates_EmptyTargetCurrencies_ThrowsArgumentException() { // Arrange - var currencies = TestData.InvalidCurrenciesForTest; - var baseCurrency = new Currency("CZK"); + var baseCurrency = new Currency(TestData.BaseCurrency); // Act & Assert Assert.ThrowsAsync(() => _provider.GetExchangeRates([], baseCurrency)); diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index c5906778c..cb25ad18d 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -3,4 +3,19 @@ This API is created with the goal of being extensible. It allows to get the curr This could be extended adding new providers with new parsers. The appsettings.json should include the allowed sources set by base currency and allowed currencies. -It will have only one GET endpoint "ExchangeRate" that will take a mandatory parameter "currencies" and an optional parameter "baseCurrency" which is currently defaulting to "CZK" as it's the only one implemented at the moment \ No newline at end of file +It will have only one GET endpoint "ExchangeRate" that will take a mandatory parameter "currencies" and an optional parameter "baseCurrency" which is currently defaulting to "CZK" as it's the only one implemented at the moment. + +So to get the exchange rates for EUR and USD you can use the following: + +/ExchangeRate?currencies=EUR,USD + +or + +/ExchangeRate?currencies=EUR,USD&baseCurrency=CZK + +since CZK is the default + +You can test the solution using swagger (/swagger) + +## Testing +The solution includes tests for the main logic. It also tests the controller, that although not ideal, it tests the expected return of the API while no integration tests are present \ No newline at end of file diff --git a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs index cf5cd99a4..1701a86d4 100644 --- a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs +++ b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs @@ -1,11 +1,11 @@ +using System; using System.Collections.Generic; using System.Linq; -using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Exceptions; using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using Microsoft.AspNetCore.Mvc; namespace ExchangeRateUpdater.Controllers; diff --git a/jobs/Backend/Task/src/Interfaces/IParserFactory.cs b/jobs/Backend/Task/src/Interfaces/IParserFactory.cs deleted file mode 100644 index 0b23e4204..000000000 --- a/jobs/Backend/Task/src/Interfaces/IParserFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ExchangeRateUpdater.Interfaces; - -public interface IParserFactory -{ - IExchangeRateParser CreateParser(string parserType); -} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs index 3f16b5721..d4b0b23eb 100644 --- a/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs +++ b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs @@ -2,7 +2,7 @@ namespace ExchangeRateUpdater.Models; public class ExchangeRateResponse { - public string SourceCurrency { get; set; } - public string TargetCurrency { get; set; } - public decimal ExchangeRate { get; set; } + public string SourceCurrency { get; init; } + public string TargetCurrency { get; init; } + public decimal ExchangeRate { get; init; } } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSources.cs b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs index bf1a39eb2..4345f6f86 100644 --- a/jobs/Backend/Task/src/Models/ExchangeRateSources.cs +++ b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs @@ -2,7 +2,7 @@ namespace ExchangeRateUpdater.Models; public class ExchangeRateSources { - public string BaseCurrency { get; set; } - public string Url { get; set; } - public string ParserType { get; set; } + public string BaseCurrency { get; init; } + public string Url { get; init; } + public string ParserType { get; init; } } \ No newline at end of file diff --git a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs index 33bd5486d..6969bb017 100644 --- a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs +++ b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs @@ -4,8 +4,8 @@ using System.Linq; using System.Xml.Linq; using ExchangeRateUpdater.Exceptions; -using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; namespace ExchangeRateUpdater.Parsers; diff --git a/jobs/Backend/Task/src/Parsers/ParserFactory.cs b/jobs/Backend/Task/src/Parsers/ParserFactory.cs index 3899f182c..1940f0749 100644 --- a/jobs/Backend/Task/src/Parsers/ParserFactory.cs +++ b/jobs/Backend/Task/src/Parsers/ParserFactory.cs @@ -1,5 +1,5 @@ -using ExchangeRateUpdater.Interfaces; using System; +using ExchangeRateUpdater.Interfaces; namespace ExchangeRateUpdater.Parsers; diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs index 729803f33..b7e6644fa 100644 --- a/jobs/Backend/Task/src/Program.cs +++ b/jobs/Backend/Task/src/Program.cs @@ -1,9 +1,8 @@ -using Microsoft.AspNetCore.Builder; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Services; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; - -using ExchangeRateUpdater.Interfaces; -using ExchangeRateUpdater.Services; using Microsoft.OpenApi; var builder = WebApplication.CreateBuilder(args); @@ -25,5 +24,6 @@ c.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0; }); app.UseSwaggerUI(); +app.MapGet("/", () => "Exchange Rate Updater is running!"); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs index 3fa9cf1fb..5cc48e4ff 100644 --- a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs @@ -1,12 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; -using System; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Exceptions; using ExchangeRateUpdater.Interfaces; using ExchangeRateUpdater.Models; -using ExchangeRateUpdater.Exceptions; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; -using System.Net.Http; using Microsoft.Extensions.Configuration; namespace ExchangeRateUpdater.Services; diff --git a/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs index 7b5b87daf..3215eaf13 100644 --- a/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs +++ b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.Extensions.Configuration; -using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; using ExchangeRateUpdater.Parsers; +using Microsoft.Extensions.Configuration; namespace ExchangeRateUpdater.Services; From b0b733daf5beb01cd799035bb6ea9fd9dac20428 Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 11:52:02 +0200 Subject: [PATCH 7/8] changed project type --- .../ExchangeRateUpdater.Tests.csproj | 2 +- jobs/Backend/Task/ExchangeRateUpdater.csproj | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 5c4af88e5..178526b1d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -19,7 +19,7 @@ - + Always diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 27744cceb..d8a899a68 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,6 +9,9 @@ + + Always + From 3be827111c66e7c5dcbd58a517e76f0076b6e8b2 Mon Sep 17 00:00:00 2001 From: pedroVL Date: Wed, 30 Jul 2025 11:54:42 +0200 Subject: [PATCH 8/8] added source to readme --- jobs/Backend/Task/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index cb25ad18d..74cca4863 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -17,5 +17,7 @@ since CZK is the default You can test the solution using swagger (/swagger) +The data used by the API comes from https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml + ## Testing -The solution includes tests for the main logic. It also tests the controller, that although not ideal, it tests the expected return of the API while no integration tests are present \ No newline at end of file +The solution includes tests for the main logic. It also tests the controller, that although not ideal, it tests the expected return of the API while no integration tests are present. \ No newline at end of file