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
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
/.vs
27 changes: 27 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Test/CurrencyTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace ExchangeRateUpdater.Test;

public class CurrencyTest
{
[Fact]
public void Currency_RecordValueComparison_ReturnsTrueForEqualCurrencies()
{
var symbol = "USD";
var currency1 = new Currency(symbol);
var currency2 = new Currency(symbol);

var result = currency1.Equals(currency2);

Assert.True(result);
}

[Fact]
public void Currency_RecordValueComparison_ReturnsFalseForDifferentCurrencies()
{
var currency1 = new Currency("USD");
var currency2 = new Currency("EUR");

var result = currency1.Equals(currency2);

Assert.False(result);
}
}
107 changes: 107 additions & 0 deletions jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateProviderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using ExchangeRateUpdater.Contracts;
using ExchangeRateUpdater.Providers.CNB;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Moq;

namespace ExchangeRateUpdater.Test;

public class ExchangeRateProviderTest
{
[Fact]
public async Task GetExchangeRatesAsync_UsesCache_WhenCalledTwice()
{
// Arrange
var targetDate = DateOnly.FromDateTime(DateTime.UtcNow);
var expectedCacheKey = $"{nameof(CNBCurrencyExchangeProvider)}_{targetDate:yyyy-MM-dd}";

var options = Options.Create(new CNBExchangeRateProviderOptions
{
Name = "CNB",
BaseUrl = "https://api.cnb.cz",
CacheAbsoluteExpiration = TimeSpan.FromMinutes(10),
CacheSlidingExpiration = TimeSpan.FromMinutes(2)
});

var apiRates = new List<ExchangeRate>
{
new(new Currency("EUR"), new Currency("CZK"), 25.10m),
new(new Currency("USD"), new Currency("CZK"), 23.40m)
};

var clientMock = new Mock<ICNBExchangeRateApiClient>(MockBehavior.Strict);
clientMock
.Setup(x => x.FetchExchangeRatesAsync(
It.Is<DateOnly>(d => d == targetDate),
It.IsAny<CancellationToken>()))
.ReturnsAsync(apiRates);

var cacheMock = new Mock<IMemoryCache>(MockBehavior.Strict);

var callCount = 0;

object? cachedObject = null;

cacheMock
.Setup(c => c.TryGetValue(It.IsAny<object>(), out cachedObject))
.Returns((object key, out object? value) =>
{
Assert.Equal(expectedCacheKey, key);

callCount++;

if (callCount == 1)
{
value = null;
return false;
}

value = apiRates;
return true;
});

// Because _cache.Set(...) is an extension method, it calls CreateEntry internally.
var entryMock = new Mock<ICacheEntry>();

cacheMock
.Setup(c => c.CreateEntry(It.Is<object>(k => k.Equals(expectedCacheKey))))
.Returns(entryMock.Object);

// The Set extension will set Value and Expiration properties and then dispose the entry.
entryMock.SetupAllProperties();
entryMock.Setup(e => e.Dispose());

var sut = new CNBCurrencyExchangeProvider(clientMock.Object, cacheMock.Object, options);

var requestedCurrencies = new[] { new Currency("EUR") };

// Act

// cache miss
var first = (await sut.GetExchangeRatesAsync(requestedCurrencies)).ToList();

// cache hit
var second = (await sut.GetExchangeRatesAsync(requestedCurrencies)).ToList();

Assert.Single(first);
Assert.Single(second);
Assert.Equal("EUR", first[0].SourceCurrency.Code);
Assert.Equal("EUR", second[0].SourceCurrency.Code);

// Assert

// client called only once
clientMock.Verify(x => x.FetchExchangeRatesAsync(
It.Is<DateOnly>(d => d == targetDate),
It.IsAny<CancellationToken>()),
Times.Once);

// cache was written only once
cacheMock.Verify(c => c.CreateEntry(It.Is<object>(k => k.Equals(expectedCacheKey))), Times.Once);
entryMock.VerifySet(e => e.Value = It.IsAny<object>(), Times.Once);
entryMock.Verify(e => e.Dispose(), Times.Once);

// TryGetValue called twice
cacheMock.Verify(c => c.TryGetValue(It.IsAny<object>(), out cachedObject), Times.Exactly(2));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<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.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>

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

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

</Project>
21 changes: 21 additions & 0 deletions jobs/Backend/Task/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
!.gitkeep
!.gitignore
!*.dll
[Oo]bj
[Bb]in
*.user
*.suo
*.[Cc]ache
*.bak
*.ncb
*.DS_Store
*.userprefs
*.iml
*.ncrunch*
.*crunch*.local.xml
.idea
[Tt]humbs.db
*.tgz
*.sublime-*

.vs/
6 changes: 6 additions & 0 deletions jobs/Backend/Task/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ExchangeRateUpdater;

public static class Constants
{
public const string CNBExchangeRateProviderName = "CNBExchangeRateProvider";
}
11 changes: 11 additions & 0 deletions jobs/Backend/Task/Contracts/ICNBExchangeRateApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Contracts;

public interface ICNBExchangeRateApiClient
{
Task<IEnumerable<ExchangeRate>> FetchExchangeRatesAsync(DateOnly date, CancellationToken ct = default);
}
21 changes: 21 additions & 0 deletions jobs/Backend/Task/Contracts/IExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Contracts;

public interface IExchangeRateProvider
{
/// <summary>
/// Returns exchange rates among the specified currencies that are defined by the source. But only those defined
/// by the source. It does not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK",
/// it does not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide
/// some of the currencies, it will be ignored.
/// </summary>
/// <param name="currencies">Source currencies whose exchange rate is needed.</param>
/// <param name="date">The date for which exchange rates are requested (null for the latest rates).</param>
/// <returns>
/// Returns a collection of ExchangeRate objects containing the requested information.
/// </returns>
Task<IEnumerable<ExchangeRate>> GetExchangeRatesAsync(IEnumerable<Currency> currencies);
}
27 changes: 13 additions & 14 deletions jobs/Backend/Task/Currency.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater;

public sealed record Currency
{
public class Currency
public Currency(string code)
{
public Currency(string code)
{
Code = code;
}
Code = code.ToUpperInvariant();
}

/// <summary>
/// Three-letter ISO 4217 code of the currency.
/// </summary>
public string Code { get; }
/// <summary>
/// Three-letter ISO 4217 code of the currency.
/// </summary>
public string Code { get; }

public override string ToString()
{
return Code;
}
public override string ToString()
{
return Code;
}
}
16 changes: 16 additions & 0 deletions jobs/Backend/Task/Exceptions/ExchangeRateProviderException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;

namespace ExchangeRateUpdater.Exceptions;

public sealed class ExchangeRateProviderException : Exception
{
public ExchangeRateProviderException(string message)
: base(message)
{
}

public ExchangeRateProviderException(string message, Exception innerException)
: base(message, innerException)
{
}
}
29 changes: 14 additions & 15 deletions jobs/Backend/Task/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater;

public class ExchangeRate
{
public class ExchangeRate
public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
{
public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
{
SourceCurrency = sourceCurrency;
TargetCurrency = targetCurrency;
Value = value;
}
SourceCurrency = sourceCurrency;
TargetCurrency = targetCurrency;
Value = value;
}

public Currency SourceCurrency { get; }
public Currency SourceCurrency { get; }

public Currency TargetCurrency { get; }
public Currency TargetCurrency { get; }

public decimal Value { get; }
public decimal Value { get; }

public override string ToString()
{
return $"{SourceCurrency}/{TargetCurrency}={Value}";
}
public override string ToString()
{
return $"{SourceCurrency}/{TargetCurrency}={Value}";
}
}
19 changes: 0 additions & 19 deletions jobs/Backend/Task/ExchangeRateProvider.cs

This file was deleted.

20 changes: 19 additions & 1 deletion jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,25 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="Polly" Version="8.6.5" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
10 changes: 8 additions & 2 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
@@ -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.36429.23 d17.14
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.Test", "..\ExchangeRateUpdater.Test\ExchangeRateUpdater.Test.csproj", "{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -15,6 +17,10 @@ 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
{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
Loading