diff --git a/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs b/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs new file mode 100644 index 0000000..bfe3ea8 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs @@ -0,0 +1,184 @@ +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.ProductTypes.Create; +using SIGCM2.Application.ProductTypes.Deactivate; +using SIGCM2.Application.ProductTypes.GetById; +using SIGCM2.Application.ProductTypes.List; +using SIGCM2.Application.ProductTypes.Update; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRD-001: ProductType catalog management. +/// Read endpoints at /api/v1/product-types — require authentication (any role). +/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'. +/// +[ApiController] +public sealed class ProductTypesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public ProductTypesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + // ── READ endpoints ───────────────────────────────────────────────────────── + + /// Returns a paginated list of ProductTypes. Requires authentication. + [HttpGet("api/v1/product-types")] + [Authorize] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ListProductTypes( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = true, + [FromQuery] string? search = null) + { + var query = new ListProductTypesQuery(page, pageSize, activo, search); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Returns a single ProductType by id. Requires authentication. + [HttpGet("api/v1/product-types/{id:int}")] + [Authorize] + [ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductTypeById([FromRoute] int id) + { + var query = new GetProductTypeByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + // ── WRITE endpoints ──────────────────────────────────────────────────────── + + /// Creates a new ProductType. Requires catalogo:tipos:gestionar. + [HttpPost("api/v1/admin/product-types")] + [RequirePermission("catalogo:tipos:gestionar")] + [ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateProductType([FromBody] CreateProductTypeRequest request) + { + var command = new CreateProductTypeCommand( + Nombre: request.Nombre ?? string.Empty, + HasDuration: request.HasDuration, + RequiresText: request.RequiresText, + RequiresCategory: request.RequiresCategory, + IsBundle: request.IsBundle, + AllowImages: request.AllowImages, + MaxImages: request.MaxImages, + MaxImageSizeMB: request.MaxImageSizeMB, + MaxImageWidth: request.MaxImageWidth, + MaxImageHeight: request.MaxImageHeight); + + 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(GetProductTypeById), new { id = result.Id }, result); + } + + /// Updates a ProductType. Requires catalogo:tipos:gestionar. + [HttpPut("api/v1/admin/product-types/{id:int}")] + [RequirePermission("catalogo:tipos:gestionar")] + [ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request) + { + var command = new UpdateProductTypeCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + HasDuration: request.HasDuration, + RequiresText: request.RequiresText, + RequiresCategory: request.RequiresCategory, + IsBundle: request.IsBundle, + AllowImages: request.AllowImages, + MaxImages: request.MaxImages, + MaxImageSizeMB: request.MaxImageSizeMB, + MaxImageWidth: request.MaxImageWidth, + MaxImageHeight: request.MaxImageHeight); + + 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 ProductType. Requires catalogo:tipos:gestionar. + [HttpDelete("api/v1/admin/product-types/{id:int}")] + [RequirePermission("catalogo:tipos:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeactivateProductType([FromRoute] int id) + { + var command = new DeactivateProductTypeCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRD-001: Create ProductType request body. +public sealed record CreateProductTypeRequest( + string? Nombre, + bool HasDuration = false, + bool RequiresText = false, + bool RequiresCategory = false, + bool IsBundle = false, + bool AllowImages = false, + int? MaxImages = null, + decimal? MaxImageSizeMB = null, + int? MaxImageWidth = null, + int? MaxImageHeight = null); + +/// PRD-001: Update ProductType request body. +public sealed record UpdateProductTypeRequest( + string? Nombre, + bool HasDuration = false, + bool RequiresText = false, + bool RequiresCategory = false, + bool IsBundle = false, + bool AllowImages = false, + int? MaxImages = null, + decimal? MaxImageSizeMB = null, + int? MaxImageWidth = null, + int? MaxImageHeight = null); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index e936231..b40f2d3 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -414,6 +414,55 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRD-001: ProductType exceptions + case ProductTypeNotFoundException productTypeNotFoundEx: + context.Result = new ObjectResult(new + { + error = "product_type_not_found", + message = productTypeNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case ProductTypeNombreDuplicadoException productTypeDupEx: + context.Result = new ObjectResult(new + { + error = "product_type_nombre_duplicado", + message = productTypeDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case ProductTypeEnUsoException productTypeEnUsoEx: + context.Result = new ObjectResult(new + { + error = "product_type_en_uso", + message = productTypeEnUsoEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case ProductTypeFlagsIncoherentesException productTypeFlagsEx: + context.Result = new ObjectResult(new + { + error = "product_type_flags_incoherentes", + message = productTypeFlagsEx.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 5649e52..062b771 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -50,8 +50,9 @@ public class AuthControllerTests // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // 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 total - Assert.Equal(25, permisos.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, 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 4fa9e24..372509b 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_Returns200With25Items() + public async Task GetPermisos_WithAdmin_Returns200With26Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -140,8 +140,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // 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 total - Assert.Equal(25, list.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, list.GetArrayLength()); } [Fact] @@ -184,7 +185,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With25Items() + public async Task GetRolPermisos_AdminRol_Returns200With26Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -195,8 +196,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // 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 total - Assert.Equal(25, list.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs new file mode 100644 index 0000000..f8104cf --- /dev/null +++ b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.ProductTypes; + +/// +/// PRD-001 — Integration tests for /api/v1/product-types and /api/v1/admin/product-types. +/// Read endpoints require authentication (any role). +/// Write endpoints require permission 'catalogo:tipos:gestionar'. +/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings. +/// +[Collection("ApiIntegration")] +public sealed class ProductTypesControllerTests : IAsyncLifetime +{ + private const string ReadEndpoint = "/api/v1/product-types"; + private const string AdminEndpoint = "/api/v1/admin/product-types"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public ProductTypesControllerTests(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; + } + + // ── 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 / 403 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); + } + + // ── POST /api/v1/admin/product-types ────────────────────────────────────── + + [Fact] + public async Task Create_WithAdmin_Returns201WithId() + { + var token = await GetAdminTokenAsync(); + var uniqueName = $"Tipo_Create_{Guid.NewGuid():N}"; + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = true, + requiresText = false, + requiresCategory = false, + isBundle = false, + allowImages = true, + maxImages = 3, + maxImageSizeMB = 1.5, + maxImageWidth = (int?)null, + maxImageHeight = (int?)null + }, 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_DuplicateNombre_Returns409() + { + var token = await GetAdminTokenAsync(); + var uniqueName = $"DupNombre_{Guid.NewGuid():N}"; + + using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + var resp2 = await _client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode); + } + + [Fact] + public async Task Create_InvalidBody_Returns400() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = string.Empty, // invalid + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + // ── GET /api/v1/product-types ───────────────────────────────────────────── + + [Fact] + public async Task List_WithAdmin_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/product-types/{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 uniqueName = $"Tipo_GetById_{Guid.NewGuid():N}"; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var id = created.GetProperty("id").GetInt32(); + + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{id}", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(id, json.GetProperty("id").GetInt32()); + Assert.Equal(uniqueName, json.GetProperty("nombre").GetString()); + } + + // ── PUT /api/v1/admin/product-types/{id} ────────────────────────────────── + + [Fact] + public async Task Update_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new + { + nombre = "No Existe", + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── DELETE /api/v1/admin/product-types/{id} ─────────────────────────────── + + [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); + } + + [Fact] + public async Task Deactivate_ExistingActive_Returns204() + { + var token = await GetAdminTokenAsync(); + var uniqueName = $"Tipo_Deactivate_{Guid.NewGuid():N}"; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var id = created.GetProperty("id").GetInt32(); + + using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{id}", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); + } +}