From 60a9a7f7db7860662afb64d0ff83cd1f948eb13a Mon Sep 17 00:00:00 2001 From: javiermengual Date: Mon, 2 Feb 2026 11:01:40 +0100 Subject: [PATCH] feat: Patch para el status que no dejaba por Update --- .../Mappers/InvoiceMapperTests.cs | 162 ---------- .../Services/InvoiceServiceTests.cs | 277 ------------------ .../Controllers/InvoicesController.cs | 14 + .../Dtos/command/UpdateInvoiceDto.cs | 4 +- .../Dtos/command/UpdateInvoiceStatusDto.cs | 10 + DoliMiddlewareApi/Services/InvoiceService.cs | 16 +- 6 files changed, 40 insertions(+), 443 deletions(-) delete mode 100644 DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs delete mode 100644 DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs create mode 100644 DoliMiddlewareApi/Dtos/command/UpdateInvoiceStatusDto.cs diff --git a/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs b/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs deleted file mode 100644 index 6225575..0000000 --- a/DoliMiddlewareApi.Tests/Mappers/InvoiceMapperTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index bd77f33..0000000 --- a/DoliMiddlewareApi.Tests/Services/InvoiceServiceTests.cs +++ /dev/null @@ -1,277 +0,0 @@ -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/Controllers/InvoicesController.cs b/DoliMiddlewareApi/Controllers/InvoicesController.cs index 1623be6..e14e518 100644 --- a/DoliMiddlewareApi/Controllers/InvoicesController.cs +++ b/DoliMiddlewareApi/Controllers/InvoicesController.cs @@ -70,4 +70,18 @@ public class InvoicesController(InvoiceService invoiceService) : ControllerBase var result = await invoiceService.AddInvoiceLineAsync(id, lineDto); return CreatedAtAction(nameof(GetInvoice), new { id }, result); } + + [HttpPatch("{id:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task UpdateInvoiceStatus( + [Range(1, int.MaxValue)] int id, + [FromBody] UpdateInvoiceStatusDto updateStatusDto) + { + await invoiceService.ChangeInvoiceStatusAsync(id, updateStatusDto.Status); + return NoContent(); + } } diff --git a/DoliMiddlewareApi/Dtos/command/UpdateInvoiceDto.cs b/DoliMiddlewareApi/Dtos/command/UpdateInvoiceDto.cs index 6184eca..522bea1 100644 --- a/DoliMiddlewareApi/Dtos/command/UpdateInvoiceDto.cs +++ b/DoliMiddlewareApi/Dtos/command/UpdateInvoiceDto.cs @@ -14,7 +14,5 @@ public class UpdateInvoiceDto public string? NotePublic { get; set; } public string? NotePrivate { get; set; } - public string Status { get; set; } = "draft"; - // Lines quitadas - usa POST /lines para añadir líneas -} \ No newline at end of file +} diff --git a/DoliMiddlewareApi/Dtos/command/UpdateInvoiceStatusDto.cs b/DoliMiddlewareApi/Dtos/command/UpdateInvoiceStatusDto.cs new file mode 100644 index 0000000..4726a8f --- /dev/null +++ b/DoliMiddlewareApi/Dtos/command/UpdateInvoiceStatusDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace DoliMiddlewareApi.Dtos.command; + +public class UpdateInvoiceStatusDto +{ + [Required] + [RegularExpression("^(draft|unpaid|paid)$")] + public string Status { get; set; } = "draft"; +} diff --git a/DoliMiddlewareApi/Services/InvoiceService.cs b/DoliMiddlewareApi/Services/InvoiceService.cs index ca36309..4a1fc46 100644 --- a/DoliMiddlewareApi/Services/InvoiceService.cs +++ b/DoliMiddlewareApi/Services/InvoiceService.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Globalization; using DoliMiddlewareApi.Dtos; using DoliMiddlewareApi.Dtos.command; @@ -108,7 +109,6 @@ public class InvoiceService(IDolibarrApiClient apiClient) if (dto.Number != null) current.@ref = dto.Number; if (dto.NotePublic != null) current.note_public = dto.NotePublic; if (dto.NotePrivate != null) current.note_private = dto.NotePrivate; - current.statut = InvoiceMapper.ConvertStatusToDolibarr(dto.Status); if (dto.ExpireDate.HasValue) current.date_lim_reglement = ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds(); // No tocar: date, socid, lines (Dolibarr no los cambia en PUT) @@ -116,6 +116,20 @@ public class InvoiceService(IDolibarrApiClient apiClient) await apiClient.PutAsync($"invoices/{id}", current); } + public async Task ChangeInvoiceStatusAsync(int id, string status) + { + var normalized = status.Trim().ToLowerInvariant(); + var endpoint = normalized switch + { + "draft" => $"invoices/{id}/settodraft", + "unpaid" => $"invoices/{id}/settounpaid", + "paid" => $"invoices/{id}/settopaid", + _ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.") + }; + + await apiClient.PostAsync(endpoint, new { }); + } + private async Task> GetClientNamesAsync(List clientIds) { var uniqueIds = clientIds.Distinct().ToList();