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); } // ── PRD-002 W1: ExceptionFilter 409 for ProductTypeInactivo ────────────── [Fact] public async Task Create_WithInactiveProductType_Returns409() { var token = await GetAdminTokenAsync(); var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); // Deactivate the ProductType first using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token); var deactivatePtResp = await _client.SendAsync(deactivatePtReq); Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode); // Now attempt to create a product with the inactive ProductType using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = $"Prod_{Guid.NewGuid():N}"[..28], medioId, productTypeId, basePrice = 50m }, token); var createResp = await _client.SendAsync(createReq); Assert.Equal(HttpStatusCode.Conflict, createResp.StatusCode); var body = await createResp.Content.ReadFromJsonAsync(); Assert.Equal("product_type_inactivo", body.GetProperty("error").GetString()); } }