Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backend .NET Home Task by Rashid Kashafutdinov #645

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
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
484 changes: 484 additions & 0 deletions jobs/Backend/Task/.gitignore

Large diffs are not rendered by default.

19 changes: 0 additions & 19 deletions jobs/Backend/Task/ExchangeRateProvider.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace ExchangeRateUpdater.Domain
{
public record ApiExchangeRate(string CurrencyCode, decimal Rate, int Amount);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Domain
{
public class Currency
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Domain
{
public class ExchangeRate
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace ExchangeRateUpdater.Domain
{
public interface IExchangeRateApiClient
{
Currency TargetCurrency { get; }

Task<IReadOnlyList<ApiExchangeRate>> GetDailyExchangeRatesAsync(LanguageCode languageCode = LanguageCode.EN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ExchangeRateUpdater.Domain
{
public interface IExchangeRateApiClientFactory
{
IExchangeRateApiClient CreateExchangeRateApiClient(string currencyCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ExchangeRateUpdater.Domain
{
public interface IExchangeRateProvider
{
Task<IReadOnlyList<ExchangeRate>> GetExchangeRatesAsync(
IEnumerable<Currency> currencies, string targetCurrencyCode);
}
}
8 changes: 8 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.Domain/LanguageCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ExchangeRateUpdater.Domain
{
public enum LanguageCode
{
EN,
CZ
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ExchangeRateUpdater.Domain
{
public static class WellKnownCurrencyCodes
{
public const string CZK = "CZK";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using ExchangeRateUpdater.Domain;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;

namespace ExchangeRateUpdater.Infrastructure.CzechNationalBank
{
public class CzechNationalBankExchangeRateApiClient(
HttpClient httpClient,
ILogger<CzechNationalBankExchangeRateApiClient> logger) : IExchangeRateApiClient
{
public Currency TargetCurrency => new(WellKnownCurrencyCodes.CZK);

public async Task<IReadOnlyList<ApiExchangeRate>> GetDailyExchangeRatesAsync(LanguageCode languageCode = LanguageCode.EN)
{
try
{
var response = await httpClient.GetFromJsonAsync<CzechNationalBankExchangeRatesResponse>($"exrates/daily?lang={languageCode}");

return response.Rates.Select(x => new ApiExchangeRate(x.CurrencyCode, x.Rate, x.Amount)).ToArray();
}
catch(Exception e)
{
logger.LogError(e, "Failed to get daily exchange rates from Czech National Bank API");
throw;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater.Infrastructure.CzechNationalBank
{
internal class CzechNationalBankExchangeRatesResponse
{
[JsonPropertyName("rates")]
public ExchangeRate[] Rates { get; init; } = [];

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

[JsonPropertyName("currencyCode")]
public string CurrencyCode { get; init; } = default!;

[JsonPropertyName("rate")]
public decimal Rate { get; init; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using ExchangeRateUpdater.Domain;
using ExchangeRateUpdater.Infrastructure.CzechNationalBank;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Infrastructure
{
public class ExchangeRateApiClientFactory(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory) : IExchangeRateApiClientFactory
{
public IExchangeRateApiClient CreateExchangeRateApiClient(string currencyCode)
{
return currencyCode switch
{
WellKnownCurrencyCodes.CZK => new CzechNationalBankExchangeRateApiClient(
httpClientFactory.CreateClient(HttpClientNames.CzechNationalBankApi),
loggerFactory.CreateLogger<CzechNationalBankExchangeRateApiClient>()),

_ => throw new NotImplementedException($"Exchange rate API client not implemented for currency {currencyCode}"),
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
</ItemGroup>

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ExchangeRateUpdater.Infrastructure
{
public static class HttpClientNames
{
public const string CzechNationalBankApi = nameof(CzechNationalBankApi);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ExchangeRateUpdater.Domain;
using ExchangeRateUpdater.Infrastructure;
using ExchangeRateUpdater.Infrastructure.CzechNationalBank;
using Microsoft.Extensions.Logging;
using Moq;

namespace ExchangeRateUpdater.UnitTests
{
public class ExchangeRateApiClientFactoryTests
{
[Fact]
public void CreateExchangeRateApiClient_ReturnsCorrectExchangeRateApiClient()
{
// Arrange
var factory = CreateExchangeRateApiClientFactory();

// Act
var exchangeRateApiClient = factory.CreateExchangeRateApiClient(WellKnownCurrencyCodes.CZK);

// Assert
Assert.NotNull(exchangeRateApiClient);
Assert.IsType<CzechNationalBankExchangeRateApiClient>(exchangeRateApiClient);
}

[Fact]
public void CreateExchangeRateApiClient_IfCurrencyCodeUnknown_ThrowsException()
{
// Arrange
var factory = CreateExchangeRateApiClientFactory();

// Act
var exception = Assert.Throws<NotImplementedException>(
() => factory.CreateExchangeRateApiClient("INVALID"));

// Assert
Assert.Equal("Exchange rate API client not implemented for currency INVALID", exception.Message);
}

private static ExchangeRateApiClientFactory CreateExchangeRateApiClientFactory()
{
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny<string>()))
.Returns<HttpClient>(null);

var loggerFactoryMock = new Mock<ILoggerFactory>();

return new(httpClientFactoryMock.Object, loggerFactoryMock.Object);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using Moq;
using ExchangeRateUpdater.Domain;

namespace ExchangeRateUpdater.UnitTests
{
public class ExchangeRateProviderTests
{
[Fact]
public async Task GetExchangeRatesAsync_IgnoresCurrenciesNotProvidedByApi()
{
// Arrange
var targetCurrencyCode = "CZK";
var requestedCurrencies = new Currency[]
{
new("USD"), new("EUR"), new("RUB")
};
var exchangeRatesProvidedByApi = new ApiExchangeRate[]
{
new("USD", 1.2m, 1), new("EUR", 1.3m, 1)
};
var apiClientFactoryMock = CreateExchangeApiClientFactoryMock(exchangeRatesProvidedByApi, targetCurrencyCode);

var exchangeRateProvider = new ExchangeRateProvider(apiClientFactoryMock.Object);

// Act
var actualExchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, targetCurrencyCode);

// Assert
Assert.Equal(2, actualExchangeRates.Count);

var actualSourceCurrencyCodes = actualExchangeRates.Select(x => x.SourceCurrency.Code).ToHashSet();
var expectedSourceCurrencyCodes = new[] { "USD", "EUR" }.ToHashSet();
Assert.Equal(expectedSourceCurrencyCodes, expectedSourceCurrencyCodes);
}

[Fact]
public async Task GetExchangeRatesAsync_WhenCurrenciesEmpty_DoesNotCallExchangeRateApi()
{
// Arrange
var targetCurrencyCode = "CZK";
var requestedCurrencies = Array.Empty<Currency>();
var apiClientFactoryMock = CreateExchangeApiClientFactoryMock([], targetCurrencyCode);

var exchangeRateProvider = new ExchangeRateProvider(apiClientFactoryMock.Object);

// Act
var actualExchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, targetCurrencyCode);

// Assert
Assert.Empty(actualExchangeRates);
apiClientFactoryMock.Verify(x => x.CreateExchangeRateApiClient(It.IsAny<string>()), Times.Never);
}

[Fact]
public async Task GetExchangeRatesAsync_ReturnsDataOnlyForRequestedCurrencies()
{
// Arrange
var targetCurrencyCode = "CZK";
var requestedCurrencies = new Currency[]
{
new("USD"), new("EUR"), new("RUB")
};
var exchangeRatesProvidedByApi = new ApiExchangeRate[]
{
new("USD", 1.2m, 1), new("EUR", 1.3m, 1), new("KES", 1.2m, 1), new("RUB", 1.3m, 1), new("HBC", 1.2m, 1)
};
var apiClientFactoryMock = CreateExchangeApiClientFactoryMock(exchangeRatesProvidedByApi, targetCurrencyCode);

var exchangeRateProvider = new ExchangeRateProvider(apiClientFactoryMock.Object);

// Act
var actualExchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, targetCurrencyCode);

// Assert
Assert.Equal(3, actualExchangeRates.Count);

var actualSourceCurrencyCodes = actualExchangeRates.Select(x => x.SourceCurrency.Code).ToHashSet();
var expectedSourceCurrencyCodes = new[] { "USD", "EUR", "RUB" }.ToHashSet();
Assert.Equal(expectedSourceCurrencyCodes, expectedSourceCurrencyCodes);
}

[Fact]
public async Task GetExchangeRatesAsync_ReturnsCorrectTargetCurrency()
{
// Arrange
var targetCurrencyCode = "CZK";
var requestedCurrencies = new Currency[]
{
new("USD"), new("EUR"), new("RUB")
};
var exchangeRatesProvidedByApi = new ApiExchangeRate[]
{
new("USD", 1.2m, 1), new("EUR", 1.3m, 1), new("RUB", 1.3m, 1)
};
var apiClientFactoryMock = CreateExchangeApiClientFactoryMock(exchangeRatesProvidedByApi, targetCurrencyCode);

var exchangeRateProvider = new ExchangeRateProvider(apiClientFactoryMock.Object);

// Act
var actualExchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, targetCurrencyCode);

// Assert
Assert.NotEmpty(actualExchangeRates);
Assert.All(actualExchangeRates, rate => Assert.Equal(targetCurrencyCode, rate.TargetCurrency.Code));
}

[Fact]
public async Task GetExchangeRatesAsync_ReturnsCorrectRateValues()
{
// Arrange
var targetCurrencyCode = "CZK";
var requestedCurrencies = new Currency[]
{
new("USD"), new("EUR"), new("RUB")
};
var exchangeRatesProvidedByApi = new ApiExchangeRate[]
{
new("USD", 0.2m, 1), new("EUR", 1.8m, 10), new("RUB", 5.8m, 100)
};
var apiClientFactoryMock = CreateExchangeApiClientFactoryMock(exchangeRatesProvidedByApi, targetCurrencyCode);

var exchangeRateProvider = new ExchangeRateProvider(apiClientFactoryMock.Object);

// Act
var actualExchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, targetCurrencyCode);

// Assert
Assert.Equal(3, actualExchangeRates.Count);
Assert.All(exchangeRatesProvidedByApi, apiRate =>
{
var expectedValue = apiRate.Rate / apiRate.Amount;
var actualValue = actualExchangeRates.FirstOrDefault(x => x.SourceCurrency.Code == apiRate.CurrencyCode)?.Value;
Assert.Equal(expectedValue, actualValue);
});
}

private static Mock<IExchangeRateApiClientFactory> CreateExchangeApiClientFactoryMock(
ApiExchangeRate[] exchangeRates, string targetCurrencyCode)
{
var exchangeRateApiClientMock = new Mock<IExchangeRateApiClient>();
exchangeRateApiClientMock.Setup(x => x.GetDailyExchangeRatesAsync(It.IsAny<LanguageCode>()))
.ReturnsAsync(exchangeRates);

var apiClientFactoryMock = new Mock<IExchangeRateApiClientFactory>();
apiClientFactoryMock.Setup(x => x.CreateExchangeRateApiClient(targetCurrencyCode))
.Returns(exchangeRateApiClientMock.Object);

return apiClientFactoryMock;
}
}
}
Loading