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