diff --git a/.gitignore b/.gitignore index fd3586545..87be8af06 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules bower_components npm-debug.log +/jobs/Backend/Task/.vs/ExchangeRateUpdater diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..54dbcdded --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,230 @@ +using ExchangeRateUpdater.Cnb; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; +using System.Net; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests : IDisposable + { + private readonly Mock> _loggerMock; + private readonly Mock _configMock; + private readonly Mock _httpHandlerMock; + private readonly HttpClient _httpClient; + + public ExchangeRateProviderTests() + { + _loggerMock = new Mock>(); + _configMock = new Mock(); + _httpHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpHandlerMock.Object) + { + Timeout = TimeSpan.FromSeconds(1) + }; + + _configMock.SetupGet(c => c.Url).Returns("https://api.cnb.cz/cnbapi/exrates/daily"); + _configMock.SetupGet(c => c.BaseCurrency).Returns("CZK"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithValidCurrencies_ReturnsExchangeRates() + { + // Arrange + var responseJson = "{" + + "\"rates\":[" + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":121," + + "\"country\":\"USA\"," + + "\"currency\":\"dollar\"," + + "\"amount\":1," + + "\"currencyCode\":\"USD\"," + + "\"rate\":23.50}," + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":122," + + "\"country\":\"EMU\"," + + "\"currency\":\"euro\"," + + "\"amount\":1," + + "\"currencyCode\":\"EUR\"," + + "\"rate\":25.75}" + + "]}"; + + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List + { + new Currency("USD"), + new Currency("EUR") + }; + + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + var resultList = result.ToList(); + Assert.Equal(2, resultList.Count); + + var usdRate = resultList.FirstOrDefault(r => r.SourceCurrency.Code == "USD"); + Assert.NotNull(usdRate); + Assert.Equal("USD", usdRate.SourceCurrency.Code); + Assert.Equal("CZK", usdRate.TargetCurrency.Code); + Assert.Equal(23.50m, usdRate.Value); + + var eurRate = resultList.FirstOrDefault(r => r.SourceCurrency.Code == "EUR"); + Assert.NotNull(eurRate); + Assert.Equal("EUR", eurRate.SourceCurrency.Code); + Assert.Equal("CZK", eurRate.TargetCurrency.Code); + Assert.Equal(25.75m, eurRate.Value); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithEmptyCurrencies_ReturnsEmptyResult() + { + // Arrange + var responseJson = "{\"rates\":[]}"; + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List(); + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithNullCurrencies_ThrowsArgumentNullException() + { + // Arrange + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(null)); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithUnavailableCurrency_ReturnsOnlyAvailableRates() + { + // Arrange + var responseJson = "{" + + "\"rates\":[" + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":121," + + "\"country\":\"USA\"," + + "\"currency\":\"dollar\"," + + "\"amount\":1," + + "\"currencyCode\":\"USD\"," + + "\"rate\":23.50}" + + "]}"; + + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List + { + new Currency("USD"), + new Currency("XYZ") // Non-existent currency + }; + + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + var resultList = result.ToList(); + Assert.Single(resultList); + Assert.Equal("USD", resultList.First().SourceCurrency.Code); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenApiReturnsError_ThrowsException() + { + // Arrange + var errorJson = "{" + + "\"description\":\"API Error\"," + + "\"endPoint\":\"/cnbapi/exrates/daily\"," + + "\"errorCode\":\"INTERNAL_SERVER_ERROR\"," + + "\"happenedAt\":\"2025-06-26T10:37:28.547Z\"," + + "\"messageId\":\"abc123\"}"; + + SetupHttpResponse(HttpStatusCode.InternalServerError, errorJson); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenNetworkFails_ThrowsException() + { + // Arrange + _httpHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Network error")) + .Verifiable(); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + + Assert.Contains("Network error", exception.Message); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithInvalidJson_ThrowsException() + { + // Arrange + var invalidJson = "{ invalid json structure"; + SetupHttpResponse(HttpStatusCode.OK, invalidJson); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + } + + private void SetupHttpResponse(HttpStatusCode statusCode, string content) + { + var response = new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json") + }; + + _httpHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(response) + .Verifiable(); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..c7a993bb6 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln new file mode 100644 index 000000000..bbe926355 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests.csproj", "{5CB870E0-6DD9-17C1-D2D3-F2421C52624D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5BAC1F21-3528-4D4A-BA6A-716F8EAA3048} + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin new file mode 100644 index 000000000..2755cadf9 Binary files /dev/null and b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin differ diff --git a/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin new file mode 100644 index 000000000..f76017047 Binary files /dev/null and b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin differ diff --git a/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin new file mode 100644 index 000000000..82f95e76c Binary files /dev/null and b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin differ diff --git a/jobs/Backend/Task/Cnb/ApiErrorResponse.cs b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs new file mode 100644 index 000000000..d14fa6860 --- /dev/null +++ b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs @@ -0,0 +1,23 @@ +using System; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Cnb +{ + public class ApiErrorResponse + { + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("endPoint")] + public string EndPoint { get; set; } + + [JsonPropertyName("errorCode")] + public string ErrorCode { get; set; } + + [JsonPropertyName("happenedAt")] + public DateTime HappenedAt { get; set; } + + [JsonPropertyName("messageId")] + public string MessageId { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/ApiResponse.cs b/jobs/Backend/Task/Cnb/ApiResponse.cs new file mode 100644 index 000000000..5f3c9a35f --- /dev/null +++ b/jobs/Backend/Task/Cnb/ApiResponse.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Cnb +{ + public class ApiResponse + { + public bool Success { get; set; } + public T Data { get; set; } + public ApiErrorResponse Error { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/CnbApiResponse.cs b/jobs/Backend/Task/Cnb/CnbApiResponse.cs new file mode 100644 index 000000000..f5a596853 --- /dev/null +++ b/jobs/Backend/Task/Cnb/CnbApiResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Cnb +{ + public class CnbApiResponse + { + [JsonPropertyName("rates")] + public List Rates { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/CnbRateDto.cs b/jobs/Backend/Task/Cnb/CnbRateDto.cs new file mode 100644 index 000000000..7add16105 --- /dev/null +++ b/jobs/Backend/Task/Cnb/CnbRateDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Cnb +{ + public class CnbRateDto + { + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } + + [JsonPropertyName("amount")] + public int Amount { get; set; } + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs new file mode 100644 index 000000000..7d13071b3 --- /dev/null +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -0,0 +1,60 @@ +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Cnb +{ + public class ExchangeRateProvider : ExchangeRateProviderBase + { + protected static new readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public ExchangeRateProvider( + IExchangeRateProviderConfiguration config, + HttpClient httpClient = null) + : base(config, httpClient) { } + + protected override async Task FetchRawDataAsync() + { + var apiResult = await GetApiDataAsync(_apiUrl); + if (!apiResult.Success) + { + Logger.Error($"API Error: {apiResult.Error}"); + throw new Exception($"API Error: {apiResult.Error?.ErrorCode} - {apiResult.Error?.Description}"); + } + return apiResult.Data; + } + + protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) + { + try + { + var apiResponse = rawData as CnbApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + if (apiResponse?.Rates == null) + { + return rates; + } + foreach (var rate in apiResponse.Rates) + { + if (!currencyCodes.Contains(rate.CurrencyCode)) + { + continue; + } + var currency = new Currency(rate.CurrencyCode); + int amount = rate.Amount; + rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / amount)); + } + return rates; + } + catch (Exception ex) + { + Logger.Error(ex, "Error mapping to exchange rates in MapToExchangeRates"); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs b/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs new file mode 100644 index 000000000..8816fd8e9 --- /dev/null +++ b/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using System.Linq; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using NLog; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace ExchangeRateUpdater.Cnb +{ + public abstract class ExchangeRateProviderBase : IExchangeRateProvider + { + protected readonly HttpClient HttpClient; + protected readonly string _apiUrl; + protected readonly Currency _baseCurrency; + protected static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + protected ExchangeRateProviderBase( + IExchangeRateProviderConfiguration config, + HttpClient httpClient = null) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + _apiUrl = config.Url; + _baseCurrency = new Currency(config.BaseCurrency); + HttpClient = httpClient ?? new HttpClient(); + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + try + { + var response = await FetchRawDataAsync(); + return MapToExchangeRates(response, currencies); + } + catch (Exception ex) + { + Logger.Error(ex, "Error in GetExchangeRatesAsync"); + throw; + } + } + + public async Task> GetApiDataAsync(string url) + { + try + { + var response = await HttpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + ApiErrorResponse apiError = GetApiErrorResponse(error); + Logger.Error($"API error: {response.StatusCode} - {error}"); + return new ApiResponse + { + Success = false, + Error = apiError + }; + } + + T data = await GetData(response); + return new ApiResponse + { + Success = true, + Data = data + }; + } + catch (Exception ex) + { + Logger.Error(ex, "Exception in GetApiDataAsync"); + return new ApiResponse + { + Success = false, + Error = new ApiErrorResponse { Description = $"Exception: {ex.Message}" } + }; + } + } + + private static ApiErrorResponse GetApiErrorResponse(string error) + { + ApiErrorResponse apiError = null; + try + { + apiError = System.Text.Json.JsonSerializer.Deserialize(error); + } + catch (Exception jsonEx) + { + Logger.Error(jsonEx, $"Failed to deserialize API error response: {error}"); + throw; + } + + return apiError; + } + + private static async Task GetData(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + T data = default; + try + { + data = System.Text.Json.JsonSerializer.Deserialize(json); + } + catch (Exception jsonEx) + { + Logger.Error(jsonEx, $"Failed to deserialize API data response: {json}"); + throw; + } + + return data; + } + + protected abstract Task FetchRawDataAsync(); + protected abstract IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs new file mode 100644 index 000000000..ddf194cc0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; +using NLog; + +namespace ExchangeRateUpdater.ExchangeRateApi +{ + public class ExchangeRateApiProvider : ExchangeRateProviderBase + { + private static new readonly Logger Logger = LogManager.GetCurrentClassLogger(); + public ExchangeRateApiProvider(IExchangeRateProviderConfiguration config) : base(config) { } + + protected override async Task FetchRawDataAsync() + { + try + { + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return result; + } + catch (Exception ex) + { + Logger.Error(ex, "Error fetching raw data in FetchRawDataAsync"); + throw; + } + } + + protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) + { + try + { + var response = rawData as ExchangeRateApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + if (response?.ConversionRates == null) + return rates; + + foreach (var code in currencyCodes) + { + decimal rate; + if (response.ConversionRates.TryGetValue(code, out rate)) + { + var currency = new Currency(code); + rates.Add(new ExchangeRate(currency, _baseCurrency, rate)); + } + } + return rates; + } + catch (Exception ex) + { + Logger.Error(ex, "Error mapping to exchange rates in MapToExchangeRates"); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs new file mode 100644 index 000000000..9e8f026f5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.ExchangeRateApi +{ + public class ExchangeRateApiResponse + { + [JsonPropertyName("result")] + public string Result { get; set; } + + [JsonPropertyName("base_code")] + public string BaseCode { get; set; } + + [JsonPropertyName("conversion_rates")] + public Dictionary ConversionRates { get; set; } + } +} 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/ExchangeRateProviderConfiguration.cs b/jobs/Backend/Task/ExchangeRateProviderConfiguration.cs new file mode 100644 index 000000000..bbe1f2323 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviderConfiguration.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater +{ + public class ExchangeRateProviderConfiguration : IExchangeRateProviderConfiguration + { + public string Url { get; set; } + public string BaseCurrency { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..c2379b74a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,6 +3,25 @@ Exe net6.0 + false + false + false + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..b9a607d3c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36203.30 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}") = "ExchangeRateUpdater.Tests", "..\Task.Tests\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{CA1A5E14-667D-B0F5-355A-2135F658ECA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ 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 + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B7362371-8027-42AD-938F-8E5625C3936D} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs new file mode 100644 index 000000000..2372cb4bf --- /dev/null +++ b/jobs/Backend/Task/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + public interface IExchangeRateProvider + { + Task> GetExchangeRatesAsync(IEnumerable currencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/IExchangeRateProviderConfiguration.cs b/jobs/Backend/Task/IExchangeRateProviderConfiguration.cs new file mode 100644 index 000000000..70dc27e42 --- /dev/null +++ b/jobs/Backend/Task/IExchangeRateProviderConfiguration.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater +{ + public interface IExchangeRateProviderConfiguration + { + string BaseCurrency { get; set; } + string Url { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/NLog.config b/jobs/Backend/Task/NLog.config new file mode 100644 index 000000000..e80574cea --- /dev/null +++ b/jobs/Backend/Task/NLog.config @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..44473e754 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; +using ExchangeRateUpdater.ExchangeRateApi; +using Microsoft.Extensions.Configuration; +using NLog; namespace ExchangeRateUpdater { @@ -8,36 +13,100 @@ public static class Program { private static IEnumerable currencies = new[] { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + }; + + public static async Task Main(string[] args) { + // Ensure NLog is initialized and test logging + var logger = LogManager.GetCurrentClassLogger(); + logger.Info("Application started."); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); + var rates = await provider.GetExchangeRatesAsync(currencies); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + // Print CNB results as returned + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates from CNB:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); } + + #region Another Source of Exchange Rates + //var anotherProvider = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); + //var anotherRates = await anotherProvider.GetExchangeRatesAsync(currencies); + + //// Print ExchangeRate-API results as returned + //Console.WriteLine($"Successfully retrieved {anotherRates.Count()} exchange rates from Exchange Rates API:"); + //Console.WriteLine($"The Exchange Rates API returns rates values if a different manner. To Compare rates it is needed to calculate it 1/rate"); + //foreach (var rate in anotherRates) + //{ + // Console.WriteLine(rate.ToString()); + //} + #endregion + } catch (Exception e) { + logger.Error($"Could not retrieve exchange rates: '{e.Message}'."); + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + if (e is TypeInitializationException && e.InnerException != null) + { + Console.WriteLine($"Inner exception: {e.InnerException.Message}"); + Console.WriteLine(e.InnerException.StackTrace); + } } Console.ReadLine(); } + + private static IExchangeRateProviderConfiguration GetRateProviderConfiguration() + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var rateProviderConfig = new ExchangeRateProviderConfiguration + { + Url = configuration["ApiConfiguration:Url"], + BaseCurrency = configuration["ApiConfiguration:BaseCurrency"] + }; + + if (string.IsNullOrWhiteSpace(rateProviderConfig.Url)) + throw new Exception("ApiConfiguration:Url is not set in appsettings.json"); + + if (string.IsNullOrWhiteSpace(rateProviderConfig.BaseCurrency)) + throw new Exception("ApiConfiguration:BaseCurrency is not set in appsettings.json"); + + return rateProviderConfig; + } + + private static IExchangeRateProviderConfiguration GetExchangeRateApiProviderConfiguration() + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var rateProviderConfig = new ExchangeRateProviderConfiguration + { + Url = configuration["ApiConfiguration:AnotherUrl"] + configuration["ApiConfiguration:AnotherBaseCurrency"], + BaseCurrency = configuration["ApiConfiguration:AnotherBaseCurrency"] + }; + + if (string.IsNullOrWhiteSpace(rateProviderConfig.Url)) + throw new Exception("ApiConfiguration:AnotherUrl is not set in appsettings.json"); + + if (string.IsNullOrWhiteSpace(rateProviderConfig.BaseCurrency)) + throw new Exception("ApiConfiguration:AnotherBaseCurrency is not set in appsettings.json"); + + return rateProviderConfig; + } } } diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md new file mode 100644 index 000000000..59d142768 --- /dev/null +++ b/jobs/Backend/Task/ReadMe.md @@ -0,0 +1,15 @@ +# ExchangeRateUpdater Solution + +This was an awesome task that gave me a great chance to explore different ways to build an exchange rate system. I enjoyed working with various APIs, designing flexible code, and thinking about real-world needs like error handling and logging. It was rewarding to create a solution that balances simplicity with good functionality. + +## Improvement List + +1. **Add Dependency Injection** - (If needed) e.g. for using in web projects. +2. **Create Unit Tests** - Add comprehensive test coverage with mocking to ensure code reliability and prevent regressions +3. **Implement Custom Exceptions** - Replace generic Exception throws with specific domain exceptions for better error handling +4. **Add Retry Policies** - Implement automatic retry logic for API calls to handle temporary network failures +5. **Implement Input Validation** - Add proper validation for currency codes and exchange rate values before processing +6. **Add Caching** - Implement response caching to reduce API calls and improve performance since exchange rates don't change frequently +7. **Add more generic methods/classes** - Add more flexibility to reuse classes for other available open APIs +8. **Use HTTP Client Generation** - Consider using libraries like NSwag to auto-generate strongly-typed HTTP clients from API specifications + diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..4f4375c44 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,8 @@ +{ + "ApiConfiguration": { + "Url": "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN", + "BaseCurrency": "CZK", + "AnotherUrl": "https://v6.exchangerate-api.com/v6/48b58d210307b06e68836c82/latest/", + "AnotherBaseCurrency": "CZK" + } +}