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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@
node_modules
bower_components
npm-debug.log
/jobs/Backend/Task/.vs/ExchangeRateUpdater/CopilotIndices/17.14.939.21063/SemanticSymbols.db
*.db-shm
*.db-wal
*.vsidx
*.v2
/jobs/Backend/Task/FileCache
*.bin
/jobs/Backend/Task/.vs/ExchangeRateUpdater/v17/TestStore/0/000.testlog
/.vs
205 changes: 205 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/CzeExchangeRateProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using System.Net;
using System.Xml.Serialization;
using ExchangeRateUpdater.Interfaces;
using ExchangeRateUpdater.Models;
using ExchangeRateUpdater.Models.Cache;
using ExchangeRateUpdater.Models.Countries.CZE;
using ExchangeRateUpdater.Services.Countries.CZE;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;

namespace ExchangeRateUpdater.Tests;

public class CzeExchangeRateProviderTests
{
private readonly Mock<ICacheService> _cacheMock = new();
private readonly Mock<IDateTimeProvider> _dateTimeProviderMock = new();
private readonly Mock<ILogger<CzeExchangeRateProvider>> _loggerMock = new();
private readonly Mock<IOptionsSnapshot<CzeSettings>> _optionsMock = new();
private readonly HttpClient _httpClient;

private readonly CzeSettings _settings = new()
{
BaseUrl = "http://fake-url",
TtlInSeconds = 60,
UpdateHourInLocalTime = "06:00:00"
};

private readonly List<Currency> _currencies = new()
{
new Currency("USD"),
new Currency("EUR")
};

public CzeExchangeRateProviderTests()
{
_optionsMock.Setup(o => o.Value).Returns(_settings);

var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
_httpClient = new HttpClient(handlerMock.Object);

_dateTimeProviderMock.Setup(d => d.UtcNow).Returns(new DateTimeOffset(2025, 8, 2, 0, 0, 0, TimeSpan.Zero));
}

[Fact]
public async Task GetExchangeRates_ReturnsEmpty_WhenBaseUrlIsEmpty()
{
_settings.BaseUrl = null;
var provider = CreateProvider();

var result = await provider.GetExchangeRates(_currencies);

Assert.Empty(result);
_loggerMock.VerifyLog(LogLevel.Warning, "Base URL is not configured", Times.Once());
}

[Fact]
public async Task GetExchangeRates_ReturnsCachedData_IfCacheValid()
{
var cachedResponse = new CzeExchangeRatesResponse
{
Table = new CzeExchangeRateTable
{
Rates = new List<CzeExchangeRate>
{
new() { Code = "USD", Amount = 1, RateRaw = "22,0" },
new() { Code = "EUR", Amount = 1, RateRaw = "24.0" },
}
}
};

var cacheObject = new CacheObject<CzeExchangeRatesResponse>
{
Data = cachedResponse,
DataExtractionTimeUTC = DateTimeOffset.UtcNow.AddHours(1)
};

_cacheMock.Setup(c => c.GetAsync<CacheObject<CzeExchangeRatesResponse>>(It.IsAny<string>()))
.ReturnsAsync(cacheObject);

var provider = CreateProvider();

var result = await provider.GetExchangeRates(_currencies);

Assert.NotEmpty(result);
Assert.Contains(result, r => r.TargetCurrency.Code == "USD" && r.Value == 22);
Assert.Contains(result, r => r.TargetCurrency.Code == "EUR" && r.Value == 24);
}

[Fact]
public async Task GetExchangeRates_FetchesFromRemoteAndCaches_WhenNoValidCache()
{
var response = new CzeExchangeRatesResponse
{
Table = new CzeExchangeRateTable
{
Rates = new List<CzeExchangeRate>
{
new() { Code = "USD", Amount = 2, RateRaw = "44,0" },
new() { Code = "EUR", Amount = 1, RateRaw = "24.0" },
}
}
};

var memoryStream = SerializeToXmlStream(response);

var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<System.Threading.CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StreamContent(memoryStream)
});

_cacheMock.Setup(c => c.GetAsync<CacheObject<CzeExchangeRatesResponse>>(It.IsAny<string>()))
.ReturnsAsync((CacheObject<CzeExchangeRatesResponse>)null);

_cacheMock.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<CacheObject<CzeExchangeRatesResponse>>(), It.IsAny<TimeSpan>()))
.Returns(Task.CompletedTask)
.Verifiable();

var provider = CreateProvider(handlerMock.Object);

var result = await provider.GetExchangeRates(_currencies);

Assert.NotEmpty(result);
Assert.Contains(result, r => r.TargetCurrency.Code == "USD" && r.Value == 22);
Assert.Contains(result, r => r.TargetCurrency.Code == "EUR" && r.Value == 24);

_cacheMock.Verify();
}

[Fact]
public async Task GetExchangeRates_ReturnsEmpty_WhenRemoteFetchFails()
{
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<System.Threading.CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));

_cacheMock.Setup(c => c.GetAsync<CacheObject<CzeExchangeRatesResponse>>(It.IsAny<string>()))
.ReturnsAsync((CacheObject<CzeExchangeRatesResponse>?)null);

var provider = CreateProvider(handlerMock.Object);

var result = await provider.GetExchangeRates(_currencies);

Assert.Empty(result);
}

[Fact]
public void GetUpdateHourInUTC_ReturnsCorrectUtcDateTime()
{
var fixedNow = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero);
_dateTimeProviderMock.Setup(d => d.UtcNow).Returns(fixedNow);

var provider = CreateProvider();

var result = provider.GetType()
.GetMethod("GetUpdateHourInUTC", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.Invoke(provider, null);

Assert.IsType<DateTimeOffset>(result);

var utcResult = (DateTimeOffset)result;

var czechZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time");
var expectedLocal = new DateTime(fixedNow.Year, fixedNow.Month, fixedNow.Day, 6, 0, 0);
var expectedUtc = new DateTimeOffset(expectedLocal, czechZone.GetUtcOffset(expectedLocal)).ToUniversalTime();

Assert.Equal(expectedUtc, utcResult);
}

private static MemoryStream SerializeToXmlStream(CzeExchangeRatesResponse response)
{
var serializer = new XmlSerializer(typeof(CzeExchangeRatesResponse));
var ms = new MemoryStream();
using (var writer = new StreamWriter(ms, new System.Text.UTF8Encoding(true), 1024, leaveOpen: true))
{
serializer.Serialize(writer, response);
}
ms.Position = 0;
return ms;
}

private CzeExchangeRateProvider CreateProvider(HttpMessageHandler? handler = null)
{
var client = handler == null ? _httpClient : new HttpClient(handler);

return new CzeExchangeRateProvider(
client,
_optionsMock.Object,
_cacheMock.Object,
_dateTimeProviderMock.Object,
_loggerMock.Object);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

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

</Project>
50 changes: 50 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/FileCacheServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ExchangeRateUpdater.Services.Cache;

namespace ExchangeRateUpdater.Tests;

public class FileCacheServiceTests : IDisposable
{
private readonly string _tempFilePath;
private readonly FileCacheService _fileCacheService;

public FileCacheServiceTests()
{
_tempFilePath = Path.Combine(Path.GetTempPath(), $"testcache_{Guid.NewGuid()}.json");
_fileCacheService = new FileCacheService(_tempFilePath);
}

[Fact]
public async Task SetAndGetAsync_StoresAndRetrievesValue()
{
var key = "test-key";
var testObject = new TestCacheObject { Value = "Hello, World!" };
var ttl = TimeSpan.FromMinutes(5);

await _fileCacheService.SetAsync(key, testObject, ttl);

var cached = await _fileCacheService.GetAsync<TestCacheObject>(key);

Assert.NotNull(cached);
Assert.Equal(testObject.Value, cached.Value);
}

[Fact]
public async Task GetAsync_ReturnsNull_WhenKeyDoesNotExist()
{
var cached = await _fileCacheService.GetAsync<TestCacheObject>("non-existent-key");
Assert.Null(cached);
}

public void Dispose()
{
if (File.Exists(_tempFilePath))
{
File.Delete(_tempFilePath);
}
}

private class TestCacheObject
{
public string Value { get; set; }
}
}
17 changes: 17 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Tests/LoggerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.Extensions.Logging;
using Moq;

namespace ExchangeRateUpdater.Tests;
public static class LoggerExtensions
{
public static void VerifyLog<T>(this Mock<ILogger<T>> loggerMock, LogLevel level, string messageFragment, Times times)
{
loggerMock.Verify(x => x.Log(
level,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains(messageFragment)),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()),
times);
}
}
Loading