diff --git a/jobs/Backend/Notes.md b/jobs/Backend/Notes.md
new file mode 100644
index 0000000000..b9fd1b8cf8
--- /dev/null
+++ b/jobs/Backend/Notes.md
@@ -0,0 +1,129 @@
+### Thoughts
+
+## Building a requirements List
+
+The task is to implement an ExchangeRateProvider for Czech National Bank.
+Find data source on their web - part of the task is to find the source of the exchange rate data and a way how to extract it from there.
+It is up to you to decide which technology (from .NET family) or package to use.
+Any code design changes/decisions to the provided skeleton are also completely up to you.
+The solution has to be buildable, runnable and the test program should output the obtained exchange rates.
+Goal is to implement a fully functional provider based on real world public data source of the assigned bank.
+To submit your solution, create a Pull Request from a fork.
+
+Please write the code like you would if you needed this to run on production environment and had to take care of it long-term.
+
+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.
+
+Translates to:
+- Finding a datasource
+- Able to state what Exchange rates we want to return based on a list of desired currencies
+- A "source" currency to calculate against.
+- A Desired response to return
+
+### Discovery
+I navigated the CNB website to retrieve information around any API/data that relates to Exchange rates and found the following link describing an API.
+https://www.cnb.cz/cs/casto-kladene-dotazy/Kurzy-devizoveho-trhu-na-www-strankach-CNB/
+
+For this project I wanted to be able to easily extract the data regarding exchange rates, there were two approaches to this:
+- Option 1: Take advantage of the Swagger docs and directly integrate with the API: https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates
+- Option 2: Utilising the TXT file directly from the page https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
+
+For a production grade system, I decided to take advantage of the Swagger Docs as they are a typical gold standard for developers on information regarding what can be returned/errors,
+and it is safe to assume the route won't change for the API whereas the website can change.
+
+### Programming Philosophies That I followed for this project
+- Adherence to SOLID principals
+- Testability
+- Readability
+- Observability
+- KISS & DRY
+
+### Project Configuration
+With my approach I decided to upgrade the project to utilise .net 10.
+It required little effort and would provide a long term benefit in terms of active support in a production environment and ensure security updates are available.
+I utilised the option pattern so that API configuration can be done with a simple application.json modification and allow for a source currency to be defined.
+
+Additionally I added Polly and FluentValidation to assist with building a more resilient system to solving transient network errors and validating user input respectively.
+The Test project mimics the folder structure of the ExchangeRateUpdater Project so that it is easy to identify where the tests are. They were also written with BDD in mind.
+
+I decided to define a contract `IExchangeRateClient` where if we needed to integrate with another API, we could be free to do so without much hassle
+
+## Core Principles for the development in the project
+## Resiliency
+This was achieved using Polly in the CzechNationalBankExchangeRateClient.
+This client uses retrys and timeouts to manage the duration of a request and give it the ablilty to deal with transient errors.
+These values are configured in appSettings.json
+
+## Observability
+ - When it comes to observability, I try to follow the idea of being able to "Follow your code via Logs", and this was a practice that I tried to follow this project too.
+I not only accounted for error cases but also what should happen normally e.g, if we recieve a response from the external api.
+Additionally i've accounted for exceptions and Logged them individually to identify specific errors.
+I've also created a `ErrorLog` to extract the important information like the status code and message should there be an error.
+
+## Maintainability
+- I've explicitly created a "EmptyResponse" object to handle the cases where there is an unexpected failure.
+This avoids returning a "null" that is just obscure but instead returns a specific object that describes a fault.
+- I've also used the ResultsPattern to help identify successful outcomes to those that are undesired.
+- Single responsiblity has been a core component of this project as each class has one purpose in mind. It has allowed for simple and easy tests that are quick to verify.
+
+### Testing
+- FluentAssertions as they are really readable and assertions just make sense.
+- Moq for mocking out dependencies; really useful and very familiar with/its wide known.
+- XUnit as familiar framework.
+
+### Testing Strategy
+- Generally I try to stay close to the Given_When_Then format as its clear to identify the objective behind a test.
+- I prioritised mocking dependencies and Followed the Arrange Act Assert Pattern to make the tests clear to a reader.
+- Utilising SOLID patterns also helped make tests easier as the objective behind a class was straight forward.
+- Tests share an identical name with their class and follow a similar folder structure to ensure easy traceability.
+
+What this project does:
+- Exposes a Swagger Endpoint for interacting with the API
+ - `api/v1/exchange-rates/daily`
+ - This endpoint fetches the desired currencies and returns the Exchange based on the Source Currency for the current business day.
+
+## Getting Started
+
+Follow these steps to build, run, and test the project
+
+### 1. Build the Solution
+Compile the entire solution to resolve dependencies and create binaries.
+```bash
+dotnet build ExchangeRateUpdater.sln
+```
+
+### 2. Run the Solution
+Compile the entire solution to resolve dependencies and create binaries.
+```bash
+dotnet run --project ExchangeRateUpdater/ExchangeRateUpdater.csproj
+```
+
+### 3. Call the endpoint
+Utilise the Swagger Docs (Accessible via /swagger/index.html)
+
+Alternatively you can use the following CURL or Call the endpoint `api/v1/exchange-rates/daily` via Postman/Insomnia.
+
+```markdown
+curl -X 'GET' \
+ 'http://localhost:5001/api/v1/exchange-rates/daily?currencies=USD¤cies=GBP¤cies=XAK' \
+ -H 'accept: application/json'
+```
+
+### Run the Tests
+```bash
+dotnet test ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj
+```
+
+### Note
+When running locally, utlilse the LaunchSettings configuration so that its simpler to run.
+
+Things I would do if I had more time:
+- Support for the ability to choose a specific date, this could be done via utlising the existing integrated API or Expanding to using the other Endpoints.
+ - This can easily be done by taking advantage of the `IExchangeRateClient` and `CzechNationalBankExchangeRateClient` since there is a defined contract to retrieve ExchangeRate Data and the Client is open to expansion.
+- Caching the API response to reduce unnecessary requests calls to the CNB api.
+- Add robust integration tests utilising the JustEat client for Interception https://github.com/justeattakeaway/httpclient-interception
+- A suite of automated Postman tests used as a smoke test between environments to ensure confidence that the system still works.
+- Setup Runbook Alerts within monitoring Frameworks like Datadog so that any unexpected errors are raised to the attention of a developer asap.
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fbe..0000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
deleted file mode 100644
index 2fc654a12b..0000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Exe
- net6.0
-
-
-
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
index 89be84daff..866ff1f11e 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,9 @@ 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}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -15,6 +17,10 @@ Global
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..06a52db21e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Business.Processors;
+using ExchangeRateUpdater.Models;
+
+namespace ExchangeRateUpdater.Business
+{
+ public class ExchangeRateProvider : IExchangeRateProvider
+ {
+ private readonly IExchangeRateProcessor _exchangeRateProcessor;
+
+ public ExchangeRateProvider(IExchangeRateProcessor exchangeRateProcessor)
+ {
+ _exchangeRateProcessor = exchangeRateProcessor;
+ }
+
+ public async Task>> GetExchangeRates(string[] currencies)
+ {
+ return await _exchangeRateProcessor.ProcessExchangeRates(currencies);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs
new file mode 100644
index 0000000000..1d68b9f3d1
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Models;
+
+namespace ExchangeRateUpdater.Business;
+
+public interface IExchangeRateProvider
+{
+ ///
+ /// 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 Task>> GetExchangeRates(string[] currencies);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs
new file mode 100644
index 0000000000..89109ddb89
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Mapping;
+using ExchangeRateUpdater.Models;
+using ExchangeRateUpdater.Models.CzechNationalBank;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Business.Processors;
+
+public class CzechNationalBankProcessor : IExchangeRateProcessor
+{
+ private readonly IExchangeRateClient _client;
+ private readonly IExchangeRateMapper _mapper;
+ private readonly ILogger _logger;
+
+ public CzechNationalBankProcessor(
+ IExchangeRateClient client,
+ IExchangeRateMapper mapper,
+ ILogger logger)
+ {
+ _client = client;
+ _mapper = mapper;
+ _logger = logger;
+ }
+
+ public async Task>> ProcessExchangeRates(string[] currenciesToProcess)
+ {
+ _logger.LogInformation("CzechNationalBankProcessor_ProcessingCurrencies");
+
+ var processedRates = new List();
+ var responseToProcess = await _client.RetrieveExchangeRatesAsync();
+
+ if (responseToProcess is EmptyResponse)
+ {
+ return Result>.Fail("CzechNationalBankProcessor_UnexpectedClientError");
+ }
+
+ var dictionaryOfReturnedRates =
+ responseToProcess.Rates.ToDictionary(key => key.CurrencyCode, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var desiredCurrency in currenciesToProcess)
+ {
+ var haveFoundEntry = dictionaryOfReturnedRates.TryGetValue(desiredCurrency, out var value);
+ if (!haveFoundEntry)
+ {
+ _logger.LogWarning("CzechNationalBankProcessor_CurrencyNotFound_{DesiredCurrency}", desiredCurrency);
+ continue;
+ }
+
+ var mappedExchangeRate = _mapper.MapToExchangeRate(value);
+ processedRates.Add(mappedExchangeRate);
+ }
+
+ _logger.LogInformation("CzechNationalBankProcessor_FinishedProcessingCurrencies");
+ return Result>.Ok(processedRates);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs
new file mode 100644
index 0000000000..9c917d0e05
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Models;
+
+namespace ExchangeRateUpdater.Business.Processors;
+
+public interface IExchangeRateProcessor
+{
+ ///
+ /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
+ ///
+ ///
+ /// List of
+ Task>> ProcessExchangeRates(string[] currenciesToProcess);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs
new file mode 100644
index 0000000000..6babeecbb5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Business;
+using ExchangeRateUpdater.Models;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Controllers;
+
+[ApiController]
+[Route("api/v1/exchange-rates")]
+[Produces("application/json")]
+public class ExchangeRatesController : ControllerBase
+{
+ private readonly IExchangeRateProvider _exchangeRateProvider;
+ private readonly IValidator _currencyCodeValidator;
+ private readonly ILogger _logger;
+
+ public ExchangeRatesController(IExchangeRateProvider exchangeRateProvider, IValidator currencyCodeValidator, ILogger logger)
+ {
+ _exchangeRateProvider = exchangeRateProvider;
+ _currencyCodeValidator = currencyCodeValidator;
+ _logger = logger;
+ }
+
+ ///
+ /// Fetches the desired currencies and returns the based on the Source Currency for the current business day.
+ /// >
+ /// Desired Currencies
+ [HttpGet("daily")]
+ [ProducesResponseType(typeof(ActionResult>),(int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ActionResult),(int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.InternalServerError)]
+ public async Task GetSelectedCurrencyCodesDaily([FromQuery] string[] currencies = null)
+ {
+ var validationResult = await _currencyCodeValidator.ValidateAsync(currencies);
+
+ if (!validationResult.IsValid)
+ {
+ var errors = validationResult.Errors.Select(x => x.ErrorMessage);
+ _logger.LogError("ExchangeRatesController_ValidationFailure {Errors}", errors);
+ return BadRequest(validationResult.Errors.Select(x => x.ErrorMessage));
+ }
+
+ var result = await _exchangeRateProvider.GetExchangeRates(currencies);
+
+ if (!result.Success)
+ {
+ _logger.LogError("ExchangeRatesController_ProviderError {Error}", result.Error);
+ return BadRequest(result.Error);
+ }
+
+ return Ok(result.Value);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
new file mode 100644
index 0000000000..6ecaebebd9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+ true
+ $(NoWarn);1591
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs
new file mode 100644
index 0000000000..9d37edb28e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Models.CzechNationalBank;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Polly;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+public class CzechNationalBankExchangeRateClient : IExchangeRateClient
+{
+ private readonly IOptions _options;
+ private readonly HttpClient _client;
+ private readonly ILogger _logger;
+ private const string DAILY_EXRATES_PATH = "exrates/daily?lang=EN";
+
+ public CzechNationalBankExchangeRateClient(IOptions options, HttpClient client,
+ ILogger logger)
+ {
+ _options = options;
+ _client = client;
+ _logger = logger;
+ }
+
+ public async Task RetrieveExchangeRatesAsync()
+ {
+ try
+ {
+ var policy =
+ Policy.Handle()
+ .WaitAndRetryAsync(
+ _options.Value.RetryCount,
+ attempt => TimeSpan.FromMilliseconds(_options.Value.TimeoutMs),
+ (exception, timeSpan, retryCount, context) =>
+ {
+ _logger.LogWarning(exception, "CzechNationalBankExchangeRateClient_RetryAttempt_{RetryAttempt}",
+ retryCount);
+ });
+
+ var result =
+ await policy.ExecuteAsync(async () =>
+ {
+ var result = await _client.GetAsync(DAILY_EXRATES_PATH);
+ return result;
+ }
+ );
+
+ _logger.LogInformation("CzechNationalBankExchangeRateClient_RetrievedResponse");
+
+ if (result.IsSuccessStatusCode)
+ {
+ var parsedResponse = await result.Content.ReadFromJsonAsync();
+ return parsedResponse;
+ }
+
+ var parsedError = await result.Content.ReadFromJsonAsync();
+ var errorLog = new ErrorLog(parsedError.description, parsedError.errorCode, result.StatusCode);
+ _logger.LogError("CzechNationalBankExchangeRateClient_UnsuccessfulResponse {ErrorLog}", errorLog);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError("CzechNationalBankExchangeRateClient_DeserializationException {ErrorLog}", ex);
+ }
+ catch (TaskCanceledException ex)
+ {
+ _logger.LogError("CzechNationalBankExchangeRateClient_Timeout {ErrorLog}" ,ex);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("CzechNationalBankExchangeRateClient_UnexpectedException {ErrorLog}",ex);
+ }
+
+ return new EmptyResponse();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs
new file mode 100644
index 0000000000..41863326f1
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs
@@ -0,0 +1,12 @@
+using System.Threading.Tasks;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+public interface IExchangeRateClient
+{
+ ///
+ /// Retrieves ExchangeRate information from a Response via an external data source.
+ ///
+ /// Response from the external data source
+ Task RetrieveExchangeRatesAsync();
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs
new file mode 100644
index 0000000000..5911fc00d3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs
@@ -0,0 +1,24 @@
+using System;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Models;
+using ExchangeRateUpdater.Models.Configuration;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using Microsoft.Extensions.Options;
+
+namespace ExchangeRateUpdater.Mapping;
+
+public class CzechNationalBankResponseMapper : IExchangeRateMapper
+{
+ private const int DESIREDDECIMALPLACE = 3;
+ private readonly string _sourceCurrency;
+
+ public CzechNationalBankResponseMapper(IOptions options)
+ {
+ _sourceCurrency = options.Value.SourceCurrency;
+ }
+ public ExchangeRate MapToExchangeRate(RecordedRate typeToConvert)
+ {
+ var calculatedRate = Math.Round(typeToConvert.Rate / typeToConvert.Amount, DESIREDDECIMALPLACE);
+ return new ExchangeRate(new Currency(_sourceCurrency), new Currency(typeToConvert.CurrencyCode), calculatedRate);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs
new file mode 100644
index 0000000000..f0b44a47ec
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs
@@ -0,0 +1,13 @@
+using ExchangeRateUpdater.Models;
+
+namespace ExchangeRateUpdater.Mapping;
+
+public interface IExchangeRateMapper
+{
+ ///
+ /// Maps the provided type to an
+ ///
+ ///
+ /// Mapped
+ ExchangeRate MapToExchangeRate(T typeToConvert);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs
new file mode 100644
index 0000000000..377bb69774
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ExchangeRateUpdater.Models.Configuration;
+
+public class ExchangeRateConfigurationOptions
+{
+ ///
+ /// Three-letter ISO 4217 code of the desired Source Currency.
+ ///
+ [Required]
+ [Length(3,3)]
+ public string SourceCurrency { get; init; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
similarity index 89%
rename from jobs/Backend/Task/Currency.cs
rename to jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
index f375776f25..8336d740ee 100644
--- a/jobs/Backend/Task/Currency.cs
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
@@ -1,4 +1,4 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRateUpdater.Models
{
public class Currency
{
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs
new file mode 100644
index 0000000000..e8bea50f10
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ExchangeRateUpdater.Models.CzechNationalBank;
+
+public class CzechNationalBankClientOptions
+{
+ [Required]
+ public string BaseUri { get; set; }
+ public int TimeoutMs { get; set; }
+ public int RetryCount { get; set; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs
new file mode 100644
index 0000000000..6431ba2838
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+using System.Linq;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+
+namespace ExchangeRateUpdater.Models.CzechNationalBank;
+
+public record Response(IEnumerable Rates);
+
+public record EmptyResponse(): Response (Enumerable.Empty());
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs
new file mode 100644
index 0000000000..c8d71e0478
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs
@@ -0,0 +1,5 @@
+using System.Net;
+
+namespace ExchangeRateUpdater.Models.CzechNationalBank;
+
+public record ErrorLog(string description, string errorCode, HttpStatusCode StatusCode);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs
new file mode 100644
index 0000000000..8d273d4e4c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs
@@ -0,0 +1,5 @@
+using System;
+
+namespace ExchangeRateUpdater.Models.CzechNationalBank;
+
+public record ErrorResponse(string description, string endPoint, string errorCode, DateTime happenedAt, string messageId);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs
new file mode 100644
index 0000000000..44ef180e3e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs
@@ -0,0 +1,5 @@
+using System;
+
+namespace ExchangeRateUpdater.Models.CzechNationalBankResponse;
+
+public record RecordedRate(int Amount, string Country, string Currency, string CurrencyCode, int Order, decimal Rate, DateTime ValidFor);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs
similarity index 93%
rename from jobs/Backend/Task/ExchangeRate.cs
rename to jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs
index 58c5bb10e0..2133586d44 100644
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs
@@ -1,4 +1,4 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRateUpdater.Models
{
public class ExchangeRate
{
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs
new file mode 100644
index 0000000000..82ab02d8d7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs
@@ -0,0 +1,20 @@
+using ExchangeRateUpdater.Models.CzechNationalBank;
+
+namespace ExchangeRateUpdater.Models;
+
+public class Result
+{
+ public bool Success { get; }
+ public T Value { get; }
+ public string Error { get; }
+
+ private Result(bool success, T value, string error)
+ {
+ Success = success;
+ Value = value;
+ Error = error;
+ }
+
+ public static Result Ok(T value) => new Result(true, value, null);
+ public static Result Fail(string error) => new Result(false, default(T), error);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json
new file mode 100644
index 0000000000..6b99d4a9a7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "ExchangeRateUpdater": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs
new file mode 100644
index 0000000000..6bde10c62d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace ExchangeRateUpdater.Startup
+{
+ [ExcludeFromCodeCoverage]
+ public static class Program
+ {
+
+ public static void Main(string[] args)
+ {
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ builder.RegisterConfigurations();
+ builder.Services.RegisterValidators();
+ builder.Services.AddControllers();
+ builder.Services.RegisterHttpClients(builder.Configuration);
+ builder.Services.AddServices();
+
+ var app = builder.Build();
+
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapSwagger();
+ app.UseSwagger();
+ app.UseSwaggerUI();
+ }
+
+ app.MapControllers();
+ app.Run();
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..94fcfcb9cd
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net.Http.Headers;
+using System.Reflection;
+using ExchangeRateUpdater.Business;
+using ExchangeRateUpdater.Business.Processors;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Mapping;
+using ExchangeRateUpdater.Models.Configuration;
+using ExchangeRateUpdater.Models.CzechNationalBank;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using ExchangeRateUpdater.Validators;
+using FluentValidation;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.OpenApi;
+
+namespace ExchangeRateUpdater.Startup;
+
+[ExcludeFromCodeCoverage]
+public static class ServiceCollectionExtensions
+{
+ public static void AddServices(this IServiceCollection services)
+ {
+ services.AddSwaggerGen(c =>
+ {
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "ExchangeRateUpdater.Docs", Version = "v1" });
+ var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
+ });
+
+ services.AddTransient, CzechNationalBankResponseMapper>();
+ services.AddTransient();
+ services.AddTransient();
+ }
+
+ public static void RegisterConfigurations(this WebApplicationBuilder builder)
+ {
+ builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
+ builder.Configuration.AddEnvironmentVariables();
+
+ builder.Services.AddOptionsWithValidateOnStart("Integrations:ExchangeRateApis:CzechNationalBank");
+ builder.Services.Configure(builder.Configuration.GetSection("ExchangeRateConfiguration"));
+ }
+
+ public static void RegisterHttpClients(this IServiceCollection service, IConfiguration configuration)
+ {
+ service.AddHttpClient, CzechNationalBankExchangeRateClient>( client =>
+ {
+ var options = configuration.GetSection("Integrations:ExchangeRateApis:CzechNationalBank").Get();
+ client.BaseAddress = new Uri($"https://{options.BaseUri}");
+ client.Timeout = TimeSpan.FromMilliseconds(options.TimeoutMs);
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ });
+ }
+
+ public static void RegisterValidators(this IServiceCollection serviceCollection)
+ {
+ serviceCollection.AddScoped, ExchangeRateCurrencyCodeValidator>();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs b/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs
new file mode 100644
index 0000000000..e71be81247
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs
@@ -0,0 +1,21 @@
+using System;
+using FluentValidation;
+
+namespace ExchangeRateUpdater.Validators;
+
+public class ExchangeRateCurrencyCodeValidator : AbstractValidator
+{
+ public ExchangeRateCurrencyCodeValidator()
+ {
+ // Input Currency Codes validated to ISO Standard without Upper Casing for Ease of use.
+ RuleFor(currencyCodes => currencyCodes)
+ .NotEmpty()
+ .WithName("Currency Codes");
+
+
+ RuleForEach(currencyCodes => currencyCodes)
+ .MaximumLength(3)
+ .MinimumLength(3)
+ .WithName("Currency Code");
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json
new file mode 100644
index 0000000000..e203e9407e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
new file mode 100644
index 0000000000..05e969a20e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
@@ -0,0 +1,21 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ },
+ "ExchangeRateConfiguration": {
+ "SourceCurrency": "CZK"
+ },
+ "Integrations": {
+ "ExchangeRateApis":{
+ "CzechNationalBank": {
+ "BaseUri": "api.cnb.cz/cnbapi/",
+ "TimeoutMs": 2000,
+ "RetryCount": 2
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/global.json b/jobs/Backend/Task/ExchangeRateUpdater/global.json
new file mode 100644
index 0000000000..a11f48e193
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.0",
+ "rollForward": "latestMajor",
+ "allowPrerelease": true
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs
new file mode 100644
index 0000000000..4db7d16836
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs
@@ -0,0 +1,35 @@
+using ExchangeRateUpdater.Business;
+using ExchangeRateUpdater.Business.Processors;
+using ExchangeRateUpdater.Models;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Business;
+
+public class ExchangeRateProviderTests
+{
+ private readonly Mock _exchangeRateProcessorMock;
+ private readonly ExchangeRateProvider _provider;
+
+ public ExchangeRateProviderTests()
+ {
+ _exchangeRateProcessorMock = new Mock();
+ _provider = new ExchangeRateProvider(_exchangeRateProcessorMock.Object);
+ }
+
+ [Fact]
+ public async Task Given_CurrencyCodes_When_GetExchangeRates_Then_CallsProcessor()
+ {
+ var currencyCodes = new[] { "USD", "EUR" };
+ var expectedResult = Result>.Ok(new List());
+ _exchangeRateProcessorMock
+ .Setup(p => p.ProcessExchangeRates(currencyCodes))
+ .ReturnsAsync(expectedResult);
+
+ var result = await _provider.GetExchangeRates(currencyCodes);
+
+ expectedResult.Should().Be(result);
+ _exchangeRateProcessorMock.Verify(p => p.ProcessExchangeRates(currencyCodes), Times.Once);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs
new file mode 100644
index 0000000000..de59856d30
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs
@@ -0,0 +1,104 @@
+using ExchangeRateUpdater.Business.Processors;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Mapping;
+using ExchangeRateUpdater.Models;
+using ExchangeRateUpdater.Models.CzechNationalBank;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Business.Processors;
+
+public class CzechNationalBankProcessorTests
+{
+ private readonly Mock> _clientMock;
+ private readonly Mock> _mapperMock;
+ private readonly Mock> _loggerMock;
+ private readonly CzechNationalBankProcessor _processor;
+
+ public CzechNationalBankProcessorTests()
+ {
+ _clientMock = new Mock>();
+ _mapperMock = new Mock>();
+ _loggerMock = new Mock>();
+ _processor = new CzechNationalBankProcessor(_clientMock.Object, _mapperMock.Object, _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Given_EmptyClientResponse_When_ProcessExchangeRates_Then_ReturnsFailure()
+ {
+ _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(new EmptyResponse());
+
+ var result = await _processor.ProcessExchangeRates(new[] { "USD" });
+
+ result.Success.Should().BeFalse();
+ result.Error.Should().Be("CzechNationalBankProcessor_UnexpectedClientError");
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_FinishedProcessingCurrencies")),
+ null,
+ It.IsAny>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Given_ValidResponse_When_ProcessExchangeRates_Then_ReturnsMappedRates()
+ {
+ var usdRate = new RecordedRate(1, "USA", "Dollar", "USD", 1, 25.0m, DateTime.Now);
+ var response = new Response(new List { usdRate });
+ _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(response);
+
+ var mappedRate = new ExchangeRate(new Currency("CZK"), new Currency("USD"), 25.0m);
+ _mapperMock.Setup(m => m.MapToExchangeRate(usdRate)).Returns(mappedRate);
+
+ var result = await _processor.ProcessExchangeRates(new[] { "USD" });
+
+ result.Success.Should().BeTrue();
+ result.Value.FirstOrDefault().Should().Be(mappedRate);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_ProcessingCurrencies")),
+ null,
+ It.IsAny>()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_FinishedProcessingCurrencies")),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_CurrencyNotFound_When_ProcessExchangeRates_Then_LogsWarningAndSkips()
+ {
+ var eurRate = new RecordedRate(1, "EMU", "Euro", "EUR", 1, 24.0m, DateTime.Now);
+ var response = new Response(new List { eurRate });
+ _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(response);
+
+ var result = await _processor.ProcessExchangeRates(new[] { "USD" });
+
+ result.Success.Should().BeTrue();
+ result.Value.Should().BeEmpty();
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_CurrencyNotFound_USD")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs
new file mode 100644
index 0000000000..4a0e078648
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs
@@ -0,0 +1,99 @@
+using ExchangeRateUpdater.Business;
+using ExchangeRateUpdater.Controllers;
+using ExchangeRateUpdater.Models;
+using FluentAssertions;
+using FluentValidation;
+using FluentValidation.Results;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Controllers;
+
+public class ExchangeRatesControllerTests
+{
+ private readonly Mock _exchangeRateProviderMock;
+ private readonly Mock> _currencyCodeValidatorMock;
+ private readonly Mock> _loggerMock;
+ private readonly ExchangeRatesController _sut;
+
+ public ExchangeRatesControllerTests()
+ {
+ _exchangeRateProviderMock = new Mock();
+ _currencyCodeValidatorMock = new Mock>();
+ _loggerMock = new Mock>();
+ _sut = new ExchangeRatesController(
+ _exchangeRateProviderMock.Object,
+ _currencyCodeValidatorMock.Object,
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Given_InvalidCurrencies_When_GetSelectedCurrencyCodesDaily_Then_ReturnsBadRequest()
+ {
+ var currencies = new[] { "INVALID" };
+ var validationFailures = new List { new("currencies", "Invalid currency code") };
+ _currencyCodeValidatorMock
+ .Setup(v => v.ValidateAsync(currencies, It.IsAny()))
+ .ReturnsAsync(new ValidationResult(validationFailures));
+
+ var result = await _sut.GetSelectedCurrencyCodesDaily(currencies);
+
+ result.Should().BeOfType()
+ .Which.Value.Should().BeEquivalentTo(new[] { "Invalid currency code" });
+
+ _loggerMock.Verify(x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) =>
+ v.ToString()!.Contains("ExchangeRatesController_ValidationFailure") && v.ToString()!.Contains(validationFailures[0].ErrorMessage)),
+ null,
+ It.IsAny>()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_ProviderReturnsFailure_When_GetSelectedCurrencyCodesDaily_Then_ReturnsBadRequestWithError()
+ {
+ const string errorMessage = "Provider error";
+ var currencies = new[] { "USD" };
+ _currencyCodeValidatorMock
+ .Setup(validator => validator.ValidateAsync(currencies, It.IsAny()))
+ .ReturnsAsync(new ValidationResult());
+ _exchangeRateProviderMock
+ .Setup(provider => provider.GetExchangeRates(currencies))
+ .ReturnsAsync(Result>.Fail(errorMessage));
+
+ var result = await _sut.GetSelectedCurrencyCodesDaily(currencies);
+
+ result.Should().BeOfType()
+ .Which.Value.Should().Be(errorMessage);
+
+ _loggerMock.Verify(x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) =>
+ v.ToString()!.Contains("ExchangeRatesController_ProviderError") &&
+ v.ToString()!.Contains(errorMessage)),
+ null,
+ It.IsAny>()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_ValidCurrencyCodes_When_GetSelectedCurrencyCodesDaily_Then_ReturnsOkWithRates()
+ {
+ var currencyCodes = new[] { "USD" };
+ var rates = new List { new(new Currency("CZK"), new Currency("USD"), 25.0m) };
+ _currencyCodeValidatorMock
+ .Setup(validator => validator.ValidateAsync(currencyCodes, It.IsAny()))
+ .ReturnsAsync(new ValidationResult());
+ _exchangeRateProviderMock
+ .Setup(provider => provider.GetExchangeRates(currencyCodes))
+ .ReturnsAsync(Result>.Ok(rates));
+
+ var result = await _sut.GetSelectedCurrencyCodesDaily(currencyCodes);
+
+ result.Should().BeOfType()
+ .Which.Value.Should().BeEquivalentTo(rates);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj
new file mode 100644
index 0000000000..fb4f41b0d3
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs
new file mode 100644
index 0000000000..f75bd3d6c2
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs
@@ -0,0 +1,196 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Models.CzechNationalBank;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Infrastructure;
+
+public class CzechNationalBankExchangeRateClientTests
+{
+ private readonly Mock _handlerMock;
+ private readonly Mock> _loggerMock;
+ private readonly CzechNationalBankExchangeRateClient _sut;
+
+ public CzechNationalBankExchangeRateClientTests()
+ {
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Value).Returns(new CzechNationalBankClientOptions
+ {
+ RetryCount = 2,
+ TimeoutMs = 10
+ });
+
+ _handlerMock = new Mock();
+ var httpClient = new HttpClient(_handlerMock.Object)
+ {
+ BaseAddress = new Uri("https://test.com/")
+ };
+
+ _loggerMock = new Mock>();
+ _sut = new CzechNationalBankExchangeRateClient(optionsMock.Object, httpClient, _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Given_SuccessfulResponse_When_RetrieveExchangeRatesAsync_Then_ReturnsResponse()
+ {
+ // Arrange
+ var expectedResponse = new Response(new List
+ {
+ new(1, "USA", "Dollar", "USD", 1, 25.125m, DateTime.Now)
+ });
+
+ _handlerMock.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = JsonContent.Create(expectedResponse)
+ });
+
+ var result = await _sut.RetrieveExchangeRatesAsync();
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetrievedResponse")),
+ null,
+ It.IsAny>()),
+ Times.Once);
+
+ var output = result.Rates.First();
+ output.Should().Be(expectedResponse.Rates.First());
+ }
+
+ [Fact]
+ public async Task Given_UnsuccessfulStatusCode_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError()
+ {
+ var errorResponse = new ErrorResponse("Error", "/test", "404", DateTime.Now, "123");
+
+ _handlerMock.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.NotFound,
+ Content = JsonContent.Create(errorResponse)
+ });
+
+ var result = await _sut.RetrieveExchangeRatesAsync();
+
+ result.Should().BeOfType();
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_UnsuccessfulResponse")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetrievedResponse")),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_HttpRequestException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError()
+ {
+ _handlerMock.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ThrowsAsync(new HttpRequestException("Network error"));
+
+ var result = await _sut.RetrieveExchangeRatesAsync();
+
+ result.Should().BeOfType();
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_UnexpectedException")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_JsonException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError()
+ {
+ _handlerMock.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ThrowsAsync(new JsonException("Error"));
+
+ var result = await _sut.RetrieveExchangeRatesAsync();
+
+ result.Should().BeOfType();
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) =>
+ v.ToString()!.Contains("CzechNationalBankExchangeRateClient_DeserializationException") &&
+ v.ToString()!.Contains("Error")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Given_TimeoutException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError()
+ {
+ _handlerMock.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ThrowsAsync(new TaskCanceledException("Timeout!"));
+
+ var result = await _sut.RetrieveExchangeRatesAsync();
+
+ result.Should().BeOfType();
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetryAttempt_2")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) =>
+ v.ToString()!.Contains("CzechNationalBankExchangeRateClient_Timeout") &&
+ v.ToString()!.Contains("Timeout!")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs
new file mode 100644
index 0000000000..913fd2c058
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs
@@ -0,0 +1,44 @@
+using System;
+using ExchangeRateUpdater.Mapping;
+using ExchangeRateUpdater.Models.Configuration;
+using ExchangeRateUpdater.Models.CzechNationalBankResponse;
+using FluentAssertions;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Mapping;
+
+public class CzechNationalBankResponseMapperTests
+{
+ private readonly CzechNationalBankResponseMapper _sut;
+
+ public CzechNationalBankResponseMapperTests()
+ {
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Value).Returns(new ExchangeRateConfigurationOptions
+ {
+ SourceCurrency = "CZK"
+ });
+ _sut = new CzechNationalBankResponseMapper(optionsMock.Object);
+ }
+
+ [Fact]
+ public void Given_RecordedRate_When_MapToExchangeRate_Then_ReturnsCorrectExchangeRate()
+ {
+ var recordedRate = new RecordedRate(
+ Amount: 1,
+ Country: "USA",
+ Currency: "Dollar",
+ CurrencyCode: "USD",
+ Order: 1,
+ Rate: 25.1236m,
+ ValidFor: DateTime.Now);
+
+ var result = _sut.MapToExchangeRate(recordedRate);
+
+ result.SourceCurrency.Code.Should().Be("CZK");
+ result.TargetCurrency.Code.Should().Be("USD");
+ result.Value.Should().Be(25.124m);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs
new file mode 100644
index 0000000000..71b7b1c88e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs
@@ -0,0 +1,38 @@
+using ExchangeRateUpdater.Validators;
+using FluentValidation.TestHelper;
+using Xunit;
+
+namespace ExchangeRateUpdaterTests.Validators;
+
+public class ExchangeRateCurrencyCodeValidatorTests
+{
+ private readonly ExchangeRateCurrencyCodeValidator _sut;
+
+ public ExchangeRateCurrencyCodeValidatorTests()
+ {
+ _sut = new ExchangeRateCurrencyCodeValidator();
+ }
+
+ [Fact]
+ public void Given_EmptyArray_When_Validating_Then_HasError()
+ {
+ var result = _sut.TestValidate([]);
+ result.ShouldHaveValidationErrorFor("Currency Codes");
+ }
+
+ [Theory]
+ [InlineData("US")]
+ [InlineData("USDE")]
+ public void Given_InvalidLengthCurrencyCode_When_Validating_Then_HasError(string code)
+ {
+ var result = _sut.TestValidate(new[] { code });
+ result.ShouldHaveValidationErrorFor("Currency Code[0]");
+ }
+
+ [Fact]
+ public void Given_ValidCurrencyCodes_When_Validating_Then_HasNoErrors()
+ {
+ var result = _sut.TestValidate(new[] { "USD", "EUR", "CZK" });
+ result.ShouldNotHaveAnyValidationErrors();
+ }
+}
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f8..0000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}