From b5c64ae04fe57a26b90919087c5f8aa07ae71f22 Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Mon, 26 May 2025 21:10:08 +0100 Subject: [PATCH 1/7] feat: Implementaion of Exchange updater console app --- .../Extentions/CzechTimeZoneExtensionsTest.cs | 76 +++++++ .../Extentions/DateTimeExtensionsTests.cs | 54 +++++ .../ExchangeRate.Tests.csproj | 34 +++ .../Providers/ExchangeRateServiceTests.cs | 193 ++++++++++++++++++ .../HttpClients/CzechApiClientTests.cs | 137 +++++++++++++ .../Cache/CnbRatesCacheTests.cs | 113 ++++++++++ .../ExchangeRate.Tests/StartupTests.cs | 105 ++++++++++ .../Task/Common/Constants/AppConstants.cs | 12 ++ .../Extensions/CzechTimeZoneExtensions.cs | 64 ++++++ .../Common/Extensions/DateTimeExtensions.cs | 19 ++ .../Task/Configuration/CzechApiSettings.cs | 7 + .../Task/Configuration/HttpServiceSettings.cs | 8 + .../Backend/Task/ExchangeRate/Dtos/CnbRate.cs | 28 +++ .../ExchangeRate/Dtos/CnbRatesResponse.cs | 11 + .../Providers/ExchangeRateService.cs | 117 +++++++++++ .../Providers/IExchangeRateService.cs | 12 ++ jobs/Backend/Task/ExchangeRateProvider.cs | 19 -- jobs/Backend/Task/ExchangeRateUpdater.csproj | 20 +- jobs/Backend/Task/ExchangeRateUpdater.sln | 26 +++ .../Task/HttpClients/CzechApiClient.cs | 46 +++++ .../Task/HttpClients/ICzechApiClient.cs | 9 + .../Infrastructure/Cache/CnbRatesCache.cs | 47 +++++ .../Infrastructure/Cache/ICnbRatesCache.cs | 11 + jobs/Backend/Task/{ => Models}/Currency.cs | 10 +- .../Backend/Task/{ => Models}/ExchangeRate.cs | 2 +- jobs/Backend/Task/Program.cs | 80 +++++++- jobs/Backend/Task/Startup.cs | 102 +++++++++ jobs/Backend/Task/appsettings.json | 17 ++ 28 files changed, 1343 insertions(+), 36 deletions(-) create mode 100644 jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs create mode 100644 jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs create mode 100644 jobs/Backend/ExchangeRate.Tests/ExchangeRate.Tests.csproj create mode 100644 jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs create mode 100644 jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs create mode 100644 jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs create mode 100644 jobs/Backend/ExchangeRate.Tests/StartupTests.cs create mode 100644 jobs/Backend/Task/Common/Constants/AppConstants.cs create mode 100644 jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs create mode 100644 jobs/Backend/Task/Common/Extensions/DateTimeExtensions.cs create mode 100644 jobs/Backend/Task/Configuration/CzechApiSettings.cs create mode 100644 jobs/Backend/Task/Configuration/HttpServiceSettings.cs create mode 100644 jobs/Backend/Task/ExchangeRate/Dtos/CnbRate.cs create mode 100644 jobs/Backend/Task/ExchangeRate/Dtos/CnbRatesResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs create mode 100644 jobs/Backend/Task/ExchangeRate/Providers/IExchangeRateService.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/HttpClients/CzechApiClient.cs create mode 100644 jobs/Backend/Task/HttpClients/ICzechApiClient.cs create mode 100644 jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs create mode 100644 jobs/Backend/Task/Infrastructure/Cache/ICnbRatesCache.cs rename jobs/Backend/Task/{ => Models}/Currency.cs (53%) rename jobs/Backend/Task/{ => Models}/ExchangeRate.cs (93%) create mode 100644 jobs/Backend/Task/Startup.cs create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs new file mode 100644 index 000000000..376774649 --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs @@ -0,0 +1,76 @@ +using System; +using System.Runtime.InteropServices; +using Xunit; +using ExchangeRateUpdater.Common.Extensions; + +namespace ExchangeRate.Tests.Common.Extensions +{ + public class CzechTimeZoneExtensionsTest + { + [Fact] + public void GetCzechTimeZone_ReturnsCorrectTimeZone() + { + // Act + var tz = CzechTimeZoneExtensions.GetCzechTimeZone(); + + // Assert + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Equal("Central Europe Standard Time", tz.Id); + else + Assert.Equal("Europe/Prague", tz.Id); + } + + [Fact] + public void GetNextCzechBankUpdateUtc_ReturnsFutureTime() + { + // Act + var nextUpdate = CzechTimeZoneExtensions.GetNextCzechBankUpdateUtc(); + + // Assert + Assert.True(nextUpdate > DateTimeOffset.UtcNow); + // Should be at 14:30 Czech time + var czechTz = CzechTimeZoneExtensions.GetCzechTimeZone(); + var local = TimeZoneInfo.ConvertTime(nextUpdate, czechTz); + Assert.Equal(14, local.Hour); + Assert.Equal(30, local.Minute); + } + + [Fact] + public void GetNextMonthUpdateUtc_ReturnsFirstDayOfNextMonthAtMidnight() + { + // Act + var nextMonth = CzechTimeZoneExtensions.GetNextMonthUpdateUtc(); + + // Assert + Assert.True(nextMonth > DateTimeOffset.UtcNow); + + var czechTz = CzechTimeZoneExtensions.GetCzechTimeZone(); + var local = TimeZoneInfo.ConvertTime(nextMonth, czechTz); + + Assert.Equal(0, local.Hour); + Assert.Equal(0, local.Minute); + Assert.Equal(1, local.Day); + } + + + [Fact] + public void GetNextMonthUpdateUtc_ForDecember_SetsToJanuaryNextYear() + { + // Arrange: Simulate a date in December + var decemberDate = new DateTimeOffset(DateTime.UtcNow.Year, 12, 15, 10, 0, 0, TimeSpan.Zero); + + // Act: Call the logic as if today is December + var nextMonth = CzechTimeZoneExtensions.GetNextMonthUpdateUtc(decemberDate); + + // Assert: Should be January 1st of next year at midnight Czech time + var czechTz = CzechTimeZoneExtensions.GetCzechTimeZone(); + var local = TimeZoneInfo.ConvertTime(nextMonth, czechTz); + + Assert.Equal(1, local.Day); + Assert.Equal(1, local.Month); + Assert.Equal(decemberDate.Year + 1, local.Year); + Assert.Equal(0, local.Hour); + Assert.Equal(0, local.Minute); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs new file mode 100644 index 000000000..ba405a1a6 --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs @@ -0,0 +1,54 @@ +using System; +using Xunit; +using ExchangeRateUpdater.Common.Extensions; + +namespace ExchangeRate.Tests.CommonTest.Extensions +{ + public class DateTimeExtensionsTests + { + [Fact] + public void GetPreviousYearMonthUtc_ReturnsPreviousMonth_ForNonJanuary() + { + // Arrange + var testDate = new DateTime(2024, 5, 15); + var expected = "2024-04"; + var originalNow = DateTime.UtcNow; + + // Act + var result = ""; + System.Func originalUtcNow = () => DateTime.UtcNow; + try + { + + var prevMonth = testDate.Month == 1 + ? new DateTime(testDate.Year - 1, 12, 1) + : new DateTime(testDate.Year, testDate.Month - 1, 1); + result = prevMonth.ToString("yyyy-MM"); + } + finally + { + + } + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetPreviousYearMonthUtc_ReturnsDecemberOfPreviousYear_ForJanuary() + { + // Arrange + var testDate = new DateTime(2024, 1, 10); + var expected = "2023-12"; + + // Act + var prevMonth = testDate.Month == 1 + ? new DateTime(testDate.Year - 1, 12, 1) + : new DateTime(testDate.Year, testDate.Month - 1, 1); + var result = prevMonth.ToString("yyyy-MM"); + + // Assert + Assert.Equal(expected, result); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRate.Tests/ExchangeRate.Tests.csproj b/jobs/Backend/ExchangeRate.Tests/ExchangeRate.Tests.csproj new file mode 100644 index 000000000..6c87f1dc6 --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/ExchangeRate.Tests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs new file mode 100644 index 000000000..0c7cc9c5c --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs @@ -0,0 +1,193 @@ +using Moq; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Infrastructure.Cache; +using ExchangeRateUpdater.ExchangeRate.Providers; +using ExchangeRateUpdater.Models; + +namespace ExchangeRate.Tests.ExchangeRate.Providers +{ + public class ExchangeRateServiceTests + { + private readonly Mock _apiClientMock; + private readonly Mock> _loggerMock; + private readonly Mock _dailyCacheMock; + private readonly Mock _monthlyCacheMock; + private readonly ExchangeRateService _service; + + public ExchangeRateServiceTests() + { + _apiClientMock = new Mock(); + _loggerMock = new Mock>(); + _dailyCacheMock = new Mock(); + _monthlyCacheMock = new Mock(); + + _service = new ExchangeRateService( + _apiClientMock.Object, + _loggerMock.Object, + _dailyCacheMock.Object, + _monthlyCacheMock.Object + ); + } + + [Fact] + public async Task GetExchangeRateAsync_ReturnsRates_FromDailyCache() + { + // Arrange + var currencies = new List { new Currency("USD"), new Currency("CZK") }; + var dailyRates = new Dictionary + { + { "USD", 25.0m }, + { "CZK", 1.0m } + }; + var monthlyRates = new Dictionary(); + + _dailyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(dailyRates); + _monthlyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(monthlyRates); + + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Contains(result, r => r.SourceCurrency.Code == "USD" && r.Value == 25.0m); + Assert.Contains(result, r => r.SourceCurrency.Code == "CZK" && r.Value == 1.0m); + } + + + [Fact] + public async Task GetExchangeRateAsync_ReturnsRates_FromMonthlyCache_IfNotInDaily() + { + // Arrange + var currencies = new List { new Currency("JPY") }; + var dailyRates = new Dictionary(); + var monthlyRates = new Dictionary + { + { "JPY", 0.2m } + }; + + _dailyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(dailyRates); + _monthlyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(monthlyRates); + + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Single(result); + Assert.Equal("JPY", result[0].SourceCurrency.Code); + Assert.Equal(0.2m, result[0].Value); + } + + [Fact] + public async Task GetExchangeRateAsync_LogsWarning_IfCurrencyNotFound() + { + // Arrange + var currencies = new List { new Currency("ABC") }; + var dailyRates = new Dictionary(); + var monthlyRates = new Dictionary(); + + _dailyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(dailyRates); + _monthlyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(monthlyRates); + + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Empty(result); + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Currency ABC not found")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetExchangeRateAsync_ReturnsEmptyList_IfNoCurrencies() + { + // Arrange + var currencies = new List(); + + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetDailyRatesAsync_ExecutesCallbackAndParsesRates() + { + // Arrange + var fakeJson = "{\"rates\":[{\"currencyCode\":\"USD\",\"rate\":25.0,\"amount\":1}]}"; + _apiClientMock.Setup(c => c.GetAsync(It.IsAny())) + .ReturnsAsync(fakeJson); + + _dailyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .Returns>>>(factory => factory()); + + _monthlyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .Returns>>>(factory => factory()); + + var currencies = new List { new Currency("USD") }; + + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Single(result); + Assert.Equal("USD", result[0].SourceCurrency.Code); + Assert.Equal(25.0m, result[0].Value); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Fetching daily exchange rates from CNB JSON API...")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetExchangeRateAsync_WhenApiClientThrows_LogsErrorAndThrows() + { + // Arrange + var currencies = new List { new Currency("USD") }; + var exception = new Exception("Simulated API failure"); + + _apiClientMock.Setup(c => c.GetAsync(It.IsAny())) + .ThrowsAsync(exception); + + _dailyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .Returns>>>(factory => factory()); + + _monthlyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(new Dictionary()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.GetExchangeRateAsync(currencies)); + Assert.Equal("Simulated API failure", ex.Message); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Failed to get daily exchange rates from cache or API.")), + exception, + It.IsAny>()), + Times.Once); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs b/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs new file mode 100644 index 000000000..4f3afd701 --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.HttpClients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using Xunit; + +namespace ExchangeRate.Tests.HttpClients +{ + public class CzechApiClientTests + { + private static CzechApiClient CreateClient(HttpResponseMessage response, string baseUrl = "https://api.cnb.cz") + { + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + var httpClient = new HttpClient(handlerMock.Object); + var options = Options.Create(new CzechApiSettings { BaseUrl = baseUrl }); + var loggerMock = new Mock>(); + + return new CzechApiClient(httpClient, options, loggerMock.Object); + } + + [Fact] + public async Task GetAsync_ReturnsContent_WhenSuccess() + { + // Arrange + var expectedContent = "test-response"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(expectedContent) + }; + var client = CreateClient(response); + + // Act + var result = await client.GetAsync("/test-endpoint"); + + // Assert + Assert.Equal(expectedContent, result); + } + + [Fact] + public async Task GetAsync_ThrowsException_WhenNotSuccess() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.BadRequest); + var client = CreateClient(response); + + // Act & Assert + await Assert.ThrowsAsync(() => client.GetAsync("/fail-endpoint")); + } + + [Fact] + public async Task GetAsync_UsesBaseUrlAndRelativeUrl() + { + // Arrange + var expectedBase = "https://api.cnb.cz"; + var expectedRelative = "/some-path"; + var expectedUrl = $"{expectedBase}{expectedRelative}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok") + }; + + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri.ToString() == expectedUrl), + ItExpr.IsAny()) + .ReturnsAsync(response) + .Verifiable(); + + var httpClient = new HttpClient(handlerMock.Object); + var options = Options.Create(new CzechApiSettings { BaseUrl = expectedBase }); + var loggerMock = new Mock>(); + + var client = new CzechApiClient(httpClient, options, loggerMock.Object); + + // Act + var result = await client.GetAsync(expectedRelative); + + // Assert + Assert.Equal("ok", result); + handlerMock.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == expectedUrl), + ItExpr.IsAny()); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenHttpClientIsNull() + { + // Arrange + var options = Options.Create(new CzechApiSettings { BaseUrl = "https://api.cnb.cz" }); + var loggerMock = new Mock>(); + + // Act & Assert + Assert.Throws(() => new CzechApiClient(null, options, loggerMock.Object)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOptionsIsNull() + { + // Arrange + var httpClient = new HttpClient(); + var loggerMock = new Mock>(); + + // Act & Assert + Assert.Throws(() => new CzechApiClient(httpClient, null, loggerMock.Object)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + // Arrange + var httpClient = new HttpClient(); + var options = Options.Create(new CzechApiSettings { BaseUrl = "https://api.cnb.cz" }); + + // Act & Assert + Assert.Throws(() => new CzechApiClient(httpClient, options, null)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs new file mode 100644 index 000000000..b5ae41915 --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Infrastructure.Cache; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ExchangeRate.Tests.Infrastructure.Cache +{ + public class CnbRatesCacheTests + { + [Fact] + public async Task GetOrCreateAsync_ReturnsValueFromFactory_WhenNotCached() + { + // Arrange + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheKey = "test-key"; + var expiration = DateTimeOffset.UtcNow.AddMinutes(5); + var loggerMock = new Mock>(); + var cache = new CnbRatesCache(memoryCache, cacheKey, () => expiration, loggerMock.Object); + + var expected = new Dictionary { { "USD", 25.0m } }; + + // Act + var result = await cache.GetOrCreateAsync(() => Task.FromResult(expected)); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetOrCreateAsync_ReturnsCachedValue_WhenAlreadyCached() + { + // Arrange + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheKey = "test-key"; + var expiration = DateTimeOffset.UtcNow.AddMinutes(5); + var loggerMock = new Mock>(); + var cache = new CnbRatesCache(memoryCache, cacheKey, () => expiration, loggerMock.Object); + + var expected = new Dictionary { { "EUR", 27.0m } }; + + // Prime the cache + await cache.GetOrCreateAsync(() => Task.FromResult(expected)); + + // Act + var result = await cache.GetOrCreateAsync(() => throw new Exception("Factory should not be called")); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetOrCreateAsync_SetsExpirationCorrectly() + { + // Arrange + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheKey = "test-key"; + var expiration = DateTimeOffset.UtcNow.AddSeconds(1); + var loggerMock = new Mock>(); + var cache = new CnbRatesCache(memoryCache, cacheKey, () => expiration, loggerMock.Object); + + var expected = new Dictionary { { "CZK", 1.0m } }; + + // Act + var result = await cache.GetOrCreateAsync(() => Task.FromResult(expected)); + + // Wait for expiration + await Task.Delay(1100); + + // Verify that "Cache miss" was logged at least once (after expiration) + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Cache miss")), + It.IsAny(), + It.IsAny>()), + Times.AtLeast(1)); + } + + [Fact] + public async Task GetOrCreateAsync_SetCache_ThrowsException() + { + // Arrange + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheKey = "test-key"; + var expiration = DateTimeOffset.UtcNow.AddSeconds(1); + var loggerMock = new Mock>(); + var cache = new CnbRatesCache(memoryCache, cacheKey, () => expiration, loggerMock.Object); + + var exception = new Exception("Simulated exception"); + + // Act + var ex = await Assert.ThrowsAsync(() => + cache.GetOrCreateAsync(() => Task.FromException>(exception)) + ); + Assert.Equal("Simulated exception", ex.Message); + + // Verify that error was logged + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error occurred while fetching or caching data")), + exception, + It.IsAny>()), + Times.Once); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRate.Tests/StartupTests.cs b/jobs/Backend/ExchangeRate.Tests/StartupTests.cs new file mode 100644 index 000000000..93156db1d --- /dev/null +++ b/jobs/Backend/ExchangeRate.Tests/StartupTests.cs @@ -0,0 +1,105 @@ +using Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ExchangeRateUpdater.HttpClients; +using System.Linq; +using ExchangeRateUpdater.Infrastructure.Cache; +using ExchangeRateUpdater.Common.Constants; +using ExchangeRateUpdater.ExchangeRate.Providers; +using ExchangeRateUpdater; + +namespace ExchangeRate.Tests +{ + public class StartupTests + { + [Fact] + public void ConfigureServices_Registers_ExchangeRateService() + { + // Arrange + var services = new ServiceCollection(); + var startup = new Startup(); + + // Act + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + // Assert + var service = provider.GetService(); + Assert.NotNull(service); + } + + [Fact] + public void ConfigureServices_Registers_MemoryCache() + { + var services = new ServiceCollection(); + var startup = new Startup(); + + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + var cache = provider.GetService(); + Assert.NotNull(cache); + } + + [Fact] + public void ConfigureServices_Registers_Logger() + { + var services = new ServiceCollection(); + var startup = new Startup(); + + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + var logger = provider.GetService>(); + Assert.NotNull(logger); + } + + [Fact] + public void ConfigureServices_Registers_HttpClient_For_CzechApiClient() + { + var services = new ServiceCollection(); + var startup = new Startup(); + + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + var client = provider.GetService(); + Assert.NotNull(client); + } + + [Fact] + public void ConfigureServices_Registers_CnbRatesCache_KeyedServices() + { + var services = new ServiceCollection(); + var startup = new Startup(); + + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + // Try to resolve all registered ICnbRatesCache keyed services + var dailyCache = provider.GetKeyedService(AppConstants.DailyRatesKeyedService); + var monthlyCache = provider.GetKeyedService(AppConstants.MonthlyRatesKeyedService); + + Assert.NotNull(dailyCache); + Assert.NotNull(monthlyCache); + } + + [Fact] + public void ConfigureServices_Registers_Options() + { + var services = new ServiceCollection(); + var startup = new Startup(); + + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + var httpOptions = provider.GetService>(); + var czechApiOptions = provider.GetService>(); + + Assert.NotNull(httpOptions); + Assert.NotNull(czechApiOptions); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Constants/AppConstants.cs b/jobs/Backend/Task/Common/Constants/AppConstants.cs new file mode 100644 index 000000000..baf5ef4df --- /dev/null +++ b/jobs/Backend/Task/Common/Constants/AppConstants.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Common.Constants +{ + public static class AppConstants + { + // Cache keys + public const string DailyRatesCacheKey = "CnbApiRatesDict"; + public const string MonthlyRatesCacheKey = "CnbApiMonthlyRatesDict"; + // Keyed services + public const string DailyRatesKeyedService = "daily"; + public const string MonthlyRatesKeyedService = "monthly"; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs b/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs new file mode 100644 index 000000000..2e69a0c7a --- /dev/null +++ b/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.Runtime.InteropServices; + +namespace ExchangeRateUpdater.Common.Extensions +{ + public static class CzechTimeZoneExtensions + { + public static TimeZoneInfo GetCzechTimeZone() + { + var tzId = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "Central Europe Standard Time" + : "Europe/Prague"; + return TimeZoneInfo.FindSystemTimeZoneById(tzId); + } + + public static DateTimeOffset GetNextCzechBankUpdateUtc() + { + var czechTimeZone = GetCzechTimeZone(); + var nowCzech = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, czechTimeZone); + var nextUpdate = new DateTimeOffset(nowCzech.Year, nowCzech.Month, nowCzech.Day, 14, 30, 0, nowCzech.Offset); + if (nowCzech >= nextUpdate) nextUpdate = nextUpdate.AddDays(1); + return nextUpdate.ToUniversalTime(); + } + + // public static DateTimeOffset GetNextMonthUpdateUtc(DateTimeOffset? now = null) + // { + // var czechTimeZone = GetCzechTimeZone(); + // var nowCzech = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, czechTimeZone); + // var nextMonth = nowCzech.Month == 12 + // ? new DateTimeOffset(nowCzech.Year + 1, 1, 1, 0, 0, 0, nowCzech.Offset) + // : new DateTimeOffset(nowCzech.Year, nowCzech.Month + 1, 1, 0, 0, 0, nowCzech.Offset); + // return nextMonth.ToUniversalTime(); + // } + + public static DateTimeOffset GetNextMonthUpdateUtc(DateTimeOffset? now = null) + { + var czechTz = GetCzechTimeZone(); + var current = now ?? TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, czechTz); + + int year = current.Year; + int month = current.Month; + + if (month == 12) + { + year += 1; + month = 1; + } + else + { + month += 1; + } + + // Create the local time for the next month's first day at midnight + var nextMonthLocal = new DateTime(year, month, 1, 0, 0, 0); + // Get the correct offset for that date (handles DST) + var offset = czechTz.GetUtcOffset(nextMonthLocal); + // Create the DateTimeOffset with the correct offset + var nextMonth = new DateTimeOffset(nextMonthLocal, offset); + + // Convert to UTC for cache expiration + return nextMonth.ToUniversalTime(); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Extensions/DateTimeExtensions.cs b/jobs/Backend/Task/Common/Extensions/DateTimeExtensions.cs new file mode 100644 index 000000000..ce946c446 --- /dev/null +++ b/jobs/Backend/Task/Common/Extensions/DateTimeExtensions.cs @@ -0,0 +1,19 @@ +using System; + +namespace ExchangeRateUpdater.Common.Extensions +{ + public static class DateTimeExtensions + { + /// + /// Gets the previous month in "yyyy-MM" format, handling January correctly. + /// + public static string GetPreviousYearMonthUtc() + { + var now = DateTime.UtcNow; + var prevMonth = now.Month == 1 + ? new DateTime(now.Year - 1, 12, 1) + : new DateTime(now.Year, now.Month - 1, 1); + return prevMonth.ToString("yyyy-MM"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/CzechApiSettings.cs b/jobs/Backend/Task/Configuration/CzechApiSettings.cs new file mode 100644 index 000000000..777bd4975 --- /dev/null +++ b/jobs/Backend/Task/Configuration/CzechApiSettings.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Configuration +{ + public class CzechApiSettings + { + public string BaseUrl { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/HttpServiceSettings.cs b/jobs/Backend/Task/Configuration/HttpServiceSettings.cs new file mode 100644 index 000000000..7bb118222 --- /dev/null +++ b/jobs/Backend/Task/Configuration/HttpServiceSettings.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Configuration +{ + public class HttpServiceSettings + { + public int RetryCount { get; set; } = 3; + public int TimeoutSeconds { get; set; } = 30; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate/Dtos/CnbRate.cs b/jobs/Backend/Task/ExchangeRate/Dtos/CnbRate.cs new file mode 100644 index 000000000..2549667ca --- /dev/null +++ b/jobs/Backend/Task/ExchangeRate/Dtos/CnbRate.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.ExchangeRate.Dtos +{ + public class CnbRate + { + [JsonPropertyName("validFor")] + public string ValidFor { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("country")] + public string Country { get; set; } + + [JsonPropertyName("currency")] + public string Currency { get; set; } + + [JsonPropertyName("amount")] + public int Amount { get; set; } + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate/Dtos/CnbRatesResponse.cs b/jobs/Backend/Task/ExchangeRate/Dtos/CnbRatesResponse.cs new file mode 100644 index 000000000..7eb53ad83 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRate/Dtos/CnbRatesResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.ExchangeRate.Dtos +{ + public class CnbRatesResponse + { + [JsonPropertyName("rates")] + public List Rates { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs new file mode 100644 index 000000000..1cc05c246 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ExchangeRateUpdater.HttpClients; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Infrastructure.Cache; +using Microsoft.Extensions.DependencyInjection; +using ExchangeRateUpdater.ExchangeRate.Dtos; +using ExchangeRateUpdater.Common.Extensions; +using ExchangeRateUpdater.Common.Constants; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.ExchangeRate.Providers +{ + public class ExchangeRateService( + ICzechApiClient czechApiClient, + ILogger logger, + [FromKeyedServices(AppConstants.DailyRatesKeyedService)] ICnbRatesCache dailyRatesCache, + [FromKeyedServices(AppConstants.MonthlyRatesKeyedService)] ICnbRatesCache monthlyRatesCache) : IExchangeRateService + { + private readonly ICzechApiClient _czechApiClient = czechApiClient; + private readonly ILogger _logger = logger; + private readonly ICnbRatesCache _dailyRatesCache = dailyRatesCache; + private readonly ICnbRatesCache _monthlyRatesCache = monthlyRatesCache; + private const string DailyRatesJsonUrl = "/exrates/daily?lang=EN"; + private const string MonthlyRatesJsonUrl = "/fxrates/daily-month?lang=EN&yearMonth={0}"; + private static readonly Currency CZK = new("CZK"); + + public async Task> GetExchangeRateAsync(IEnumerable currencies) + { + try + { + var dailyRates = await GetDailyRatesAsync(); + var monthlyRates = await GetMonthlyRatesAsync(); + + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + var result = new List(); + + foreach (var code in currencyCodes) + { + if (dailyRates.TryGetValue(code, out var value)) + { + result.Add(new Models.ExchangeRate(new Currency(code), CZK, value)); + } + else if (monthlyRates.TryGetValue(code, out var mValue)) + { + result.Add(new Models.ExchangeRate(new Currency(code), CZK, mValue)); + } + else + { + _logger.LogWarning("Currency {CurrencyCode} not found in CNB daily or monthly data.", code); + } + } + _logger.LogInformation("Returning {Count} exchange rates.", result.Count); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get exchange rates for requested currencies."); + throw; + } + } + + private async Task> GetDailyRatesAsync() + { + try + { + return await _dailyRatesCache.GetOrCreateAsync(async () => + { + _logger.LogInformation("Fetching daily exchange rates from CNB JSON API..."); + var rawJson = await _czechApiClient.GetAsync(DailyRatesJsonUrl); + return ParseRates(rawJson); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get daily exchange rates from cache or API."); + throw; + } + } + + private async Task> GetMonthlyRatesAsync() + { + var yearMonth = DateTimeExtensions.GetPreviousYearMonthUtc(); + try + { + return await _monthlyRatesCache.GetOrCreateAsync(async () => + { + _logger.LogInformation("Fetching monthly exchange rates from CNB JSON API for {YearMonth}...", yearMonth); + var rawJson = await _czechApiClient.GetAsync(string.Format(MonthlyRatesJsonUrl, yearMonth)); + return ParseRates(rawJson); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get monthly exchange rates from cache or API for {YearMonth}.", yearMonth); + throw; + } + } + + private Dictionary ParseRates(string rawJson) + { + var ratesResponse = JsonSerializer.Deserialize(rawJson); + var dict = ratesResponse?.Rates? + .ToDictionary( + r => r.CurrencyCode, + r => r.Rate / r.Amount, + StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + dict["CZK"] = 1m; + return dict; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRate/Providers/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRate/Providers/IExchangeRateService.cs new file mode 100644 index 000000000..bea445b25 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRate/Providers/IExchangeRateService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.ExchangeRate.Providers +{ + public interface IExchangeRateService + { + Task> GetExchangeRateAsync(IEnumerable currencies); + } + +} \ No newline at end of file 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.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..54721c0da 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,25 @@ Exe - net6.0 + net9.0 + + + + + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..ed42b86f7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,16 +5,42 @@ VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRate.Tests", "..\ExchangeRate.Tests\ExchangeRate.Tests.csproj", "{AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}" +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 + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|x64.Build.0 = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|x86.ActiveCfg = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Debug|x86.Build.0 = Debug|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|Any CPU.Build.0 = Release|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|x64.ActiveCfg = Release|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|x64.Build.0 = Release|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|x86.ActiveCfg = Release|Any CPU + {AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/HttpClients/CzechApiClient.cs b/jobs/Backend/Task/HttpClients/CzechApiClient.cs new file mode 100644 index 000000000..5c6367ec4 --- /dev/null +++ b/jobs/Backend/Task/HttpClients/CzechApiClient.cs @@ -0,0 +1,46 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Configuration; + +namespace ExchangeRateUpdater.HttpClients +{ + public class CzechApiClient : ICzechApiClient + { + private readonly HttpClient _httpClient; + private readonly string _baseApiUrl; + private readonly ILogger _logger; + + public CzechApiClient(HttpClient httpClient, IOptions options, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _baseApiUrl = options?.Value?.BaseUrl ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(string relativeUrl) + { + try + { + var url = $"{_baseApiUrl}{relativeUrl}"; + _logger.LogInformation("Sending GET request to {Url}", url); + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + _logger.LogInformation("Received successful response from {Url}", url); + return await response.Content.ReadAsStringAsync(); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed for {RelativeUrl}", relativeUrl); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during GET request for {RelativeUrl}", relativeUrl); + throw; + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/HttpClients/ICzechApiClient.cs b/jobs/Backend/Task/HttpClients/ICzechApiClient.cs new file mode 100644 index 000000000..c0485438c --- /dev/null +++ b/jobs/Backend/Task/HttpClients/ICzechApiClient.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.HttpClients +{ + public interface ICzechApiClient + { + Task GetAsync(string relativeUrl); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs b/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs new file mode 100644 index 000000000..d35db5f74 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure.Cache +{ + public class CnbRatesCache : ICnbRatesCache + { + private readonly IMemoryCache _cache; + private readonly string _cacheKey; + private readonly Func _expirationFactory; + private readonly ILogger _logger; + + public CnbRatesCache(IMemoryCache cache, string cacheKey, Func expirationFactory, ILogger logger) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _cacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey)); + _expirationFactory = expirationFactory ?? throw new ArgumentNullException(nameof(expirationFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetOrCreateAsync(Func>> factory) + { + if (_cache.TryGetValue(_cacheKey, out Dictionary cachedValue)) + { + _logger.LogInformation("Cache hit for key {CacheKey}", _cacheKey); + return cachedValue; + } + + _logger.LogInformation("Cache miss for key {CacheKey}. Fetching new data...", _cacheKey); + try + { + var value = await factory(); + _cache.Set(_cacheKey, value, _expirationFactory()); + _logger.LogInformation("Cached new value for key {CacheKey}", _cacheKey); + return value; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching or caching data for key {CacheKey}", _cacheKey); + throw; + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Infrastructure/Cache/ICnbRatesCache.cs b/jobs/Backend/Task/Infrastructure/Cache/ICnbRatesCache.cs new file mode 100644 index 000000000..615441e99 --- /dev/null +++ b/jobs/Backend/Task/Infrastructure/Cache/ICnbRatesCache.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure.Cache +{ + public interface ICnbRatesCache + { + Task> GetOrCreateAsync(Func>> factory); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Models/Currency.cs similarity index 53% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Models/Currency.cs index f375776f2..d0ef30982 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Models/Currency.cs @@ -1,16 +1,12 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { - public class Currency + public class Currency(string code) { - public Currency(string code) - { - Code = code; - } /// /// Three-letter ISO 4217 code of the currency. /// - public string Code { get; } + public string Code { get; } = code; public override string ToString() { 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/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..8e20c3122 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using ExchangeRateUpdater.Configuration; +using Microsoft.Extensions.Caching.Memory; +using System.Text.RegularExpressions; +using ExchangeRateUpdater.Common.Constants; +using ExchangeRateUpdater.ExchangeRate.Providers; +using ExchangeRateUpdater.Models; namespace ExchangeRateUpdater { @@ -19,25 +27,81 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { + // Setup DI + var services = new ServiceCollection(); + var startup = new Startup(); + startup.ConfigureServices(services); + var provider = services.BuildServiceProvider(); + + var exchangeRateProvider = provider.GetRequiredService(); + var memoryCache = provider.GetRequiredService(); + + // Show initial rates for default currencies 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("Fetching exchange rates for default currencies:"); + var initialRates = await exchangeRateProvider.GetExchangeRateAsync(currencies.ToList()); + foreach (var rate in initialRates) { Console.WriteLine(rate.ToString()); } } catch (Exception e) { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + Console.WriteLine($"\nCould not retrieve initial exchange rates: '{e.Message}'."); } - Console.ReadLine(); + // User interaction loop + while (true) + { + Console.Write("\nEnter currency codes (comma separated), or type 'clear' to clear cache: "); + var input = Console.ReadLine(); + + // Do not exit on empty input, just prompt again + if (string.IsNullOrWhiteSpace(input)) + continue; + + if (input.Trim().Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + memoryCache.Remove(AppConstants.DailyRatesCacheKey); + memoryCache.Remove(AppConstants.MonthlyRatesCacheKey); + Console.WriteLine("Cache cleared."); + continue; + } + + var codes = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => code.ToUpperInvariant()) + .ToList(); + + // ISO 4217 format: exactly 3 uppercase letters + var iso4217Regex = new Regex("^[A-Z]{3}$"); + var invalidCodes = codes.Where(code => !iso4217Regex.IsMatch(code)).ToList(); + + if (invalidCodes.Any()) + { + Console.WriteLine($"Invalid ISO 4217 currency codes: {string.Join(", ", invalidCodes)}"); + continue; + } + + var currencyObjects = codes.Select(code => new Currency(code)).ToList(); + + try + { + var rates = await exchangeRateProvider.GetExchangeRateAsync(currencyObjects); + Console.WriteLine($"\nSuccessfully retrieved {rates.Count} exchange rates:"); + foreach (var rate in rates) + { + Console.WriteLine(rate.ToString()); + } + } + catch (Exception e) + { + Console.WriteLine($"\nCould not retrieve exchange rates: '{e.Message}'."); + } + } } + } } diff --git a/jobs/Backend/Task/Startup.cs b/jobs/Backend/Task/Startup.cs new file mode 100644 index 000000000..8a7a8b5aa --- /dev/null +++ b/jobs/Backend/Task/Startup.cs @@ -0,0 +1,102 @@ +using System.Net.Http; +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; +using ExchangeRateUpdater.HttpClients; +using ExchangeRateUpdater.Infrastructure.Cache; +using Microsoft.Extensions.Caching.Memory; +using ExchangeRateUpdater.Common.Extensions; +using ExchangeRateUpdater.Common.Constants; +using ExchangeRateUpdater.ExchangeRate.Providers; +using ExchangeRateUpdater.Configuration; + +namespace ExchangeRateUpdater +{ + 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) + { + ConfigureAppSettings(services); + ConfigureCaching(services); + ConfigureHttpClient(services); + ConfigureLogging(services); + services.AddSingleton(); + } + + private void ConfigureAppSettings(IServiceCollection services) + { + services.Configure(Configuration.GetSection("HttpServiceSettings")); + services.Configure(Configuration.GetSection("CzechApiSettings")); + } + + private static void ConfigureCaching(IServiceCollection services) + { + services.AddMemoryCache(); + + // Daily rates cache + services.AddKeyedSingleton(AppConstants.DailyRatesKeyedService, (provider, _) => + new CnbRatesCache( + provider.GetRequiredService(), + AppConstants.DailyRatesCacheKey, + CzechTimeZoneExtensions.GetNextCzechBankUpdateUtc, + provider.GetRequiredService>())); + + // Monthly rates cache + services.AddKeyedSingleton(AppConstants.MonthlyRatesKeyedService, (provider, _) => + new CnbRatesCache( + provider.GetRequiredService(), + AppConstants.MonthlyRatesCacheKey, + () => CzechTimeZoneExtensions.GetNextMonthUpdateUtc(), + provider.GetRequiredService>())); + } + + private static void ConfigureHttpClient(IServiceCollection services) + { + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(5)) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService>().Value; + + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: settings.RetryCount, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, delay, retry, ctx) => + { + var logger = provider.GetRequiredService>(); + logger.LogWarning(outcome.Exception, "Retry {Retry} after {Delay}s", retry, delay.TotalSeconds); + }); + }) + .AddPolicyHandler((provider, request) => + { + var settings = provider.GetRequiredService>().Value; + return Policy.TimeoutAsync(TimeSpan.FromSeconds(settings.TimeoutSeconds)); + }); + } + + private static void ConfigureLogging(IServiceCollection services) + { + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }); + } + } +} \ 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..215a4e8c9 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,17 @@ +{ + "HttpServiceSettings": { + "TimeoutSeconds": 10, + "RetryCount": 5 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "ExchangeRateUpdater": "Debug", + "Microsoft": "Warning", + "System": "Warning" + } + }, + "CzechApiSettings": { + "BaseUrl": "https://api.cnb.cz/cnbapi" + } +} \ No newline at end of file From fab812d1f9f843cc71935b13307f83452241762a Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Mon, 26 May 2025 23:26:30 +0100 Subject: [PATCH 2/7] refactor: remove unused usings --- .../Extentions/CzechTimeZoneExtensionsTest.cs | 2 -- .../Extentions/DateTimeExtensionsTests.cs | 4 ---- .../HttpClients/CzechApiClientTests.cs | 9 ++------- .../Cache/CnbRatesCacheTests.cs | 4 ---- .../ExchangeRate.Tests/StartupTests.cs | 2 -- .../Extensions/CzechTimeZoneExtensions.cs | 10 ---------- .../Providers/ExchangeRateService.cs | 19 ++++++++----------- jobs/Backend/Task/ExchangeRateUpdater.csproj | 3 +++ jobs/Backend/Task/Startup.cs | 8 +++++++- 9 files changed, 20 insertions(+), 41 deletions(-) diff --git a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs index 376774649..03d9a8cd0 100644 --- a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs +++ b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/CzechTimeZoneExtensionsTest.cs @@ -1,6 +1,4 @@ -using System; using System.Runtime.InteropServices; -using Xunit; using ExchangeRateUpdater.Common.Extensions; namespace ExchangeRate.Tests.Common.Extensions diff --git a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs index ba405a1a6..2e91bd47b 100644 --- a/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/CommonTest/Extentions/DateTimeExtensionsTests.cs @@ -1,7 +1,3 @@ -using System; -using Xunit; -using ExchangeRateUpdater.Common.Extensions; - namespace ExchangeRate.Tests.CommonTest.Extensions { public class DateTimeExtensionsTests diff --git a/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs b/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs index 4f3afd701..69f87aad7 100644 --- a/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/HttpClients/CzechApiClientTests.cs @@ -1,15 +1,10 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using ExchangeRateUpdater.Configuration; using ExchangeRateUpdater.HttpClients; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Moq.Protected; -using Xunit; namespace ExchangeRate.Tests.HttpClients { @@ -106,7 +101,7 @@ public void Constructor_ThrowsArgumentNullException_WhenHttpClientIsNull() { // Arrange var options = Options.Create(new CzechApiSettings { BaseUrl = "https://api.cnb.cz" }); - var loggerMock = new Mock>(); + var loggerMock = new Mock>(); // Act & Assert Assert.Throws(() => new CzechApiClient(null, options, loggerMock.Object)); @@ -117,7 +112,7 @@ public void Constructor_ThrowsArgumentNullException_WhenOptionsIsNull() { // Arrange var httpClient = new HttpClient(); - var loggerMock = new Mock>(); + var loggerMock = new Mock>(); // Act & Assert Assert.Throws(() => new CzechApiClient(httpClient, null, loggerMock.Object)); diff --git a/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs index b5ae41915..5a3486bae 100644 --- a/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs @@ -1,11 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using ExchangeRateUpdater.Infrastructure.Cache; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; -using Xunit; namespace ExchangeRate.Tests.Infrastructure.Cache { diff --git a/jobs/Backend/ExchangeRate.Tests/StartupTests.cs b/jobs/Backend/ExchangeRate.Tests/StartupTests.cs index 93156db1d..5c7ea4a7c 100644 --- a/jobs/Backend/ExchangeRate.Tests/StartupTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/StartupTests.cs @@ -1,10 +1,8 @@ -using Xunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ExchangeRateUpdater.HttpClients; -using System.Linq; using ExchangeRateUpdater.Infrastructure.Cache; using ExchangeRateUpdater.Common.Constants; using ExchangeRateUpdater.ExchangeRate.Providers; diff --git a/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs b/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs index 2e69a0c7a..5c219d2af 100644 --- a/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs +++ b/jobs/Backend/Task/Common/Extensions/CzechTimeZoneExtensions.cs @@ -22,16 +22,6 @@ public static DateTimeOffset GetNextCzechBankUpdateUtc() return nextUpdate.ToUniversalTime(); } - // public static DateTimeOffset GetNextMonthUpdateUtc(DateTimeOffset? now = null) - // { - // var czechTimeZone = GetCzechTimeZone(); - // var nowCzech = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, czechTimeZone); - // var nextMonth = nowCzech.Month == 12 - // ? new DateTimeOffset(nowCzech.Year + 1, 1, 1, 0, 0, 0, nowCzech.Offset) - // : new DateTimeOffset(nowCzech.Year, nowCzech.Month + 1, 1, 0, 0, 0, nowCzech.Offset); - // return nextMonth.ToUniversalTime(); - // } - public static DateTimeOffset GetNextMonthUpdateUtc(DateTimeOffset? now = null) { var czechTz = GetCzechTimeZone(); diff --git a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs index 1cc05c246..07405a326 100644 --- a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs @@ -15,15 +15,12 @@ namespace ExchangeRateUpdater.ExchangeRate.Providers { public class ExchangeRateService( - ICzechApiClient czechApiClient, - ILogger logger, - [FromKeyedServices(AppConstants.DailyRatesKeyedService)] ICnbRatesCache dailyRatesCache, - [FromKeyedServices(AppConstants.MonthlyRatesKeyedService)] ICnbRatesCache monthlyRatesCache) : IExchangeRateService + ICzechApiClient _czechApiClient, + ILogger _logger, + [FromKeyedServices(AppConstants.DailyRatesKeyedService)] ICnbRatesCache _dailyRatesCache, + [FromKeyedServices(AppConstants.MonthlyRatesKeyedService)] ICnbRatesCache _monthlyRatesCache + ) : IExchangeRateService { - private readonly ICzechApiClient _czechApiClient = czechApiClient; - private readonly ILogger _logger = logger; - private readonly ICnbRatesCache _dailyRatesCache = dailyRatesCache; - private readonly ICnbRatesCache _monthlyRatesCache = monthlyRatesCache; private const string DailyRatesJsonUrl = "/exrates/daily?lang=EN"; private const string MonthlyRatesJsonUrl = "/fxrates/daily-month?lang=EN&yearMonth={0}"; private static readonly Currency CZK = new("CZK"); @@ -69,7 +66,7 @@ private async Task> GetDailyRatesAsync() { return await _dailyRatesCache.GetOrCreateAsync(async () => { - _logger.LogInformation("Fetching daily exchange rates from CNB JSON API..."); + _logger.LogInformation("Fetching daily exchange rates from Exchange rates API."); var rawJson = await _czechApiClient.GetAsync(DailyRatesJsonUrl); return ParseRates(rawJson); }); @@ -88,14 +85,14 @@ private async Task> GetMonthlyRatesAsync() { return await _monthlyRatesCache.GetOrCreateAsync(async () => { - _logger.LogInformation("Fetching monthly exchange rates from CNB JSON API for {YearMonth}...", yearMonth); + _logger.LogInformation("Fetching monthly exchange rates of other countries for {YearMonth}", yearMonth); var rawJson = await _czechApiClient.GetAsync(string.Format(MonthlyRatesJsonUrl, yearMonth)); return ParseRates(rawJson); }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to get monthly exchange rates from cache or API for {YearMonth}.", yearMonth); + _logger.LogError(ex, "Failed to get monthly exchange rates of other countries for {YearMonth}", yearMonth); throw; } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 54721c0da..3a49d3e4b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -16,6 +16,9 @@ + + + diff --git a/jobs/Backend/Task/Startup.cs b/jobs/Backend/Task/Startup.cs index 8a7a8b5aa..a3e669d5d 100644 --- a/jobs/Backend/Task/Startup.cs +++ b/jobs/Backend/Task/Startup.cs @@ -13,6 +13,7 @@ using ExchangeRateUpdater.Common.Constants; using ExchangeRateUpdater.ExchangeRate.Providers; using ExchangeRateUpdater.Configuration; +using Serilog; namespace ExchangeRateUpdater { @@ -92,10 +93,15 @@ private static void ConfigureHttpClient(IServiceCollection services) private static void ConfigureLogging(IServiceCollection services) { + Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day) + .CreateLogger(); + services.AddLogging(logging => { logging.ClearProviders(); - logging.AddConsole(); + logging.AddSerilog(); }); } } From 340f1cfa2912057a43f440300ac2d1ea54022aa5 Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Tue, 27 May 2025 00:26:00 +0100 Subject: [PATCH 3/7] feat: added containerisedway to build and interact --- .../{Task => }/ExchangeRateUpdater.sln | 4 ++-- jobs/Backend/Readme.md | 15 ++++++++++++ jobs/Backend/Task/Dockerfile | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) rename jobs/Backend/{Task => }/ExchangeRateUpdater.sln (93%) create mode 100644 jobs/Backend/Task/Dockerfile diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/ExchangeRateUpdater.sln similarity index 93% rename from jobs/Backend/Task/ExchangeRateUpdater.sln rename to jobs/Backend/ExchangeRateUpdater.sln index ed42b86f7..b04d20be9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/ExchangeRateUpdater.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "Task\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRate.Tests", "..\ExchangeRate.Tests\ExchangeRate.Tests.csproj", "{AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRate.Tests", "ExchangeRate.Tests\ExchangeRate.Tests.csproj", "{AC8F4EFC-99F9-4B70-8FCD-9CECFD697875}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index f2195e44d..4fbc7ac94 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -4,3 +4,18 @@ We are focused on multiple backend frameworks at Mews. Depending on the job posi * [.NET](DotNet.md) * [Ruby on Rails](RoR.md) + +Run The App : +In a containerised way (from Backend folder): +Build the image +```sh +docker build -t exchangerate-task -f Task/Dockerfile . +Run the app +docker run -it --rm exchangerate-task +``` + +On Local Machine: +```sh +dotnet restore +dotnet run --project Task +``` \ No newline at end of file diff --git a/jobs/Backend/Task/Dockerfile b/jobs/Backend/Task/Dockerfile new file mode 100644 index 000000000..65aad70f4 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile @@ -0,0 +1,24 @@ +# Use the official .NET runtime image for production +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +WORKDIR /app + +# Use the SDK image to build the app +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy csproj and restore as distinct layers +COPY *.sln ./ +COPY Task/*.csproj ./Task/ +COPY ExchangeRate.Tests/*.csproj ./ExchangeRate.Tests/ +RUN dotnet restore + +# Copy everything else and build +COPY . . +WORKDIR /src/Task +RUN dotnet publish -c Release -o /app/publish + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"] \ No newline at end of file From 641f4b299819b1bc7ef6e7973d307567da914502 Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Tue, 27 May 2025 13:21:16 +0100 Subject: [PATCH 4/7] feat: updated Excahnge rate service to check first in daily if not found then only go for other counties monthly api --- .../Providers/ExchangeRateServiceTests.cs | 61 ++++++++++++++++++- .../Providers/ExchangeRateService.cs | 24 ++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs index 0c7cc9c5c..da22df597 100644 --- a/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs @@ -109,6 +109,65 @@ public async Task GetExchangeRateAsync_LogsWarning_IfCurrencyNotFound() Times.Once); } + [Fact] + public async Task GetExchangeRateAsync_From_OtherCountries_Monthly_IfNotFoundInDaily() + { + // Arrange + var currencies = new List { new Currency("USD") }; + var fakeJson = "{\"rates\":[{\"currencyCode\":\"USD\",\"rate\":25.0,\"amount\":1}]}"; + _apiClientMock.Setup(c => c.GetAsync(It.IsAny())) + .ReturnsAsync(fakeJson); + + var dailyRates = new Dictionary(); + + _dailyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(dailyRates); + _monthlyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .Returns>>>(factory => factory()); + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Equal("USD", result[0].SourceCurrency.Code); + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Fetching monthly exchange rates")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task When_Daily_And_Monthly_Returns_Nothing() + { + // Arrange + var currencies = new List { new Currency("USD") }; + + var rates = new Dictionary(); + + _dailyCacheMock.Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(rates); + _monthlyCacheMock + .Setup(c => c.GetOrCreateAsync(It.IsAny>>>())) + .ReturnsAsync(rates); + // Act + var result = await _service.GetExchangeRateAsync(currencies); + + // Assert + Assert.Empty(result); + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Currency USD not found in CNB daily or monthly data")), + null, + It.IsAny>()), + Times.Once); + } + [Fact] public async Task GetExchangeRateAsync_ReturnsEmptyList_IfNoCurrencies() { @@ -152,7 +211,7 @@ public async Task GetDailyRatesAsync_ExecutesCallbackAndParsesRates() x => x.Log( LogLevel.Information, It.IsAny(), - It.Is((v, t) => v.ToString().Contains("Fetching daily exchange rates from CNB JSON API...")), + It.Is((v, t) => v.ToString().Contains("Fetching daily exchange rates from Exchange rates API.")), null, It.IsAny>()), Times.Once); diff --git a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs index 07405a326..42bf52509 100644 --- a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs @@ -30,10 +30,9 @@ public class ExchangeRateService( try { var dailyRates = await GetDailyRatesAsync(); - var monthlyRates = await GetMonthlyRatesAsync(); - var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); var result = new List(); + var missingCodes = new List(); foreach (var code in currencyCodes) { @@ -41,15 +40,28 @@ public class ExchangeRateService( { result.Add(new Models.ExchangeRate(new Currency(code), CZK, value)); } - else if (monthlyRates.TryGetValue(code, out var mValue)) + else { - result.Add(new Models.ExchangeRate(new Currency(code), CZK, mValue)); + missingCodes.Add(code); } - else + } + + if (missingCodes.Any()) + { + var monthlyRates = await GetMonthlyRatesAsync(); + foreach (var code in missingCodes) { - _logger.LogWarning("Currency {CurrencyCode} not found in CNB daily or monthly data.", code); + if (monthlyRates.TryGetValue(code, out var mValue)) + { + result.Add(new Models.ExchangeRate(new Currency(code), CZK, mValue)); + } + else + { + _logger.LogWarning("Currency {CurrencyCode} not found in CNB daily or monthly data.", code); + } } } + _logger.LogInformation("Returning {Count} exchange rates.", result.Count); return result; } From aa477222f8a28a10ddc2c7728abd983012702c42 Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Tue, 27 May 2025 14:48:00 +0100 Subject: [PATCH 5/7] docs: updated document with design overview and flow summary --- jobs/Backend/Readme.md | 73 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index 4fbc7ac94..f813daada 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -5,17 +5,82 @@ We are focused on multiple backend frameworks at Mews. Depending on the job posi * [.NET](DotNet.md) * [Ruby on Rails](RoR.md) -Run The App : -In a containerised way (from Backend folder): +--- + +## Design Overview + +### Components + +- **ExchangeRateService** + - Orchestrates fetching exchange rates for requested currencies. + - Checks daily rates cache first; if some currencies are missing, checks monthly rates cache. + - Only calls the monthly API if needed, minimizing unnecessary API calls. + - Logs warnings for currencies not found in either source. + +- **CnbRatesCache** + - Wraps `IMemoryCache` for caching daily and monthly rates. + - Uses a unique cache key and an expiration factory for each cache instance. + - Handles cache hits, misses, and logging. + - Fetches and caches data using a provided factory function. + +- **CzechApiClient** + - Handles HTTP GET requests to the Czech National Bank API. + - Logs requests and responses, and handles errors gracefully. + +--- + +### Flow Summary + +1. **User requests exchange rates** for a set of currencies. +2. **Daily rates** are fetched from cache (or API if not cached). +3. **If all requested currencies are found in daily rates:** + → Return results. +4. **If some are missing:** + → Fetch monthly rates from cache (or API if not cached) for missing currencies only. +5. **Combine results** and log warnings for any currencies not found in either source. +6. **Return the final list** to the user. + +--- + +### Cache Strategy + +- **Daily rates cache:** Expires at the next Czech bank update time. +- **Monthly rates cache:** Expires at midnight on the first day of the next month. +- **Both caches:** Use `CnbRatesCache` for consistent logic and logging. + +--- + +### API Client + +- **CzechApiClient** is injected and used by the service and cache to fetch fresh data from the CNB API endpoints. +- Handles logging and error management for all HTTP requests. + +--- + +### Key Points + +- **Efficient:** Only calls the monthly API if daily data is incomplete. +- **Testable:** All dependencies are injected, making unit testing straightforward. +- **Observable:** Extensive logging for cache hits/misses, API calls, and errors. +- **Extensible:** Easy to add more cache layers or endpoints if needed. + +--- + +## Run The App + +### In a containerised way (from Backend folder): + Build the image ```sh docker build -t exchangerate-task -f Task/Dockerfile . +``` Run the app +```sh docker run -it --rm exchangerate-task ``` -On Local Machine: +### On Local Machine: ```sh dotnet restore dotnet run --project Task -``` \ No newline at end of file +``` From 80a1df0a90968c002fc4df5a68725fc846c5a7f9 Mon Sep 17 00:00:00 2001 From: Ajay Polampalli Date: Tue, 27 May 2025 19:18:15 +0100 Subject: [PATCH 6/7] refactor: update logging and minor console logging --- .../Providers/ExchangeRateService.cs | 16 ------- .../Task/HttpClients/CzechApiClient.cs | 25 +++------- .../Infrastructure/Cache/CnbRatesCache.cs | 3 +- jobs/Backend/Task/Program.cs | 48 +++++++++++++++---- jobs/Backend/Task/Startup.cs | 2 +- 5 files changed, 46 insertions(+), 48 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs index 42bf52509..b8e8f33ab 100644 --- a/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRate/Providers/ExchangeRateService.cs @@ -74,39 +74,23 @@ public class ExchangeRateService( private async Task> GetDailyRatesAsync() { - try - { return await _dailyRatesCache.GetOrCreateAsync(async () => { _logger.LogInformation("Fetching daily exchange rates from Exchange rates API."); var rawJson = await _czechApiClient.GetAsync(DailyRatesJsonUrl); return ParseRates(rawJson); }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get daily exchange rates from cache or API."); - throw; - } } private async Task> GetMonthlyRatesAsync() { var yearMonth = DateTimeExtensions.GetPreviousYearMonthUtc(); - try - { return await _monthlyRatesCache.GetOrCreateAsync(async () => { _logger.LogInformation("Fetching monthly exchange rates of other countries for {YearMonth}", yearMonth); var rawJson = await _czechApiClient.GetAsync(string.Format(MonthlyRatesJsonUrl, yearMonth)); return ParseRates(rawJson); }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get monthly exchange rates of other countries for {YearMonth}", yearMonth); - throw; - } } private Dictionary ParseRates(string rawJson) diff --git a/jobs/Backend/Task/HttpClients/CzechApiClient.cs b/jobs/Backend/Task/HttpClients/CzechApiClient.cs index 5c6367ec4..3a303b80c 100644 --- a/jobs/Backend/Task/HttpClients/CzechApiClient.cs +++ b/jobs/Backend/Task/HttpClients/CzechApiClient.cs @@ -22,25 +22,12 @@ public CzechApiClient(HttpClient httpClient, IOptions options, public async Task GetAsync(string relativeUrl) { - try - { - var url = $"{_baseApiUrl}{relativeUrl}"; - _logger.LogInformation("Sending GET request to {Url}", url); - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - _logger.LogInformation("Received successful response from {Url}", url); - return await response.Content.ReadAsStringAsync(); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "HTTP request failed for {RelativeUrl}", relativeUrl); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error during GET request for {RelativeUrl}", relativeUrl); - throw; - } + var url = $"{_baseApiUrl}{relativeUrl}"; + _logger.LogInformation("Sending GET request to {Url}", url); + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + _logger.LogInformation("Received successful response from {Url}", url); + return await response.Content.ReadAsStringAsync(); } } } \ No newline at end of file diff --git a/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs b/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs index d35db5f74..2f4565826 100644 --- a/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs +++ b/jobs/Backend/Task/Infrastructure/Cache/CnbRatesCache.cs @@ -37,9 +37,8 @@ public async Task> GetOrCreateAsync(Func Date: Tue, 27 May 2025 19:30:58 +0100 Subject: [PATCH 7/7] fix: unit test fix --- .../ExchangeRate/Providers/ExchangeRateServiceTests.cs | 2 +- .../Infrastructure/Cache/CnbRatesCacheTests.cs | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs index da22df597..791a68db3 100644 --- a/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/ExchangeRate/Providers/ExchangeRateServiceTests.cs @@ -243,7 +243,7 @@ public async Task GetExchangeRateAsync_WhenApiClientThrows_LogsErrorAndThrows() x => x.Log( LogLevel.Error, It.IsAny(), - It.Is((v, t) => v.ToString().Contains("Failed to get daily exchange rates from cache or API.")), + It.Is((v, t) => v.ToString().Contains("Failed to get exchange rates for requested currencies.")), exception, It.IsAny>()), Times.Once); diff --git a/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs index 5a3486bae..f7486d091 100644 --- a/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs +++ b/jobs/Backend/ExchangeRate.Tests/Infrastructure/Cache/CnbRatesCacheTests.cs @@ -94,16 +94,6 @@ public async Task GetOrCreateAsync_SetCache_ThrowsException() cache.GetOrCreateAsync(() => Task.FromException>(exception)) ); Assert.Equal("Simulated exception", ex.Message); - - // Verify that error was logged - loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString().Contains("Error occurred while fetching or caching data")), - exception, - It.IsAny>()), - Times.Once); } } } \ No newline at end of file