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,85 @@
using ExchangeRateProviderAPI_PaolaRojas.Controllers;
using ExchangeRateProviderAPI_PaolaRojas.Models;
using ExchangeRateProviderAPI_PaolaRojas.Models.Requests;
using ExchangeRateProviderAPI_PaolaRojas.Models.Responses;
using ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;

namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Controllers
{
public class ExchangeRatesControllerTests
{
[Fact]
public async Task Should_Return_200_With_ExchangeRates()
{
var testRequest = new CurrencyRequest
{
Currencies = new List<Currency> { new("USD") }
};

var response = new ExchangeRateResponse
{
ExchangeRates = new[]
{
new ExchangeRate(new Currency("USD"), new Currency("CZK"), 21.90m)
}
};

var mockService = MockExchangeRateService.WithResult(response);
var controller = new ExchangeRatesController(mockService.Object);

var result = await controller.GetExchangeRates(testRequest);

result.Should().BeOfType<OkObjectResult>();
var okResult = result as OkObjectResult;
okResult!.Value.Should().BeEquivalentTo(response);
}

[Fact]
public async Task Should_Return_404_When_ExchangeRates_Are_Empty()
{
var testRequest = new CurrencyRequest
{
Currencies = new List<Currency> { new("XYZ") }
};

var emptyResponse = new ExchangeRateResponse { ExchangeRates = [] };
var mockService = MockExchangeRateService.WithResult(emptyResponse);
var controller = new ExchangeRatesController(mockService.Object);

var result = await controller.GetExchangeRates(testRequest);

result.Should().BeOfType<NotFoundObjectResult>();
}

[Fact]
public async Task Should_Return_404_When_Response_Is_Null()
{
var testRequest = new CurrencyRequest
{
Currencies = [new("USD")]
};

var mockService = MockExchangeRateService.WithResult(null);
var controller = new ExchangeRatesController(mockService.Object);

var result = await controller.GetExchangeRates(testRequest);

result.Should().BeOfType<NotFoundObjectResult>();
}

[Fact]
public async Task Should_Return_404_For_Empty_Input_List()
{
var testRequest = new CurrencyRequest { Currencies = [] };

var mockService = MockExchangeRateService.WithResult(new ExchangeRateResponse { ExchangeRates = [] });
var controller = new ExchangeRatesController(mockService.Object);

var result = await controller.GetExchangeRates(testRequest);

result.Should().BeOfType<NotFoundObjectResult>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<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="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ExchangeRateProviderAPI_PaolaRojas\ExchangeRateProviderAPI_PaolaRojas.csproj" />
</ItemGroup>

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using ExchangeRateProviderAPI_PaolaRojas.Models;
using ExchangeRateProviderAPI_PaolaRojas.Models.Responses;
using ExchangeRateProviderAPI_PaolaRojas.Services;
using Moq;

namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks
{
public static class MockExchangeRateService
{
public static Mock<IExchangeRateService> WithResult(ExchangeRateResponse? response)
{
var mock = new Mock<IExchangeRateService>();
mock.Setup(s => s.GetExchangeRatesAsync(It.IsAny<IEnumerable<Currency>>()))
.ReturnsAsync(response);
return mock;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Net;

namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks
{
public class MockHttpMessageHandler : HttpMessageHandler
{
private string? _mockContent;
private Exception? _exception;

public void SetMockResponse(string content)
{
_mockContent = content;
_exception = null;
}

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

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_exception is not null)
{
throw _exception;
}

var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_mockContent ?? "")
};

return Task.FromResult(response);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using ExchangeRateProviderAPI_PaolaRojas.Models;
using ExchangeRateProviderAPI_PaolaRojas.Models.Options;
using ExchangeRateProviderAPI_PaolaRojas.Services;
using ExchangeRateProviderAPI_PaolaRojas.UnitTests.Mocks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;

namespace ExchangeRateProviderAPI_PaolaRojas.UnitTests.Services
{
public class ExchangeRateServiceTests
{
private readonly MockHttpMessageHandler _httpHandler;
private readonly IMemoryCache _cache;
private readonly IOptions<CnbOptions> _options;
private readonly ILogger<ExchangeRateService> _logger;

private const string MockCnbText = """
29 Apr 2025 #82
Country|Currency|Amount|Code|Rate
USA|dollar|1|USD|21.909
EMU|euro|1|EUR|24.920
India|rupee|100|INR|25.732
BADLINE
""";

public ExchangeRateServiceTests()
{
_httpHandler = new MockHttpMessageHandler();
_cache = new MemoryCache(new MemoryCacheOptions());
_options = Options.Create(new CnbOptions
{
DailyExchangeBaseUrl = "https://mock-cnb-url.com"
});
_logger = Mock.Of<ILogger<ExchangeRateService>>();
}

private ExchangeRateService CreateService()
{
_httpHandler.SetMockResponse(MockCnbText);
var httpClient = new HttpClient(_httpHandler);
return new ExchangeRateService(_options, _cache, _logger, httpClient);
}

[Fact]
public async Task Should_Return_Filtered_ExchangeRates()
{
var service = CreateService();
var result = await service.GetExchangeRatesAsync([new Currency("USD")]);

result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "USD");
}

[Fact]
public async Task Should_Return_Empty_When_Currency_Not_Found()
{
var service = CreateService();
var result = await service.GetExchangeRatesAsync([new Currency("XYZ")]);

result.ExchangeRates.Should().BeEmpty();
}

[Fact]
public async Task Should_Ignore_Malformed_Lines()
{
var service = CreateService();
var result = await service.GetExchangeRatesAsync([new Currency("INR")]);

result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "INR");
}

[Fact]
public async Task Should_Handle_Duplicate_Currencies_Gracefully()
{
var service = CreateService();
var result = await service.GetExchangeRatesAsync([new Currency("USD"), new Currency("usd"), new Currency("USD")]);

result.ExchangeRates.Should().ContainSingle(e => e.SourceCurrency.Code == "USD");
}

[Fact]
public async Task Should_Filter_Multiple_Valid_Currencies()
{
var service = CreateService();
var result = await service.GetExchangeRatesAsync([new Currency("USD"), new Currency("EUR"), new Currency("XYZ")]);

result.ExchangeRates.Should().HaveCount(2);
}

[Fact]
public async Task Should_Return_Cached_Result_On_Second_Call()
{
var service = CreateService();
var first = await service.GetExchangeRatesAsync([new Currency("USD")]);
var second = await service.GetExchangeRatesAsync([new Currency("USD")]);

second.Should().BeEquivalentTo(first);
}

[Fact]
public async Task Should_Return_Empty_Response_When_Exception_Occurs()
{
var throwingHandler = new MockHttpMessageHandler();
throwingHandler.SetException(new HttpRequestException("CNB is down"));

var httpClient = new HttpClient(throwingHandler);
var service = new ExchangeRateService(_options, _cache, _logger, httpClient);

var currencies = new[] { new Currency("USD") };

var result = await service.GetExchangeRatesAsync(currencies);

result.Should().NotBeNull();
result.ExchangeRates.Should().BeEmpty();
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using ExchangeRateProviderAPI_PaolaRojas.Models.Handlers;
using ExchangeRateProviderAPI_PaolaRojas.Models.Requests;
using ExchangeRateProviderAPI_PaolaRojas.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace ExchangeRateProviderAPI_PaolaRojas.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = ApiKeyAuthenticationOptions.DefaultScheme)]
public class ExchangeRatesController(IExchangeRateService exchangeRateService) : ControllerBase
{
private readonly IExchangeRateService _exchangeRateService = exchangeRateService;

[HttpPost("rates")]
public async Task<IActionResult> GetExchangeRates([FromBody] CurrencyRequest request)
{
var result = await _exchangeRateService.GetExchangeRatesAsync(request.Currencies);
return result?.ExchangeRates != null && result.ExchangeRates.Any()
? Ok(result)
: NotFound("No exchange rates found for the provided currencies.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

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

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@ExchangeRateProviderAPI_PaolaRojas_HostAddress = http://localhost:5156

### Valid Request: Get exchange rate for USD and EUR
POST https://localhost:5001/api/ExchangeRates/exchange_rates
X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh
Content-Type: application/json

{
"currencies": ["USD", "EUR"]
}

###

### Unauthorized Request: Missing API key
POST https://localhost:5001/api/ExchangeRates/exchange_rates
Content-Type: application/json

{
"currencies": ["USD"]
}

###

### No Matching Currency
POST https://localhost:5001/api/ExchangeRates/exchange_rates
X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh
Content-Type: application/json

{
"currencies": ["XYZ"]
}

###

### Empty Currency List
POST https://localhost:5001/api/ExchangeRates/exchange_rates
X-API-KEY: jH93f7Zk9Vt1@Xy!nP4qLm#Bv8eW2^tR6kUd$Aa0Gh
Content-Type: application/json

{
"currencies": []
}
Loading