Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
node_modules
bower_components
npm-debug.log

# .Net Backend Task
jobs/Backend/Task/.vs/*
134 changes: 134 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using ExchangeRateUpdater.Common;
using ExchangeRateUpdater.Configuration;
using ExchangeRateUpdater.Services;
using ExchangeRateUpdater.Services.Models.External;
using ExchangeRateUpdater.Tests.Services.TestHelper;
using FakeItEasy;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using System.Text.Json;

namespace ExchangeRateUpdater.Tests.Services;

public class CnbApiClientTests
{
private readonly HttpClient _httpClient;
private readonly FakeHttpMessageHandler _httpHandler;
private readonly ApiConfiguration _config;
private readonly TestLogger<CnbApiClient> _logger;
private readonly IDateTimeSource _dateTimeSource;

public CnbApiClientTests()
{
_httpHandler = new FakeHttpMessageHandler();
_httpClient = new HttpClient(_httpHandler);

_config = new ApiConfiguration
{
Language = "en",
ExchangeRateEndpoint = "https://api.example.com/rates"
};

_logger = new TestLogger<CnbApiClient>();
_dateTimeSource = A.Fake<IDateTimeSource>();
A.CallTo(() => _dateTimeSource.UtcNow)
.Returns(new DateTime(2025, 10, 27));
}

[Fact]
public async Task GetExchangeRatesAsync_ReturnsRates_WhenResponseIsValid()
{
var expectedRates = new[]
{
new CnbRate ("USD", 1.0m, 1),
new CnbRate ("EUR", 0.9m, 1)
};

var response = new CnbExchangeResponse(expectedRates);
_httpHandler.SetResponse(response);

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

var result = await client.GetExchangeRatesAsync();

result.Should().BeEquivalentTo(expectedRates);
}

[Fact]
public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenRatesAreNull()
{
var response = new CnbExchangeResponse(null);
_httpHandler.SetResponse(response);

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

var result = await client.GetExchangeRatesAsync();

result.Should().BeEmpty();
}

[Fact]
public async Task GetExchangeRatesAsync_ThrowsException_WhenHttpFails()
{
_httpHandler.SetException(new HttpRequestException("Network error"));

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

Func<Task> act = async () => await client.GetExchangeRatesAsync();

await act.Should().ThrowAsync<HttpRequestException>()
.WithMessage("Network error");

_logger.LogMessages.Should().Contain(m =>
m.Message.Contains("An error occurred")
&& m.LogLevel == LogLevel.Error);
}

[Fact]
public async Task GetExchangeRatesAsync_LogsRequestUri()
{
var response = new CnbExchangeResponse([]);
_httpHandler.SetResponse(response);

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

await client.GetExchangeRatesAsync();

_logger.LogMessages.Should().Contain(m =>
m.Message.Contains("https://api.example.com/rates")
&& m.LogLevel == LogLevel.Information);
}

[Fact]
public async Task GetExchangeRatesAsync_ThrowsJsonException_WhenResponseIsMalformed()
{
_httpHandler.SetRawResponse("not valid json");

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

var act = client.GetExchangeRatesAsync;

await act.Should().ThrowAsync<JsonException>();

_logger.LogMessages.Should().Contain(m =>
m.Message.Contains("An error occurred")
&& m.LogLevel == LogLevel.Error);
}

[Fact]
public async Task GetExchangeRatesAsync_ThrowsTaskCanceledException_WhenRequestTimesOut()
{
_httpHandler.SetDelayedResponse(TimeSpan.FromSeconds(10));
_httpClient.Timeout = TimeSpan.FromMilliseconds(100); // Force timeout

var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource);

var act = client.GetExchangeRatesAsync;

await act.Should().ThrowAsync<TaskCanceledException>();

_logger.LogMessages.Should().Contain(m =>
m.Message.Contains("An error occurred")
&& m.LogLevel == LogLevel.Error);
}
}
101 changes: 101 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using ExchangeRateUpdater.Services;
using ExchangeRateUpdater.Services.Interfaces;
using ExchangeRateUpdater.Services.Models;
using ExchangeRateUpdater.Services.Models.External;
using ExchangeRateUpdater.Tests.Services.TestHelper;
using FakeItEasy;
using FluentAssertions;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Tests.Services;

public class ExchangeRateProviderTests
{
private readonly IApiClient<CnbRate> _apiClient;
private readonly IExchangeRateCacheService _cacheService;
private readonly TestLogger<ExchangeRateProvider> _logger;
private readonly ExchangeRateProvider _provider;

public ExchangeRateProviderTests()
{
_apiClient = A.Fake<IApiClient<CnbRate>>();
_cacheService = A.Fake<ExchangeRateCacheService>();
_logger = new TestLogger<ExchangeRateProvider>();
_provider = new ExchangeRateProvider(_apiClient, _cacheService, _logger);
}

[Fact]
public async Task GetExchangeRates_ShouldReturnRates_WhenCurrenciesAreValid()
{
// Arrange
var currencies = new[] { new Currency("USD"), new Currency("EUR"), new Currency("CZK") };
var apiRates = new List<CnbRate>
{
new CnbRate("USD", 23.5m, 1),
new CnbRate("EUR", 25.0m, 10)
};

A.CallTo(() => _apiClient.GetExchangeRatesAsync())
.Returns(Task.FromResult<IEnumerable<CnbRate>>(apiRates));

// Act
var result = await _provider.GetExchangeRates(currencies);

// Assert
result.Should().HaveCount(2);
result.Should().Contain(r => r.SourceCurrency.Code == "USD" && r.TargetCurrency.Code == "CZK" && r.Value == 23.5m);
result.Should().Contain(r => r.SourceCurrency.Code == "EUR" && r.TargetCurrency.Code == "CZK" && r.Value == 2.50m);
}

[Fact]
public async Task GetExchangeRates_ShouldIgnoreTargetCurrency()
{
// Arrange
var currencies = new[] { new Currency("CZK") };

// Act
var result = await _provider.GetExchangeRates(currencies);

// Assert
result.Should().BeEmpty();
A.CallTo(() => _apiClient.GetExchangeRatesAsync())
.MustNotHaveHappened();
}

[Fact]
public async Task GetExchangeRates_ShouldLogWarning_WhenSomeRatesAreMissing()
{
// Arrange
var currencies = new[] { new Currency("USD"), new Currency("GBP") };
var apiRates = new List<CnbRate>
{
new CnbRate("USD", 23.5m, 1)
};

A.CallTo(() => _apiClient.GetExchangeRatesAsync())
.Returns(Task.FromResult<IEnumerable<CnbRate>>(apiRates));

// Act
var result = await _provider.GetExchangeRates(currencies);

// Assert
result.Should().HaveCount(1);
result.Should().ContainSingle(r => r.SourceCurrency.Code == "USD");

_logger.LogMessages.Should().Contain(m =>
m.Message.Contains("GBP")
&& m.LogLevel == LogLevel.Warning);
}

[Fact]
public async Task GetExchangeRates_ShouldThrow_WhenCurrenciesIsNull()
{
// Act
var act = async () => await _provider.GetExchangeRates(null);

// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
A.CallTo(() => _apiClient.GetExchangeRatesAsync())
.MustNotHaveHappened();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FakeItEasy" Version="8.3.0" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</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,71 @@
using System.Net;
using System.Text;
using System.Text.Json;

namespace ExchangeRateUpdater.Tests.Services.TestHelper;

public class FakeHttpMessageHandler : HttpMessageHandler
{
private HttpResponseMessage _response;
private Exception _exception;
private TimeSpan _delay = TimeSpan.Zero;
private Func<object>? _responseFactory;

public void SetResponse(object content)
{
var json = JsonSerializer.Serialize(content);
_response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}

public void SetException(Exception ex)
{
_exception = ex;
}

public void SetRawResponse(string rawJson)
{
_response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(rawJson, Encoding.UTF8, "application/json")
};
}

public void SetDelayedResponse(TimeSpan delay)
{
_response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};

_delay = delay;
}

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (_responseFactory is not null)
{
var result = _responseFactory();
if (result is Exception ex)
throw ex;

var json = JsonSerializer.Serialize(result);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}

if (_exception != null)
throw _exception;

if (_delay > TimeSpan.Zero)
return Task.Delay(_delay, cancellationToken).ContinueWith(_ => _response, cancellationToken);

return Task.FromResult(_response);
}
}
Loading