diff --git a/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..54e7ab23e --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/ExchangeRateProviderTests.cs @@ -0,0 +1,73 @@ +using ExchangeRateUpdater; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTests.ExchangeRateProviderTests +{ + + public class ExchangeRateProviderTests + { + [Fact] + public async Task GetExchangeRatesAsync_ReturnsRatesFromService_WhenCurrenciesProvided() + { + // Arrange + var currencies = new[] + { + new Currency("CZK"), + new Currency("USD") + }; + + var expectedRates = new List<ExchangeRate> + { + new ExchangeRate(new Currency("CZK"), new Currency("USD"), 22.5m) + }; + + var serviceMock = new Mock<IExchangeRateProviderService>(); + serviceMock.Setup(s => s.GetExchangeRateAsync(It.Is<IEnumerable<Currency>>(c => c.SequenceEqual(currencies)))) + .ReturnsAsync(expectedRates); + + var provider = new ExchangeRateProvider(serviceMock.Object); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("USD", result.First().TargetCurrency.Code); + serviceMock.Verify(s => s.GetExchangeRateAsync(It.IsAny<IEnumerable<Currency>>()), Times.Once); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenCurrencyListIsNull() + { + // Arrange + var serviceMock = new Mock<IExchangeRateProviderService>(); + var provider = new ExchangeRateProvider(serviceMock.Object); + + // Act + var result = await provider.GetExchangeRatesAsync(null); + + // Assert + Assert.Empty(result); + serviceMock.Verify(s => s.GetExchangeRateAsync(It.IsAny<IEnumerable<Currency>>()), Times.Never); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenCurrencyListIsEmpty() + { + // Arrange + var serviceMock = new Mock<IExchangeRateProviderService>(); + var provider = new ExchangeRateProvider(serviceMock.Object); + + // Act + var result = await provider.GetExchangeRatesAsync(new List<Currency>()); + + // Assert + Assert.Empty(result); + serviceMock.Verify(s => s.GetExchangeRateAsync(It.IsAny<IEnumerable<Currency>>()), Times.Never); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/Integration/ExchangeRateProviderIntegrationTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/Integration/ExchangeRateProviderIntegrationTests.cs new file mode 100644 index 000000000..3e4cda4d4 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateProviderTests/Integration/ExchangeRateProviderIntegrationTests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdaterTests.StartupTests; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RichardSzalay.MockHttp; +using Xunit; + +namespace ExchangeRateUpdater.Tests.Integration +{ + public class ExchangeRateProviderIntegrationTests + { + [Fact] + public async Task GetExchangeRatesAsync_ReturnsCombinedRates_FromAllFetchers() + { + // Arrange + + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.When("https://mock-daily") + .Respond("text/plain", @" + 11 Jun 2025 #123 + Country|Currency|Amount|Code|Rate + United States|Dollar|1|USD|22,123"); + + mockHttp.When("https://mock-other") + .Respond("text/plain", @" + 11 Jun 2025 #123 + Country|Currency|Amount|Code|Rate|Source + Switzerland|Franc|1|CHF|25,456|SNB"); + + var httpClient = mockHttp.ToHttpClient(); + + var inMemorySettings = new Dictionary<string, string> + { + ["CzechBankSettings:DailyRatesUrl"] = "https://mock-daily", + ["CzechBankSettings:OtherCurrencyRatesUrl"] = "https://mock-other", + ["CzechBankSettings:TimeoutSeconds"] = "10", + ["CzechBankSettings:RetryCount"] = "2" + }; + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(inMemorySettings) + .Build(); + + var services = new ServiceCollection(); + var startup = new StartupForTest(config); + startup.ConfigureServices(services); + + services.AddSingleton(httpClient); + + var provider = services.BuildServiceProvider(); + + var httpFactory = new TestHttpClientFactory(httpClient); + services.AddSingleton<IHttpClientFactory>(httpFactory); + + var finalProvider = services.BuildServiceProvider(); + + var exchangeRateProvider = finalProvider.GetRequiredService<IExchangeRateProviderService>(); + + // Act + var currencies = new[] { new Currency("USD"), new Currency("CHF") }; + var rates = await exchangeRateProvider.GetExchangeRateAsync(currencies); + + // Assert + Assert.NotEmpty(rates); + Assert.Contains(rates, r => r.TargetCurrency.Code == "USD"); + Assert.Contains(rates, r => r.TargetCurrency.Code == "CHF"); + } + + // IHttpClientFactory override + private class TestHttpClientFactory : IHttpClientFactory + { + private readonly HttpClient _client; + + public TestHttpClientFactory(HttpClient client) + { + _client = client; + } + + public HttpClient CreateClient(string name) => _client; + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj new file mode 100644 index 000000000..930ef3def --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -0,0 +1,22 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <IsPackable>false</IsPackable> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.4" /> + <PackageReference Include="Moq" Version="4.20.72" /> + <PackageReference Include="xunit" Version="2.4.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.2" /> + <PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Task\ExchangeRateUpdater.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdaterTests/HttpClients/ExchangeRateFetcherTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/HttpClients/ExchangeRateFetcherTests.cs new file mode 100644 index 000000000..e692ec8a2 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/HttpClients/ExchangeRateFetcherTests.cs @@ -0,0 +1,33 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using Xunit; + +namespace ExchangeRateUpdaterTests.HttpClients +{ + public class ExchangeRateFetcherTests + { + [Fact] + public async Task FetchAsync_ReturnsExpectedRawText() + { + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When("https://mock-daily") + .Respond("text/plain", "Mock CNB Data"); + var mockLogger = new Mock<ILogger<DailyExchangeRateFetcher>>(); + + var httpClient = mockHttp.ToHttpClient(); + + var fetcher = new DailyExchangeRateFetcher( + httpClient, + Options.Create(new CzechBankSettings { DailyRatesUrl = "https://mock-daily" }), + mockLogger.Object + ); + + var result = await fetcher.FetchAsync(); + Assert.Equal("Mock CNB Data", result); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdaterTests/Parsers/CzechNationalBankTextRateParserTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/Parsers/CzechNationalBankTextRateParserTests.cs new file mode 100644 index 000000000..5474d605b --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/Parsers/CzechNationalBankTextRateParserTests.cs @@ -0,0 +1,104 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ExchangeRateUpdater.Tests.Parsers +{ + public class CzechNationalBankTextRateParserTests + { + private readonly CzechNationalBankTextRateParser _parser; + + public CzechNationalBankTextRateParserTests() + { + var mockLogger = new Mock<ILogger<CzechNationalBankTextRateParser>>(); + _parser = new CzechNationalBankTextRateParser(5, mockLogger.Object); + } + + [Fact] + public void Parse_ParsesValidLine_Correctly() + { + var raw = @" +11 Jun 2025 #123 +Country|Currency|Amount|Code|Rate +United States|Dollar|1|USD|22,123 +"; + + var filter = new List<Currency> { new Currency("USD") }; + var result = _parser.Parse(raw, filter, new Currency("CZK")).ToList(); + + Assert.Single(result); + var rate = result[0]; + Assert.Equal("CZK", rate.SourceCurrency.Code); + Assert.Equal("USD", rate.TargetCurrency.Code); + Assert.Equal(22.123m, rate.Value); + } + + [Fact] + public void Parse_SkipsLines_WithTooFewColumns() + { + var raw = @" +11 Jun 2025 #123 +Country|Currency|Amount|Code|Rate +Broken|Line|Only|3 +United States|Dollar|1|USD|22,123 +"; + + var filter = new List<Currency> { new Currency("USD") }; + var result = _parser.Parse(raw, filter, new Currency("CZK")).ToList(); + + Assert.Single(result); + Assert.Equal("USD", result[0].TargetCurrency.Code); + } + + [Fact] + public void Parse_SkipsCurrencies_NotInFilter() + { + var raw = @" +11 Jun 2025 #123 +Country|Currency|Amount|Code|Rate +United States|Dollar|1|USD|22,123 +Eurozone|Euro|1|EUR|25,456 +"; + + var filter = new List<Currency> { new Currency("EUR") }; + var result = _parser.Parse(raw, filter, new Currency("CZK")).ToList(); + + Assert.Single(result); + Assert.Equal("EUR", result[0].TargetCurrency.Code); + } + + [Fact] + public void Parse_NormalizesRate_BasedOnAmount() + { + var raw = @" +11 Jun 2025 #123 +Country|Currency|Amount|Code|Rate +Japan|Yen|100|JPY|50,000 +"; + + var filter = new List<Currency> { new Currency("JPY") }; + var result = _parser.Parse(raw, filter, new Currency("CZK")).ToList(); + + Assert.Single(result); + Assert.Equal("JPY", result[0].TargetCurrency.Code); + Assert.Equal(0.500, (double)result[0].Value); // 50,000 / 100 + } + + [Fact] + public void Parse_ReturnsEmpty_WhenNoLinesMatch() + { + var raw = @" +11 Jun 2025 #123 +Country|Currency|Amount|Code|Rate +United States|Dollar|1|USD|22,123 +"; + + var filter = new List<Currency> { new Currency("EUR") }; // no EUR present + var result = _parser.Parse(raw, filter, new Currency("CZK")).ToList(); + + Assert.Empty(result); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdaterTests/ProviderServiceTests/ExchangeRateProviderServiceTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/ProviderServiceTests/ExchangeRateProviderServiceTests.cs new file mode 100644 index 000000000..e67774b82 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/ProviderServiceTests/ExchangeRateProviderServiceTests.cs @@ -0,0 +1,123 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using Xunit; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderServiceTests + { + [Fact] + public async Task GetExchangeRateAsync_ReturnsRates_FromMultipleFetchers() + { + // Arrange + var provider = CreateProviderWithError(dailyFails: false, otherFails: false); + + var currencies = new[] + { + new Currency("USD"), + new Currency("CHF") + }; + + // Act + var rates = await provider.GetExchangeRateAsync(currencies); + + // Assert + Assert.NotEmpty(rates); + Assert.Contains(rates, r => r.TargetCurrency.Code == "USD"); + Assert.Contains(rates, r => r.TargetCurrency.Code == "CHF"); + } + + [Fact] + public async Task GetExchangeRateAsync_FallbackToCache_WhenFetchFails() + { + // Arrange + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var provider = CreateProviderWithError(dailyFails: false, otherFails: false, memoryCache); + + var currencies = new[] { new Currency("USD") }; + + // Prime the cache + var initial = await provider.GetExchangeRateAsync(currencies); + Assert.NotEmpty(initial); + + // Now simulate both fetchers failing + var providerWithFailure = CreateProviderWithError( + dailyFails: true, + otherFails: true, + memoryCache); + + // Act + var fallbackResult = await providerWithFailure.GetExchangeRateAsync(currencies); + + // Assert + Assert.NotEmpty(fallbackResult); + Assert.Equal(initial.Count, fallbackResult.Count); // cache fallback works + } + + [Fact] + public async Task GetExchangeRateAsync_Throws_WhenNoCacheAndAllFetchersFail() + { + // Arrange + var provider = CreateProviderWithError(dailyFails: true, otherFails: true); + + var requested = new[] { new Currency("USD") }; + + // Act & Assert + await Assert.ThrowsAsync<ApplicationException>(async() => + { + await provider.GetExchangeRateAsync(requested); + }); + } + + private ExchangeRateProviderService CreateProviderWithError(bool dailyFails, bool otherFails, IMemoryCache memoryCache = null) + { + var mockHttp = new MockHttpMessageHandler(); + + if (dailyFails) + mockHttp.When("https://mock-daily").Respond(System.Net.HttpStatusCode.InternalServerError); + else + mockHttp.When("https://mock-daily").Respond("text/plain", "Country|Currency|Amount|Code|Rate\nDate\nUnited States|dollar|1|USD|22,345\n"); + + if (otherFails) + mockHttp.When("https://mock-other").Respond(System.Net.HttpStatusCode.InternalServerError); + else + mockHttp.When("https://mock-other").Respond("text/plain", "Country|Currency|Amount|Code|Rate|Source\nDate\nSwitzerland|franc|1|CHF|25,123|SNB\n"); + + var httpClient = mockHttp.ToHttpClient(); + + var mockDailyLogger = new Mock<ILogger<DailyExchangeRateFetcher>>(); + var mockOtherLogger = new Mock<ILogger<OtherCurrencyExchangeRateFetcher>>(); + + var dailyFetcher = new DailyExchangeRateFetcher( + httpClient, + Options.Create(new CzechBankSettings { DailyRatesUrl = "https://mock-daily" }), mockDailyLogger.Object); + + var otherFetcher = new OtherCurrencyExchangeRateFetcher( + httpClient, + Options.Create(new CzechBankSettings { OtherCurrencyRatesUrl = "https://mock-other" }), mockOtherLogger.Object); + + var fetchers = new List<IExhangeRateFetcher> { dailyFetcher, otherFetcher }; + + var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug()); + var parserLogger = loggerFactory.CreateLogger<CzechNationalBankTextRateParser>(); + var parser = new CzechNationalBankTextRateParser(5, parserLogger); + + var providerLogger = loggerFactory.CreateLogger<ExchangeRateProviderService>(); + + return new ExchangeRateProviderService( + fetchers, + parser, + memoryCache ?? new MemoryCache(new MemoryCacheOptions()), + providerLogger + ); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupForTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupForTests.cs new file mode 100644 index 000000000..35760c318 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupForTests.cs @@ -0,0 +1,72 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; + +namespace ExchangeRateUpdaterTests.StartupTests +{ + public class StartupForTest : Startup + { + public new IConfiguration Configuration { get; } + + public StartupForTest(IConfiguration configuration) + { + Configuration = configuration; + } + + public new void ConfigureServices(IServiceCollection services) + { + services.Configure<CzechBankSettings>(Configuration.GetSection("CzechBankSettings")); + + services.AddMemoryCache(); + services.AddSingleton<IExchangeRateParser>(sp => + new CzechNationalBankTextRateParser(5, sp.GetRequiredService<ILogger<CzechNationalBankTextRateParser>>())); + + services.AddHttpClient<DailyExchangeRateFetcher>() + .ConfigureHttpClient((provider, client) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + }) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + return Polly.Extensions.Http.HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(settings.RetryCount, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + }); + + services.AddHttpClient<OtherCurrencyExchangeRateFetcher>() + .ConfigureHttpClient((provider, client) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + }) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + return Polly.Extensions.Http.HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(settings.RetryCount, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + }); + + services.AddSingleton<IExhangeRateFetcher, DailyExchangeRateFetcher>(); + services.AddSingleton<IExhangeRateFetcher, OtherCurrencyExchangeRateFetcher>(); + services.AddSingleton<IExchangeRateProviderService, ExchangeRateProviderService>(); + + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.AddConfiguration(Configuration.GetSection("Logging")); + logging.AddConsole(); + }); + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupTests.cs b/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupTests.cs new file mode 100644 index 000000000..93af46cfa --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdaterTests/StartupTests/StartupTests.cs @@ -0,0 +1,81 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace ExchangeRateUpdaterTests.StartupTests +{ + public class StartupTests + { + [Fact] + public void ConfigureServices_RegistersDependenciesCorrectly() + { + var startup = new Startup(); + var services = new ServiceCollection(); + startup.ConfigureServices(services); + + var provider = services.BuildServiceProvider(); + + var providerService = provider.GetRequiredService<IExchangeRateProviderService>(); + Assert.NotNull(providerService); + + var cache = provider.GetRequiredService<IMemoryCache>(); + Assert.NotNull(cache); + + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>(); + Assert.NotNull(settings); + Assert.False(string.IsNullOrWhiteSpace(settings.Value.DailyRatesUrl)); + Assert.False(string.IsNullOrWhiteSpace(settings.Value.OtherCurrencyRatesUrl)); + Assert.True(settings.Value.TimeoutSeconds > 0); + Assert.True(settings.Value.RetryCount >= 0); + + var parser = provider.GetRequiredService<IExchangeRateParser>(); + Assert.NotNull(parser); + + var fetchers = provider.GetServices<IExhangeRateFetcher>().ToList(); + Assert.NotEmpty(fetchers); + + bool hasDaily = fetchers.Any(f => f is DailyExchangeRateFetcher); + bool hasOther = fetchers.Any(f => f is OtherCurrencyExchangeRateFetcher); + + Assert.True(hasDaily); + Assert.True(hasOther); + + var httpFactory = provider.GetRequiredService<IHttpClientFactory>(); + Assert.NotNull(httpFactory); + + var loggerFactory = provider.GetRequiredService<ILoggerFactory>(); + Assert.NotNull(loggerFactory); + + var logger = loggerFactory.CreateLogger<StartupTests>(); + Assert.NotNull(logger); + } + + [Fact] + public async Task ConfigureServices_Throws_WhenUrlsAreMissing() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary<string, string?>()) + .Build(); + + var services = new ServiceCollection(); + + var startup = new StartupForTest(config); + startup.ConfigureServices(services); + + var provider = services.BuildServiceProvider(); + // Act + Assert: provider resolution should throw due to missing URLs + Assert.Throws<ArgumentNullException>(() => + { + var _ = provider.GetRequiredService<IExchangeRateProviderService>(); + }); + } + } +} diff --git a/jobs/Backend/Task/.gitignore b/jobs/Backend/Task/.gitignore new file mode 100644 index 000000000..dc661aeb1 --- /dev/null +++ b/jobs/Backend/Task/.gitignore @@ -0,0 +1,2 @@ +/.vs +publish/ \ No newline at end of file diff --git a/jobs/Backend/Task/CHANGELOG.md b/jobs/Backend/Task/CHANGELOG.md new file mode 100644 index 000000000..fe76f0855 --- /dev/null +++ b/jobs/Backend/Task/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com), +and this project adheres to [Semantic Versioning](https://semver.org). + +## [Unreleased] + +### Added +- 2 HttpClients which fetch the Czech National Bank data sources +- Parser for parsing data from the http call +- Integration Test testing entire flow with mocked Http Responses +- The `CHANGELOG` file itself +- `Deployment.md` and `Dockerfile` for deployment steps +- Detailed `README` file + +### Fixed +- Single Responsibility Principle was being violated by the `ExchangeRateProviderService` +- Moved the `ExchangeRateProvider` to Services folder keeping root clean +- Unit tests which were failing from the new `ExchangeRateProvider` implementation +- csproj organisation and using .net 9 packages on a project with target of .net 6 +- `appsettings` to folow the removal of `HttpServiceSettings` +- Use Dependency Injection on `Program.cs` +- Spelling mistake in test file name +- Git ignore the publish folder + +### Removed +- `HttpServiceSettings`; moved into the `CzechBankSettings` + +--- + +## [1.0.0] - 2025-05-22 + +### Added +- Initial implementation of `ExchangeRateProvider` +- Fetch rates from CNB daily and other endpoints +- Dependency injection setup in console app +- Logging and configuration via `appsettings.json` +- HTTP timeout and retry support via Polly, configurable from appsettings +- Unit tests for `ExchangeRateProvider` +- Structured logging using Microsoft.Extensions.Logging +- Cache fallback logic + +### Fixed +- PlatformNotSupportedException from EventLog on non-Windows systems +- ArgumentOutOfRangeException for timeout value diff --git a/jobs/Backend/Task/Configuration/CzechBankSettings.cs b/jobs/Backend/Task/Configuration/CzechBankSettings.cs new file mode 100644 index 000000000..fec7ae7d2 --- /dev/null +++ b/jobs/Backend/Task/Configuration/CzechBankSettings.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Configuration +{ + public class CzechBankSettings + { + public string DailyRatesUrl { get; set; } + public string OtherCurrencyRatesUrl { get; set; } + public int TimeoutSeconds { get; set; } = 10; + public int RetryCount { get; set; } = 3; + } +} diff --git a/jobs/Backend/Task/Configuration/Startup.cs b/jobs/Backend/Task/Configuration/Startup.cs new file mode 100644 index 000000000..d802d2237 --- /dev/null +++ b/jobs/Backend/Task/Configuration/Startup.cs @@ -0,0 +1,95 @@ +using System; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly.Extensions.Http; +using Polly; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.HttpClients; + +namespace ExchangeRateUpdater.Configuration +{ + public class Startup + { + public IConfiguration Configuration { get; } + + public Startup() + { + var builder = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false); + + Configuration = builder.Build(); + } + + public void ConfigureServices(IServiceCollection services) + { + // Bind configurations + services.Configure<CzechBankSettings>(Configuration.GetSection("CzechBankSettings")); + services.AddSingleton<ExchangeRateProvider>(); + + + // Add dependencies + services.AddMemoryCache(); + + // Register the parser + services.AddSingleton<IExchangeRateParser>(sp => new CzechNationalBankTextRateParser(5, + sp.GetRequiredService<ILogger<CzechNationalBankTextRateParser>>())); + + // === Add resilient HTTP clients with Polly === + services.AddHttpClient<DailyExchangeRateFetcher>() + .ConfigureHttpClient((provider, client) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + }) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: settings.RetryCount, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + ); + }); + + services.AddHttpClient<OtherCurrencyExchangeRateFetcher>() + .ConfigureHttpClient((provider, client) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + }) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService<IOptions<CzechBankSettings>>().Value; + + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: settings.RetryCount, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + ); + }); + + // Register fetchers + services.AddSingleton<IExhangeRateFetcher, DailyExchangeRateFetcher>(); + services.AddSingleton<IExhangeRateFetcher, OtherCurrencyExchangeRateFetcher>(); + + // Register main provider + services.AddSingleton<IExchangeRateProviderService, ExchangeRateProviderService>(); + + // Add logging + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.AddConfiguration(Configuration.GetSection("Logging")); + logging.AddConsole(); + }); + } + } +} diff --git a/jobs/Backend/Task/Dockerfile b/jobs/Backend/Task/Dockerfile new file mode 100644 index 000000000..5acbc69b7 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile @@ -0,0 +1,8 @@ +# Use official .NET runtime image +FROM mcr.microsoft.com/dotnet/runtime:6.0 + +WORKDIR /app + +COPY ./publish/ . + +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"] 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 - { - /// <summary> - /// 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. - /// </summary> - public IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies) - { - return Enumerable.Empty<ExchangeRate>(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..b2359b985 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,29 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <OutputType>Exe</OutputType> - <TargetFramework>net6.0</TargetFramework> - </PropertyGroup> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net6.0</TargetFramework> + <PublishSingleFile>true</PublishSingleFile> + <SelfContained>true</SelfContained> + <RuntimeIdentifier>win-x64</RuntimeIdentifier> <!-- or linux-x64 for Linux servers --> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.3" /> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.1" /> + <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.36" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.1" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.1" /> + </ItemGroup> + + <ItemGroup> + <None Update="appsettings.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="appsettings.development.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> </Project> \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..03c459cd2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FD84211C-7857-4F86-AF9D-8EF05EE3C34B}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + CHANGELOG.md = CHANGELOG.md + README.md = README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "..\ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{3F178CA6-0C53-4407-9285-6862F4A26158}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +24,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 + {3F178CA6-0C53-4407-9285-6862F4A26158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F178CA6-0C53-4407-9285-6862F4A26158}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F178CA6-0C53-4407-9285-6862F4A26158}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F178CA6-0C53-4407-9285-6862F4A26158}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Extensions/CurrencyExtensions.cs b/jobs/Backend/Task/Extensions/CurrencyExtensions.cs new file mode 100644 index 000000000..646a287ac --- /dev/null +++ b/jobs/Backend/Task/Extensions/CurrencyExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Extensions +{ + public static class CurrencyExtensions + { + /// <summary> + /// Checks if the list of Currency contains a currency with the given code. + /// </summary> + public static bool Contains(this IEnumerable<Currency> currencies, string currencyCode) + { + if (currencyCode is null) + throw new ArgumentNullException(nameof(currencyCode)); + + return currencies.Any(c => + string.Equals(c.Code, currencyCode, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/jobs/Backend/Task/HttpClients/DailyExchangeRateFetcher.cs b/jobs/Backend/Task/HttpClients/DailyExchangeRateFetcher.cs new file mode 100644 index 000000000..0c81f7016 --- /dev/null +++ b/jobs/Backend/Task/HttpClients/DailyExchangeRateFetcher.cs @@ -0,0 +1,51 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.HttpClients +{ + public class DailyExchangeRateFetcher : IExhangeRateFetcher + { + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly ILogger<DailyExchangeRateFetcher> _logger; + + public DailyExchangeRateFetcher( + HttpClient httpClient, + IOptions<CzechBankSettings> options, + ILogger<DailyExchangeRateFetcher> logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _url = options.Value.DailyRatesUrl ?? throw new ArgumentNullException(nameof(options.Value.DailyRatesUrl)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task<string> FetchAsync() + { + _logger.LogInformation("Fetching daily exchange rates from {Url}", _url); + + try + { + var start = DateTimeOffset.UtcNow; + + var response = await _httpClient.GetAsync(_url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + var duration = DateTimeOffset.UtcNow - start; + _logger.LogInformation("Fetched daily exchange rates in {Duration} ms", duration.TotalMilliseconds); + + return content; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching daily exchange rates from {Url}", _url); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/HttpClients/IExhangeRateFetcher.cs b/jobs/Backend/Task/HttpClients/IExhangeRateFetcher.cs new file mode 100644 index 000000000..8a64c18c5 --- /dev/null +++ b/jobs/Backend/Task/HttpClients/IExhangeRateFetcher.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.HttpClients +{ + public interface IExhangeRateFetcher + { + Task<string> FetchAsync(); + } +} diff --git a/jobs/Backend/Task/HttpClients/OtherCurrencyExchangeRateFetcher.cs b/jobs/Backend/Task/HttpClients/OtherCurrencyExchangeRateFetcher.cs new file mode 100644 index 000000000..c2b45760b --- /dev/null +++ b/jobs/Backend/Task/HttpClients/OtherCurrencyExchangeRateFetcher.cs @@ -0,0 +1,51 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.HttpClients +{ + public class OtherCurrencyExchangeRateFetcher : IExhangeRateFetcher + { + private readonly HttpClient _httpClient; + private readonly string _url; + private readonly ILogger<OtherCurrencyExchangeRateFetcher> _logger; + + public OtherCurrencyExchangeRateFetcher( + HttpClient httpClient, + IOptions<CzechBankSettings> options, + ILogger<OtherCurrencyExchangeRateFetcher> logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _url = options.Value.OtherCurrencyRatesUrl ?? throw new ArgumentNullException(nameof(options.Value.OtherCurrencyRatesUrl)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task<string> FetchAsync() + { + _logger.LogInformation("Fetching other currency exchange rates from {Url}", _url); + + try + { + var start = DateTimeOffset.UtcNow; + + var response = await _httpClient.GetAsync(_url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + var duration = DateTimeOffset.UtcNow - start; + _logger.LogInformation("Fetched other currency exchange rates in {Duration} ms", duration.TotalMilliseconds); + + return content; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching other currency exchange rates from {Url}", _url); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Models/Currency.cs similarity index 89% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Models/Currency.cs index f375776f2..8336d740e 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/Models/ExchangeRate.cs index 58c5bb10e..2133586d4 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/Parsers/CzechNationalBankTextRateParser.cs b/jobs/Backend/Task/Parsers/CzechNationalBankTextRateParser.cs new file mode 100644 index 000000000..94c6729cb --- /dev/null +++ b/jobs/Backend/Task/Parsers/CzechNationalBankTextRateParser.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using ExchangeRateUpdater.Extensions; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Parsers +{ + public class CzechNationalBankTextRateParser : IExchangeRateParser + { + private readonly int _expectedColumns; + private readonly ILogger<CzechNationalBankTextRateParser> _logger; + + public CzechNationalBankTextRateParser(int expectedColumns, ILogger<CzechNationalBankTextRateParser> logger) + { + _expectedColumns = expectedColumns; + _logger = logger; + } + + public IEnumerable<ExchangeRate> Parse(string raw, IEnumerable<Currency> filter, Currency sourceCurrency) + { + var parsedRates = new List<ExchangeRate>(); + var filterSet = new HashSet<string>( + filter.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase); + + var lines = raw.Split('\n', StringSplitOptions.RemoveEmptyEntries); + _logger.LogDebug("Parsing CNB text: total lines (including header): {LineCount}", lines.Length); + + for (int i = 2; i < lines.Length; i++) // skip header + date line + { + var parts = lines[i].Split('|'); + + if (parts.Length < _expectedColumns) + { + _logger.LogWarning("Skipping line {LineIndex}: expected {ExpectedColumns} columns but got {ActualColumns}", + i, _expectedColumns, parts.Length); + continue; + } + + string code = parts[3].Trim(); + if (!filterSet.Contains(code)) + { + _logger.LogDebug("Skipping currency {CurrencyCode} as it's not in the filter list.", code); + continue; + } + + try + { + int amount = int.Parse(parts[2].Trim()); + decimal rate = decimal.Parse( + parts[4].Trim().Replace(',', '.'), + CultureInfo.InvariantCulture); + + decimal normalized = rate / amount; + + var exchangeRate = new ExchangeRate(sourceCurrency, new Currency(code), normalized); + parsedRates.Add(exchangeRate); + + _logger.LogDebug("Parsed: {Source}/{Target} = {Value}", + sourceCurrency, code, normalized); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse line {LineIndex}: {LineContent}", i, lines[i]); + } + } + + _logger.LogInformation("Finished parsing. Extracted {ParsedCount} exchange rates.", parsedRates.Count); + return parsedRates; + } + } +} diff --git a/jobs/Backend/Task/Parsers/IExchangeRateParser.cs b/jobs/Backend/Task/Parsers/IExchangeRateParser.cs new file mode 100644 index 000000000..a8194d451 --- /dev/null +++ b/jobs/Backend/Task/Parsers/IExchangeRateParser.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Parsers +{ + public interface IExchangeRateParser + { + IEnumerable<ExchangeRate> Parse(string content, IEnumerable<Currency> filter, Currency source); + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..1e2b2c327 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { @@ -21,10 +27,20 @@ public static class Program public static void Main(string[] args) { + var startup = new Startup(); + + using IHost host = Host.CreateDefaultBuilder(args) + .ConfigureServices(startup.ConfigureServices) + .Build(); + + // Get services + var logger = host.Services.GetRequiredService<ILoggerFactory>() + .CreateLogger("Main"); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = host.Services.GetRequiredService<ExchangeRateProvider>(); + var rates = provider.GetExchangeRatesAsync(currencies).Result; Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..02d7e394f --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,72 @@ +# CzechNationalBank ExchangeRateUpdater + +A robust .NET 6 console application that pulls daily and other currency exchange rates from the Czech National Bank, parses and caches them, and exposes a reusable provider for integration into other systems. + +--- + +## Features + +- ✅ Fetches official CNB daily and other currency exchange rates +- ✅ Handles parsing of CNB's custom text formats +- ✅ Uses resilient HTTP client with Polly retry and timeout policies +- ✅ Caches rates until the next official update (2:30 PM daily) +- ✅ Fully unit-tested and integration-tested +- ✅ Runs standalone or inside a Docker container + +--- + +## Getting Started + +### Requirements + +- [.NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) +- [Docker Desktop](https://www.docker.com/products/docker-desktop) (optional) + +--- + +### Build & Run Locally + +`bash` +# Build & run +dotnet build +dotnet run --project ExchangeRateUpdater + +--- + +## Run with Docker + +### Publish build +dotnet publish -c Release -o ./publish + +### Build Docker image +docker build -t exchangerate-updater . + +### Run in Docker +docker run --rm exchangerate-updater + +--- + +# Configuration +The app reads from `appsettings.json` + +Override these settings at runtime with env variables eg: + +`docker run --rm -e CzechBankSettings__DailyRatesUrl=https://mock exchangerate-updater` + +--- + +## Run Tests + +`dotnet test ExchangeRateUpdaterTests` + +--- + +## Deployment + +See [deployment.md](./deployment.md) for detailed instructions + +--- + +## Changelog + +[Changelog](./CHANGELOG.md) \ No newline at end of file diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..60d404c21 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProvider + { + private readonly IExchangeRateProviderService _service; + + public ExchangeRateProvider(IExchangeRateProviderService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + /// <summary> + /// 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. + /// </summary> + public async Task<IEnumerable<ExchangeRate>> GetExchangeRatesAsync(IEnumerable<Currency> currencies) + { + if (currencies == null || !currencies.Any()) + { + return Enumerable.Empty<ExchangeRate>(); + } + return await _service.GetExchangeRateAsync(currencies); + } + } +} diff --git a/jobs/Backend/Task/Services/ExchangeRateProviderService.cs b/jobs/Backend/Task/Services/ExchangeRateProviderService.cs new file mode 100644 index 000000000..94d7a2bfd --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProviderService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProviderService : IExchangeRateProviderService + { + private readonly IEnumerable<IExhangeRateFetcher> _fetchers; + private readonly IExchangeRateParser _parser; + private readonly ILogger<ExchangeRateProviderService> _logger; + private readonly IMemoryCache _cache; + private const string CacheKey = nameof(CacheKey); + private const string SourceCurrency = "CZK"; + + public ExchangeRateProviderService( + IEnumerable<IExhangeRateFetcher> fetchers, + IExchangeRateParser parser, + IMemoryCache cache, + ILogger<ExchangeRateProviderService> logger) + { + _fetchers = fetchers; + _parser = parser; + _cache = cache; + _logger = logger; + } + + public async Task<List<ExchangeRate>> GetExchangeRateAsync(IEnumerable<Currency> currencies) + { + var targetCodes = new HashSet<string>( + currencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase + ); + + + if (_cache.TryGetValue(CacheKey, out List<ExchangeRate> cachedRates)) + { + _logger.LogInformation("Returning filtered exchange rates from cache."); + return cachedRates + .Where(rate => targetCodes.Contains(rate.TargetCurrency.Code)) + .ToList(); + } + + // Parallel fetch + var fetchTasks = _fetchers.Select(s => s.FetchAsync()).ToList(); + + string[] content; + try + { + content = await Task.WhenAll(fetchTasks); + } + catch (Exception ex) + { + _logger.LogError(ex, "One or more sources failed during fetch."); + // If we have fallback cache, return that: + if (_cache.TryGetValue(CacheKey, out List<ExchangeRate> fallback)) + { + _logger.LogWarning("Returning cached rates due to fetch failure."); + return fallback.Where(rate => targetCodes.Contains(rate.TargetCurrency.Code)).ToList(); + } + + throw new ApplicationException("Failed to fetch exchange rates and no cached fallback is available.", ex); + } + + var exchangeRates = new List<ExchangeRate>(); + foreach (var raw in content) + { + exchangeRates.AddRange(_parser.Parse(raw, currencies, new Currency(SourceCurrency))); + } + + + + var now = DateTimeOffset.UtcNow; + var nextUpdate = new DateTimeOffset(now.Date.AddHours(13.5)); // 2:30 PM CET Daily update according to their website + if (now >= nextUpdate) nextUpdate = nextUpdate.AddDays(1); + + _cache.Set(CacheKey, exchangeRates, nextUpdate); + + _logger.LogInformation("Successfully fetched exchange rates until {NextUpdate}.", nextUpdate); + return exchangeRates + .Where(rate => targetCodes.Contains(rate.TargetCurrency.Code)) + .ToList(); + } + } +} diff --git a/jobs/Backend/Task/Services/IExchangeRateProviderService.cs b/jobs/Backend/Task/Services/IExchangeRateProviderService.cs new file mode 100644 index 000000000..09a9302d5 --- /dev/null +++ b/jobs/Backend/Task/Services/IExchangeRateProviderService.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + + namespace ExchangeRateUpdater.Services; + +public interface IExchangeRateProviderService +{ + Task<List<ExchangeRate>> GetExchangeRateAsync(IEnumerable<Currency> currencies); +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.development.json b/jobs/Backend/Task/appsettings.development.json new file mode 100644 index 000000000..4c8786c1a --- /dev/null +++ b/jobs/Backend/Task/appsettings.development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + }, + "CzechBankSettings": { + "DailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "OtherCurrencyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/fx-rates-of-other-currencies/fx-rates-of-other-currencies/fx_rates.txt", + "TimeoutSeconds": 10, + "RetryCount": 3 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..4fd3d6668 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "ExchangeRateUpdater": "Debug", + "Microsoft": "Warning", + "System": "Warning" + } + }, + "CzechBankSettings": { + "DailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "OtherCurrencyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/fx-rates-of-other-currencies/fx-rates-of-other-currencies/fx_rates.txt", + "TimeoutSeconds": 10, + "RetryCount": 3 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/deployment.md b/jobs/Backend/Task/deployment.md new file mode 100644 index 000000000..a39d7222b --- /dev/null +++ b/jobs/Backend/Task/deployment.md @@ -0,0 +1,12 @@ +# Deployment Guide: ExchangeRateUpdater + +This document explains how to deploy and run the Exchange Rate Updater console app in production. + +--- + +## Build & Publish + +Publish as a **self-contained single file executable** for your target OS: + +```bash +dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./publish