diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore
new file mode 100644
index 000000000..cd967fc3a
--- /dev/null
+++ b/jobs/Backend/Task/.dockerignore
@@ -0,0 +1,25 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs
deleted file mode 100644
index f375776f2..000000000
--- a/jobs/Backend/Task/Currency.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class Currency
- {
- public Currency(string code)
- {
- Code = code;
- }
-
- ///
- /// Three-letter ISO 4217 code of the currency.
- ///
- public string Code { get; }
-
- public override string ToString()
- {
- return Code;
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs
deleted file mode 100644
index 58c5bb10e..000000000
--- a/jobs/Backend/Task/ExchangeRate.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ExchangeRateUpdater
-{
- public class ExchangeRate
- {
- public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
- {
- SourceCurrency = sourceCurrency;
- TargetCurrency = targetCurrency;
- Value = value;
- }
-
- public Currency SourceCurrency { get; }
-
- public Currency TargetCurrency { get; }
-
- public decimal Value { get; }
-
- public override string ToString()
- {
- return $"{SourceCurrency}/{TargetCurrency}={Value}";
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs
deleted file mode 100644
index 6f82a97fb..000000000
--- a/jobs/Backend/Task/ExchangeRateProvider.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public class ExchangeRateProvider
- {
- ///
- /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined
- /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
- /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
- /// some of the currencies, ignore them.
- ///
- public IEnumerable GetExchangeRates(IEnumerable currencies)
- {
- return Enumerable.Empty();
- }
- }
-}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs
new file mode 100644
index 000000000..980bc8ce4
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs
@@ -0,0 +1,39 @@
+using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Domain.ApiClients;
+
+public sealed class ExchangeRateApiClient(HttpClient httpClient, ILogger logger)
+ : IExchangeRateApiClient
+{
+ private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ private const string ErrorMessage = "Request to ExchangeRate source failed.";
+
+ public async Task GetExchangeRatesXml(CancellationToken cancellationToken)
+ {
+ const string endpoint = "/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml";
+ var requestUri = new Uri(_httpClient.BaseAddress!, endpoint);
+
+ var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
+
+ var response = await _httpClient.SendAsync(requestMessage, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ _logger.LogError(
+ "HTTP GET {Url} failed with status {StatusCode}: {Content}",
+ requestUri,
+ response.StatusCode,
+ errorContent
+ );
+
+ throw new HttpRequestException(ErrorMessage);
+ }
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ return content;
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs
new file mode 100644
index 000000000..1cf6e4f43
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Domain.ApiClients.Interfaces;
+
+public interface IExchangeRateApiClient
+{
+ Task GetExchangeRatesXml(CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs
new file mode 100644
index 000000000..e19566b7d
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using System.Xml.Serialization;
+
+namespace ExchangeRateUpdater.Domain.ApiClients.Models;
+
+[XmlRoot("kurzy")]
+public class ExchangeRatesResponse
+{
+ [XmlElement("tabulka")]
+ public ExchangeRateTable[] Tables { get; set; } = [];
+}
+
+public class ExchangeRateTable
+{
+ [XmlElement("radek")]
+ public ExchangeRateRow[] Rows { get; set; } = [];
+}
+
+public class ExchangeRateRow
+{
+ [XmlAttribute("kod")]
+ public string CurrencyCode { get; set; } = string.Empty;
+
+ [XmlAttribute("mena")]
+ public string CurrencyName { get; set; } = string.Empty;
+
+ [XmlAttribute("kurz")]
+ public string RateString { get; set; } = string.Empty;
+
+ [XmlAttribute("mnozstvi")]
+ public string AmountString { get; set; } = "1";
+
+ [XmlIgnore]
+ public decimal Rate => decimal.Parse(RateString, NumberStyles.Number, new CultureInfo("cs-CZ"));
+
+ [XmlIgnore]
+ public decimal Amount => decimal.Parse(AmountString, NumberStyles.Number, new CultureInfo("cs-CZ"));
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs
new file mode 100644
index 000000000..5dc22e09c
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Domain.Exceptions;
+
+public sealed class InvalidExchangeRateDataException(string message) : Exception(message);
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs
new file mode 100644
index 000000000..fabb4494e
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs
@@ -0,0 +1,3 @@
+namespace ExchangeRateUpdater.Domain.Exceptions;
+
+public sealed class UnknownCurrencyException(string code) : Exception($"Unknown currency code: {code}");
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj
new file mode 100644
index 000000000..70ddad264
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs
new file mode 100644
index 000000000..e19e61d84
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs
@@ -0,0 +1,8 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public sealed class Currency(string code)
+{
+ public string Code { get; } = code ?? throw new ArgumentNullException(nameof(code));
+
+ public override string ToString() => Code;
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs
new file mode 100644
index 000000000..4e5a4d3f8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs
@@ -0,0 +1,10 @@
+namespace ExchangeRateUpdater.Domain.Models;
+
+public sealed record ExchangeRate
+{
+ public required Currency SourceCurrency { get; init; }
+ public required Currency TargetCurrency { get; init; }
+ public required decimal Value { get; init; }
+
+ public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}";
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs
new file mode 100644
index 000000000..e61abfa49
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs
@@ -0,0 +1,87 @@
+using System.Text.Json;
+using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Services.Interfaces;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Domain.Services.Implementations
+{
+ public sealed class ExchangeRateUpdaterProvider(
+ IExchangeRateApiClient apiClient,
+ IExchangeRateParser parser,
+ IDistributedCache cache,
+ ILogger logger)
+ : IExchangeRateProvider
+ {
+ private const string CacheKey = "ExchangeRates";
+
+ public async Task> GetExchangeRatesForCurrenciesAsync(IEnumerable currencies, CancellationToken cancellationToken)
+ {
+ List? exchangeRatesPerCurrency = [];
+ var currenciesContainer = currencies.ToArray();
+ var targetCurrencyCodes = currenciesContainer.Select(x => x.Code).ToHashSet();
+
+ if (targetCurrencyCodes.Count == 0)
+ {
+ logger.LogWarning("Input currencies should contain codes. Currently none is existing");
+ return [];
+ }
+
+ try
+ {
+ var cached = await cache.GetStringAsync(CacheKey, token: cancellationToken);
+
+ if (!string.IsNullOrEmpty(cached))
+ exchangeRatesPerCurrency = JsonSerializer.Deserialize>(cached);
+
+ if (exchangeRatesPerCurrency is not { Count: > 0 })
+ {
+ var xmlRates = await apiClient.GetExchangeRatesXml(cancellationToken);
+
+ var parsedRates = await parser.ParseAsync(xmlRates);
+
+ var exchangeRates = parsedRates as ExchangeRate[] ?? parsedRates.ToArray();
+ if (exchangeRates.Length <= 0)
+ {
+ logger.LogWarning("Parsed Rates yielding no values");
+ return [];
+ }
+
+ var exchangeRatesSerialized = JsonSerializer.Serialize(exchangeRates);
+
+ if (string.IsNullOrEmpty(exchangeRatesSerialized))
+ {
+ logger.LogWarning("Parsed Rates serialized is empty. Returning zero results");
+ return [];
+ }
+
+ await PersistDataIntoCache(exchangeRatesSerialized, cancellationToken);
+
+ return exchangeRates.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code));
+ }
+
+ }
+ catch (JsonException jsonException)
+ {
+ logger.LogError("Json Exception while deserializing Cached Entries : {JsonException}", jsonException);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Unhandled exception : {Exception}", ex);
+ throw;
+ }
+
+ return exchangeRatesPerCurrency.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code));
+ }
+
+ private async Task PersistDataIntoCache(string exchangeRatesSerialized, CancellationToken cancellationToken)
+ {
+ await cache.SetStringAsync(CacheKey, exchangeRatesSerialized, new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
+ }, token: cancellationToken);
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs
new file mode 100644
index 000000000..430f13735
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs
@@ -0,0 +1,49 @@
+using System.Xml.Serialization;
+using ExchangeRateUpdater.Domain.ApiClients.Models;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Services.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace ExchangeRateUpdater.Domain.Services.Implementations;
+public sealed class XmlExchangeRatesParser(ILogger logger) : IExchangeRateParser
+{
+ public Task> ParseAsync(string xmlContent)
+ {
+ var rates = new List();
+ try
+ {
+ var serializer = new XmlSerializer(typeof(ExchangeRatesResponse));
+ using var reader = new StringReader(xmlContent);
+ var deserializedContent = serializer.Deserialize(reader) as ExchangeRatesResponse;
+
+ if (deserializedContent == null || deserializedContent.Tables.Length <= 0)
+ {
+ logger.LogWarning("Deserialized ExchangeRates Xml content is null or empty");
+ return Task.FromResult>([]);
+ }
+
+ foreach (var table in deserializedContent.Tables)
+ {
+ foreach (var row in table.Rows)
+ {
+ if(string.IsNullOrEmpty(row.CurrencyCode))
+ continue;
+
+ rates.Add(new ExchangeRate
+ {
+ SourceCurrency = new Currency("CZK"),
+ TargetCurrency = new Currency(row.CurrencyCode),
+ Value = row.Rate / row.Amount
+ });
+ }
+ }
+
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to deserialize CNB XML.");
+ }
+
+ return Task.FromResult>(rates);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs
new file mode 100644
index 000000000..ad7c3f0b7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs
@@ -0,0 +1,8 @@
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Domain.Services.Interfaces;
+
+public interface IExchangeRateParser
+{
+ Task> ParseAsync(string xmlContent);
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs
new file mode 100644
index 000000000..0fcbb7f08
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs
@@ -0,0 +1,9 @@
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Domain.Services.Interfaces;
+
+public interface IExchangeRateProvider
+{
+ Task> GetExchangeRatesForCurrenciesAsync(IEnumerable currencies, CancellationToken cancellationToken);
+
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj
new file mode 100644
index 000000000..308fb1002
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ Always
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs
new file mode 100644
index 000000000..505aba80a
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using ExchangeRateUpdater.Domain.Models;
+using FluentAssertions;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests.Services;
+
+public class ExchangeRateUpdaterProviderTests(TestFixture fixture) : IClassFixture, IAsyncLifetime
+{
+ private const string CzkCode = "CZK";
+ private const string CacheKey = "ExchangeRates";
+ private const string XmlResultString = "DummyXmlResult";
+
+ public async Task InitializeAsync()
+ {
+ var cache = fixture.Services.GetRequiredService();
+ await cache.RemoveAsync(CacheKey);
+ fixture.ApiClientMock.ClearReceivedCalls();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task WhenCacheKeyExists_ExchangeRatesShouldBeReturned()
+ {
+ // Arrange
+ var eur = new Currency("EUR");
+ var usd = new Currency("USD");
+ var expectedRates = new List
+ {
+ new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m },
+ new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = usd, Value = 22.615m }
+ };
+
+ var cache = fixture.Services.GetRequiredService();
+ var serialized = JsonSerializer.Serialize(expectedRates);
+ await cache.SetStringAsync(CacheKey, serialized);
+
+ // Act
+ var result = await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None);
+
+ // Assert
+ result.Should().BeEquivalentTo(expectedRates, opts => opts.WithStrictOrdering());
+ await fixture.ApiClientMock.DidNotReceive().GetExchangeRatesXml(Arg.Any());
+ }
+
+ [Fact]
+ public async Task WhenCacheIsNotPresent_ExchangeRatesShouldBeFetchedAndReturned()
+ {
+ // Arrange
+ var eur = new Currency("EUR");
+ var usd = new Currency("USD");
+
+
+ fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any())
+ .Returns(Task.FromResult(XmlResultString));
+
+ var parsedRates = new List
+ {
+ new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m },
+ new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = usd, Value = 22.615m }
+ };
+ fixture.ParserMock.ParseAsync(XmlResultString).Returns(Task.FromResult(parsedRates.AsEnumerable()));
+
+ // Act
+ var result = await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None);
+
+ // Assert
+ result.Should().BeEquivalentTo(parsedRates, opts => opts.WithStrictOrdering());
+ await fixture.ApiClientMock.Received(1).GetExchangeRatesXml(Arg.Any());
+ }
+
+ [Fact]
+ public async Task WhenCurrencyIsUnsupported_ShouldIgnoreAndReturnMatchingExchangeRates()
+ {
+ // Arrange
+ var eur = new Currency("EUR");
+ var xyz = new Currency("XYZ");
+
+
+ fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any())
+ .Returns(Task.FromResult(XmlResultString));
+
+ var parsedRates = new List
+ {
+ new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m }
+ };
+
+ fixture.ParserMock.ParseAsync(XmlResultString).Returns(Task.FromResult(parsedRates.AsEnumerable()));
+
+ // Act
+ var result =
+ await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, xyz], CancellationToken.None);
+
+ // Assert
+ result.Should().BeEquivalentTo(parsedRates, opts => opts.WithStrictOrdering());
+ }
+
+ [Fact]
+ public async Task WhenXmlIsIncorrect_ShouldReturnEmptyRates()
+ {
+ // Arrange
+ var eur = new Currency("EUR");
+ var usd = new Currency("USD");
+
+ fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any())
+ .Returns(Task.FromResult(XmlResultString));
+
+ fixture.ParserMock.ParseAsync(XmlResultString)
+ .Returns([]);
+
+ var result =
+ await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs
new file mode 100644
index 000000000..93a46dbe8
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs
@@ -0,0 +1,45 @@
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Services.Implementations;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace ExchangeRateUpdater.Tests.Services;
+
+public class XmlExchangeRatesParserTests
+{
+ private const string ExchangeRatesCorrectFileXmlName = "ExchangeRatesCorrectFile.xml";
+ private const string ExchangeRatesIncorrectFormatName = "ExchangeRatesIncorrectFormat.xml";
+ private readonly XmlExchangeRatesParser _parser;
+
+ public XmlExchangeRatesParserTests()
+ {
+ var logger = Substitute.For>();
+ _parser = new XmlExchangeRatesParser(logger);
+ }
+
+ [Fact]
+ public async Task ParseAsync_ShouldReturnCorrectRates_WhenXmlIsValid()
+ {
+ var xml = TestDataHelper.LoadTestData(ExchangeRatesCorrectFileXmlName);
+
+ var rates = await _parser.ParseAsync(xml);
+
+ var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray();
+ exchangeRates.Should().ContainSingle(x => x.TargetCurrency.Code == "EUR" && x.Value == 24.300m);
+ exchangeRates.Should().ContainSingle(x => x.TargetCurrency.Code == "USD" && x.Value == 20.563m);
+ }
+
+ [Fact]
+ public async Task ParseAsync_ShouldReturnEmptyRates_WhenDeserializationOfXmlIsNotPossible()
+ {
+ var xml = TestDataHelper.LoadTestData(ExchangeRatesIncorrectFormatName);
+
+ var rates = await _parser.ParseAsync(xml);
+
+ var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray();
+ exchangeRates.Should().BeEmpty();
+
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml
new file mode 100644
index 000000000..9b1673a24
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml
new file mode 100644
index 000000000..80629e3bf
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs
new file mode 100644
index 000000000..06ca822f1
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs
@@ -0,0 +1,15 @@
+namespace ExchangeRateUpdater.Tests;
+
+public static class TestDataHelper
+{
+ public static string LoadTestData(string fileName)
+ {
+ var basePath = Path.Combine(AppContext.BaseDirectory, "TestData");
+ var filePath = Path.Combine(basePath, fileName);
+
+ if (!File.Exists(filePath))
+ throw new FileNotFoundException($"Test data file not found: {filePath}");
+
+ return File.ReadAllText(filePath);
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs
new file mode 100644
index 000000000..9ee409285
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs
@@ -0,0 +1,36 @@
+using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
+using ExchangeRateUpdater.Domain.Services.Implementations;
+using ExchangeRateUpdater.Domain.Services.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace ExchangeRateUpdater.Tests;
+
+public class TestFixture
+{
+ public IExchangeRateProvider ExchangeRateProvider { get; }
+ public IServiceProvider Services { get; }
+ public IExchangeRateApiClient ApiClientMock { get; }
+ public IExchangeRateParser ParserMock { get; }
+
+ public TestFixture()
+ {
+ var services = new ServiceCollection();
+
+ services.AddDistributedMemoryCache();
+
+ ApiClientMock = Substitute.For();
+ ParserMock = Substitute.For();
+
+ services.AddSingleton(ApiClientMock);
+ services.AddSingleton(ParserMock);
+ services.AddSingleton(Substitute.For>());
+ services.AddSingleton(Substitute.For>());
+
+ services.AddSingleton();
+
+ Services = services.BuildServiceProvider();
+ ExchangeRateProvider = Services.GetRequiredService();
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj
deleted file mode 100644
index 2fc654a12..000000000
--- a/jobs/Backend/Task/ExchangeRateUpdater.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Exe
- net6.0
-
-
-
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln
index 89be84daf..c8cab12e5 100644
--- a/jobs/Backend/Task/ExchangeRateUpdater.sln
+++ b/jobs/Backend/Task/ExchangeRateUpdater.sln
@@ -3,7 +3,11 @@ 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}") = "ExchangeRateUpdater.Domain", "ExchangeRateUpdater.Domain\ExchangeRateUpdater.Domain.csproj", "{DF866C07-3AC6-4509-8297-CDCD94D06331}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -15,6 +19,14 @@ 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
+ {DF866C07-3AC6-4509-8297-CDCD94D06331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF866C07-3AC6-4509-8297-CDCD94D06331}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF866C07-3AC6-4509-8297-CDCD94D06331}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF866C07-3AC6-4509-8297-CDCD94D06331}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs b/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs
new file mode 100644
index 000000000..95623f0db
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs
@@ -0,0 +1,38 @@
+using System;
+using ExchangeRateUpdater.Domain.ApiClients;
+using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
+using ExchangeRateUpdater.Domain.Services.Implementations;
+using ExchangeRateUpdater.Domain.Services.Interfaces;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ExchangeRateUpdater.Configuration;
+
+public static class DependencyInjection
+{
+ public static void ConfigureApplicationServices(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.ConfigureServices();
+ services.ConfigureParsers();
+ services.ConfigureHttpClients();
+ }
+
+ private static void ConfigureServices(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+
+ private static void ConfigureParsers(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+
+ private static void ConfigureHttpClients(this IServiceCollection services)
+ {
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri("https://www.cnb.cz/");
+ client.Timeout = TimeSpan.FromSeconds(10);
+ });
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs
new file mode 100644
index 000000000..95b19847f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Controllers.v1.Requests;
+using ExchangeRateUpdater.Controllers.v1.Responses;
+using ExchangeRateUpdater.Domain.Exceptions;
+using ExchangeRateUpdater.Domain.Models;
+using ExchangeRateUpdater.Domain.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ExchangeRateUpdater.Controllers.v1;
+
+[ApiController]
+[Route("api/[controller]")]
+
+public sealed class ExchangeRatesController(IExchangeRateProvider exchangeRateProvider) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(typeof(GetExchangeRatesForCurrenciesResponse), 200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(500)]
+ [Route(nameof(GetExchangeRatesForCurrencies))]
+ public async Task GetExchangeRatesForCurrencies(GetExchangeRatesForCurrenciesRequest getExchangeRatesForCurrenciesRequest, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var currencies = getExchangeRatesForCurrenciesRequest.Currencies.Select(x =>
+ new Currency(x)).ToArray();
+
+ var result = await exchangeRateProvider.GetExchangeRatesForCurrenciesAsync(currencies, cancellationToken);
+ var rates = result as ExchangeRate[] ?? result.ToArray();
+
+ var exchangeRatesForCurrenciesResponse = new GetExchangeRatesForCurrenciesResponse
+ {
+ ExchangeRates = rates.ToImmutableArray()
+ };
+
+ return Ok(exchangeRatesForCurrenciesResponse);
+ }
+ catch (UnknownCurrencyException unknownCurrencyException)
+ {
+ return BadRequest(unknownCurrencyException.Message);
+ }
+ catch (InvalidExchangeRateDataException invalidExchangeRateDataException)
+ {
+ return BadRequest(invalidExchangeRateDataException.Message);
+ }
+ catch (Exception exception)
+ {
+ return Problem(exception.Message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs
new file mode 100644
index 000000000..c82f5da91
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs
@@ -0,0 +1,6 @@
+namespace ExchangeRateUpdater.Controllers.v1.Requests;
+
+public class GetExchangeRatesForCurrenciesRequest
+{
+ public string[] Currencies { get; init; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs
new file mode 100644
index 000000000..78a4572ab
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+using ExchangeRateUpdater.Domain.Models;
+
+namespace ExchangeRateUpdater.Controllers.v1.Responses;
+
+public class GetExchangeRatesForCurrenciesResponse
+{
+ public IReadOnlyCollection ExchangeRates { get; init; }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile b/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile
new file mode 100644
index 000000000..47f633df7
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile
@@ -0,0 +1,20 @@
+FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
+WORKDIR /app
+EXPOSE 80
+
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
+WORKDIR /src
+COPY ["ExchangeRateUpdater/ExchangeRateUpdater.csproj", "ExchangeRateUpdater/"]
+COPY ["ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj", "ExchangeRateUpdater.Domain/"]
+RUN dotnet restore "ExchangeRateUpdater/ExchangeRateUpdater.csproj"
+COPY . .
+WORKDIR "/src/ExchangeRateUpdater"
+RUN dotnet build "ExchangeRateUpdater.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "ExchangeRateUpdater.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"]
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
new file mode 100644
index 000000000..f5bda13d5
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net8.0
+ Linux
+
+
+
+
+ .dockerignore
+
+
+
+
+
+
+
+
+
+
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs
new file mode 100644
index 000000000..4f7a242ae
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs
@@ -0,0 +1,54 @@
+using ExchangeRateUpdater;
+using ExchangeRateUpdater.Domain.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+var builder = WebApplication.CreateBuilder(args);
+
+var startup = new Startup(builder.Configuration);
+startup.ConfigureServices(builder.Services);
+
+var app = builder.Build();
+Startup.Configure(app, builder.Environment);
+
+// Run console-style logic as background task
+using (var scope = app.Services.CreateScope())
+{
+ var provider = scope.ServiceProvider.GetRequiredService();
+
+ _ = Task.Run(async () =>
+ {
+ var currencies = new[]
+ {
+ new Currency("USD"),
+ new Currency("EUR"),
+ new Currency("CZK"),
+ new Currency("JPY"),
+ new Currency("KES"),
+ new Currency("RUB"),
+ new Currency("THB"),
+ new Currency("TRY"),
+ new Currency("XYZ")
+ };
+
+ try
+ {
+ var rates = await provider.GetExchangeRatesForCurrenciesAsync(currencies, CancellationToken.None);
+ var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray();
+ Console.WriteLine($"Successfully retrieved {exchangeRates.Length} exchange rates:");
+ foreach (var rate in exchangeRates)
+ Console.WriteLine($"{rate.SourceCurrency.Code}/{rate.TargetCurrency.Code} = {rate.Value}");
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
+ }
+ });
+}
+
+// Run the web host (Swagger + API)
+await app.RunAsync();
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs
new file mode 100644
index 000000000..7ba86327f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs
@@ -0,0 +1,59 @@
+using System.Threading.Tasks;
+using ExchangeRateUpdater.Configuration;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace ExchangeRateUpdater;
+
+public sealed class Startup(IConfiguration configuration)
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ var redisConnection = configuration.GetConnectionString("Redis");
+
+ if (string.IsNullOrWhiteSpace(redisConnection))
+ {
+ services.AddDistributedMemoryCache();
+ }
+ else
+ {
+ services.AddStackExchangeRedisCache(options =>
+ {
+ options.Configuration = redisConnection;
+ });
+ }
+
+ services.ConfigureApplicationServices(configuration);
+ services.AddControllers();
+ services.AddEndpointsApiExplorer();
+ services.AddSwaggerGen();
+ }
+
+ public static void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseSwagger();
+ app.UseSwaggerUI();
+
+ app.UseRouting();
+ app.UseAuthorization();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+
+ // Redirect root to swagger
+ endpoints.MapGet("/", context =>
+ {
+ context.Response.Redirect("/swagger");
+ return Task.CompletedTask;
+ });
+ });
+ }
+}
\ 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 000000000..0c208ae91
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json
new file mode 100644
index 000000000..c68a5c034
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json
@@ -0,0 +1,11 @@
+{
+ "ConnectionStrings": {
+ "Redis": "redis:6379"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
new file mode 100644
index 000000000..19b1d7246
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "System": "Warning"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml b/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml
new file mode 100644
index 000000000..61b462b5f
--- /dev/null
+++ b/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml
@@ -0,0 +1,15 @@
+version: '3.8'
+services:
+ api:
+ build: ./ExchangeRateUpdater
+ ports:
+ - "5000:80"
+ depends_on:
+ - redis
+ environment:
+ - DOTNET_ENVIRONMENT=Development
+
+ redis:
+ image: redis:7
+ ports:
+ - "6379:6379"
diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs
deleted file mode 100644
index 379a69b1f..000000000
--- a/jobs/Backend/Task/Program.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace ExchangeRateUpdater
-{
- public static class Program
- {
- private static IEnumerable currencies = new[]
- {
- new Currency("USD"),
- new Currency("EUR"),
- new Currency("CZK"),
- new Currency("JPY"),
- new Currency("KES"),
- new Currency("RUB"),
- new Currency("THB"),
- new Currency("TRY"),
- new Currency("XYZ")
- };
-
- public static void Main(string[] args)
- {
- try
- {
- var provider = new ExchangeRateProvider();
- var rates = provider.GetExchangeRates(currencies);
-
- Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
- foreach (var rate in rates)
- {
- Console.WriteLine(rate.ToString());
- }
- }
- catch (Exception e)
- {
- Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
- }
-
- Console.ReadLine();
- }
- }
-}
diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md
new file mode 100644
index 000000000..97369dc8f
--- /dev/null
+++ b/jobs/Backend/Task/README.md
@@ -0,0 +1,17 @@
+# ExchangeRateUpdater
+
+## Overview
+A .NET 8 project that fetches daily exchange rates from the **Czech National Bank**, with:
+- `IDistributedCache` support (Memory in dev, Redis in prod)
+- XML parser with models
+- REST API with Swagger
+- Unit tests (xUnit + FluentAssertions + NSubstitute)
+- Docker + docker-compose
+
+---
+
+## Run Locally
+
+```bash
+dotnet build
+dotnet run --project ExchangeRateUpdater