feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002)
This commit is contained in:
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public sealed class ProductsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateProductCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateProductCommand> _updateValidator;
|
||||||
|
|
||||||
|
public ProductsController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateProductCommand> createValidator,
|
||||||
|
IValidator<UpdateProductCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/products")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> 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<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a single Product by id. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/products/{id:int}")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetProductById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetProductByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
|
||||||
|
[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<IActionResult> 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<CreateProductCommand, ProductCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
|
||||||
|
[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<IActionResult> 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<UpdateProductCommand, ProductUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
|
||||||
|
[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<IActionResult> DeactivateProduct([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateProductCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>PRD-002: Create Product request body.</summary>
|
||||||
|
public sealed record CreateProductRequest(
|
||||||
|
string? Nombre,
|
||||||
|
int MedioId = 0,
|
||||||
|
int ProductTypeId = 0,
|
||||||
|
int? RubroId = null,
|
||||||
|
decimal BasePrice = 0m,
|
||||||
|
int? PriceDurationDays = null);
|
||||||
|
|
||||||
|
/// <summary>PRD-002: Update Product request body.</summary>
|
||||||
|
public sealed record UpdateProductRequest(
|
||||||
|
string? Nombre,
|
||||||
|
int? RubroId = null,
|
||||||
|
decimal BasePrice = 0m,
|
||||||
|
int? PriceDurationDays = null);
|
||||||
@@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ public class AuthControllerTests
|
|||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, permisos.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, permisos.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: invalid credentials return 401 with opaque error
|
// Scenario: invalid credentials return 401 with opaque error
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPermisos_WithAdmin_Returns200With26Items()
|
public async Task GetPermisos_WithAdmin_Returns200With27Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
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
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, list.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
|
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
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
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, list.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
339
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
339
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<string> GetAdminTokenAsync()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||||
|
{
|
||||||
|
username = AdminUsername,
|
||||||
|
password = AdminPassword
|
||||||
|
});
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user