feat: add CORS configuration and implement unit tests for InvoiceMapper and InvoiceService

This commit is contained in:
javiermengual 2026-01-16 15:43:28 +01:00
parent a1fd7c6418
commit 72b15eeff6
4 changed files with 482 additions and 3 deletions

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DoliMiddlewareApi\DoliMiddlewareApi.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,162 @@
using DoliMiddlewareApi.Dtos.Dolibarr;
using DoliMiddlewareApi.Mappers;
namespace DoliMiddlewareApi.Tests.Mappers;
public class InvoiceMapperTests
{
// ARRANGE → Preparar datos de entrada
// ACT → Ejecutar método bajo test
// ASSERT → Verificar resultado
[Fact]
public void MapToInvoiceDto_WithValidResponse_ReturnsCorrectDto()
{
// ARRANGE: Creamos un objeto response simulado
var response = new InvoiceResponse
{
id = "123",
@ref = "FAC-2024-001",
date = 1704067200, // 01/01/2024 en Unix timestamp
date_lim_reglement = 1706745600, // 01/02/2024
socid = "456",
total_ttc = "1234.56",
remaintopay = "500.00",
statut = "1" // unpaid
};
// ACT: Ejecutamos el método bajo test
var result = InvoiceMapper.MapToInvoiceDto(response);
// ASSERT: Verificamos el resultado
Assert.Equal(123, result.Id);
Assert.Equal("FAC-2024-001", result.Number);
Assert.Equal(new DateTime(2024, 1, 1), result.Date);
Assert.Equal(new DateTime(2024, 2, 1), result.ExpireDate);
Assert.Equal(456, result.ClientId);
Assert.Equal(1234.56m, result.Total);
Assert.Equal(500.00m, result.RemainToPay);
Assert.Equal("unpaid", result.Status);
}
[Fact]
public void MapToInvoiceDto_WithNullValues_HandlesGracefully()
{
// ARRANGE: Probamos edge cases (casos límite)
var response = new InvoiceResponse
{
id = "invalid",
@ref = null,
date = null,
date_lim_reglement = null,
socid = "not-a-number",
total_ttc = "not-a-decimal",
remaintopay = "",
statut = "999" // status desconocido
};
// ACT
var result = InvoiceMapper.MapToInvoiceDto(response);
// ASSERT: Verificamos que se maneja gracefulmente
Assert.Equal(0, result.Id); // fallback para parseo fallido
Assert.Equal("SIN-REF", result.Number); // valor por defecto
Assert.Null(result.Date); // null si no hay timestamp
Assert.Null(result.ExpireDate);
Assert.Equal(0, result.ClientId);
Assert.Null(result.Total); // null si parseo falla
Assert.Null(result.RemainToPay);
Assert.Equal("unknown", result.Status); // status desconocido
}
[Theory]
[InlineData("0", "draft")]
[InlineData("1", "unpaid")]
[InlineData("2", "paid")]
[InlineData("3", "cancelled")]
[InlineData("999", "unknown")]
public void ConvertStatusToWord_WithDifferentStatuses_ReturnsCorrectWord(
string statusCode,
string expectedWord)
{
// ARRANGE
var response = new InvoiceResponse
{
id = "1",
statut = statusCode
};
// ACT
var result = InvoiceMapper.MapToInvoiceDto(response);
// ASSERT
Assert.Equal(expectedWord, result.Status);
}
[Fact]
public void MapToInvoiceDetailDto_WithLines_ReturnsDtoWithLines()
{
// ARRANGE: Creamos una respuesta compleja con líneas
var response = new InvoiceDetailResponse
{
id = "1",
@ref = "FAC-001",
date = 1704067200,
socid = "10",
total_ttc = "100.00",
remaintopay = "0.00",
statut = "2", // paid
Lines = new List<InvoiceLineResponse>
{
new InvoiceLineResponse
{
id = "1",
description = "Product A",
qty = "2",
subprice = "50.00",
tva_tx = "21.00",
total_ttc = "121.00"
}
}
};
// ACT
var result = InvoiceMapper.MapToInvoiceDetailDto(response);
// ASSERT
Assert.NotEmpty(result.Lines);
Assert.Single(result.Lines);
Assert.Equal("Product A", result.Lines[0].Description);
Assert.Equal(2, result.Lines[0].Quantity);
Assert.Equal(50.00m, result.Lines[0].UnitPrice);
Assert.Equal(21.00m, result.Lines[0].TaxRate);
Assert.Equal(121.00m, result.Lines[0].Total);
}
[Fact]
public void MapToInvoiceLineDto_WithValidLine_ReturnsCorrectDto()
{
// ARRANGE
var lineResponse = new InvoiceLineResponse
{
id = "42",
desc = "Fallback description",
description = "Main description",
qty = "3",
subprice = "99.99",
tva_tx = "10.00",
total_ttc = "329.97"
};
// ACT
var result = InvoiceMapper.MapToInvoiceLineDto(lineResponse);
// ASSERT
Assert.Equal(42, result.Id);
Assert.Equal("Main description", result.Description); // priority: description > desc
Assert.Equal(3, result.Quantity);
Assert.Equal(99.99m, result.UnitPrice);
Assert.Equal(10.00m, result.TaxRate);
Assert.Equal(329.97m, result.Total);
}
}

View File

@ -0,0 +1,277 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using DoliMiddlewareApi.Dtos.Dolibarr;
using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services;
using DoliMiddlewareApi.Services.Clients;
using Moq;
using Moq.Protected;
namespace DoliMiddlewareApi.Tests.Services;
public class InvoiceServiceTests
{
private readonly Mock<IDolibarrApiClient> _mockApiClient;
private readonly InvoiceService _invoiceService;
public InvoiceServiceTests()
{
// ARRANGE: Crear mock del cliente API
_mockApiClient = new Mock<IDolibarrApiClient>();
_invoiceService = new InvoiceService(_mockApiClient.Object);
}
// ===== TESTS POSITIVOS =====
[Fact]
public async Task GetInvoiceAsync_WithValidId_ReturnsInvoiceDetail()
{
// ARRANGE: Preparamos el mock para que devuelva datos
var expectedResponse = new InvoiceDetailResponse
{
id = "1",
@ref = "FAC-001",
date = 1704067200,
socid = "10",
total_ttc = "100.00",
remaintopay = "0.00",
statut = "2"
};
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"))
.ReturnsAsync(expectedResponse);
// ACT: Ejecutamos el método bajo test
var result = await _invoiceService.GetInvoiceAsync(1);
// ASSERT: Verificamos que el mock fue llamado con el endpoint correcto
_mockApiClient.Verify(
client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"),
Times.Once
);
// Y que el resultado es el esperado
Assert.Equal(1, result.Id);
Assert.Equal("FAC-001", result.Number);
Assert.Equal(100.00m, result.Total);
Assert.Equal("paid", result.Status);
}
[Fact]
public async Task GetInvoicesAsync_WithDefaultParams_ReturnsList()
{
// ARRANGE
var expectedList = new List<InvoiceResponse>
{
new InvoiceResponse { id = "1", @ref = "FAC-001", statut = "0" },
new InvoiceResponse { id = "2", @ref = "FAC-002", statut = "1" }
};
_mockApiClient
.Setup(client => client.GetCollectionAsync<InvoiceResponse>("invoices?limit=50&page=0"))
.ReturnsAsync(expectedList);
// ACT
var result = await _invoiceService.GetInvoicesAsync();
// ASSERT
Assert.Equal(2, result.Count);
Assert.Equal(1, result[0].Id);
Assert.Equal(2, result[1].Id);
}
[Fact]
public async Task GetInvoicesAsync_WithParams_ReturnsCorrectList()
{
// ARRANGE
var expectedList = new List<InvoiceResponse>
{
new InvoiceResponse { id = "3", @ref = "FAC-003", statut = "2" }
};
_mockApiClient
.Setup(client => client.GetCollectionAsync<InvoiceResponse>("invoices?limit=10&page=1&status=2"))
.ReturnsAsync(expectedList);
// ACT: Pasamos página 2 (que se convierte a page=1 en el endpoint)
var result = await _invoiceService.GetInvoicesAsync(limit: 10, page: 2, status: "2");
// ASSERT
Assert.Single(result);
Assert.Equal(3, result[0].Id);
}
[Fact]
public async Task CreateInvoiceAsync_WithValidDto_ReturnsNewId()
{
// ARRANGE
var dto = new Dtos.command.CreateInvoiceDto
{
ClientId = 10,
Date = DateTime.Now,
Status = "unpaid",
Reference = "TEST-001",
Lines = new List<Dtos.command.CreateInvoiceLineDto>
{
new Dtos.command.CreateInvoiceLineDto
{
Description = "Test Product",
Quantity = 2,
UnitPrice = 50.00m,
TaxRate = 21.00m
}
}
};
_mockApiClient
.Setup(client => client.PostAsync("invoices", It.IsAny<object>()))
.ReturnsAsync("42");
// ACT
var result = await _invoiceService.CreateInvoiceAsync(dto);
// ASSERT
Assert.Equal(42, result);
_mockApiClient.Verify(client => client.PostAsync("invoices", It.IsAny<object>()), Times.Once);
}
// ===== TESTS DE EXCEPCIONES =====
[Fact]
public async Task GetInvoiceAsync_WhenNotFound_ThrowsNotFoundException()
{
// ARRANGE: Simulamos que el API client lanza NotFoundException
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/999"))
.ThrowsAsync(new NotFoundException("Invoice not found"));
// ACT + ASSERT: Verificamos que se propaga la excepción
await Assert.ThrowsAsync<NotFoundException>(
async () => await _invoiceService.GetInvoiceAsync(999)
);
}
[Fact]
public async Task AddInvoiceLineAsync_WhenInvoiceNotDraft_ThrowsForbiddenException()
{
// ARRANGE: Invoice no está en borrador
var invoiceResponse = new InvoiceDetailResponse
{
id = "1",
statut = "2" // paid, no draft (0)
};
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"))
.ReturnsAsync(invoiceResponse);
var lineDto = new Dtos.command.CreateInvoiceLineDto
{
Description = "Test",
Quantity = 1,
UnitPrice = 10.00m,
TaxRate = 21.00m
};
// ACT + ASSERT
await Assert.ThrowsAsync<ForbiddenException>(
async () => await _invoiceService.AddInvoiceLineAsync(1, lineDto)
);
}
[Fact]
public async Task UpdateInvoiceAsync_WhenInvoiceNotDraft_ThrowsForbiddenException()
{
// ARRANGE
var currentInvoice = new InvoiceDetailResponse
{
id = "1",
statut = "1" // unpaid, no draft (0)
};
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"))
.ReturnsAsync(currentInvoice);
var updateDto = new Dtos.command.UpdateInvoiceDto
{
Number = "NEW-REF"
};
// ACT + ASSERT
await Assert.ThrowsAsync<ForbiddenException>(
async () => await _invoiceService.UpdateInvoiceAsync(1, updateDto)
);
}
[Fact]
public async Task UpdateInvoiceAsync_WithValidDraft_UpdatesCorrectly()
{
// ARRANGE
var currentInvoice = new InvoiceDetailResponse
{
id = "1",
@ref = "OLD-REF",
statut = "0", // draft
note_public = null,
note_private = null
};
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"))
.ReturnsAsync(currentInvoice);
_mockApiClient
.Setup(client => client.PutAsync("invoices/1", It.IsAny<object>()))
.ReturnsAsync("OK");
var updateDto = new Dtos.command.UpdateInvoiceDto
{
Number = "NEW-REF",
NotePublic = "Public note",
Status = "unpaid" // Cambia a status "1"
};
// ACT
await _invoiceService.UpdateInvoiceAsync(1, updateDto);
// ASSERT
_mockApiClient.Verify(client => client.PutAsync("invoices/1", It.IsAny<object>()), Times.Once);
}
[Fact]
public async Task AddInvoiceLineAsync_WithValidDraft_AddsLineSuccessfully()
{
// ARRANGE
var invoiceResponse = new InvoiceDetailResponse
{
id = "1",
statut = "0" // draft
};
_mockApiClient
.Setup(client => client.GetResourceAsync<InvoiceDetailResponse>("invoices/1"))
.ReturnsAsync(invoiceResponse);
_mockApiClient
.Setup(client => client.PostAsync("invoices/1/lines", It.IsAny<object>()))
.ReturnsAsync("new-line-id");
var lineDto = new Dtos.command.CreateInvoiceLineDto
{
Description = "Test Product",
Quantity = 3,
UnitPrice = 25.00m,
TaxRate = 10.00m
};
// ACT
var result = await _invoiceService.AddInvoiceLineAsync(1, lineDto);
// ASSERT
Assert.Equal("new-line-id", result);
_mockApiClient.Verify(client => client.PostAsync("invoices/1/lines", It.IsAny<object>()), Times.Once);
}
}

View File

@ -45,9 +45,20 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}; };
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
// Swagger/OpenAPI // CORS Configuration
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowVueApp",
policy => policy
.WithOrigins("http://localhost:3001", "http://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
// Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
@ -191,7 +202,10 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseCors("AllowVueApp");
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();