From 72b15eeff6ebf906679eb913e43a77f86245e376 Mon Sep 17 00:00:00 2001 From: javiermengual Date: Fri, 16 Jan 2026 15:43:28 +0100 Subject: [PATCH] feat: add CORS configuration and implement unit tests for InvoiceMapper and InvoiceService --- .../DoliMiddlewareApi.Tests.csproj | 26 ++ .../Mappers/InvoiceMapperTests.cs | 162 ++++++++++ .../Services/InvoiceServiceTests.cs | 277 ++++++++++++++++++ DoliMiddlewareApi/Program.cs | 20 +- 4 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 DoliMiddlewareApi.Tests/DoliMiddlewareApi.Tests.csproj create mode 100644 DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs create mode 100644 DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs diff --git a/DoliMiddlewareApi.Tests/DoliMiddlewareApi.Tests.csproj b/DoliMiddlewareApi.Tests/DoliMiddlewareApi.Tests.csproj new file mode 100644 index 0000000..c2e31d9 --- /dev/null +++ b/DoliMiddlewareApi.Tests/DoliMiddlewareApi.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs b/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs new file mode 100644 index 0000000..6225575 --- /dev/null +++ b/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs @@ -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 + { + 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); + } +} diff --git a/DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs b/DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs new file mode 100644 index 0000000..bd77f33 --- /dev/null +++ b/DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs @@ -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 _mockApiClient; + private readonly InvoiceService _invoiceService; + + public InvoiceServiceTests() + { + // ARRANGE: Crear mock del cliente API + _mockApiClient = new Mock(); + _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("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("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 + { + new InvoiceResponse { id = "1", @ref = "FAC-001", statut = "0" }, + new InvoiceResponse { id = "2", @ref = "FAC-002", statut = "1" } + }; + + _mockApiClient + .Setup(client => client.GetCollectionAsync("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 + { + new InvoiceResponse { id = "3", @ref = "FAC-003", statut = "2" } + }; + + _mockApiClient + .Setup(client => client.GetCollectionAsync("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 + { + new Dtos.command.CreateInvoiceLineDto + { + Description = "Test Product", + Quantity = 2, + UnitPrice = 50.00m, + TaxRate = 21.00m + } + } + }; + + _mockApiClient + .Setup(client => client.PostAsync("invoices", It.IsAny())) + .ReturnsAsync("42"); + + // ACT + var result = await _invoiceService.CreateInvoiceAsync(dto); + + // ASSERT + Assert.Equal(42, result); + _mockApiClient.Verify(client => client.PostAsync("invoices", It.IsAny()), 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("invoices/999")) + .ThrowsAsync(new NotFoundException("Invoice not found")); + + // ACT + ASSERT: Verificamos que se propaga la excepción + await Assert.ThrowsAsync( + 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("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( + 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("invoices/1")) + .ReturnsAsync(currentInvoice); + + var updateDto = new Dtos.command.UpdateInvoiceDto + { + Number = "NEW-REF" + }; + + // ACT + ASSERT + await Assert.ThrowsAsync( + 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("invoices/1")) + .ReturnsAsync(currentInvoice); + + _mockApiClient + .Setup(client => client.PutAsync("invoices/1", It.IsAny())) + .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()), Times.Once); + } + + [Fact] + public async Task AddInvoiceLineAsync_WithValidDraft_AddsLineSuccessfully() + { + // ARRANGE + var invoiceResponse = new InvoiceDetailResponse + { + id = "1", + statut = "0" // draft + }; + + _mockApiClient + .Setup(client => client.GetResourceAsync("invoices/1")) + .ReturnsAsync(invoiceResponse); + + _mockApiClient + .Setup(client => client.PostAsync("invoices/1/lines", It.IsAny())) + .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()), Times.Once); + } +} diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index 36f8693..570df52 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -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.AddSwaggerGen(); @@ -191,7 +202,10 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); -app.UseAuthentication(); + +app.UseCors("AllowVueApp"); + +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();