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);
+ }
+}