From 165abc82451088716170eedd909ab49e683b43d3 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:17:31 -0300 Subject: [PATCH] feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002) --- .../Controllers/ProductsController.cs | 169 +++++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 62 ++++ .../Auth/AuthControllerTests.cs | 5 +- .../Permisos/PermisosEndpointTests.cs | 14 +- .../Products/ProductsControllerTests.cs | 339 ++++++++++++++++++ 5 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 src/api/SIGCM2.Api/Controllers/ProductsController.cs create mode 100644 tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ProductsController.cs b/src/api/SIGCM2.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..d4ae3d2 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductsController.cs @@ -0,0 +1,169 @@ +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Products.Create; +using SIGCM2.Application.Products.Deactivate; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Application.Products.List; +using SIGCM2.Application.Products.Update; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRD-002: Product catalog management. +/// Read endpoints at /api/v1/products — require authentication (any role). +/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'. +/// +[ApiController] +public sealed class ProductsController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public ProductsController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + // ── READ endpoints ───────────────────────────────────────────────────────── + + /// Returns a paginated list of Products. Requires authentication. + [HttpGet("api/v1/products")] + [Authorize] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ListProducts( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = true, + [FromQuery] string? search = null, + [FromQuery] int? medioId = null, + [FromQuery] int? productTypeId = null, + [FromQuery] int? rubroId = null) + { + var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Returns a single Product by id. Requires authentication. + [HttpGet("api/v1/products/{id:int}")] + [Authorize] + [ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductById([FromRoute] int id) + { + var query = new GetProductByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + // ── WRITE endpoints ──────────────────────────────────────────────────────── + + /// Creates a new Product. Requires catalogo:productos:gestionar. + [HttpPost("api/v1/admin/products")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task CreateProduct([FromBody] CreateProductRequest request) + { + var command = new CreateProductCommand( + Nombre: request.Nombre ?? string.Empty, + MedioId: request.MedioId, + ProductTypeId: request.ProductTypeId, + RubroId: request.RubroId, + BasePrice: request.BasePrice, + PriceDurationDays: request.PriceDurationDays); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result); + } + + /// Updates a Product. Requires catalogo:productos:gestionar. + [HttpPut("api/v1/admin/products/{id:int}")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request) + { + var command = new UpdateProductCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + RubroId: request.RubroId, + BasePrice: request.BasePrice, + PriceDurationDays: request.PriceDurationDays); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar. + [HttpDelete("api/v1/admin/products/{id:int}")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateProduct([FromRoute] int id) + { + var command = new DeactivateProductCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRD-002: Create Product request body. +public sealed record CreateProductRequest( + string? Nombre, + int MedioId = 0, + int ProductTypeId = 0, + int? RubroId = null, + decimal BasePrice = 0m, + int? PriceDurationDays = null); + +/// PRD-002: Update Product request body. +public sealed record UpdateProductRequest( + string? Nombre, + int? RubroId = null, + decimal BasePrice = 0m, + int? PriceDurationDays = null); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index b40f2d3..e5c8c73 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRD-002: Product exceptions + case ProductNotFoundException productNotFoundEx: + context.Result = new ObjectResult(new + { + error = "product_not_found", + message = productNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case ProductNombreDuplicadoEnMedioTipoException productDupEx: + context.Result = new ObjectResult(new + { + error = "product_nombre_duplicado", + message = productDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case ProductTipoFlagsIncoherentesException productFlagsEx: + context.Result = new ObjectResult(new + { + error = "product_flags_incoherentes", + field = productFlagsEx.Field, + message = productFlagsEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + + case ProductTypeInactivoException productTypeInactivoEx: + context.Result = new ObjectResult(new + { + error = "product_type_inactivo", + message = productTypeInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + + case RubroInactivoException rubroInactivoEx: + context.Result = new ObjectResult(new + { + error = "rubro_inactivo", + message = rubroInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + // ADM-008: PuntoDeVenta exceptions case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: context.Result = new ObjectResult(new diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 062b771..7eeb4b8 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -51,8 +51,9 @@ public class AuthControllerTests // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, permisos.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index 372509b..a363606 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos — catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With26Items() + public async Task GetPermisos_WithAdmin_Returns200With27Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, list.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, list.GetArrayLength()); } [Fact] @@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With26Items() + public async Task GetRolPermisos_AdminRol_Returns200With27Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, list.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs new file mode 100644 index 0000000..bfb795b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs @@ -0,0 +1,339 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Products; + +/// +/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products. +/// Read endpoints require authentication (any role). +/// Write endpoints require permission 'catalogo:productos:gestionar'. +/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings. +/// +[Collection("ApiIntegration")] +public sealed class ProductsControllerTests : IAsyncLifetime +{ + private const string ReadEndpoint = "/api/v1/products"; + private const string AdminEndpoint = "/api/v1/admin/products"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public ProductsControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token) + { + // Create a Medio via SQL (we don't have a Medio controller endpoint available here) + // Use product-types endpoint to create a ProductType and insert Medio directly + var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new + { + codigo = $"PM{Guid.NewGuid():N}"[..6], + nombre = $"Medio Test {Guid.NewGuid():N}"[..30], + tipo = 1 + }, token)); + medioResp.EnsureSuccessStatusCode(); + var medioJson = await medioResp.Content.ReadFromJsonAsync(); + var medioId = medioJson.GetProperty("id").GetInt32(); + + var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new + { + nombre = $"PT_{Guid.NewGuid():N}"[..30], + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token)); + ptResp.EnsureSuccessStatusCode(); + var ptJson = await ptResp.Content.ReadFromJsonAsync(); + var productTypeId = ptJson.GetProperty("id").GetInt32(); + + return (medioId, productTypeId); + } + + // ── 401 guards on READ endpoints ─────────────────────────────────────────── + + [Fact] + public async Task List_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Get, ReadEndpoint); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task GetById_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999"); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + // ── 401 guards on WRITE endpoints ────────────────────────────────────────── + + [Fact] + public async Task Create_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Update_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Deactivate_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1"); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + // ── POST /api/v1/admin/products ─────────────────────────────────────────── + + [Fact] + public async Task Create_WithAdmin_Returns201WithId() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30]; + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + medioId, + productTypeId, + basePrice = 100.50m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("id").GetInt32() > 0); + Assert.Equal(uniqueName, json.GetProperty("nombre").GetString()); + Assert.True(json.GetProperty("isActive").GetBoolean()); + } + + [Fact] + public async Task Create_InvalidBody_Returns400() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = string.Empty, // invalid + medioId = 1, + productTypeId = 1, + basePrice = 10m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Create_DuplicateNombre_Returns409() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30]; + + var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m }; + + using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token); + var resp2 = await _client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode); + } + + [Fact] + public async Task Create_MedioNotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = $"Prod_{Guid.NewGuid():N}"[..30], + medioId = 999999, + productTypeId = 1, + basePrice = 50m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── GET /api/v1/products ────────────────────────────────────────────────── + + [Fact] + public async Task List_WithAuth_Returns200WithPaginatedResult() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _)); + Assert.True(json.TryGetProperty("total", out _)); + } + + // ── GET /api/v1/products/{id} ───────────────────────────────────────────── + + [Fact] + public async Task GetById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + [Fact] + public async Task GetById_ExistingId_Returns200() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 75m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token); + var getResp = await _client.SendAsync(getReq); + + Assert.Equal(HttpStatusCode.OK, getResp.StatusCode); + var getJson = await getResp.Content.ReadFromJsonAsync(); + Assert.Equal(productId, getJson.GetProperty("id").GetInt32()); + Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString()); + } + + // ── DELETE /api/v1/admin/products/{id} ──────────────────────────────────── + + [Fact] + public async Task Deactivate_ExistingId_Returns204() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Del_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 50m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token); + var delResp = await _client.SendAsync(delReq); + + Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode); + } + + [Fact] + public async Task Deactivate_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── PUT /api/v1/admin/products/{id} ─────────────────────────────────────── + + [Fact] + public async Task Update_ExistingProduct_Returns200() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 50m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + var newName = $"Upd2_{Guid.NewGuid():N}"[..28]; + using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new + { + nombre = newName, + basePrice = 200m + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); + var updateJson = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal(newName, updateJson.GetProperty("nombre").GetString()); + } + + [Fact] + public async Task Update_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new + { + nombre = "Test", basePrice = 10m + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } +}