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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using ExchangeRateUpdater.Cnb;

namespace ExchangeRateUpdater.Tests.Cnb
{
public class CnbRatesTests
{

[Theory]
[InlineData(1, 32.2, 32.2)]
[InlineData(100, 32.2, 0.322)]
[InlineData(50, 32.2, 0.644)]
public void ShouldCalculateRealAmount(int amount, decimal rate, decimal expectedAmount)
{
var cnbRate = new CnbExchangeRate()
{
Amount = amount,
Rate = rate
};
Assert.Equal(expectedAmount, cnbRate.RealRate);
}
}
}
46 changes: 46 additions & 0 deletions jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/CurrencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace ExchangeRateUpdater.Tests;

public class CurrencyTests
{
[Fact]
public void ShouldInitNewCurrency()
{
var curr = new Currency("USD");
Assert.NotNull(curr);
Assert.Equal("USD", curr.Code);
}

[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
[InlineData(" ")]
public void ShouldThrowArgumentExceptionWhenCodeIsNullOrWhitespace(string code)
{
Assert.Throws<ArgumentNullException>(() => new Currency(code));
}

[Theory]
[InlineData("USDC")]
[InlineData("US")]
public void ShouldThrowIfInvalidLength(string code)
{
Assert.Throws<ArgumentException>(() => new Currency(code));
}

[Theory]
[InlineData("usd")]
[InlineData("Usd")]
public void ShouldThrowIfCaseMismatch(string code)
{
Assert.Throws<ArgumentException>(() => new Currency(code));
}

[Theory]
[InlineData("AB1")]
[InlineData("AR%")]
public void ShouldThrowIfNotAllLetters(string code)
{
Assert.Throws<ArgumentException>(() => new Currency(code));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using ExchangeRateUpdater.Cnb;
using Moq;

namespace ExchangeRateUpdater.Tests
{
public class ExchangeRateProviderTests
{
private readonly Mock<IBankApi> _bankApiMock = new Mock<IBankApi>();
private readonly ExchangeRateProvider _exchangeRateProvider;

public ExchangeRateProviderTests()
{
_exchangeRateProvider = new ExchangeRateProvider(_bankApiMock.Object);
}

[Fact]
public async Task ShouldProvideOnlyCurrenciesSpecifiedInList()
{
ArrangeApiRates();

var currencies = new List<Currency> { new Currency("USD"), new Currency("EUR") };
var result = await _exchangeRateProvider.GetExchangeRates(currencies);

var expected = new List<ExchangeRate>()
{
ExchangeRate.FromCZK(new Currency("USD"), 2.0M),
ExchangeRate.FromCZK(new Currency("EUR"), 1.0M),
};
Assert.Equal(expected, result);
}

[Fact]
public async Task ShouldReturnEmptyListIfEmptyRequestedCurrencies()
{
ArrangeApiRates();

var currencies = Enumerable.Empty<Currency>();

var result = await _exchangeRateProvider.GetExchangeRates(currencies);
Assert.Empty(result);
}

[Fact]
public async Task ShouldThrowIfExpectedCurrenciesAreNull()
{
ArrangeApiRates();

IEnumerable<Currency> currencies = null!; // whoopsie
var result = _exchangeRateProvider.GetExchangeRates(currencies);
await Assert.ThrowsAsync<ArgumentNullException>(() => result);
}

private void ArrangeApiRates()
{
_bankApiMock.Setup(x => x.GetTodayExchangeRates()).ReturnsAsync(new List<CnbExchangeRate>()
{
new CnbExchangeRate()
{
Amount = 1,
Rate = 2.0M,
CurrencyCode = "USD"
},
new CnbExchangeRate()
{
Amount = 1,
Rate = 1.0M,
CurrencyCode = "EUR"
},
new CnbExchangeRate()
{
Amount = 1,
Rate = 3.0M,
CurrencyCode = "AUD"
},
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

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

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

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.2.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"/>
</ItemGroup>

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

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

</Project>
38 changes: 38 additions & 0 deletions jobs/Backend/Task/Cnb/CnbApi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Cnb
{
internal interface IBankApi
{
Task<IEnumerable<CnbExchangeRate>> GetTodayExchangeRates();
}

/// <summary>
/// Api for Česká národní banka
/// <seealso href="https://api.cnb.cz/cnbapi/swagger-ui.html"/>
/// </summary>
internal sealed class CnbApi : IBankApi
{
private readonly HttpClient _httpClient;

public CnbApi(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task<IEnumerable<CnbExchangeRate>> GetTodayExchangeRates()
{
const string exchangeRatesEndpoint = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
var response = await _httpClient.GetFromJsonAsync<CnbExchangeRates>(exchangeRatesEndpoint);
if (response == null)
{
throw new InvalidOperationException("Unable to get exchange rates. Response was null.");
}
return response.Rates;
}
}
}
23 changes: 23 additions & 0 deletions jobs/Backend/Task/Cnb/CnbRates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater.Cnb
{
internal class CnbExchangeRates
{
[JsonPropertyName("rates")]
[JsonInclude]
public IEnumerable<CnbExchangeRate> Rates { get; private set; } = null!; // required and json don't work well
}

internal sealed class CnbExchangeRate
{
[JsonPropertyName("currencyCode")] public string CurrencyCode { get; init; } = null!; // required and json don't work well

[JsonPropertyName("amount")] public int Amount { get; init; }

[JsonPropertyName("rate")] public decimal Rate { get; init; }

public decimal RealRate => Rate / Amount;
}
}
38 changes: 36 additions & 2 deletions jobs/Backend/Task/Currency.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
namespace ExchangeRateUpdater
using System;
using System.Linq;

namespace ExchangeRateUpdater
{
public class Currency
internal sealed class Currency
{
internal static readonly Currency Czk = new Currency("CZK");

public Currency(string code)
{
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentNullException(nameof(code),
"code is null or empty. Provide a valid ISO 4217 currency code.");
}

if (code.Length != 3)
{
throw new ArgumentException("code must be exactly 3 characters.", nameof(code));
}

if (!code.All(x => char.IsUpper(x) && char.IsLetter(x)))
{
throw new ArgumentException("code must be consist of all upper characters", nameof(code));
}
Code = code;
}

Expand All @@ -12,6 +32,20 @@ public Currency(string code)
/// </summary>
public string Code { get; }

public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj)) return true;
if (obj is null) return false;
if (obj is not Currency other) return false;

return Code == other.Code;
}

public override int GetHashCode()
{
return Code.GetHashCode();
}

public override string ToString()
{
return Code;
Expand Down
32 changes: 30 additions & 2 deletions jobs/Backend/Task/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace ExchangeRateUpdater
using System;

namespace ExchangeRateUpdater
{
public class ExchangeRate
internal sealed class ExchangeRate
{
public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
{
Expand All @@ -15,6 +17,32 @@ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal va

public decimal Value { get; }

/// <summary>
/// Returns Exchange Rate with CZK as a source currency
/// </summary>
/// <param name="targetCurrency"></param>
/// <param name="value"></param>
/// <returns></returns>
public static ExchangeRate FromCZK(Currency targetCurrency, decimal value)
{
return new ExchangeRate(Currency.Czk, targetCurrency, value);
}

public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj)) return true;
if (obj is null) return false;
if (obj is not ExchangeRate other) return false;

return Value == other.Value && SourceCurrency.Equals(other.SourceCurrency) &&
TargetCurrency.Equals(other.TargetCurrency);
}

public override int GetHashCode()
{
return HashCode.Combine(Value, SourceCurrency, TargetCurrency);
}

public override string ToString()
{
return $"{SourceCurrency}/{TargetCurrency}={Value}";
Expand Down
26 changes: 22 additions & 4 deletions jobs/Backend/Task/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ExchangeRateUpdater.Cnb;

namespace ExchangeRateUpdater
{
public class ExchangeRateProvider
internal sealed class ExchangeRateProvider
{
private readonly IBankApi _bankApi;

public ExchangeRateProvider(IBankApi bankApi)
{
_bankApi = bankApi;
}

/// <summary>
/// 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.
/// </summary>
public IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies)
public async Task<IEnumerable<ExchangeRate>> GetExchangeRates(IEnumerable<Currency>? currencies)
{
return Enumerable.Empty<ExchangeRate>();
if (currencies == null)
{
throw new ArgumentNullException(nameof(currencies), "currencies is null");
}
var cnbRates = await _bankApi.GetTodayExchangeRates();
var selectedRates = cnbRates.Select(x => ExchangeRate.FromCZK(new Currency(x.CurrencyCode), x.RealRate))
// TODO: seems should be filtered by source currency as well, but our source is always CZK I only need target currency then?
.Where(x => currencies.Contains(x.TargetCurrency));
return selectedRates;
}
}
}
11 changes: 9 additions & 2 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ExchangeRateUpdater.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

</Project>
Loading