Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a2b4ad9
Add the path `/jobs/Backend/Task/.vs/ExchangeRateUpdater` to the `.gi…
miroshkin Jun 20, 2025
84cbcb4
Add http client and cnb api link
miroshkin Jun 23, 2025
d9333e6
Add app configuration
miroshkin Jun 23, 2025
bdca563
Refactor json handling
miroshkin Jun 24, 2025
eb5bc93
Refactor classes - extract interfaces and create separate files
miroshkin Jun 24, 2025
2de687d
Simplify configuration for provider
miroshkin Jun 24, 2025
0d6574b
Refactoring - Rename classes
miroshkin Jun 24, 2025
2625304
Add second API
miroshkin Jun 25, 2025
0dd8210
Refactoring - Moving files to folders
miroshkin Jun 25, 2025
68fc4d5
Add NLog logger
miroshkin Jun 25, 2025
bc62a45
Add test project
miroshkin Jun 25, 2025
892b3e0
Add some basic tests
miroshkin Jun 25, 2025
f066f2c
Refactoring - Rename and comment unnecessary code
miroshkin Jun 25, 2025
118de58
Add test project in correct place
miroshkin Jun 25, 2025
68f3b96
Fix the build
miroshkin Jun 25, 2025
91a8ffc
Add loggers
miroshkin Jun 25, 2025
8042a42
Add logging to file
miroshkin Jun 26, 2025
b61305e
Add api error handling
miroshkin Jun 26, 2025
b782e7a
Add basic tests for provider
miroshkin Jun 26, 2025
bc12668
Configure tests for ExchangeRateProvider
miroshkin Jun 26, 2025
d335b64
Refactor exchange rate provider configuration and logging
miroshkin Jun 26, 2025
07a04a1
Add Readme.md file
miroshkin Jun 26, 2025
f58e4b7
Correct Dependency Injection in improvement list
miroshkin Jun 26, 2025
81cc21b
Add generic method improvement into Readme.md
miroshkin Jun 26, 2025
545f11f
Readme - Add point regarding client generation
miroshkin Jun 27, 2025
6af4ed4
Readme - Add introduction paragraph
miroshkin Jun 27, 2025
e5d7c29
Add comment regarding another API
miroshkin Jun 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@
node_modules
bower_components
npm-debug.log
/jobs/Backend/Task/.vs/ExchangeRateUpdater
Original file line number Diff line number Diff line change
@@ -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<ILogger<ExchangeRateProvider>> _loggerMock;
private readonly Mock<IExchangeRateProviderConfiguration> _configMock;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly HttpClient _httpClient;

public ExchangeRateProviderTests()
{
_loggerMock = new Mock<ILogger<ExchangeRateProvider>>();
_configMock = new Mock<IExchangeRateProviderConfiguration>();
_httpHandlerMock = new Mock<HttpMessageHandler>();
_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<Currency>
{
new Currency("USD"),
new Currency("EUR")
};

var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act
var result = await provider.GetExchangeRatesAsync<CnbApiResponse>(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<Currency>();
var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act
var result = await provider.GetExchangeRatesAsync<CnbApiResponse>(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<Exception>(() =>
provider.GetExchangeRatesAsync<CnbApiResponse>(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<Currency>
{
new Currency("USD"),
new Currency("XYZ") // Non-existent currency
};

var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act
var result = await provider.GetExchangeRatesAsync<CnbApiResponse>(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<Currency> { new Currency("USD") };
var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act & Assert
await Assert.ThrowsAsync<Exception>(() =>
provider.GetExchangeRatesAsync<CnbApiResponse>(currencies));
}

[Fact]
public async Task GetExchangeRatesAsync_WhenNetworkFails_ThrowsException()
{
// Arrange
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ThrowsAsync(new HttpRequestException("Network error"))
.Verifiable();

var currencies = new List<Currency> { new Currency("USD") };
var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(() =>
provider.GetExchangeRatesAsync<CnbApiResponse>(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<Currency> { new Currency("USD") };
var provider = new ExchangeRateProvider(_configMock.Object, _httpClient);

// Act & Assert
await Assert.ThrowsAsync<Exception>(() =>
provider.GetExchangeRatesAsync<CnbApiResponse>(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<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(response)
.Verifiable();
}

public void Dispose()
{
_httpClient?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Task\ExchangeRateUpdater.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
23 changes: 23 additions & 0 deletions jobs/Backend/Task/Cnb/ApiErrorResponse.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
9 changes: 9 additions & 0 deletions jobs/Backend/Task/Cnb/ApiResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace ExchangeRateUpdater.Cnb
{
public class ApiResponse<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public ApiErrorResponse Error { get; set; }
}
}
11 changes: 11 additions & 0 deletions jobs/Backend/Task/Cnb/CnbApiResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater.Cnb
{
public class CnbApiResponse
{
[JsonPropertyName("rates")]
public List<CnbRateDto> Rates { get; set; }
}
}
16 changes: 16 additions & 0 deletions jobs/Backend/Task/Cnb/CnbRateDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Loading