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..b6c364128d 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,18 +3,44 @@ 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", "{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.Build.0 = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU
+ {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.Build.0 = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.Build.0 = Debug|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.ActiveCfg = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.Build.0 = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.ActiveCfg = Release|Any CPU
+ {AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs
new file mode 100644
index 0000000000..5c552f25fd
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IDateTimeService.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace ExchangeRateUpdater.Contracts;
+
+public interface IDateTimeService
+{
+ DateTime GetUtcNow();
+
+ DateTime GetNow();
+
+ DateTime GetToday();
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs
new file mode 100644
index 0000000000..866b3225c8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IExchangeRateLoader.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Models;
+
+namespace ExchangeRateUpdater.Contracts;
+
+public interface IExchangeRateLoader
+{
+ IRateRefreshScheduler RateRefreshSchedule { get; }
+ Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime date);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs
new file mode 100644
index 0000000000..18e2c3b97c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Contracts/IRateRefreshSchedule.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace ExchangeRateUpdater.Contracts;
+
+public interface IRateRefreshScheduler
+{
+ DateTimeOffset GetNextRefreshTime();
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs
new file mode 100644
index 0000000000..f86821372c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbExchangeRateLoader.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.DataSource.Cnb.Dto;
+using ExchangeRateUpdater.DataSources;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Infrastructure.Config;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.DataSource.Cnb;
+
+internal class CnbExchangeRateLoader : IExchangeRateLoader
+{
+ private const string DailyExchangeRateUrlPath = "/cnbapi/exrates/daily?date={0}&lang=EN";
+ private readonly IRequestHandler requestHandler;
+ private readonly CnbRateConverter converter;
+ private readonly ILogger logger;
+ private readonly IDateTimeService dateTimeService;
+ private readonly Uri baseApiUri;
+ private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
+
+ public CnbExchangeRateLoader(IRequestHandler requestHandler,
+ CnbRateConverter converter,
+ ILogger logger,
+ IRefreshScheduleFactory refreshScheduleFactory,
+ IDateTimeService dateTimeService,
+ [FromKeyedServices(Constants.CnbServiceKey)] ExchangeRateLoaderConfig config)
+ {
+ this.requestHandler = requestHandler;
+ this.converter = converter;
+ this.logger = logger;
+ this.dateTimeService = dateTimeService;
+ baseApiUri = new Uri(config.BaseApiUrl);
+ RateRefreshSchedule = refreshScheduleFactory.CreateRefreshSchedule(config.RefreshScheduleConfig);
+ logger.LogInformation("Initialized data source with URL {} and refresh schedule: {}", baseApiUri, RateRefreshSchedule);
+ }
+
+ public IRateRefreshScheduler RateRefreshSchedule { get; }
+
+ // discussable: can consider adding "retry" logic inside this method. In this case the method will need CancellationToken parameter to stop pending retries.
+ public async Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime date)
+ {
+ ArgumentNullException.ThrowIfNull(currencies);
+
+ if (date >= dateTimeService.GetToday().AddDays(1))
+ throw new ArgumentException("The date should not be in the future", nameof(date));
+
+ if (!currencies.Any())
+ {
+ return [];
+ }
+
+ try
+ {
+ var dateParam = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var requestUri = new Uri(baseApiUri, string.Format(DailyExchangeRateUrlPath, dateParam)).ToString();
+
+ var stopwatch = new Stopwatch();
+ stopwatch.Start();
+
+ using var stream = await requestHandler.GetStreamAsync(requestUri);
+
+ CnbRateResponse response = await JsonSerializer.DeserializeAsync(stream, serializerOptions);
+
+ stopwatch.Stop(); // Further the elapsed time can be put into metrics
+ logger.LogInformation("Loaded {} rates in {} ms", response.Rates.Length, stopwatch.ElapsedMilliseconds);
+
+ var requestedCurrencies = new HashSet(currencies);
+ var result = response.Rates
+ .Where(r => requestedCurrencies.Contains(r.CurrencyCode))
+ .Select(converter.Convert)
+ .Where(r => r != null)
+ .ToList();
+
+ return result;
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new RateLoaderException($"API responded with error status code: {ex.StatusCode}", ex);
+ }
+ catch (Exception ex)
+ {
+ throw new RateLoaderException("Unable load exchange rates from CNB", ex);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs
new file mode 100644
index 0000000000..3bd0ba170c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/CnbRateConverter.cs
@@ -0,0 +1,29 @@
+using System;
+using ExchangeRateUpdater.DataSource.Cnb.Dto;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.DataSource.Cnb;
+
+internal class CnbRateConverter
+{
+ private readonly ILogger logger;
+
+ public CnbRateConverter( ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public ExchangeRate Convert(CnbRate rate)
+ {
+ IsoCurrencyCode sourceCurrency;
+ // poor man validation
+ if (!Enum.TryParse(rate.CurrencyCode, out sourceCurrency) || rate.Amount <= 0)
+ {
+ logger.LogWarning("Invalid rate for {} received from CNB, ignoring", rate.CurrencyCode);
+ return null;
+ }
+
+ return new ExchangeRate(new Currency(sourceCurrency), new Currency(IsoCurrencyCode.CZK), rate.Rate / rate.Amount);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs
new file mode 100644
index 0000000000..50ff69d935
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRate.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace ExchangeRateUpdater.DataSource.Cnb.Dto;
+
+internal sealed record CnbRate
+{
+ [JsonPropertyName("amount")]
+ public long Amount { get; init; }
+
+ [JsonPropertyName("country")]
+ public string Country { get; init; }
+
+ [JsonPropertyName("currency")]
+ public string Currency { get; init; }
+
+ [JsonPropertyName("currencyCode")]
+ public string CurrencyCode { get; init; }
+
+ [JsonPropertyName("order")]
+ public int Order { get; init; }
+
+ [JsonPropertyName("rate")]
+ public Decimal Rate { get; init; }
+
+ [JsonPropertyName("validFor")]
+ public string ValidFor { get; init; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs
new file mode 100644
index 0000000000..1a8531d15a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/Cnb/Dto/CnbRateResponse.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace ExchangeRateUpdater.DataSource.Cnb.Dto;
+
+internal sealed record CnbRateResponse
+{
+ [JsonPropertyName("rates")]
+ public CnbRate[] Rates { get; init; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs
new file mode 100644
index 0000000000..2b81d5be94
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RateLoaderException.cs
@@ -0,0 +1,7 @@
+using System;
+
+namespace ExchangeRateUpdater.DataSources;
+
+public class RateLoaderException(string message, Exception inner) : Exception(message, inner)
+{
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs
new file mode 100644
index 0000000000..aeadab60d8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/DefaultRateRefreshScheduler.cs
@@ -0,0 +1,32 @@
+using System;
+using ExchangeRateUpdater.Contracts;
+
+namespace ExchangeRateUpdater.DataSources.RefreshSchedule;
+
+internal class DefaultRateRefreshScheduler : IRateRefreshScheduler
+{
+ private readonly TimeOnly time;
+ private readonly TimeZoneInfo timeZone;
+ private readonly IDateTimeService dateTimeService;
+
+ public DefaultRateRefreshScheduler(TimeOnly time, TimeZoneInfo timeZone, IDateTimeService dateTimeService)
+ {
+ this.time = time;
+ this.timeZone = timeZone;
+ this.dateTimeService = dateTimeService;
+ }
+
+ public DateTimeOffset GetNextRefreshTime()
+ {
+ var refreshTimeForCurrentDateInUtc =
+ TimeZoneInfo.ConvertTimeToUtc(new DateTime(DateOnly.FromDateTime(dateTimeService.GetToday()), time, DateTimeKind.Unspecified), timeZone);
+
+ DateTime nextRefreshDate = dateTimeService.GetUtcNow() < refreshTimeForCurrentDateInUtc ?
+ refreshTimeForCurrentDateInUtc :
+ refreshTimeForCurrentDateInUtc.AddDays(1);
+
+ return nextRefreshDate;
+ }
+
+ public override string ToString() => $"{time:t} {timeZone.StandardName}";
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs
new file mode 100644
index 0000000000..71f526959a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/NoRefreshScheduler.cs
@@ -0,0 +1,11 @@
+using System;
+using ExchangeRateUpdater.Contracts;
+
+namespace ExchangeRateUpdater.DataSources.RefreshSchedule;
+
+internal sealed class NoRefreshScheduler : IRateRefreshScheduler
+{
+ public DateTimeOffset GetNextRefreshTime() => DateTimeOffset.MaxValue;
+
+ public override string ToString() => "No refresh";
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs
new file mode 100644
index 0000000000..66793f5ef9
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/DataSources/RefreshSchedule/RefreshScheduleFactory.cs
@@ -0,0 +1,27 @@
+using System;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Infrastructure.Config;
+
+namespace ExchangeRateUpdater.DataSources.RefreshSchedule;
+
+internal class RefreshScheduleFactory : IRefreshScheduleFactory
+{
+ private readonly IDateTimeService dateTimeService;
+
+ public RefreshScheduleFactory(IDateTimeService dateTimeService)
+ {
+ this.dateTimeService = dateTimeService;
+ }
+
+ public IRateRefreshScheduler CreateRefreshSchedule(RefreshScheduleConfig config)
+ {
+ return config == null
+ ? new NoRefreshScheduler()
+ : new DefaultRateRefreshScheduler(
+ TimeOnly.Parse(config.Time),
+ TimeZoneInfo.FindSystemTimeZoneById(config.TimeZone),
+ dateTimeService);
+
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs
new file mode 100644
index 0000000000..62236a5a3f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater
+{
+ public class ExchangeRateProvider
+ {
+ private readonly ILogger logger;
+ private readonly IExchangeRateLoader loader;
+ private readonly IMemoryCache currencyCache;
+ private readonly IDateTimeService dateTimeService;
+
+ public ExchangeRateProvider(IExchangeRateLoader loader,
+ ILogger logger,
+ IMemoryCache currencyCache,
+ IDateTimeService dateTimeService)
+ {
+ this.loader = loader;
+ this.logger = logger;
+ this.currencyCache = currencyCache;
+ this.dateTimeService = dateTimeService;
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ // discussable: do we need force manual cleaning cache during runtime?
+ public async Task> GetExchangeRates(IEnumerable currencies)
+ {
+ ArgumentNullException.ThrowIfNull(currencies);
+
+ if (!currencies.Any()) return [];
+
+ var ratesFromCache = GetRatesFromCache(currencies);
+ var currenciesFromCache = ratesFromCache.Select(r => r.SourceCurrency);
+ var currenciesToLoad = currencies.Except(currenciesFromCache).ToList();
+ if (currenciesToLoad.Count == 0)
+ {
+ logger.LogInformation("All rates loaded from cache - no need to call Loader");
+ return ratesFromCache;
+ }
+
+ var ratesFromDataSource = await GetRatesFromDataSource(currenciesToLoad);
+
+ PutRatesToCache(ratesFromDataSource);
+
+ logger.LogInformation("Loaded {} rates from data source, {} rates from cache", ratesFromDataSource.Count, ratesFromCache.Count);
+
+ return ratesFromDataSource.Union(ratesFromCache);
+ }
+
+ private List GetRatesFromCache(IEnumerable currencies)
+ {
+ var ratesFromCache = new List();
+
+ foreach (var currency in currencies)
+ {
+ if (currencyCache.TryGetValue(currency.Code, out var exchangeRate))
+ {
+ ratesFromCache.Add(exchangeRate);
+ }
+ }
+
+ return ratesFromCache;
+ }
+
+ // discussable: can handle exceptions and return empty result, or can re-throw exceptions, or can return a combined "Result" object containing result and optional exception
+ private async Task> GetRatesFromDataSource(IEnumerable currenciesToLoad)
+ {
+ List ratesFromDataSource;
+
+ try
+ {
+ ratesFromDataSource = (await loader.GetExchangeRatesAsync(currenciesToLoad.Select(c => c.Code.ToString()), dateTimeService.GetNow())).ToList();
+ }
+ catch (Exception e)
+ {
+ logger.LogError("Failed loading rates from data source: {}", e.Message);
+ ratesFromDataSource = [];
+ }
+
+ return ratesFromDataSource;
+ }
+
+ private void PutRatesToCache(IEnumerable ratesFromDataSource)
+ {
+ DateTimeOffset expiration = loader.RateRefreshSchedule.GetNextRefreshTime();
+
+ foreach (var loadedRate in ratesFromDataSource)
+ {
+ currencyCache.Set(loadedRate.SourceCurrency.Code, loadedRate, expiration);
+ logger.LogInformation("Cached rate {} with expiration: {}", loadedRate, expiration);
+ }
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
new file mode 100644
index 0000000000..4400107331
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs
new file mode 100644
index 0000000000..3dfae2acac
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/AppConfiguration.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Infrastructure.Config;
+
+internal sealed record AppConfiguration
+{
+ public required ExchangeRateLoaderConfig CnbExchangeRateLoaderConfig { get; init; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs
new file mode 100644
index 0000000000..c7051b4203
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/ExchangeRateLoaderConfig.cs
@@ -0,0 +1,8 @@
+namespace ExchangeRateUpdater.Infrastructure.Config;
+
+internal record ExchangeRateLoaderConfig
+{
+ public required string BaseApiUrl { get; init; }
+
+ public RefreshScheduleConfig RefreshScheduleConfig { get; init; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs
new file mode 100644
index 0000000000..8a84052c3a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Config/RefreshScheduleConfig.cs
@@ -0,0 +1,7 @@
+namespace ExchangeRateUpdater.Infrastructure.Config;
+
+internal record RefreshScheduleConfig
+{
+ public required string Time { get; init; }
+ public required string TimeZone { get; init; }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs
new file mode 100644
index 0000000000..8f1572ffa8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/Constants.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Infrastructure;
+
+public static class Constants
+{
+ public const string CnbServiceKey = "cnb";
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs
new file mode 100644
index 0000000000..6d7be4795e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/DependencyExtensions.cs
@@ -0,0 +1,35 @@
+using System.Net.Http;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.DataSource.Cnb;
+using ExchangeRateUpdater.DataSources.RefreshSchedule;
+using ExchangeRateUpdater.Infrastructure.Config;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+public static class DependencyExtensions
+{
+ public static IServiceCollection AddExchangeRatesProvider(this IServiceCollection services)
+ {
+ services.AddLogging(builder => builder.AddConsole());
+ services.AddMemoryCache();
+ services.AddSingleton();
+
+ var config = new ConfigurationBuilder()
+ .AddJsonFile("appSettings.json")
+ .Build()
+ .Get(); // TODO: return default configuration if can't load from json;
+ services.AddKeyedSingleton(Constants.CnbServiceKey, config.CnbExchangeRateLoaderConfig);
+
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ return services;
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs
new file mode 100644
index 0000000000..5ee8e13ca4
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/HttpRequestHandler.cs
@@ -0,0 +1,20 @@
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+internal class HttpRequestHandler : IRequestHandler
+{
+ private HttpClient httpClient;
+
+ public HttpRequestHandler(HttpClient httpClient)
+ {
+ this.httpClient = httpClient;
+ }
+
+ public Task GetStreamAsync(string requestUri)
+ {
+ return httpClient.GetStreamAsync(requestUri);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs
new file mode 100644
index 0000000000..fa3128aea1
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRefreshScheduleFactory.cs
@@ -0,0 +1,9 @@
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.Infrastructure.Config;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+internal interface IRefreshScheduleFactory
+{
+ IRateRefreshScheduler CreateRefreshSchedule(RefreshScheduleConfig config);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs
new file mode 100644
index 0000000000..04e175d4af
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IRequestHandler.cs
@@ -0,0 +1,9 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+internal interface IRequestHandler
+{
+ Task GetStreamAsync(string requestUri);
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs
new file mode 100644
index 0000000000..b66a7d3662
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/LocalDateTimeService.cs
@@ -0,0 +1,22 @@
+using System;
+using ExchangeRateUpdater.Contracts;
+
+namespace ExchangeRateUpdater.Infrastructure;
+
+internal class LocalDateTimeService : IDateTimeService
+{
+ public DateTime GetUtcNow()
+ {
+ return DateTime.UtcNow;
+ }
+
+ public DateTime GetNow()
+ {
+ return DateTime.Now;
+ }
+
+ public DateTime GetToday()
+ {
+ return DateTime.Today;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
similarity index 60%
rename from jobs/Backend/Task/Currency.cs
rename to jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
index f375776f25..fe80944103 100644
--- a/jobs/Backend/Task/Currency.cs
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs
@@ -1,8 +1,8 @@
-namespace ExchangeRateUpdater
+namespace ExchangeRateUpdater.Models
{
public class Currency
{
- public Currency(string code)
+ public Currency(IsoCurrencyCode code)
{
Code = code;
}
@@ -10,11 +10,11 @@ public Currency(string code)
///
/// Three-letter ISO 4217 code of the currency.
///
- public string Code { get; }
+ public IsoCurrencyCode Code { get; }
public override string ToString()
{
- return Code;
+ return Code.ToString();
}
}
}
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/IsoCurrencyCode.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs
new file mode 100644
index 0000000000..0bd5c51e73
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/IsoCurrencyCode.cs
@@ -0,0 +1,34 @@
+using System.Runtime.Serialization;
+
+namespace ExchangeRateUpdater.Models;
+
+///
+/// Three-letter ISO 4217 code of the currency.
+///
+//[JsonConverter(typeof(StringEnumConverter))] ???
+public enum IsoCurrencyCode
+{
+ [EnumMember(Value = "USD")]
+ USD,
+
+ [EnumMember(Value = "EUR")]
+ EUR,
+
+ [EnumMember(Value = "CZK")]
+ CZK,
+
+ [EnumMember(Value = "JPY")]
+ JPY,
+
+ [EnumMember(Value = "KES")]
+ KES,
+
+ [EnumMember(Value = "RUB")]
+ RUB,
+
+ [EnumMember(Value = "THB")]
+ THB,
+
+ [EnumMember(Value = "TRY")]
+ TRY
+}
\ 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 0000000000..f3c8d57ece
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater
+{
+ public static class Program
+ {
+ private static IEnumerable currencies = new[]
+ {
+ new Currency(IsoCurrencyCode.USD),
+ new Currency(IsoCurrencyCode.EUR),
+ new Currency(IsoCurrencyCode.CZK),
+ new Currency(IsoCurrencyCode.JPY),
+ new Currency(IsoCurrencyCode.KES),
+ new Currency(IsoCurrencyCode.RUB),
+ new Currency(IsoCurrencyCode.THB),
+ new Currency(IsoCurrencyCode.TRY)
+ };
+
+ public static async Task Main(string[] args)
+ {
+ IServiceProvider serviceProvider = GetConfiguredServiceProvider();
+
+ try
+ {
+ var provider = serviceProvider.GetRequiredService();
+ var rates = await 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}'.");
+ }
+ }
+
+ private static IServiceProvider GetConfiguredServiceProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddExchangeRatesProvider();
+ return services.BuildServiceProvider();
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json
new file mode 100644
index 0000000000..ae63d5a03d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appSettings.json
@@ -0,0 +1,9 @@
+{
+ "CnbExchangeRateLoaderConfig": {
+ "BaseApiUrl": "https://api.cnb.cz",
+ "RefreshScheduleConfig": {
+ "Time": "14:30",
+ "TimeZone": "Central European Standard Time"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs
new file mode 100644
index 0000000000..02b86c928d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/CnbExchangeRateLoaderTests.cs
@@ -0,0 +1,150 @@
+using System.Text;
+using System.Text.Json;
+using AutoFixture;
+using AutoFixture.Xunit2;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.DataSource.Cnb;
+using ExchangeRateUpdater.DataSource.Cnb.Dto;
+using ExchangeRateUpdater.Infrastructure;
+using ExchangeRateUpdater.Infrastructure.Config;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace ExchangeRateUpdaterTests;
+
+public class CnbExchangeRateLoaderTests
+{
+ private static readonly DateTime Today = new(2025, 11, 09);
+
+ [Theory, LocalData]
+ internal async Task ThrowExceptionInCaseOfNullCurrenciesParam(CnbExchangeRateLoader loader)
+ {
+ await Assert.ThrowsAsync(() => loader.GetExchangeRatesAsync(null, Today));
+ }
+
+ [Theory, LocalData]
+ internal async Task ThrowExceptionIfDateIsInFuture(CnbExchangeRateLoader loader)
+ {
+ var tomorrow = Today.AddDays(1);
+ var ex = await Assert.ThrowsAsync(() => loader.GetExchangeRatesAsync([], tomorrow));
+
+ Assert.Equal("The date should not be in the future (Parameter 'date')", ex.Message);
+ }
+
+ [Theory, LocalData]
+ internal async Task ReturnEmptyListIfNoCurrenciesWasRequested(CnbExchangeRateLoader loader)
+ {
+ var currencies = new string[0];
+ var rates = await loader.GetExchangeRatesAsync(currencies, Today);
+
+ Assert.Empty(rates);
+ }
+
+ [Theory, LocalData]
+ internal void ThrowExceptionIfUriIsInvalid(
+ Mock requestHandlerMock,
+ Mock> loggerMock,
+ Mock factoryMock,
+ CnbRateConverter rateConverter,
+ Mock dateTimeServiceMock)
+ {
+ var incorrectUrl = "tratata";
+ var config = new ExchangeRateLoaderConfig() { BaseApiUrl = incorrectUrl, RefreshScheduleConfig = null };
+
+ Assert.Throws(
+ () => new CnbExchangeRateLoader(requestHandlerMock.Object, rateConverter, loggerMock.Object, factoryMock.Object, dateTimeServiceMock.Object, config));
+ }
+
+ [Theory, LocalData]
+ internal async Task CanLoadRates(
+ Mock requestHandlerMock,
+ CnbExchangeRateLoader loader)
+ {
+ var response = new CnbRateResponse()
+ {
+ Rates =
+ [
+ new CnbRate() { CurrencyCode = IsoCurrencyCode.EUR.ToString(), Amount = 1, Rate = 24.335M },
+ new CnbRate() { CurrencyCode = IsoCurrencyCode.JPY.ToString(), Amount = 100, Rate = 14.11M },
+ new CnbRate() { CurrencyCode = IsoCurrencyCode.TRY.ToString(), Amount = 100, Rate = 5.22M }
+ ]
+ };
+ var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response)));
+ requestHandlerMock.Setup(h => h.GetStreamAsync(It.IsAny())).Returns(Task.FromResult(stream));
+
+ var result = (await loader.GetExchangeRatesAsync(["EUR", "JPY"], Today)).ToArray();
+
+ Assert.Equal(2, result.Length);
+ var eurRate = result.Single(r => r.SourceCurrency.Code == IsoCurrencyCode.EUR);
+ Assert.Equal(IsoCurrencyCode.EUR, eurRate.SourceCurrency.Code);
+ Assert.Equal(IsoCurrencyCode.CZK, eurRate.TargetCurrency.Code);
+ Assert.Equal(24.335M, eurRate.Value);
+
+ var jpyRate = result.Single(r => r.SourceCurrency.Code == IsoCurrencyCode.JPY);
+ Assert.Equal(IsoCurrencyCode.JPY, jpyRate.SourceCurrency.Code);
+ Assert.Equal(IsoCurrencyCode.CZK, jpyRate.TargetCurrency.Code);
+ Assert.Equal(0.1411M, jpyRate.Value);
+ }
+
+ [Theory, LocalData]
+ internal async Task DoNotReturnNonExistentCurrency(
+ Mock requestHandlerMock,
+ CnbExchangeRateLoader loader)
+ {
+ var response = new CnbRateResponse()
+ {
+ Rates =
+ [
+ new CnbRate() { CurrencyCode = IsoCurrencyCode.EUR.ToString(), Amount = 1, Rate = 24.335M },
+ new CnbRate() { CurrencyCode = "XYZ", Amount = 1, Rate = 15M }
+ ]
+ };
+ var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response)));
+ requestHandlerMock.Setup(h => h.GetStreamAsync(It.IsAny())).Returns(Task.FromResult(stream));
+
+ var result = (await loader.GetExchangeRatesAsync(["XYZ", "EUR"], Today)).ToArray();
+
+ Assert.Single(result);
+ Assert.Equal(IsoCurrencyCode.EUR, result[0].SourceCurrency.Code);
+ Assert.Equal(IsoCurrencyCode.CZK, result[0].TargetCurrency.Code);
+ Assert.Equal(24.335M, result[0].Value);
+ }
+
+ private class LocalDataAttribute : AutoDataAttribute
+ {
+ public LocalDataAttribute() : base(CreateFixture) { }
+
+ private static IFixture CreateFixture()
+ {
+ var fixture = new Fixture();
+
+ var scheduleConfig = new RefreshScheduleConfig() { Time = "14:30", TimeZone = "Central European Standard Time" };
+ var config = new ExchangeRateLoaderConfig() { BaseApiUrl = "https://api.tratata", RefreshScheduleConfig = scheduleConfig };
+
+ var loaderLoggerMock = new Mock>();
+ var converterLoggerMock = new Mock>();
+
+ var requestHandlerMock = new Mock();
+
+ var refreshScheduleFactoryMock = new Mock();
+ refreshScheduleFactoryMock.Setup(f => f.CreateRefreshSchedule(scheduleConfig)).Returns(new Mock().Object);
+
+ var dateTimeServiceMock = new Mock();
+ dateTimeServiceMock.Setup(s => s.GetToday()).Returns(Today);
+
+ var rateConverter = new CnbRateConverter(converterLoggerMock.Object);
+
+ var loader = new CnbExchangeRateLoader(requestHandlerMock.Object, rateConverter, loaderLoggerMock.Object, refreshScheduleFactoryMock.Object, dateTimeServiceMock.Object, config);
+
+ fixture.Inject(requestHandlerMock);
+ fixture.Inject(loaderLoggerMock);
+ fixture.Inject(refreshScheduleFactoryMock);
+ fixture.Inject(dateTimeServiceMock);
+ fixture.Inject(rateConverter);
+ fixture.Inject(loader);
+
+ return fixture;
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs
new file mode 100644
index 0000000000..9c52e716b0
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/DefaultRateRefreshSchedulerTests.cs
@@ -0,0 +1,49 @@
+using AutoFixture.Xunit2;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.DataSources.RefreshSchedule;
+using Moq;
+
+namespace ExchangeRateUpdaterTests;
+
+public class DefaultRateRefreshSchedulerTests
+{
+ [Theory, AutoData]
+ public void ReturnCurrentDateIfRefreshTimeHasNotPassed(Mock dateTimeServiceMock)
+ {
+ var utcNow = new DateTime(2025, 11, 9, 12, 45, 0);
+ var today = utcNow.Date;
+
+ dateTimeServiceMock.Setup(s => s.GetUtcNow()).Returns(utcNow);
+ dateTimeServiceMock.Setup(s => s.GetToday()).Returns(today);
+
+ var scheduler = new DefaultRateRefreshScheduler(
+ new TimeOnly(14, 30),
+ TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"),
+ dateTimeServiceMock.Object);
+
+ var expectedRefreshTime = new DateTime(2025, 11, 9, 13, 30, 0);
+ var result = scheduler.GetNextRefreshTime();
+
+ Assert.Equal(expectedRefreshTime, result.DateTime);
+ }
+
+ [Theory, AutoData]
+ public void ReturnNextDateIfRefreshTimeHasPassed(Mock dateTimeServiceMock)
+ {
+ var utcNow = new DateTime(2025, 11, 9, 13, 45, 0);
+ var today = utcNow.Date;
+
+ dateTimeServiceMock.Setup(s => s.GetUtcNow()).Returns(utcNow);
+ dateTimeServiceMock.Setup(s => s.GetToday()).Returns(today);
+
+ var scheduler = new DefaultRateRefreshScheduler(
+ new TimeOnly(14, 30),
+ TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"),
+ dateTimeServiceMock.Object);
+
+ var expectedRefreshTime = new DateTime(2025, 11, 10, 13, 30, 0);
+ var result = scheduler.GetNextRefreshTime();
+
+ Assert.Equal(expectedRefreshTime, result.DateTime);
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs
new file mode 100644
index 0000000000..05be02950d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateProviderTests.cs
@@ -0,0 +1,132 @@
+using AutoFixture;
+using AutoFixture.Xunit2;
+using ExchangeRateUpdater;
+using ExchangeRateUpdater.Contracts;
+using ExchangeRateUpdater.Models;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace ExchangeRateUpdaterTests;
+
+public class ExchangeRateProviderTests
+{
+ [Theory, LocalData]
+ public async Task ThrowArgumentExceptionIfCurrenciesParamIsNull(ExchangeRateProvider provider)
+ {
+ await Assert.ThrowsAsync(() => provider.GetExchangeRates(null));
+ }
+
+ [Theory, LocalData]
+ public async Task ReturnEmptyListIfNoCurrenciesWasRequested(ExchangeRateProvider provider)
+ {
+ var rates = await provider.GetExchangeRates(Enumerable.Empty());
+
+ Assert.Empty(rates);
+ }
+
+ [Theory, LocalData]
+ public async Task ReturnRatesFromCacheIfCacheHasIt(ExchangeRateProvider provider, Mock currencyCache, Mock loader)
+ {
+ var rate = new ExchangeRate(new Currency(IsoCurrencyCode.EUR), new Currency(IsoCurrencyCode.CZK), 24.335M);
+
+ object? valueMock = rate;
+ currencyCache.Setup(c => c.TryGetValue(rate.SourceCurrency.Code, out valueMock)).Returns(true);
+
+ var result = (await provider.GetExchangeRates([rate.SourceCurrency])).ToArray();
+
+ loader.Verify(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()), Times.Never);
+
+ Assert.Single(result);
+ Assert.Equal(rate, result[0]);
+ }
+
+ [Theory, LocalData]
+ public async Task ReturnRatesByLoaderIfCacheIsEmpty(ExchangeRateProvider provider, Mock currencyCache, Mock loader)
+ {
+ var rate = new ExchangeRate(new Currency(IsoCurrencyCode.JPY), new Currency(IsoCurrencyCode.CZK), 0.13745M);
+
+ object? valueMock = rate;
+ currencyCache.Setup(c => c.TryGetValue(rate.SourceCurrency.Code, out valueMock)).Returns(false);
+
+ loader
+ .Setup(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync([rate]);
+
+ var result = (await provider.GetExchangeRates([rate.SourceCurrency])).ToArray();
+
+ Assert.Single(result);
+ Assert.Equal(rate, result[0]);
+ }
+
+ [Theory, LocalData]
+ public async Task ReturnRatesFromBothCacheAndLoader(ExchangeRateProvider provider, Mock currencyCache, Mock loader)
+ {
+ var cachedRate = new ExchangeRate(new Currency(IsoCurrencyCode.EUR), new Currency(IsoCurrencyCode.CZK), 24.335M);
+
+ object? valueMock = cachedRate;
+ currencyCache.Setup(c => c.TryGetValue(cachedRate.SourceCurrency.Code, out valueMock)).Returns(true);
+
+ var rateToLoad = new ExchangeRate(new Currency(IsoCurrencyCode.JPY), new Currency(IsoCurrencyCode.CZK), 0.13745M);
+ loader
+ .Setup(l => l.GetExchangeRatesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync([rateToLoad]);
+
+ var result = (await provider.GetExchangeRates([cachedRate.SourceCurrency, rateToLoad.SourceCurrency])).ToArray();
+
+ Assert.Equal(2, result.Length);
+ Assert.Contains(cachedRate, result);
+ Assert.Contains(rateToLoad, result);
+ }
+
+ [Theory, LocalData]
+ public async Task PutLoadedRatesToCache(ExchangeRateProvider provider, Mock currencyCache, Mock loader)
+ {
+ object? valueMock = null;
+ currencyCache.Setup(c => c.TryGetValue(It.IsAny