diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore new file mode 100644 index 000000000..3729ff0cd --- /dev/null +++ b/jobs/Backend/Task/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/jobs/Backend/Task/DEVJOURNAL.md b/jobs/Backend/Task/DEVJOURNAL.md new file mode 100644 index 000000000..3fbf29b39 --- /dev/null +++ b/jobs/Backend/Task/DEVJOURNAL.md @@ -0,0 +1,63 @@ +# Mews backend developer task + +The task of implementing an ExchangeRateProvider for Czech National Bank seems relatively simple on the surface but has some gotchas and is open-ended enough which gives me some good oportunities to think of and apply various dotnet best practices and design patterns. At the same time this also bears the risk of over-engineering. + + +### Functional Requirements +The main functional requirement is well defined with a clear example in Program.cs + +- The 'solution' should return current exchange rates for given currency codes that CNB provides data and ONLY those. Exclude unsupported or missing currencies. +- The source of the exchange rate must be CNB +- The TargetCurrency we will have exchange rates for is CZK +- Our Result model should have SourceCurrency, TargetCurrency, Value as minimum +- The cnb.cz source has the exchange rates from SourceCurrency (USD,POUND etc) to target currency (CZK) +and we do not need to calculate and return the opposite (CZK to USD) +- The cnb.cz source SourceCurrency sometimes has amount 1 (USD,POUND etc) and sometimes has amount 100 (TRY,THB). For our return model we will simplify and base every exchange rate to 1 SourceCurrency. + +- Result Model +``` +- `SourceCurrency` +- `TargetCurrency` (CZK) +- `Value` (rate value normalized per 1 unit) +- `ValidFor` (the CNB bulletin’s date) +``` + + +### Non-Functional Requirements + +For non-functional requirements I will have to take some liberties and make some assumptions on the basis of the solution being complete enough for a "production environment". + +- Avoid fetching from CNB on every request. I will need to come up with some caching strategy + +- Resilience. Retry transient errors (HTTP 5xx, 429) with backoff (e.g., via Polly). Optional: fall back to TXT/XML source if the API is unavailable. + +- Maintainability & Extensibility. I will try to make good use of patterns like strategy+factory combo which seems like a good match for the task and setup the project for future extensibility. I need to split application logic from any 'client'(Api, Console app) and setup the project structure accordingly. I will try to aim for 100% Unit Testing. I need to have good logging coverage accross the application. + +- Api. I will try to expose the ExchangeRateProvider via an API and deploy it all the way to "production". + +- For a real-world deployment, the solution should be containerized and deployable via CI/CD pipeline (GitHub Actions or Azure DevOps), though this may be beyond the scope of the exercise. + +## Gotchas & Assumptions + +- I need to identify the best source possible from Czech National Bank. The task emphasizes on finding data source and extracting the data. After investigating it seems that CNB offers multiple ways to access the data + +- Those are + - API (https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates/dailyUsingGET_1) + - Text file (https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt) + - XML (https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml) + + +By far the simplest and more standard/best practice way seems to be using their API. The text file is also relatevly very simple and can be parsed easily with a package like csvhelper which I have used extensively. A possible implementation could have both ways to create some resiliency and fall back in case of API (and all retries) failing. Thats is something to consider but for now I will use the banks API. + +- The solution should be able to balance handling load but also making sure to not serve stale data. + +This could be potentially critical. On one hand I wouldnt like the idea of requesting the API or downloading file for new data for every request. On the other hand I wouldnt like to serve stale data. I will need some caching strategy. At the bank website it states + +"Exchange rates of commonly traded currencies are declared every working day after 2.30 p.m. and are valid for the current working day and, where relevant, the following Saturday, Sunday or public holiday (for example, an exchange rate declared on Tuesday 23 December is valid for Tuesday 23 December, the public holidays 24–26 December, and Saturday 27 December and Sunday 28 December)." + +But... what does "after 2:30 pm" means,when excactly, after 2:30 can be 3,4,6pm? + +I will make the assumption that the data are available sometime between 2:31pm-3:31pm Prague Time and refresh cache every 5 minutes starting at 2:31pm until 3:31 pm. After that I will assume the data are stable and refresh cache every hour. I will reduce to 12 hours on weekends and ignore holidays for siplicity. In case of failing to get fresh data I will allow some cached data to be served. + + +- Unsupported currencies: If a requested currency is not present in the CNB publication or is not a currency code it is silently ignored (not returned as an error). diff --git a/jobs/Backend/Task/ExchangeRateApi.Tests/Controllers/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateApi.Tests/Controllers/ExchangeRateControllerTests.cs new file mode 100644 index 000000000..181df9d51 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi.Tests/Controllers/ExchangeRateControllerTests.cs @@ -0,0 +1,238 @@ +using ExchangeRateApi.Controllers; +using ExchangeRateApi.Models; +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using NSubstitute; +using NUnit.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Mvc; +using ExchangeRateApi.Tests.TestHelpers; + +namespace ExchangeRateApi.Tests.Controllers; + +[TestFixture] +public class ExchangeRateControllerTests +{ + private IExchangeRateService _exchangeRateService = null!; + private ILogger _logger = null!; + private ExchangeRateController _controller = null!; + + private static readonly CancellationToken TestToken = CancellationToken.None; + + [SetUp] + public void SetUp() + { + _exchangeRateService = Substitute.For(); + _logger = Substitute.For>(); + _controller = new ExchangeRateController(_exchangeRateService, _logger); + } + + [Test] + public async Task GetExchangeRates_ValidRequest_ReturnsOk() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List{"USD","EUR"}, TargetCurrency = "CZK" }; + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 22.5m), + new(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + _exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any()).Returns(rates); + + // Act + var result = await _controller.GetExchangeRates(request, TestToken); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Result, Is.TypeOf()); + var response = (ExchangeRateResponse)((OkObjectResult)result.Result!).Value!; + Assert.That(response.Rates, Has.Count.EqualTo(2)); + Assert.That(response.TargetCurrency, Is.EqualTo("CZK")); + _logger.VerifyLogContaining(1, LogLevel.Information, "Received request for exchange rates"); + _logger.VerifyLogContaining(1, LogLevel.Information, "Successfully retrieved"); + }); + } + + [Test] + public async Task GetExchangeRates_LowercaseCode_ReturnsBadRequest() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List{"usd"}, TargetCurrency = "CZK" }; // invalid casing + + // Act + var result = await _controller.GetExchangeRates(request, TestToken); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Result, Is.TypeOf()); + var bad = (BadRequestObjectResult)result.Result!; + Assert.That(bad.Value, Is.InstanceOf()); + Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("uppercase")); + _logger.VerifyLogContaining(1, LogLevel.Warning, "Validation failed for request"); + }); + } + + [Test] + public void GetExchangeRates_NoCodes_ThrowsArgumentException() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List(), TargetCurrency = "CZK" }; + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => await _controller.GetExchangeRates(request, TestToken)); + Assert.Multiple(() => + { + Assert.That(ex!.Message, Does.Contain("At least one currency code")); + _logger.VerifyLogContaining(1, LogLevel.Warning, "empty currency codes"); + }); + } + + [Test] + public async Task GetExchangeRates_DefaultTargetCurrency_WhenNull() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List{"USD"}, TargetCurrency = null }; + var rates = new List{ new(new Currency("USD"), new Currency("CZK"), 22.5m)}; + _exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any()).Returns(rates); + + // Act + var result = await _controller.GetExchangeRates(request, TestToken); + + // Assert + Assert.Multiple(() => + { + var response = (ExchangeRateResponse)((OkObjectResult)result.Result!).Value!; + Assert.That(response.TargetCurrency, Is.EqualTo("CZK")); + _logger.VerifyLogContaining(1, LogLevel.Information, "Received request for exchange rates"); + _logger.VerifyLogContaining(1, LogLevel.Information, "Successfully retrieved"); + }); + } + + [Test] + public void GetExchangeRates_InvalidOperation_Propagates() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List{"USD"}, TargetCurrency = "XXX" }; + _exchangeRateService.When(s => s.GetExchangeRatesAsync("XXX", Arg.Any>(), Arg.Any())) + .Do(_ => throw new InvalidOperationException("no provider")); + + // Act & Assert + Assert.ThrowsAsync(async () => await _controller.GetExchangeRates(request, TestToken)); + } + + [Test] + public void GetExchangeRates_UnexpectedException_BubblesUp() + { + // Arrange + var request = new ExchangeRateRequest { CurrencyCodes = new List{"USD"}, TargetCurrency = "CZK" }; + _exchangeRateService.When(s => s.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any())) + .Do(_ => throw new Exception("boom")); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => await _controller.GetExchangeRates(request, TestToken)); + Assert.That(ex!.Message, Is.EqualTo("boom")); + } + + // GET endpoint tests + [Test] + public async Task GetExchangeRatesQuery_Valid_ReturnsOk() + { + // Arrange + var rates = new List{ new(new Currency("USD"), new Currency("CZK"), 22.5m)}; + _exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any()).Returns(rates); + + // Act + var result = await _controller.GetExchangeRatesQuery("USD", "CZK", TestToken); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Result, Is.TypeOf()); + _logger.VerifyLogContaining(1, LogLevel.Information, "Received request for exchange rates"); + _logger.VerifyLogContaining(1, LogLevel.Information, "Successfully retrieved"); + }); + } + + [Test] + public async Task GetExchangeRatesQuery_InvalidFormat_ReturnsBadRequest() + { + // Arrange / Act + var result = await _controller.GetExchangeRatesQuery("USDX,EUR", "CZK", TestToken); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Result, Is.TypeOf()); + var bad = (BadRequestObjectResult)result.Result!; + Assert.That(bad.Value, Is.InstanceOf()); + Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("format")); + }); + } + + [Test] + public async Task GetExchangeRatesQuery_TooLong_ReturnsBadRequest() + { + // Arrange + var longString = new string('A', 1001); + // Act + var result = await _controller.GetExchangeRatesQuery(longString, "CZK", TestToken); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Result, Is.TypeOf()); + var bad = (BadRequestObjectResult)result.Result!; + Assert.That(((ErrorResponse)bad.Value!).Error, Does.Contain("too long")); + }); + } + + [Test] + public async Task GetExchangeRatesQuery_ManyCodes_ReturnsOk() + { + // Arrange - validate controller accepts more than 10 (limit removed in refactor) + var codes = string.Join(',', new [] { "USD","EUR","JPY","GBP","CHF","AUD","CAD","NZD","CNY","INR","PLN" }); + var rates = new List{ new(new Currency("USD"), new Currency("CZK"), 22.5m)}; + _exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any()).Returns(rates); + + // Act + var result = await _controller.GetExchangeRatesQuery(codes, "CZK", TestToken); + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task GetExchangeRatesQuery_DefaultTarget_WhenNull() + { + // Arrange + var rates = new List{ new(new Currency("USD"), new Currency("CZK"), 22.5m)}; + _exchangeRateService.GetExchangeRatesAsync("CZK", Arg.Any>(), Arg.Any()).Returns(rates); + + // Act + var result = await _controller.GetExchangeRatesQuery("USD", null, TestToken); + + // Assert + Assert.Multiple(() => + { + var ok = (OkObjectResult)result.Result!; + var response = (ExchangeRateResponse)ok.Value!; + Assert.That(response.TargetCurrency, Is.EqualTo("CZK")); + _logger.VerifyLogContaining(1, LogLevel.Information, "Received request for exchange rates"); + _logger.VerifyLogContaining(1, LogLevel.Information, "Successfully retrieved"); + }); + } + + [Test] + public void GetAvailableProviders_ReturnsOkWithProviderList() + { + // Arrange / Act + var result = _controller.GetAvailableProviders(); + + // Assert + Assert.Multiple(() => + { + var ok = result.Result as OkObjectResult; + Assert.That(ok, Is.Not.Null); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi.Tests/ExchangeRateApi.Tests.csproj b/jobs/Backend/Task/ExchangeRateApi.Tests/ExchangeRateApi.Tests.csproj new file mode 100644 index 000000000..097022093 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi.Tests/ExchangeRateApi.Tests.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateApi.Tests/GlobalExceptionFilterTests.cs b/jobs/Backend/Task/ExchangeRateApi.Tests/GlobalExceptionFilterTests.cs new file mode 100644 index 000000000..7f892539f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi.Tests/GlobalExceptionFilterTests.cs @@ -0,0 +1,87 @@ +using ExchangeRateApi.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; + +namespace ExchangeRateApi.Tests; + +[TestFixture] +public class GlobalExceptionFilterTests +{ + private GlobalExceptionFilter _filter = null!; + private ILogger _logger = null!; + + [SetUp] + public void SetUp() + { + _logger = Substitute.For>(); + _filter = new GlobalExceptionFilter(_logger); + } + + [Test] + public void OnException_ArgumentException_ReturnsBadRequestErrorResponse() + { + var ctx = CreateExceptionContext(new ArgumentException("bad args")); + _filter.OnException(ctx); + + Assert.Multiple(() => + { + Assert.That(ctx.ExceptionHandled, Is.True); + var result = ctx.Result as ObjectResult; + Assert.That(result, Is.Not.Null); + Assert.That(result!.StatusCode, Is.EqualTo(400)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That(((ErrorResponse)result.Value!).Error, Does.Contain("bad args")); + }); + + // verify an error log happened (any error-level log) + _logger.ReceivedWithAnyArgs().Log(default, default, default, default, default!); + } + + [Test] + public void OnException_InvalidOperationException_ReturnsBadRequest() + { + var ctx = CreateExceptionContext(new InvalidOperationException("not allowed")); + _filter.OnException(ctx); + + var result = ctx.Result as ObjectResult; + Assert.Multiple(() => + { + Assert.That(result, Is.Not.Null); + Assert.That(result!.StatusCode, Is.EqualTo(400)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That(((ErrorResponse)result.Value!).Error, Does.Contain("not allowed")); + }); + } + + [Test] + public void OnException_GenericException_ReturnsInternalServerErrorAnonymousPayload() + { + var ctx = CreateExceptionContext(new Exception("boom")); + _filter.OnException(ctx); + + var result = ctx.Result as ObjectResult; + Assert.Multiple(() => + { + Assert.That(result, Is.Not.Null); + Assert.That(result!.StatusCode, Is.EqualTo(500)); + Assert.That(result.Value, Is.Not.InstanceOf()); + var errorProp = result.Value!.GetType().GetProperty("Error"); + Assert.That(errorProp, Is.Not.Null); + Assert.That(errorProp!.GetValue(result.Value) as string, Is.EqualTo("An error occurred while processing your request")); + }); + } + + private static ExceptionContext CreateExceptionContext(Exception ex) + { + var httpContext = new DefaultHttpContext(); + httpContext.TraceIdentifier = Guid.NewGuid().ToString(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = ex }; + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi.Tests/TestHelpers/LogVerificationExtensions.cs b/jobs/Backend/Task/ExchangeRateApi.Tests/TestHelpers/LogVerificationExtensions.cs new file mode 100644 index 000000000..9eb349fa7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi.Tests/TestHelpers/LogVerificationExtensions.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateApi.Tests.TestHelpers; + +public static class LogVerificationExtensions +{ + /// + /// Verifies that a logger received a log message with the specified level and exact message content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The expected log level + /// The exact message content expected + public static void VerifyLog(this ILogger logger, int times, LogLevel level, string message) + { + logger.Received(times).Log( + level, + Arg.Any(), + Arg.Is(v => v.ToString() == message), + Arg.Any(), + Arg.Any>()); + } + + /// + /// Verifies that a logger received a log message with the specified level and message containing the specified text. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The expected log level + /// Text that should be contained in the log message + /// The expected exception (optional) + public static void VerifyLogContaining(this ILogger logger, int times, LogLevel level, string messageContains, Exception? exception = null) + { + if (exception != null) + { + logger.Received(times).Log( + level, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains(messageContains)), + exception, + Arg.Any>()); + } + else + { + logger.Received(times).Log( + level, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains(messageContains)), + Arg.Any(), + Arg.Any>()); + } + } + + /// + /// Verifies that a logger received a Debug level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogDebug(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Debug, message); + } + + /// + /// Verifies that a logger received an Information level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogInformation(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Information, message); + } + + /// + /// Verifies that a logger received a Warning level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogWarning(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Warning, message); + } + + /// + /// Verifies that a logger received an Error level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogError(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Error, message); + } + + /// + /// Verifies that a logger received an Error level log message containing the specified text. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// Text that should be contained in the error log message + /// The expected exception (optional) + public static void VerifyLogErrorContaining(this ILogger logger, int times, string messageContains, Exception? exception = null) + { + logger.VerifyLogContaining(times, LogLevel.Error, messageContains, exception); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/ApiEndpoints.cs b/jobs/Backend/Task/ExchangeRateApi/ApiEndpoints.cs new file mode 100644 index 000000000..f7c383600 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/ApiEndpoints.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateApi; + +public static class ApiEndpoints +{ + public const string ApiVersion = "v1"; + private const string ApiBase = $"{ApiVersion}/api"; + + public static class ExchangeRates + { + public const string Base = $"{ApiBase}/exchange-rates"; + public const string GetAllByRequestBody = Base; + public const string GetAllByQueryParams = Base; + } + + public static class Providers + { + public const string Base = $"{ApiBase}/providers"; + public const string GetAll = Base; + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/Controllers/ExchangeRateController.cs b/jobs/Backend/Task/ExchangeRateApi/Controllers/ExchangeRateController.cs new file mode 100644 index 000000000..4b3c7376f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Controllers/ExchangeRateController.cs @@ -0,0 +1,204 @@ +using ExchangeRateApi.Models; +using ExchangeRateApi.Models.Validators; +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace ExchangeRateApi.Controllers; + +[ApiController] +[SwaggerTag("Exchange Rate operations for retrieving currency exchange rates")] +public class ExchangeRateController : ControllerBase +{ + private readonly IExchangeRateService _exchangeRateService; + private readonly ILogger _logger; + private static readonly CurrencyQueryParamValidator _currencyQueryParamValidator = new(); + private static readonly ExchangeRateRequestValidator _exchangeRateRequestValidator = new(); + private const string DefaultTargetCurrency = "CZK"; + + public ExchangeRateController( + IExchangeRateService exchangeRateService, + ILogger logger) + { + _exchangeRateService = exchangeRateService; + _logger = logger; + } + + /// + /// Get exchange rates for specified currencies using JSON request + /// + /// The exchange rate request containing currency codes and optional target currency + /// Request cancellation token + /// Exchange rates for the requested currencies + /// Returns the exchange rates for the requested currencies + /// If the request is invalid or no currency codes are provided + /// If there was an internal server error + [HttpPost(ApiEndpoints.ExchangeRates.GetAllByRequestBody)] + [SwaggerOperation( + Summary = "Get exchange rates using POST request", + Description = "Retrieves exchange rates for specified currencies using a JSON request body. Supports multiple currencies and custom target currency.", + OperationId = "GetExchangeRatesPost")] + [SwaggerResponse(200, "Exchange rates retrieved successfully", typeof(ExchangeRateResponse))] + [SwaggerResponse(400, "Invalid request - missing or invalid currency codes", typeof(ErrorResponse))] + [SwaggerResponse(500, "Internal server error", typeof(ErrorResponse))] + public async Task> GetExchangeRates( + [FromBody] ExchangeRateRequest request, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("Received request for exchange rates with {Count} currencies", request.CurrencyCodes.Count); + + if (request.CurrencyCodes == null || !request.CurrencyCodes.Any()) + { + _logger.LogWarning("Exchange rate request received with empty currency codes"); + throw new ArgumentException("At least one currency code must be provided"); + } + + var validationResult = await _exchangeRateRequestValidator.ValidateAsync(request, cancellationToken); + + if (ToBadRequestIfInvalid(validationResult) is { } badRequest) return badRequest; + + var requestedCurrencies = GetRequestedCurrencies(request); + + var targetCurrency = request.TargetCurrency?.ToUpperInvariant() ?? DefaultTargetCurrency; + + var currencyRates = await GetExchangeRatesForCurrenciesAsync(targetCurrency, requestedCurrencies, cancellationToken); + + _logger.LogInformation("Successfully retrieved {Count} exchange rates for target currency {TargetCurrency}", + currencyRates.Count(), targetCurrency); + + var response = new ExchangeRateResponse + { + TargetCurrency = targetCurrency, + Rates = currencyRates.Select(rate => new ExchangeRateDto + { + SourceCurrency = rate.SourceCurrency.Code, + TargetCurrency = rate.TargetCurrency.Code, + Rate = rate.Value, + ValidFor = rate.ValidFor + }).ToList() + }; + + return Ok(response); + } + + /// + /// Get exchange rates for specified currencies using query parameters + /// + /// Comma-separated list of currency codes (e.g., "USD,EUR,JPY") + /// The target currency (defaults to "CZK") + /// Request cancellation token + /// Exchange rates for the requested currencies + /// Returns the exchange rates for the requested currencies + /// If no currencies are provided or the request is invalid + /// If there was an internal server error + [HttpGet(ApiEndpoints.ExchangeRates.GetAllByQueryParams)] + [SwaggerOperation( + Summary = "Get exchange rates using GET request", + Description = "Retrieves exchange rates for specified currencies using query parameters. Convenient for simple requests.", + OperationId = "GetExchangeRatesGet")] + [SwaggerResponse(200, "Exchange rates retrieved successfully", typeof(ExchangeRateResponse))] + [SwaggerResponse(400, "Invalid request - missing currency codes or format violation", typeof(ErrorResponse))] + [SwaggerResponse(500, "Internal server error", typeof(ErrorResponse))] + public async Task> GetExchangeRatesQuery( + [FromQuery, SwaggerParameter("Comma-separated currency codes (e.g., USD,EUR,JPY)", Required = true)] string currencies, + [FromQuery, SwaggerParameter("Target currency code (defaults to 'CZK')")] string? targetCurrency = null, + CancellationToken cancellationToken = default) + { + var validationResult = _currencyQueryParamValidator.Validate(currencies); + + var validationErrors = HandleValidationResult(validationResult); + + if (ToBadRequestIfInvalid(validationResult) is { } badRequest) return badRequest; + + var request = new ExchangeRateRequest + { + CurrencyCodes = GetCurrenctyCodesFromQueryParams(currencies), + TargetCurrency = targetCurrency?.ToUpperInvariant() + }; + + return await GetExchangeRates(request, cancellationToken); + } + + /// + /// Get information about available exchange rate providers + /// + /// Just some hardcoded list of available currency providers + /// Returns the list of available providers + [HttpGet(ApiEndpoints.Providers.GetAll)] + [SwaggerOperation( + Summary = "Get available exchange rate providers", + Description = "Returns information about all available exchange rate providers and their supported currencies.", + OperationId = "GetAvailableProviders")] + [SwaggerResponse(200, "Available providers retrieved successfully")] + public ActionResult GetAvailableProviders() + { + var providers = new[] + { + new { + CurrencyCode = "CZK", + Name = "Czech National Bank", + Description = "Provides exchange rates with CZK as target currency", + Endpoint = "https://api.cnb.cz/cnbapi/exrates/daily" + }, + new { + CurrencyCode = "USD", + Name = "FED", + Description = "Provides exchange rates with USD as target currency", + Endpoint = "Mock data for testing purposes" + } + }; + + return Ok(new { Providers = providers }); + } + + private async Task> GetExchangeRatesForCurrenciesAsync(string targetCurrency, IEnumerable currencies, CancellationToken cancellationToken) + { + if (!currencies.Any()) + { + _logger.LogWarning("No valid currency codes provided after filtering"); + throw new ArgumentException("No valid currency codes provided"); + } + + var exchangeRates = await _exchangeRateService.GetExchangeRatesAsync(targetCurrency, currencies, cancellationToken); + + return exchangeRates; + } + + private List GetCurrenctyCodesFromQueryParams(string currencies) + { + return currencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(c => c.Trim().ToUpperInvariant()) + .ToList(); + } + + private ErrorResponse? HandleValidationResult(FluentValidation.Results.ValidationResult validationResult) + { + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(e => e.ErrorMessage).ToArray(); + _logger.LogWarning("Validation failed for request: {Errors}", string.Join(", ", errors)); + return new ErrorResponse + { + Error = string.Join("; ", errors) + }; + } + + return null; + } + + private List GetRequestedCurrencies(ExchangeRateRequest request) + { + return request.CurrencyCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => new Currency(code.ToUpperInvariant())) + .ToList(); + } + + private ActionResult? ToBadRequestIfInvalid(FluentValidation.Results.ValidationResult result) + { + var error = HandleValidationResult(result); + return error == null ? null : BadRequest(error); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Dockerfile b/jobs/Backend/Task/ExchangeRateApi/Dockerfile new file mode 100644 index 000000000..a38237d7b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ["ExchangeRateApi/ExchangeRateApi.csproj", "ExchangeRateApi/"] +COPY ["ExchangeRateProviders/ExchangeRateProviders.csproj", "ExchangeRateProviders/"] +RUN dotnet restore "ExchangeRateApi/ExchangeRateApi.csproj" +COPY . . +WORKDIR "/src/ExchangeRateApi" +RUN dotnet publish "ExchangeRateApi.csproj" -c Release -o /app + +# runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=build /app . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "ExchangeRateApi.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApi.csproj b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApi.csproj new file mode 100644 index 000000000..e77992454 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApi.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateApi/GlobalExceptionFilter.cs b/jobs/Backend/Task/ExchangeRateApi/GlobalExceptionFilter.cs new file mode 100644 index 000000000..86937a037 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/GlobalExceptionFilter.cs @@ -0,0 +1,46 @@ +using ExchangeRateApi.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ExchangeRateApi; + +/// +/// Simple global exception filter for consistent API error responses +/// +public class GlobalExceptionFilter : IExceptionFilter +{ + private readonly ILogger _logger; + + public GlobalExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + var exception = context.Exception; + var traceId = context.HttpContext.TraceIdentifier; + + _logger.LogError(exception, "Unhandled exception occurred. TraceId: {TraceId}", traceId); + + var (statusCode, message) = exception switch + { + ArgumentException => (400, exception.Message), + InvalidOperationException => (400, exception.Message), + _ => (500, "An error occurred while processing your request") + }; + + object payload = statusCode switch + { + 400 => new ErrorResponse { Error = message }, + _ => new { Error = message } + }; + + context.Result = new ObjectResult(payload) + { + StatusCode = statusCode + }; + + context.ExceptionHandled = true; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/ErrorResponse.cs b/jobs/Backend/Task/ExchangeRateApi/Models/ErrorResponse.cs new file mode 100644 index 000000000..35ee78138 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/ErrorResponse.cs @@ -0,0 +1,22 @@ +using Swashbuckle.AspNetCore.Annotations; + +namespace ExchangeRateApi.Models +{ + /// + /// Standard error response returned by the API for 4xx and 5xx outcomes. + /// + /// + /// The field contains a human‑readable description of the problem. + /// Additional diagnostic data (trace id, etc.) can be appended in future without breaking compatibility. + /// + [SwaggerSchema(Description = "Standard error response returned by the API for unsuccessful requests.")] + public class ErrorResponse + { + /// + /// Human readable error message describing why the request failed. + /// + /// Currency codes must be in XXX,YYY,ZZZ format with 3-letter codes + [SwaggerSchema(Description = "Human readable error message describing why the request failed.")] + public required string Error { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateDto.cs new file mode 100644 index 000000000..b02192d32 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateDto.cs @@ -0,0 +1,45 @@ +using Swashbuckle.AspNetCore.Annotations; + +namespace ExchangeRateApi.Models; + +/// +/// Exchange rate data transfer object +/// +[SwaggerSchema("Individual exchange rate information")] +public class ExchangeRateDto +{ + /// + /// Source currency code (e.g., "USD") + /// + /// USD + [SwaggerSchema("Source currency ISO 4217 code")] + public string SourceCurrency { get; set; } = string.Empty; + + /// + /// Target currency code (e.g., "CZK") + /// + /// CZK + [SwaggerSchema("Target currency ISO 4217 code")] + public string TargetCurrency { get; set; } = string.Empty; + + /// + /// Exchange rate value + /// + /// 22.5000 + [SwaggerSchema("Exchange rate value (how many target currency units for 1 source currency unit)")] + public decimal Rate { get; set; } + + /// + /// Date the rate is valid for (UTC) if supplied by upstream provider + /// + /// 2025-01-02T00:00:00Z + [SwaggerSchema("Date (UTC) the upstream provider states the rate is valid for; null if not provided")] + public DateTime? ValidFor { get; set; } + + /// + /// String representation of the exchange rate + /// + /// USD/CZK=22.5000 + [SwaggerSchema("Human-readable representation of the exchange rate")] + public string DisplayValue => $"{SourceCurrency}/{TargetCurrency}={Rate:F4}"; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateRequest.cs b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateRequest.cs new file mode 100644 index 000000000..425865dc7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateRequest.cs @@ -0,0 +1,26 @@ +using Swashbuckle.AspNetCore.Annotations; +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateApi.Models; + +/// +/// Request model for getting exchange rates +/// +[SwaggerSchema("Request for exchange rates containing currency codes and optional target currency")] +public class ExchangeRateRequest +{ + /// + /// List of currency codes to get exchange rates for (e.g., ["USD", "EUR", "JPY"]) + /// + /// ["USD", "EUR", "JPY", "GBP"] + [Required] + [SwaggerSchema("List of ISO 4217 currency codes")] + public List CurrencyCodes { get; set; } = new(); + + /// + /// The target currency to get rates for (defaults to "CZK") + /// + /// CZK + [SwaggerSchema("ISO 4217 target currency code (defaults to CZK if not specified)")] + public string? TargetCurrency { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateResponse.cs new file mode 100644 index 000000000..4ab0caf4d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/ExchangeRateResponse.cs @@ -0,0 +1,23 @@ +using Swashbuckle.AspNetCore.Annotations; + +namespace ExchangeRateApi.Models; + +/// +/// Response model for exchange rates +/// +[SwaggerSchema("Response containing exchange rates and metadata")] +public class ExchangeRateResponse +{ + /// + /// The target currency used for the exchange rates + /// + /// CZK + [SwaggerSchema("Target currency code used for all exchange rates")] + public string TargetCurrency { get; set; } = string.Empty; + + /// + /// List of exchange rates + /// + [SwaggerSchema("Array of exchange rate objects")] + public List Rates { get; set; } = new(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/Validators/CurrencyQueryParamValidator.cs b/jobs/Backend/Task/ExchangeRateApi/Models/Validators/CurrencyQueryParamValidator.cs new file mode 100644 index 000000000..5fe41c1fa --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/Validators/CurrencyQueryParamValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using System.Text.RegularExpressions; + +namespace ExchangeRateApi.Models.Validators +{ + public class CurrencyQueryParamValidator : AbstractValidator + { + private static readonly Regex QueryCodesRegex = new("^[A-Za-z]{3}(?:,[A-Za-z]{3})*$", RegexOptions.Compiled); + + public CurrencyQueryParamValidator() + { + RuleFor(s => s) + .NotEmpty().WithMessage("Currency codes parameter is required") + .Must(s => !string.IsNullOrWhiteSpace(s)).WithMessage("Currency codes parameter is required") + .MaximumLength(1000).WithMessage("Currency query parameter is too long") + .Must(s => QueryCodesRegex.IsMatch(s)) + .WithMessage("Currency codes must be in XXX,YYY,ZZZ format with 3-letter codes"); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/Models/Validators/ExchangeRateRequestValidator.cs b/jobs/Backend/Task/ExchangeRateApi/Models/Validators/ExchangeRateRequestValidator.cs new file mode 100644 index 000000000..e7fc172d5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Models/Validators/ExchangeRateRequestValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace ExchangeRateApi.Models.Validators; + +public class ExchangeRateRequestValidator : AbstractValidator +{ + private const int MinCodes = 1; + private const int MaxCodes = 180; // POST limit + + public ExchangeRateRequestValidator() + { + RuleFor(r => r.CurrencyCodes) + .NotNull().WithMessage("Currency codes are required") + .Must(list => list.Count >= MinCodes && list.Count <= MaxCodes) + .WithMessage($"Currency codes collection must contain between {MinCodes} and {MaxCodes} items"); + + RuleForEach(r => r.CurrencyCodes) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithMessage("Currency code cannot be empty") + .Must(c => c.Trim().Length == 3) + .WithMessage("Currency code must be exactly 3 characters") + .Must(c => c.ToUpperInvariant() == c) + .WithMessage("Currency code must be uppercase") + .Matches("^[A-Z]{3}$").WithMessage("Currency code must match pattern 'AAA'"); + + RuleFor(r => r.TargetCurrency) + .Must(c => c == null || c.Length == 3) + .WithMessage("Target currency must be exactly 3 characters"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Program.cs b/jobs/Backend/Task/ExchangeRateApi/Program.cs new file mode 100644 index 000000000..50ac39584 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Program.cs @@ -0,0 +1,77 @@ +using ExchangeRateApi; +using ExchangeRateApi.Models; +using ExchangeRateApi.Models.Validators; +using ExchangeRateProviders; +using FluentValidation; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers(options => +{ + options.Filters.Add(); +}); +builder.Services.AddSingleton, ExchangeRateRequestValidator>(); + +// Add Swagger/OpenAPI services +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Exchange Rate API", + Version = ApiEndpoints.ApiVersion, + Description = "API for retrieving exchange rates from various providers", + Contact = new OpenApiContact + { + Name = "Exchange Rate API", + Email = "support@exchangerate.api" + } + }); + + // Include XML comments for better documentation + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + { + c.IncludeXmlComments(xmlPath); + } + + // Add example schemas + c.EnableAnnotations(); +}); + +// Add logging +builder.Logging.AddConsole(); + +// Register Exchange Rate Provider services via extension +builder.Services.AddExchangeRateProviders(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseSwagger(); +app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", $"Exchange Rate API {ApiEndpoints.ApiVersion}"); + c.RoutePrefix = "swagger"; + c.DocumentTitle = "Exchange Rate API Documentation"; + c.DefaultModelsExpandDepth(2); + c.DefaultModelRendering(Swashbuckle.AspNetCore.SwaggerUI.ModelRendering.Example); + c.DisplayRequestDuration(); + c.EnableDeepLinking(); + c.EnableFilter(); + c.ShowExtensions(); + }); + +app.UseAuthorization(); + +app.MapControllers(); + +// Log startup information +var logger = app.Services.GetRequiredService>(); +logger.LogInformation("Exchange Rate API started successfully"); +logger.LogInformation("Swagger UI available at: /swagger"); + +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateApi/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateApi/Properties/launchSettings.json new file mode 100644 index 000000000..5c35b2065 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchUrl": "swagger", + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchUrl": "swagger", + "launchBrowser": true, + "applicationUrl": "https://localhost:7000;http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateApi/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/appsettings.json b/jobs/Backend/Task/ExchangeRateApi/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/ExchangeRateExample.sln b/jobs/Backend/Task/ExchangeRateExample.sln new file mode 100644 index 000000000..7fc4352ce --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateExample.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36221.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{E39A19C8-6621-203D-F9FE-4698E92A1314}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProviders", "ExchangeRateProviders\ExchangeRateProviders.csproj", "{D3A4B529-AF47-FCFC-A6A6-5F88A9C291DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProviders.Tests", "ExchangeRateProviders.Tests\ExchangeRateProviders.Tests.csproj", "{3C00A82C-55F2-4AA7-8E39-CCADFE0F3F9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateApi", "ExchangeRateApi\ExchangeRateApi.csproj", "{E104B573-1755-4000-B7F6-D69F6A14CBAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateApi.Tests", "ExchangeRateApi.Tests\ExchangeRateApi.Tests.csproj", "{3AD7D48F-B742-4C2B-99DF-E0736FF8DCCD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E39A19C8-6621-203D-F9FE-4698E92A1314}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E39A19C8-6621-203D-F9FE-4698E92A1314}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E39A19C8-6621-203D-F9FE-4698E92A1314}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E39A19C8-6621-203D-F9FE-4698E92A1314}.Release|Any CPU.Build.0 = Release|Any CPU + {D3A4B529-AF47-FCFC-A6A6-5F88A9C291DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3A4B529-AF47-FCFC-A6A6-5F88A9C291DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3A4B529-AF47-FCFC-A6A6-5F88A9C291DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3A4B529-AF47-FCFC-A6A6-5F88A9C291DC}.Release|Any CPU.Build.0 = Release|Any CPU + {3C00A82C-55F2-4AA7-8E39-CCADFE0F3F9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C00A82C-55F2-4AA7-8E39-CCADFE0F3F9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C00A82C-55F2-4AA7-8E39-CCADFE0F3F9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C00A82C-55F2-4AA7-8E39-CCADFE0F3F9C}.Release|Any CPU.Build.0 = Release|Any CPU + {E104B573-1755-4000-B7F6-D69F6A14CBAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E104B573-1755-4000-B7F6-D69F6A14CBAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E104B573-1755-4000-B7F6-D69F6A14CBAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E104B573-1755-4000-B7F6-D69F6A14CBAD}.Release|Any CPU.Build.0 = Release|Any CPU + {3AD7D48F-B742-4C2B-99DF-E0736FF8DCCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3AD7D48F-B742-4C2B-99DF-E0736FF8DCCD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AD7D48F-B742-4C2B-99DF-E0736FF8DCCD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3AD7D48F-B742-4C2B-99DF-E0736FF8DCCD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6390C8CB-CF21-43C0-8DEF-DA1F92436071} + EndGlobalSection +EndGlobal 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/ExchangeRateProviders.Tests/Core/CurrencyValidatorTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/CurrencyValidatorTests.cs new file mode 100644 index 000000000..dfa4db9ca --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/CurrencyValidatorTests.cs @@ -0,0 +1,145 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Exception; +using ExchangeRateProviders.Core.Model; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests.Core; + +[TestFixture] +public class CurrencyValidatorTests +{ + [Test] + public void ValidateCurrencyCodes_ValidCurrencies_DoesNotThrow() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("GBP"), + new Currency("JPY") + }; + + // Act & Assert + Assert.DoesNotThrow(() => CurrencyValidator.ValidateCurrencyCodes(currencies)); + } + + [Test] + public void ValidateCurrencyCodes_NullCollection_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => CurrencyValidator.ValidateCurrencyCodes(null!)); + } + + [Test] + public void ValidateCurrencyCodes_EmptyCollection_DoesNotThrow() + { + // Arrange + var currencies = Array.Empty(); + + // Act & Assert + Assert.DoesNotThrow(() => CurrencyValidator.ValidateCurrencyCodes(currencies)); + } + + [Test] + public void ValidateCurrencyCodes_SingleInvalidCurrency_ThrowsInvalidCurrencyException() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency("XYZ") // Invalid currency + }; + + // Act & Assert + var exception = Assert.Throws(() => + CurrencyValidator.ValidateCurrencyCodes(currencies)); + + Assert.Multiple(() => + { + Assert.That(exception!.InvalidCurrencyCodes, Has.Count.EqualTo(1)); + Assert.That(exception.InvalidCurrencyCodes, Does.Contain("XYZ")); + Assert.That(exception.Message, Does.Contain("Invalid currency codes: XYZ")); + }); + } + + [Test] + public void ValidateCurrencyCodes_MultipleInvalidCurrencies_ThrowsInvalidCurrencyException() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), // Valid + new Currency("XYZ"), // Invalid + new Currency("ABC"), // Invalid + new Currency("EUR") // Valid + }; + + // Act & Assert + var exception = Assert.Throws(() => + CurrencyValidator.ValidateCurrencyCodes(currencies)); + + Assert.Multiple(() => + { + Assert.That(exception!.InvalidCurrencyCodes, Has.Count.EqualTo(2)); + Assert.That(exception.InvalidCurrencyCodes, Does.Contain("XYZ")); + Assert.That(exception.InvalidCurrencyCodes, Does.Contain("ABC")); + Assert.That(exception.Message, Does.Contain("Invalid currency codes: XYZ, ABC")); + }); + } + + [Test] + public void ValidateCurrencyCodes_NullCurrencyCode_ThrowsInvalidCurrencyException() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency(null!) // This will create a currency with null code + }; + + // Act & Assert + var exception = Assert.Throws(() => + CurrencyValidator.ValidateCurrencyCodes(currencies)); + + Assert.Multiple(() => + { + Assert.That(exception!.InvalidCurrencyCodes, Has.Count.EqualTo(1)); + Assert.That(exception.InvalidCurrencyCodes, Does.Contain("null")); + }); + } + + [Test] + public void ValidateCurrencyCodes_EmptyStringCurrencyCode_ThrowsInvalidCurrencyException() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency("") // Empty string + }; + + // Act & Assert + var exception = Assert.Throws(() => + CurrencyValidator.ValidateCurrencyCodes(currencies)); + + Assert.That(exception!.InvalidCurrencyCodes, Has.Count.EqualTo(1)); + } + + [Test] + public void ValidateCurrencyCodes_WhitespaceOnlyCurrencyCode_ThrowsInvalidCurrencyException() + { + // Arrange + var currencies = new[] + { + new Currency("USD"), + new Currency(" ") // Whitespace only + }; + + // Act & Assert + var exception = Assert.Throws(() => + CurrencyValidator.ValidateCurrencyCodes(currencies)); + + Assert.That(exception!.InvalidCurrencyCodes, Has.Count.EqualTo(1)); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/ExchangeRateDataProviderFactoryTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/ExchangeRateDataProviderFactoryTests.cs new file mode 100644 index 000000000..a91f387f6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/ExchangeRateDataProviderFactoryTests.cs @@ -0,0 +1,63 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Czk.Config; +using ExchangeRateProviders.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests.Core; + +[TestFixture] +public class ExchangeRateDataProviderFactoryTests +{ + [Test] + public void GetProvider_UnknownCurrency_Throws() + { + // Arrange + var logger = Substitute.For>(); + var factory = new ExchangeRateDataProviderFactory(Array.Empty(), logger); + + // Act & Assert + Assert.Multiple(() => + { + Assert.Throws(() => factory.GetProvider("USD")); + logger.VerifyLogError(1, "No exchange rate provider registered for currency USD"); + }); + } + + [Test] + public void GetProvider_NullCurrency_Throws() + { + // Arrange + var logger = Substitute.For>(); + var factory = new ExchangeRateDataProviderFactory(Array.Empty(), logger); + + // Act & Assert + Assert.Multiple(() => + { + Assert.Throws(() => factory.GetProvider(null!)); + logger.VerifyLogError(1, "Attempted to get provider with null currency code."); + }); + } + + [Test] + public void GetProvider_KnownCurrency_ReturnsProvider() + { + // Arrange + var dataProvider = Substitute.For(); + dataProvider.ExchangeRateProviderTargetCurrencyCode.Returns(Constants.ExchangeRateProviderCurrencyCode); + + var factoryLogger = Substitute.For>(); + var factory = new ExchangeRateDataProviderFactory(new[] { dataProvider }, factoryLogger); + + // Act + var resolved = factory.GetProvider(Constants.ExchangeRateProviderCurrencyCode); + + // Assert + Assert.Multiple(() => + { + Assert.That(resolved, Is.SameAs(dataProvider)); + factoryLogger.VerifyLogDebug(1, $"Resolved exchange rate provider for currency {Constants.ExchangeRateProviderCurrencyCode}"); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/InvalidCurrencyExceptionTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/InvalidCurrencyExceptionTests.cs new file mode 100644 index 000000000..a19eaff5d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Core/InvalidCurrencyExceptionTests.cs @@ -0,0 +1,63 @@ +using ExchangeRateProviders.Core.Exception; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests.Core; + +[TestFixture] +public class InvalidCurrencyExceptionTests +{ + [Test] + public void Ctor_WithInvalidCodes_SetsMessageAndCodes() + { + // Arrange + var invalid = new [] { "XYZ", "ABC" }; + + // Act + var ex = new InvalidCurrencyException(invalid); + + // Assert + Assert.Multiple(() => + { + Assert.That(ex.InvalidCurrencyCodes, Is.EquivalentTo(invalid)); + Assert.That(ex.Message, Is.EqualTo("Invalid currency codes: XYZ, ABC")); + Assert.That(ex.InnerException, Is.Null); + }); + } + + [Test] + public void Ctor_WithInnerException_PopulatesInner() + { + // Arrange + var inner = new ArgumentException("inner boom"); + var invalid = new [] { "ZZZ" }; + + // Act + var ex = new InvalidCurrencyException(invalid, inner); + + // Assert + Assert.Multiple(() => + { + Assert.That(ex.InvalidCurrencyCodes, Is.EquivalentTo(invalid)); + Assert.That(ex.Message, Is.EqualTo("Invalid currency codes: ZZZ")); + Assert.That(ex.InnerException, Is.SameAs(inner)); + }); + } + + [Test] + public void InvalidCurrencyCodes_IsReadOnlySnapshot() + { + // Arrange + var list = new List { "BAD" }; + var ex = new InvalidCurrencyException(list); + + // Mutate original list after construction + list.Add("NEW"); + + // Assert original snapshot unaffected + Assert.Multiple(() => + { + Assert.That(ex.InvalidCurrencyCodes, Has.Count.EqualTo(1)); + Assert.That(ex.InvalidCurrencyCodes, Does.Not.Contain("NEW")); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Clients/CzkCnbApiClientTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Clients/CzkCnbApiClientTests.cs new file mode 100644 index 000000000..5ca53aa71 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Clients/CzkCnbApiClientTests.cs @@ -0,0 +1,210 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using ExchangeRateProviders.Czk.Clients; +using ExchangeRateProviders.Czk.Model; +using ExchangeRateProviders.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Polly; + +namespace ExchangeRateProviders.Tests.Czk.Clients; + +[TestFixture] +public class CzkCnbApiClientTests +{ + [Test] + public async Task GetDailyRatesRawAsync_ValidResponse_ReturnsRates() + { + // Arrange + var logger = Substitute.For>(); + var responseData = new CnbApiCzkExchangeRateResponse + { + Rates = new List + { + new() { CurrencyCode = "USD", Amount = 1, Rate = 22.50m, ValidFor = DateTime.UtcNow }, + new() { CurrencyCode = "EUR", Amount = 1, Rate = 24.00m, ValidFor = DateTime.UtcNow } + } + }; + var json = JsonSerializer.Serialize(responseData, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var handler = new TestHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler); + var client = new CzkCnbApiClient(httpClient, logger, ZeroBackoffRetry()); + + // Act + var result = (await client.GetDailyRatesRawAsync()).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].CurrencyCode, Is.EqualTo("USD")); + Assert.That(result[0].Rate, Is.EqualTo(22.50m)); + Assert.That(result[1].CurrencyCode, Is.EqualTo("EUR")); + Assert.That(result[1].Rate, Is.EqualTo(24.00m)); + Assert.That(handler.CallCount, Is.EqualTo(1)); + logger.VerifyLogInformation(1, "Requesting CNB rates from https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"); + logger.VerifyLogInformation(1, "CNB returned 2 raw rates."); + }); + } + + [Test] + public async Task GetDailyRatesRawAsync_EmptyRates_ReturnsEmptyList() + { + // Arrange + var logger = Substitute.For>(); + var responseData = new CnbApiCzkExchangeRateResponse { Rates = new List() }; + var json = JsonSerializer.Serialize(responseData); + var handler = new TestHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler); + var client = new CzkCnbApiClient(httpClient, logger, ZeroBackoffRetry()); + + // Act + var result = (await client.GetDailyRatesRawAsync()).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.Empty); + Assert.That(handler.CallCount, Is.EqualTo(1)); + logger.VerifyLogInformation(1, "Requesting CNB rates from https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"); + logger.VerifyLogWarning(1, "CNB rates response empty."); + }); + } + + [Test] + public async Task GetDailyRatesRawAsync_ServerError_RetriesAndSucceeds() + { + // Arrange + var logger = Substitute.For>(); + var responseData = new CnbApiCzkExchangeRateResponse + { + Rates = new List + { + new() { CurrencyCode = "JPY", Amount = 100, Rate = 17.00m, ValidFor = DateTime.UtcNow } + } + }; + var json = JsonSerializer.Serialize(responseData); + var handler = new TestHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.InternalServerError), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler); + var client = new CzkCnbApiClient(httpClient, logger, ZeroBackoffRetry()); + + // Act + var result = (await client.GetDailyRatesRawAsync()).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].CurrencyCode, Is.EqualTo("JPY")); + Assert.That(handler.CallCount, Is.EqualTo(2)); // Initial call + 1 retry + logger.VerifyLogInformation(1, "Requesting CNB rates from https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"); + logger.VerifyLogInformation(1, "CNB returned 1 raw rates."); + }); + } + + [Test] + public void GetDailyRatesRawAsync_TooManyRequests_RetriesAndThrows() + { + // Arrange + var logger = Substitute.For>(); + var handler = new TestHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.TooManyRequests), + new HttpResponseMessage(HttpStatusCode.TooManyRequests), + new HttpResponseMessage(HttpStatusCode.TooManyRequests), + new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + var httpClient = new HttpClient(handler); + var client = new CzkCnbApiClient(httpClient, logger, ZeroBackoffRetry()); + + // Act & Assert + Assert.Multiple(() => + { + Assert.ThrowsAsync(() => client.GetDailyRatesRawAsync()); + Assert.That(handler.CallCount, Is.EqualTo(4)); // Initial + 3 retries + logger.VerifyLogInformation(1, "Requesting CNB rates from https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"); + logger.VerifyLogInformationNotContaining("CNB returned"); + logger.VerifyLogWarningNotContaining("CNB rates response empty"); + }); + } + + [Test] + public void GetDailyRatesRawAsync_RespectsCancellationToken() + { + // Arrange + var logger = Substitute.For>(); + var handler = new DelayHttpMessageHandler(TimeSpan.FromSeconds(5)); + var httpClient = new HttpClient(handler); + var client = new CzkCnbApiClient(httpClient, logger, ZeroBackoffRetry()); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + // Act & Assert + Assert.Multiple(() => + { + Assert.That(async () => await client.GetDailyRatesRawAsync(cts.Token), + Throws.InstanceOf()); + Assert.That(handler.CallCount, Is.EqualTo(1)); + + // Verify exact log message that was logged (before cancellation) + logger.VerifyLogInformation(1, "Requesting CNB rates from https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"); + logger.VerifyLogInformationNotContaining("CNB returned"); + logger.VerifyLogWarningNotContaining("CNB rates response empty"); + }); + } + + private static IAsyncPolicy ZeroBackoffRetry(int retries = 3) => + Policy + .Handle() + .OrResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429) + .WaitAndRetryAsync(retries, _ => TimeSpan.Zero); + + private class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses; + public int CallCount { get; private set; } + + public TestHttpMessageHandler(params HttpResponseMessage[] responses) + { + _responses = new Queue(responses); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + var response = _responses.Count > 0 ? _responses.Dequeue() : new HttpResponseMessage(HttpStatusCode.NotFound); + return Task.FromResult(response); + } + } + + private class DelayHttpMessageHandler : HttpMessageHandler + { + private readonly TimeSpan _delay; + public int CallCount { get; private set; } + + public DelayHttpMessageHandler(TimeSpan delay) + { + _delay = delay; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + await Task.Delay(_delay, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"rates":[]}""", Encoding.UTF8, "application/json") + }; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Mappers/ContractMappingTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Mappers/ContractMappingTests.cs new file mode 100644 index 000000000..8c3e38178 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Mappers/ContractMappingTests.cs @@ -0,0 +1,104 @@ +using ExchangeRateProviders.Czk.Config; +using ExchangeRateProviders.Czk.Mappers; +using ExchangeRateProviders.Czk.Model; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests.Czk.Mappers; + +[TestFixture] +public class ContractMappingTests +{ + [Test] + public void MapToExchangeRate_AmountOne_ReturnsPerUnit() + { + // Arrange + var when = new DateTime(2025,1,2,0,0,0,DateTimeKind.Utc); + var dto = new CnbApiExchangeRateDto + { + CurrencyCode = "USD", + Amount = 1, + Rate = 22.50m, + ValidFor = when + }; + + // Act + var result = dto.MapToExchangeRate(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.SourceCurrency.Code, Is.EqualTo("USD")); + Assert.That(result.TargetCurrency.Code, Is.EqualTo(Constants.ExchangeRateProviderCurrencyCode)); + Assert.That(result.Value, Is.EqualTo(22.50m)); + Assert.That(result.ValidFor, Is.EqualTo(when)); + }); + } + + [Test] + public void MapToExchangeRate_MultiUnitAmount_Normalizes() + { + // Arrange + var when = DateTime.UtcNow.Date; + var dto = new CnbApiExchangeRateDto + { + CurrencyCode = "JPY", + Amount = 100, + Rate = 17.00m, + ValidFor = when + }; + + // Act + var result = dto.MapToExchangeRate(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Value, Is.EqualTo(0.17m)); + Assert.That(result.ValidFor, Is.EqualTo(when)); + }); + } + + [Test] + public void MapToExchangeRate_LowerCaseCurrency_UpperCases() + { + var when = DateTime.UtcNow; + var dto = new CnbApiExchangeRateDto + { + CurrencyCode = "eur", + Amount = 1, + Rate = 24.00m, + ValidFor = when + }; + + var result = dto.MapToExchangeRate(); + + Assert.Multiple(() => + { + Assert.That(result.SourceCurrency.Code, Is.EqualTo("EUR")); + Assert.That(result.ValidFor, Is.EqualTo(when)); + }); + } + + [Test] + public void MapToExchangeRates_ValidList_MapsAll() + { + // Arrange + var now = DateTime.UtcNow.Date; + var source = new List + { + new() { CurrencyCode = "USD", Amount = 1, Rate = 22.50m, ValidFor = now }, + new() { CurrencyCode = "EUR", Amount = 2, Rate = 48.00m, ValidFor = now }, // per-unit 24.00 + new() { CurrencyCode = "JPY", Amount = 100, Rate = 17.00m, ValidFor = now } // per-unit 0.17 + }; + + // Act + var result = source.MapToExchangeRates().ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(3)); + Assert.That(result.All(r => r.ValidFor == now)); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CnbCacheStrategyTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CnbCacheStrategyTests.cs new file mode 100644 index 000000000..6bd5daa50 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CnbCacheStrategyTests.cs @@ -0,0 +1,48 @@ +using ExchangeRateProviders.Czk.Config; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests.Czk.Services; + +[TestFixture] +public class CnbCacheStrategyTests +{ + [Test] + public void GetCacheOptionsBasedOnPragueTime_ReturnsValidOptions() + { + // Act + var options = CnbCacheStrategy.GetCacheOptionsBasedOnPragueTime(); + + // Assert + Assert.Multiple(() => + { + Assert.That(options.Duration, Is.GreaterThan(TimeSpan.Zero)); + Assert.That(options.FailSafeMaxDuration, Is.GreaterThan(TimeSpan.Zero)); + Assert.That(options.FailSafeMaxDuration.Ticks, Is.EqualTo(options.Duration.Ticks * 2)); + }); + } + + [Test] + public void CacheDuration_WeekendVsWeekday_DifferentDurations() + { + // Since we can't easily mock time, we'll test the logic indirectly + // by checking that the cache options have reasonable durations + + // Act + var options = CnbCacheStrategy.GetCacheOptionsBasedOnPragueTime(); + + // Assert - verify the duration is one of the expected values (5 min, 1 hour, or 12 hours) + var durationMinutes = options.Duration.TotalMinutes; + Assert.That(durationMinutes, Is.EqualTo(5).Or.EqualTo(60).Or.EqualTo(720), + "Cache duration should be 5 minutes (publication window), 1 hour (weekday stable), or 24 hours (weekend)"); + } + + [Test] + public void FailSafeMaxDuration_AlwaysDoubleOfDuration() + { + // Act + var options = CnbCacheStrategy.GetCacheOptionsBasedOnPragueTime(); + + // Assert + Assert.That(options.FailSafeMaxDuration.Ticks, Is.EqualTo(options.Duration.Ticks * 2)); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CzkExchangeRateDataProviderSeviceTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CzkExchangeRateDataProviderSeviceTests.cs new file mode 100644 index 000000000..72d1c68a6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/Czk/Services/CzkExchangeRateDataProviderSeviceTests.cs @@ -0,0 +1,81 @@ +using ExchangeRateProviders.Czk.Clients; +using ExchangeRateProviders.Czk.Model; +using ExchangeRateProviders.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using ZiggyCreatures.Caching.Fusion; +using ExchangeRateProviders.Czk.Config; +using ExchangeRateProviders.Czk; + +namespace ExchangeRateProviders.Tests.Czk.Services; + +[TestFixture] +public class CzkExchangeRateDataProviderSeviceTests +{ + [Test] + public async Task GetDailyRatesAsync_FirstCall_FetchesAndMaps() + { + // Arrange + var cache = new FusionCache(new FusionCacheOptions()); + var apiClient = Substitute.For(); + var logger = Substitute.For>(); + var service = new CzkExchangeRateDataProvider(cache, apiClient, logger); + + var raw = new List + { + new() { CurrencyCode = "USD", Amount = 1, Rate = 22.50m, ValidFor = DateTime.UtcNow }, + new() { CurrencyCode = "EUR", Amount = 1, Rate = 24.00m, ValidFor = DateTime.UtcNow } + }; + + apiClient.GetDailyRatesRawAsync(Arg.Any()) + .Returns(Task.FromResult>(raw)); + + // Act + var result = (await service.GetDailyRatesAsync()).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].SourceCurrency.Code, Is.EqualTo("USD")); + Assert.That(result[1].SourceCurrency.Code, Is.EqualTo("EUR")); + apiClient.Received(1).GetDailyRatesRawAsync(Arg.Any()); + logger.VerifyLogInformation(1, "Cache miss for CNB daily rates. Fetching and mapping."); + logger.VerifyLogInformation(1, $"Mapped 2 CNB exchange rates (target currency {Constants.ExchangeRateProviderCurrencyCode})."); + }); + } + + [Test] + public async Task GetDailyRatesAsync_SubsequentCall_UsesCache() + { + // Arrange + var cache = new FusionCache(new FusionCacheOptions()); + var apiClient = Substitute.For(); + var logger = Substitute.For>(); + var service = new CzkExchangeRateDataProvider(cache, apiClient, logger); + + var raw = new List + { + new() { CurrencyCode = "JPY", Amount = 100, Rate = 17.00m, ValidFor = DateTime.UtcNow } + }; + + apiClient.GetDailyRatesRawAsync(Arg.Any()) + .Returns(Task.FromResult>(raw)); + + // Act + var first = (await service.GetDailyRatesAsync()).ToList(); + var second = (await service.GetDailyRatesAsync()).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(first, Is.EqualTo(second)); // same cached content + Assert.That(first.Single().SourceCurrency.Code, Is.EqualTo("JPY")); + Assert.That(first.Single().Value, Is.EqualTo(0.17m)); + apiClient.Received(1).GetDailyRatesRawAsync(Arg.Any()); // only once due to caching + logger.VerifyLogInformation(1, "Cache miss for CNB daily rates. Fetching and mapping."); + logger.VerifyLogInformation(1, $"Mapped 1 CNB exchange rates (target currency {Constants.ExchangeRateProviderCurrencyCode})."); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj new file mode 100644 index 000000000..fafa335e4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj.Backup.tmp b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj.Backup.tmp new file mode 100644 index 000000000..c9ff8176b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateProviders.Tests.csproj.Backup.tmp @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateServiceTests.cs new file mode 100644 index 000000000..95f9bbcf0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/ExchangeRateServiceTests.cs @@ -0,0 +1,163 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using ExchangeRateProviders.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; + +namespace ExchangeRateProviders.Tests; + +[TestFixture] +public class ExchangeRateServiceTests +{ + private IExchangeRateDataProviderFactory _dataProviderFactory = null!; + private IExchangeRateDataProvider _dataProvider = null!; + private ILogger _logger = null!; + private ExchangeRateService _service = null!; + + [SetUp] + public void SetUp() + { + _dataProviderFactory = Substitute.For(); + _dataProvider = Substitute.For(); + _logger = Substitute.For>(); + _service = new ExchangeRateService(_dataProviderFactory, _logger); + } + + [Test] + public async Task GetExchangeRatesAsync_NullCurrencies_ReturnsEmpty() + { + // Act + var result = await _service.GetExchangeRatesAsync("CZK", null!, CancellationToken.None); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.Empty); + _logger.VerifyLogWarning(1, "Requested currencies collection is null. Returning empty result."); + }); + } + + [Test] + public async Task GetExchangeRatesAsync_FiltersToRequestedCurrencies() + { + // Arrange + var targetCurrency = "CZK"; + var allRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m), + new ExchangeRate(new Currency("JPY"), new Currency("CZK"), 0.17m) + }; + + _dataProvider.ExchangeRateProviderTargetCurrencyCode.Returns(targetCurrency); + _dataProvider.GetDailyRatesAsync(Arg.Any()).Returns(allRates); + _dataProviderFactory.GetProvider(targetCurrency).Returns(_dataProvider); + + var requestedCurrencies = new[] { new Currency("USD"), new Currency("JPY") }; + + // Act + var result = (await _service.GetExchangeRatesAsync(targetCurrency, requestedCurrencies, CancellationToken.None)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Any(r => r.SourceCurrency.Code == "USD")); + Assert.That(result.Any(r => r.SourceCurrency.Code == "JPY")); + Assert.That(result.All(r => r.TargetCurrency.Code == "CZK")); + _logger.VerifyLogDebug(1, "Fetching exchange rates for 2 requested currencies via provider CZK."); + _logger.VerifyLogInformation(1, "Provider CZK returned 2/3 matching rates."); + }); + } + + [Test] + public async Task GetExchangeRatesAsync_EmptyRequestedCurrencies_ReturnsEmpty() + { + // Arrange + var targetCurrency = "CZK"; + var allRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + + _dataProvider.ExchangeRateProviderTargetCurrencyCode.Returns(targetCurrency); + _dataProvider.GetDailyRatesAsync(Arg.Any()).Returns(allRates); + _dataProviderFactory.GetProvider(targetCurrency).Returns(_dataProvider); + + var requestedCurrencies = Array.Empty(); + + // Act + var result = (await _service.GetExchangeRatesAsync(targetCurrency, requestedCurrencies, CancellationToken.None)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.Empty); + _logger.VerifyLogDebug(1, "Fetching exchange rates for 0 requested currencies via provider CZK."); + _logger.VerifyLogInformation(1, "Provider CZK returned 0/2 matching rates."); + }); + } + + [Test] + public async Task GetExchangeRatesAsync_CaseInsensitiveMatching_ReturnsMatchingRates() + { + // Arrange + var targetCurrency = "CZK"; + var allRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + + _dataProvider.ExchangeRateProviderTargetCurrencyCode.Returns(targetCurrency); + _dataProvider.GetDailyRatesAsync(Arg.Any()).Returns(allRates); + _dataProviderFactory.GetProvider(targetCurrency).Returns(_dataProvider); + + // Request currencies with different casing + var requestedCurrencies = new[] { new Currency("usd"), new Currency("Eur") }; + + // Act + var result = (await _service.GetExchangeRatesAsync(targetCurrency, requestedCurrencies, CancellationToken.None)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Any(r => r.SourceCurrency.Code == "USD")); + Assert.That(result.Any(r => r.SourceCurrency.Code == "EUR")); + _logger.VerifyLogDebug(1, "Fetching exchange rates for 2 requested currencies via provider CZK."); + _logger.VerifyLogInformation(1, "Provider CZK returned 2/2 matching rates."); + }); + } + + [Test] + public async Task GetExchangeRatesAsync_NoMatchingCurrencies_ReturnsEmpty() + { + // Arrange + var targetCurrency = "CZK"; + var allRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 22.5m), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 24.0m) + }; + + _dataProvider.ExchangeRateProviderTargetCurrencyCode.Returns(targetCurrency); + _dataProvider.GetDailyRatesAsync(Arg.Any()).Returns(allRates); + _dataProviderFactory.GetProvider(targetCurrency).Returns(_dataProvider); + + var requestedCurrencies = new[] { new Currency("GBP"), new Currency("CAD") }; + + // Act + var result = (await _service.GetExchangeRatesAsync(targetCurrency, requestedCurrencies, CancellationToken.None)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.Empty); + _logger.VerifyLogDebug(1, "Fetching exchange rates for 2 requested currencies via provider CZK."); + _logger.VerifyLogInformation(1, "Provider CZK returned 0/2 matching rates."); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders.Tests/TestHelpers/LogVerificationExtensions.cs b/jobs/Backend/Task/ExchangeRateProviders.Tests/TestHelpers/LogVerificationExtensions.cs new file mode 100644 index 000000000..cdc85ce24 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders.Tests/TestHelpers/LogVerificationExtensions.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateProviders.Tests.TestHelpers; + +public static class LogVerificationExtensions +{ + /// + /// Verifies that a logger received a log message with the specified level and exact message content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The expected log level + /// The exact message content expected + public static void VerifyLog(this ILogger logger, int times, LogLevel level, string message) + { + logger.Received(times).Log( + level, + Arg.Any(), + Arg.Is(v => v.ToString() == message), + Arg.Any(), + Arg.Any>()); + } + + /// + /// Verifies that a logger received a Debug level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogDebug(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Debug, message); + } + + /// + /// Verifies that a logger received an Information level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogInformation(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Information, message); + } + + /// + /// Verifies that a logger received a Warning level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogWarning(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Warning, message); + } + + /// + /// Verifies that a logger received an Error level log message with the specified exact content. + /// + /// The logger category type + /// The substitute logger instance + /// Expected number of times the log should be called + /// The exact message content expected + public static void VerifyLogError(this ILogger logger, int times, string message) + { + logger.VerifyLog(times, LogLevel.Error, message); + } + + /// + /// Verifies that a logger did NOT receive any log message containing the specified text. + /// + /// The logger category type + /// The substitute logger instance + /// The log level to check + /// Text that should NOT appear in any log message + public static void VerifyLogNotContaining(this ILogger logger, LogLevel level, string messageContains) + { + logger.DidNotReceive().Log( + level, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains(messageContains)), + Arg.Any(), + Arg.Any>()); + } + + /// + /// Verifies that a logger did NOT receive any Information level log message containing the specified text. + /// + /// The logger category type + /// The substitute logger instance + /// Text that should NOT appear in any Information log message + public static void VerifyLogInformationNotContaining(this ILogger logger, string messageContains) + { + logger.VerifyLogNotContaining(LogLevel.Information, messageContains); + } + + /// + /// Verifies that a logger did NOT receive any Warning level log message containing the specified text. + /// + /// The logger category type + /// The substitute logger instance + /// Text that should NOT appear in any Warning log message + public static void VerifyLogWarningNotContaining(this ILogger logger, string messageContains) + { + logger.VerifyLogNotContaining(LogLevel.Warning, messageContains); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/CurrencyValidator.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/CurrencyValidator.cs new file mode 100644 index 000000000..3932aa668 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/CurrencyValidator.cs @@ -0,0 +1,68 @@ +using ExchangeRateProviders.Core.Exception; +using ExchangeRateProviders.Core.Model; + +namespace ExchangeRateProviders.Core +{ + public static class CurrencyValidator + { + public static void ValidateCurrencyCodes(IEnumerable currencies) + { + if (currencies == null) + { + return; + } + + var invalidCurrencies = currencies + .Where(c => !IsValidCurrencyCode(c.Code)) + .Select(c => c.Code ?? "null") + .ToList(); + + if (invalidCurrencies.Any()) + { + throw new InvalidCurrencyException(invalidCurrencies); + } + } + + private static bool IsValidCurrencyCode(string? code) + { + if (string.IsNullOrWhiteSpace(code) || + code.Length != 3 || + !code.All(char.IsLetter)) + { + return false; + } + + return _iso4217Currencies.Contains(code); + } + + private static readonly HashSet _iso4217Currencies = new(StringComparer.OrdinalIgnoreCase) + { + // Major currencies + "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", + + // Asia & Pacific + "CNY", "INR", "KRW", "SGD", "HKD", "TWD", "THB", "MYR", "IDR", "PHP", "VND", + "PKR", "BDT", "LKR", "NPR", "MMK", "KHR", "LAK", "MNT", "PGK", "FJD", "WST", + + // Europe + "SEK", "NOK", "DKK", "PLN", "CZK", "HUF", "BGN", "RON", "HRK", "RUB", "TRY", + "ISK", "UAH", "BYN", "MKD", "MDL", "GEL", "AMD", "AZN", "ALL", "BAM", "RSD", + + // Middle East + "ILS", "AED", "SAR", "KWD", "QAR", "OMR", "BHD", "JOD", "LBP", "SYP", "YER", + "IQD", "IRR", "AFN", "PKR", + + // Africa + "EGP", "ZAR", "NGN", "KES", "TZS", "UGX", "GHS", "MAD", "TND", "DZD", "LYD", + "SDG", "ETB", "MZN", "AOA", "NAD", "BWP", "LSL", "SZL", "MWK", "ZMW", "SOS", + "RWF", "BIH", "SLL", "CVE", "XOF", "XAF", "KMF", + + // Americas + "MXN", "BRL", "ARS", "CLP", "COP", "PEN", "UYU", "PYG", "BOB", "VEF", "VES", + "GYD", "SRD", "BBD", "BSD", "TTD", "JMD", "HTG", "DOP", "CUP", "KYD", "BZD", + + // Oceania microstates + "TVD", "KID", "TOP", "VUV" + }; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/Exception/InvalidCurrencyException.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/Exception/InvalidCurrencyException.cs new file mode 100644 index 000000000..80f5d0879 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/Exception/InvalidCurrencyException.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateProviders.Core.Exception +{ + [Serializable] + public class InvalidCurrencyException : ArgumentException + { + public IReadOnlyList InvalidCurrencyCodes { get; } + + public InvalidCurrencyException(IEnumerable invalidCodes) + : base(BuildMessage(invalidCodes)) + { + InvalidCurrencyCodes = invalidCodes.ToList().AsReadOnly(); + } + + public InvalidCurrencyException(IEnumerable invalidCodes, ArgumentException inner) + : base(BuildMessage(invalidCodes), inner) + { + InvalidCurrencyCodes = invalidCodes.ToList().AsReadOnly(); + } + + private static string BuildMessage(IEnumerable invalidCodes) + => $"Invalid currency codes: {string.Join(", ", invalidCodes)}"; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/ExchangeRateDataProviderFactory.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/ExchangeRateDataProviderFactory.cs new file mode 100644 index 000000000..8ff556fe0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/ExchangeRateDataProviderFactory.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProviders.Core; + +public class ExchangeRateDataProviderFactory : IExchangeRateDataProviderFactory +{ + private readonly Dictionary _providers; + private readonly ILogger _logger; + + public ExchangeRateDataProviderFactory(IEnumerable providers, ILogger logger) + { + _logger = logger; + _providers = providers.ToDictionary(p => p.ExchangeRateProviderTargetCurrencyCode, StringComparer.OrdinalIgnoreCase); + } + + public IExchangeRateDataProvider GetProvider(string exchangeRateProviderCurrencyCode) + { + if (exchangeRateProviderCurrencyCode == null) + { + _logger.LogError("Attempted to get provider with null currency code."); + throw new ArgumentNullException(nameof(exchangeRateProviderCurrencyCode)); + } + if (_providers.TryGetValue(exchangeRateProviderCurrencyCode, out var provider)) + { + _logger.LogDebug("Resolved exchange rate provider for currency {Currency}", exchangeRateProviderCurrencyCode); + return provider; + } + _logger.LogError("No exchange rate provider registered for currency {Currency}", exchangeRateProviderCurrencyCode); + throw new InvalidOperationException($"No exchange rate provider registered for currency '{exchangeRateProviderCurrencyCode}'."); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProvider.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProvider.cs new file mode 100644 index 000000000..fdba7bcdb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProvider.cs @@ -0,0 +1,10 @@ +using ExchangeRateProviders.Core.Model; + +namespace ExchangeRateProviders.Core +{ + public interface IExchangeRateDataProvider + { + string ExchangeRateProviderTargetCurrencyCode { get; } + Task> GetDailyRatesAsync(CancellationToken cancellationToken); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProviderFactory.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProviderFactory.cs new file mode 100644 index 000000000..5a0b0424e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateDataProviderFactory.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateProviders.Core +{ + public interface IExchangeRateDataProviderFactory + { + IExchangeRateDataProvider GetProvider(string exchangeRateProviderCurrencyCode); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateService.cs new file mode 100644 index 000000000..cfd644ab3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/IExchangeRateService.cs @@ -0,0 +1,8 @@ +using ExchangeRateProviders.Core.Model; + +namespace ExchangeRateProviders.Core; + +public interface IExchangeRateService +{ + Task> GetExchangeRatesAsync(string TargetCurrency, IEnumerable currencies, CancellationToken cancellationToken); +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/Model/Currency.cs similarity index 88% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/ExchangeRateProviders/Core/Model/Currency.cs index f375776f2..6c55a40bd 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/Model/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateProviders.Core.Model { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateProviders/Core/Model/ExchangeRate.cs similarity index 74% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRateProviders/Core/Model/ExchangeRate.cs index 58c5bb10e..4a6624e45 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateProviders/Core/Model/ExchangeRate.cs @@ -1,12 +1,13 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateProviders.Core.Model { public class ExchangeRate { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value, DateTime? validFor = null) { SourceCurrency = sourceCurrency; TargetCurrency = targetCurrency; Value = value; + ValidFor = validFor; } public Currency SourceCurrency { get; } @@ -15,6 +16,8 @@ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal va public decimal Value { get; } + public DateTime? ValidFor { get; } + public override string ToString() { return $"{SourceCurrency}/{TargetCurrency}={Value}"; diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/CzkCnbApiClient.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/CzkCnbApiClient.cs new file mode 100644 index 000000000..85e9f5a22 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/CzkCnbApiClient.cs @@ -0,0 +1,52 @@ +using ExchangeRateProviders.Czk.Model; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Polly; +using ExchangeRateProviders.Czk.Config; + +namespace ExchangeRateProviders.Czk.Clients; + +public class CzkCnbApiClient : ICzkCnbApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IAsyncPolicy _retryPolicy; + private static readonly Uri Endpoint = new(Constants.CnbApiDailyRatesEndpoint); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private static IAsyncPolicy DefaultPolicy => + Policy + .Handle() + .OrResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429) + .WaitAndRetryAsync(3, _ => TimeSpan.FromSeconds(2)); + + public CzkCnbApiClient( + HttpClient httpClient, + ILogger logger, + IAsyncPolicy? retryPolicy = null) + { + _httpClient = httpClient; + _logger = logger; + _retryPolicy = retryPolicy ?? DefaultPolicy; + } + + public async Task> GetDailyRatesRawAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Requesting CNB rates from {Endpoint}", Endpoint); + var response = await _retryPolicy.ExecuteAsync( + () => _httpClient.GetAsync(Endpoint, HttpCompletionOption.ResponseHeadersRead, cancellationToken)); + + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + var list = root?.Rates ?? new List(); + + if (list.Count == 0) + _logger.LogWarning("CNB rates response empty."); + else + _logger.LogInformation("CNB returned {Count} raw rates.", list.Count); + + return list; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/ICzkCnbApiClient.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/ICzkCnbApiClient.cs new file mode 100644 index 000000000..3b9081eab --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Clients/ICzkCnbApiClient.cs @@ -0,0 +1,8 @@ +using ExchangeRateProviders.Czk.Model; + +namespace ExchangeRateProviders.Czk.Clients; + +public interface ICzkCnbApiClient +{ + Task> GetDailyRatesRawAsync(CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/CnbCacheStrategy.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/CnbCacheStrategy.cs new file mode 100644 index 000000000..131bb103a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/CnbCacheStrategy.cs @@ -0,0 +1,85 @@ +using System.Runtime.InteropServices; +using ZiggyCreatures.Caching.Fusion; + +namespace ExchangeRateProviders.Czk.Config +{ + /// + /// Provides intelligent caching strategies for Czech National Bank (CNB) exchange rate data + /// based on their publication schedule and Prague timezone. + /// + public static class CnbCacheStrategy + { + /// + /// Determines cache duration based on Prague time and CNB publication schedule. + /// CNB publishes rates after 2:30 PM Prague time on working days only. + /// Strategy: + /// - Between 2:31 PM - 3:31 PM (weekdays): 5 minutes (fresh data likely available) + /// - Other weekday times: 1 hour (data is stable) + /// - Weekends: 12 hours (no new data ever released) + /// + public static FusionCacheEntryOptions GetCacheOptionsBasedOnPragueTime() + { + var pragueTime = GetPragueTime(); + var isWeekend = pragueTime.DayOfWeek == DayOfWeek.Saturday || pragueTime.DayOfWeek == DayOfWeek.Sunday; + + TimeSpan duration; + + if (isWeekend) + { + // Weekends: CNB never releases new data, cache for 12 hours to avoid unnecessary API calls + duration = TimeSpan.FromHours(12); + } + else if (IsWithinPublishWindow(pragueTime)) + { + // Publication window (2:31 PM - 3:31 PM): refresh frequently to catch new data + duration = TimeSpan.FromMinutes(5); + } + else + { + // Outside publication window on weekdays: data is stable, cache for 1 hour + duration = TimeSpan.FromHours(1); + } + + return new FusionCacheEntryOptions + { + Duration = duration, + // Allow serving stale data if fetch fails (fallback for up to 2x the normal duration) + FailSafeMaxDuration = TimeSpan.FromTicks(duration.Ticks * 2) + }; + } + + /// + /// Gets current time in Prague timezone (Central European Time). + /// + private static DateTime GetPragueTime() + { + var pragueTimeZone = GetPragueTimeZone(); + return TimeZoneInfo.ConvertTime(DateTime.UtcNow, pragueTimeZone); + } + + /// + /// Checks if current Prague time is within the CNB publication window (2:31 PM - 3:31 PM). + /// + private static bool IsWithinPublishWindow(DateTime pragueTime) + { + var time = pragueTime.TimeOfDay; + var publishStart = new TimeSpan(14, 31, 0); // 2:31 PM + var publishEnd = new TimeSpan(15, 31, 0); // 3:31 PM + + return time >= publishStart && time <= publishEnd; + } + + /// + /// Gets Prague timezone using OS-specific timezone resolution. + /// Supports Windows and Unix-like systems (Linux/macOS). + /// + private static TimeZoneInfo GetPragueTimeZone() + { + string timeZoneId = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "Central Europe Standard Time" // Windows format + : "Europe/Prague"; // IANA format (Linux/macOS) + + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/Constants.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/Constants.cs new file mode 100644 index 000000000..810280fcb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Config/Constants.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateProviders.Czk.Config +{ + public static class Constants + { + public const string ExchangeRateProviderCurrencyCode = "CZK"; + public const string CnbApiDailyRatesEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/CzkExchangeRateDataProvider.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/CzkExchangeRateDataProvider.cs new file mode 100644 index 000000000..3dcfdfdbb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/CzkExchangeRateDataProvider.cs @@ -0,0 +1,46 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using ExchangeRateProviders.Czk.Clients; +using ExchangeRateProviders.Czk.Config; +using ExchangeRateProviders.Czk.Mappers; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace ExchangeRateProviders.Czk +{ + public class CzkExchangeRateDataProvider : IExchangeRateDataProvider + { + private const string CacheKey = "CnbDailyRates"; + + private readonly IFusionCache _cache; + private readonly ICzkCnbApiClient _apiClient; + private readonly ILogger _logger; + + public CzkExchangeRateDataProvider( + IFusionCache cache, + ICzkCnbApiClient apiClient, + ILogger logger) + { + _cache = cache; + _apiClient = apiClient; + _logger = logger; + } + + public string ExchangeRateProviderTargetCurrencyCode => Constants.ExchangeRateProviderCurrencyCode; + + public async Task> GetDailyRatesAsync(CancellationToken cancellationToken = default) + { + var cacheOptions = CnbCacheStrategy.GetCacheOptionsBasedOnPragueTime(); + _logger.LogDebug("Using cache duration: {Duration} minutes", cacheOptions.Duration.TotalMinutes); + + return await _cache.GetOrSetAsync(CacheKey, async _ => + { + _logger.LogInformation("Cache miss for CNB daily rates. Fetching and mapping."); + var raw = await _apiClient.GetDailyRatesRawAsync(cancellationToken).ConfigureAwait(false); + var mapped = raw.MapToExchangeRates(); + _logger.LogInformation("Mapped {Count} CNB exchange rates (target currency {TargetCurrency}).", mapped.Count(), Constants.ExchangeRateProviderCurrencyCode); + return mapped; + }, cacheOptions); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Mappers/ContractMapping.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Mappers/ContractMapping.cs new file mode 100644 index 000000000..f021942bb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Mappers/ContractMapping.cs @@ -0,0 +1,46 @@ +using ExchangeRateProviders.Core.Model; +using ExchangeRateProviders.Czk.Config; +using ExchangeRateProviders.Czk.Model; + +namespace ExchangeRateProviders.Czk.Mappers; + +public static class ContractMapping +{ + public static ExchangeRate MapToExchangeRate(this CnbApiExchangeRateDto dto) + { + if (dto is null) + { + throw new ArgumentNullException(nameof(dto)); + } + + if (dto.Amount <= 0) + { + throw new ArgumentException($"Amount must be positive for currency {dto.CurrencyCode}", nameof(dto)); + } + + var sourceCurrency = new Currency(dto.CurrencyCode.ToUpperInvariant()); + var targetCurrency = new Currency(Constants.ExchangeRateProviderCurrencyCode); + var perUnitRate = dto.Rate / dto.Amount; + + return new ExchangeRate(sourceCurrency, targetCurrency, perUnitRate, dto.ValidFor); + } + + public static IEnumerable MapToExchangeRates(this IEnumerable sourceRates) + { + if (sourceRates is null) + { + throw new ArgumentNullException(nameof(sourceRates)); + } + + var list = sourceRates is ICollection col + ? new List(col.Count) + : new List(); + + foreach (var dto in sourceRates) + { + list.Add(dto.MapToExchangeRate()); + } + + return list; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiCzkExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiCzkExchangeRateResponse.cs new file mode 100644 index 000000000..c0695c9ca --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiCzkExchangeRateResponse.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateProviders.Czk.Model +{ + public class CnbApiCzkExchangeRateResponse + { + public List Rates { get; set; } = new(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiExchangeRateDto.cs new file mode 100644 index 000000000..a39af5c8c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Czk/Model/CnbApiExchangeRateDto.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateProviders.Czk.Model +{ + public class CnbApiExchangeRateDto + { + public DateTime ValidFor { get; set; } + public int Amount { get; set; } + public string CurrencyCode { get; set; } = string.Empty; + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProviders.csproj b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProviders.csproj new file mode 100644 index 000000000..472cab6aa --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProviders.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProvidersDoc.md b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProvidersDoc.md new file mode 100644 index 000000000..731bd8163 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateProvidersDoc.md @@ -0,0 +1,136 @@ +# ExchangeRateProviders Library Documentation + +A modular .NET 9 library for retrieving foreign exchange rates from pluggable data sources. It provides: + +- A unified service interface (`IExchangeRateService`) for requesting exchange rates with target currency specification. +- A factory (`IExchangeRateDataProviderFactory`) to resolve data providers by target currency code (e.g. "CZK"). +- A data provider abstraction (`IExchangeRateDataProvider`) separating raw data acquisition from business filtering logic. +- A concrete Czech National Bank (CNB) implementation (`CzkExchangeRateDataProvider`) that provides CZK-based rates. + +## Project Goals + +1. Encapsulate integration details of individual central‑bank / market data feeds. +2. Provide a consistent async API with cancellation support. +3. Make adding new target‑currency providers straightforward (open/closed principle). +4. Offer good observability via structured logging. +5. Separate concerns between data acquisition and business logic filtering. + +## Key Interfaces +public interface IExchangeRateService +{ + Task> GetExchangeRatesAsync(string TargetCurrency, IEnumerable currencies, CancellationToken cancellationToken); +} + +public interface IExchangeRateDataProvider +{ + string ExchangeRateProviderTargetCurrencyCode { get; } + Task> GetDailyRatesAsync(CancellationToken cancellationToken); +} + +public interface IExchangeRateDataProviderFactory +{ + IExchangeRateDataProvider GetProvider(string exchangeRateProviderCurrencyCode); +} +## Architecture & Flow + +The library follows a layered architecture with clear separation of concerns: + +1. **Service Layer**: `ExchangeRateService` provides the main business logic and acts as the primary entry point. +2. **Factory Layer**: `ExchangeRateDataProviderFactory` resolves the appropriate data provider based on target currency. +3. **Provider Layer**: Individual `IExchangeRateDataProvider` implementations handle data source-specific logic. + +### Request Flow + +1. Client calls `IExchangeRateService.GetExchangeRatesAsync()` with target currency and requested source currencies. +2. Service uses `IExchangeRateDataProviderFactory` to resolve the appropriate data provider for the target currency. +3. Data provider fetches (or returns cached) full daily rate set from its data source. +4. Service filters results to only the requested source currencies (case‑insensitive matching). +5. Structured logs emit debug (fetch intent) and information (result stats) messages. + +## Provided Implementation: CZK (Czech National Bank) + +`CzkExchangeRateDataProvider`: +- Target currency: `CZK` (see `Constants.ExchangeRateProviderCurrencyCode`). +- Fetches data from Czech National Bank (CNB) HTTP API via `ICzkCnbApiClient`. +- Implements intelligent caching using FusionCache with Prague timezone-aware cache expiration. +- Maps raw CNB API responses to standardized `ExchangeRate` objects. + +### Registration Example +services.AddFusionCache(); +services.AddHttpClient(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +## Adding a New Provider + +To add support for a new target currency (e.g., USD from Federal Reserve): + +1. **Implement the Data Provider**:public class UsdExchangeRateDataProvider : IExchangeRateDataProvider +{ + public string ExchangeRateProviderTargetCurrencyCode => "USD"; + + public async Task> GetDailyRatesAsync(CancellationToken cancellationToken) + { + // Fetch from Fed API, apply caching, map to ExchangeRate objects + } + } +2. **Register in DI Container**:services.AddSingleton(); +3. **Use the Service**:var rates = await exchangeRateService.GetExchangeRatesAsync("USD", currencies, cancellationToken); +The factory automatically discovers and indexes providers by their target currency code. + +## Usage Examples + +### Basic Usagevar currencies = new List { new("USD"), new("EUR"), new("JPY") }; +var rates = await exchangeRateService.GetExchangeRatesAsync("CZK", currencies, cancellationToken); + +foreach (var rate in rates) +{ + Console.WriteLine($"{rate.SourceCurrency.Code} -> {rate.TargetCurrency.Code}: {rate.Value}"); +} +### With Dependency Injectionpublic class ExchangeRateController : ControllerBase +{ + private readonly IExchangeRateService _exchangeRateService; + + public ExchangeRateController(IExchangeRateService exchangeRateService) + { + _exchangeRateService = exchangeRateService; + } + + [HttpGet] + public async Task GetRates(string targetCurrency, [FromQuery] string[] currencies) + { + var currencyObjects = currencies.Select(c => new Currency(c)); + var rates = await _exchangeRateService.GetExchangeRatesAsync(targetCurrency, currencyObjects, HttpContext.RequestAborted); + return Ok(rates); + } +} +## Caching Strategy + +The library uses FusionCache for intelligent caching: + +- **CZK Provider**: Uses Prague timezone-aware cache expiration (refreshes after CNB publishes new rates). +- **Extensible**: Each provider can implement its own caching strategy based on data source characteristics. +- **Configurable**: Supports both in-memory and distributed caching backends. + +## Error Handling + +- **Null Safety**: Handles null currency collections gracefully. +- **Provider Resolution**: Throws `InvalidOperationException` for unsupported target currencies. +- **Logging**: Comprehensive structured logging for debugging and monitoring. +- **Cancellation**: Full support for cooperative cancellation throughout the call chain. + +## Testing + +- Service behavior is unit tested (filtering, null handling, provider resolution, logging). +- Data provider implementations have dedicated test coverage. +- Integration tests validate end-to-end scenarios. + +## Notes + +- Library targets .NET 9. +- Uses FusionCache for caching with configurable strategies per provider. +- Designed for extension with additional providers (e.g., ECB, Fed, custom market feeds) without modifying existing code. +- All public async APIs accept `CancellationToken` for cooperative cancellation. +- Case-insensitive currency code matching for improved usability. + diff --git a/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateService.cs new file mode 100644 index 000000000..d0aef8739 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/ExchangeRateService.cs @@ -0,0 +1,39 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProviders; + +public class ExchangeRateService : IExchangeRateService +{ + private readonly IExchangeRateDataProviderFactory _dataProviderFactory; + private readonly ILogger _logger; + + public ExchangeRateService(IExchangeRateDataProviderFactory dataProviderFactory, ILogger logger) + { + _dataProviderFactory = dataProviderFactory; + _logger = logger; + } + + public async Task> GetExchangeRatesAsync(string TargetCurrency, IEnumerable currencies, CancellationToken cancellationToken = default) + { + if (currencies == null) + { + _logger.LogWarning("Requested currencies collection is null. Returning empty result."); + return Enumerable.Empty(); + } + + var requestedCurrencies = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + CurrencyValidator.ValidateCurrencyCodes(currencies); + + _logger.LogDebug("Fetching exchange rates for {Count} requested currencies via provider {ProviderCurrency}.", requestedCurrencies.Count, TargetCurrency); + + var provider = _dataProviderFactory.GetProvider(TargetCurrency); + var allRates = await provider.GetDailyRatesAsync(cancellationToken); + var requestedCurrenciesRates = allRates.Where(r => requestedCurrencies.Contains(r.SourceCurrency.Code)).ToList(); + + _logger.LogInformation("Provider {ProviderCurrency} returned {Filtered}/{Total} matching rates.", provider.ExchangeRateProviderTargetCurrencyCode, requestedCurrenciesRates.Count, allRates.Count()); + return requestedCurrenciesRates; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateProviders/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..4893e682a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using ExchangeRateProviders.Core; +using Microsoft.Extensions.DependencyInjection; +using ExchangeRateProviders.Czk; +using ExchangeRateProviders.Czk.Clients; +using ExchangeRateProviders.Usd; + +namespace ExchangeRateProviders; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers ExchangeRateProviders core services, data providers and dependencies. + /// + /// The service collection. + /// The same service collection for chaining. + public static IServiceCollection AddExchangeRateProviders(this IServiceCollection services) + { + // FusionCache (caller can configure options separately if desired) + services.AddFusionCache(); + + // HTTP clients for external APIs (add more as new providers are implemented) + services.AddHttpClient(); + + // Data providers (add any additional provider registrations here) + services.AddSingleton(); + services.AddSingleton(); + + // Factory & service + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Usd/Config/Constants.cs b/jobs/Backend/Task/ExchangeRateProviders/Usd/Config/Constants.cs new file mode 100644 index 000000000..c6a4dae4f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Usd/Config/Constants.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateProviders.Usd.Config +{ + public static class Constants + { + public const string ExchangeRateProviderCurrencyCode = "USD"; + } +} diff --git a/jobs/Backend/Task/ExchangeRateProviders/Usd/UsdExchangeRateDataProvider.cs b/jobs/Backend/Task/ExchangeRateProviders/Usd/UsdExchangeRateDataProvider.cs new file mode 100644 index 000000000..67ed983a5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviders/Usd/UsdExchangeRateDataProvider.cs @@ -0,0 +1,47 @@ +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using ExchangeRateProviders.Usd.Config; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace ExchangeRateProviders.Usd +{ + public class UsdExchangeRateDataProvider : IExchangeRateDataProvider + { + private readonly ILogger _logger; + + public UsdExchangeRateDataProvider( + IFusionCache cache, + ILogger logger) + { + _logger = logger; + } + + public string ExchangeRateProviderTargetCurrencyCode => Constants.ExchangeRateProviderCurrencyCode; + + public async Task> GetDailyRatesAsync(CancellationToken cancellationToken = default) + { + var allRates = new List + { + new(new Currency("EUR"), new Currency("USD"), 1.18m, DateTime.UtcNow), + new(new Currency("JPY"), new Currency("USD"), 0.009m, DateTime.UtcNow), + new(new Currency("GBP"), new Currency("USD"), 1.33m, DateTime.UtcNow), + new(new Currency("AUD"), new Currency("USD"), 0.74m, DateTime.UtcNow), + new(new Currency("CAD"), new Currency("USD"), 0.80m, DateTime.UtcNow), + new(new Currency("CZK"), new Currency("USD"), 0.044m, DateTime.UtcNow), + new(new Currency("CHF"), new Currency("USD"), 1.10m, DateTime.UtcNow), + new(new Currency("SEK"), new Currency("USD"), 0.095m, DateTime.UtcNow), + new(new Currency("NOK"), new Currency("USD"), 0.093m, DateTime.UtcNow), + new(new Currency("DKK"), new Currency("USD"), 0.158m, DateTime.UtcNow), + new(new Currency("NZD"), new Currency("USD"), 0.61m, DateTime.UtcNow), + new(new Currency("CNY"), new Currency("USD"), 0.14m, DateTime.UtcNow), + new(new Currency("INR"), new Currency("USD"), 0.012m, DateTime.UtcNow), + new(new Currency("BRL"), new Currency("USD"), 0.20m, DateTime.UtcNow), + new(new Currency("MXN"), new Currency("USD"), 0.058m, DateTime.UtcNow), + new(new Currency("ZAR"), new Currency("USD"), 0.052m, DateTime.UtcNow) + }; + + return await Task.FromResult(allRates.AsEnumerable()); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daf..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 000000000..4072b0a95 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs new file mode 100644 index 000000000..c57ed1afd --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs @@ -0,0 +1,54 @@ +using ExchangeRateProviders; +using ExchangeRateProviders.Core; +using ExchangeRateProviders.Core.Model; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +const string ExchangeRateProviderTargetCurrencyCode = "CZK"; + +var currencies = new List +{ + new("USD"), + new("EUR"), + new("CZK"), + new("JPY"), + new("KES"), + new("RUB"), + new("THB"), + new("TRY") +}; + +var builder = Host.CreateApplicationBuilder(args); +ConfigureServices(builder.Services); + +using var host = builder.Build(); + +try +{ + var exchangeRateService = host.Services.GetRequiredService(); + var czkrates = await exchangeRateService.GetExchangeRatesAsync(ExchangeRateProviderTargetCurrencyCode, currencies, CancellationToken.None); + + Console.WriteLine($"Successfully retrieved {czkrates.Count()} exchange rates:"); + foreach (var rate in czkrates) + { + Console.WriteLine(rate.ToString()); + } + + var usdrates = await exchangeRateService.GetExchangeRatesAsync("USD", currencies, CancellationToken.None); + Console.WriteLine($"Successfully retrieved {czkrates.Count()} exchange rates:"); + foreach (var rate in usdrates) + { + Console.WriteLine(rate.ToString()); + } +} +catch (Exception e) +{ + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'"); +} + +return; + +static void ConfigureServices(IServiceCollection services) +{ + services.AddExchangeRateProviders(); +} \ 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/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..bbda5214d --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,130 @@ +# Exchange Rates Example Solution + +A .NET 9 multi-project solution demonstrating a clean, extensible approach to fetching and exposing foreign exchange rates with a modern service-oriented architecture. + +## Projects + +- **ExchangeRateApi** (ASP.NET Core Web API) + - Exposes HTTP endpoints for requesting exchange rates and listing providers. + - Uses FluentValidation for request validation and Swagger / Swashbuckle for OpenAPI docs. + - Integrates with ExchangeRateProviders via `IExchangeRateService`. + +- **ExchangeRateProviders** (Class Library) + - Core service abstraction (`IExchangeRateService`) for unified exchange rate operations. + - Data provider abstractions (`IExchangeRateDataProvider`, `IExchangeRateDataProviderFactory`) separating data acquisition from business logic. + - Concrete CZK implementation (`CzkExchangeRateDataProvider`) backed by Czech National Bank API. + - Service-oriented architecture with clear separation of concerns. + - Intelligent caching with FusionCache and timezone-aware strategies. + +- **ExchangeRateApi.Tests** (NUnit) + - Unit tests for controller behavior, validation, logging, and endpoint responses. + +- **ExchangeRateProviders.Tests** (NUnit) + - Unit tests for service logic, provider resolution, filtering, logging, and edge cases. + +- **ExchangeRateUpdater** (Console App) + - Simple example consumer demonstrating `IExchangeRateService` usage. + +## Architecture Overview + +The solution follows a layered architecture with clear separation of concerns: + +### ExchangeRateProviders Library Architecture + +1. **Service Layer**: `ExchangeRateService` - Main business logic and entry point +2. **Factory Layer**: `ExchangeRateDataProviderFactory` - Resolves providers by target currency +3. **Provider Layer**: `IExchangeRateDataProvider` implementations - Handle data source-specific logic + +### Key Interfaces +// Main service interface - primary entry point +public interface IExchangeRateService +{ + Task> GetExchangeRatesAsync(string TargetCurrency, IEnumerable currencies, CancellationToken cancellationToken); +} + +// Data provider abstraction +public interface IExchangeRateDataProvider +{ + string ExchangeRateProviderTargetCurrencyCode { get; } + Task> GetDailyRatesAsync(CancellationToken cancellationToken); +} + +// Factory for provider resolution +public interface IExchangeRateDataProviderFactory +{ + IExchangeRateDataProvider GetProvider(string exchangeRateProviderCurrencyCode); +} +## Get up and Running with Docker +docker compose -f docker-compose.dev.yml up -d --build +- The API will be available at `http://localhost:8080/swagger/index.html` +- Alternatively public access to the API via http://128.140.72.56:18080/swagger/index.html, deployed on hetzner cloud via github actions CI/CD pipeline. + +## Endpoint Summary (v1) + +All endpoints are versioned under `v1/api`. + +- `POST v1/api/exchange-rates` - Request rates via JSON body (list of source currency codes + optional target currency; defaults to CZK). +- `GET v1/api/exchange-rates?currencies=USD,EUR&targetCurrency=CZK` - Query version. +- `GET v1/api/providers` - List available providers. + +Swagger UI: `/swagger` (served once the API is running). + +## API Documentation + +You can access the interactive Swagger UI documentation at: +- **Swagger UI**: `/swagger` +- **OpenAPI JSON**: `/swagger/v1/swagger.json` + +The Swagger UI provides: +- Interactive API testing +- Detailed endpoint documentation +- Request/response examples +- Schema definitions +- Authentication details (when applicable) + +## Adding a New Provider + +The new architecture makes adding providers straightforward: + +1. **Implement `IExchangeRateDataProvider`** for your target currency:public class UsdExchangeRateDataProvider : IExchangeRateDataProvider +{ + public string ExchangeRateProviderTargetCurrencyCode => "USD"; + + public async Task> GetDailyRatesAsync(CancellationToken cancellationToken) + { + // Fetch from Fed API, apply caching, map to ExchangeRate objects + } + } +2. **Register in DI container**:services.AddSingleton(); +3. **Use the service** - the factory automatically discovers providers by target currency:var rates = await exchangeRateService.GetExchangeRatesAsync("USD", currencies, cancellationToken); +## Configuration & Extensibility + +- **Logging**: Console provider added by default; extend via `appsettings.json` or additional logging providers. +- **Caching**: FusionCache with intelligent caching strategies per provider (e.g., Prague timezone-aware for CNB). +- **Validation**: Add new validators implementing `IValidator` and register in DI. +- **Service Registration**: Simple DI registration automatically wires up the service layer. + +## Current Implementation: CZK Provider + +- **Target Currency**: CZK (Czech Koruna) +- **Data Source**: Czech National Bank (CNB) HTTP API +- **Caching**: Prague timezone-aware cache expiration +- **Features**: + - Automatic rate normalization (handles multi-unit amounts like JPY/100) + - Comprehensive error handling and logging + - Resilient HTTP client with retry policies + +## Architecture Benefits + +- **Separation of Concerns**: Clear boundaries between service logic, provider resolution, and data acquisition +- **Extensibility**: Easy to add new target currencies without modifying existing code +- **Testability**: Each layer can be independently unit tested +- **Flexibility**: Target currency specified per request, supporting multi-currency scenarios +- **Observability**: Comprehensive structured logging throughout the call chain + +## Future Enhancements + +- Additional provider implementations (ECB for EUR, Federal Reserve for USD, etc.) +- Fallback rate sources (CSV, XML, database) for high availability +- Health checks for monitoring provider status +- Nuget package ExchangeRateProviders library diff --git a/jobs/Backend/Task/docker-compose.dev.yml b/jobs/Backend/Task/docker-compose.dev.yml new file mode 100644 index 000000000..2dca489b1 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.dev.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + exchangerate-api: + build: + context: . + dockerfile: ExchangeRateApi/Dockerfile + container_name: exchangerate-api-dev + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + - Logging__LogLevel__Default=Information + - Logging__LogLevel__Microsoft.AspNetCore=Information + networks: + - exchangerate-network + restart: unless-stopped + volumes: + - ./ExchangeRateApi/logs:/app/logs + +networks: + exchangerate-network: + driver: bridge \ No newline at end of file