using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests.Products;
///
/// PRD-003 — Integration tests for:
/// GET /api/v1/products/{id}/prices (requires authentication)
/// POST /api/v1/admin/products/{id}/prices (requires catalogo:productos:gestionar)
/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
///
/// Each test creates its own product (via SQL) to avoid inter-test dependencies.
///
[Collection("ApiIntegration")]
public sealed class ProductPricesControllerTests : IAsyncLifetime
{
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
// Seeded once per class — valid MedioId and ProductTypeId from canonical data
private int _medioId;
private int _productTypeId;
public ProductPricesControllerTests(TestWebAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
public async Task InitializeAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Resolve a valid MedioId (dbo.Medio uses 'Activo' column)
_medioId = await conn.QuerySingleAsync(
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
// Resolve or seed a valid ProductTypeId (dbo.ProductType uses 'IsActive' column)
var ptId = await conn.QuerySingleOrDefaultAsync(
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
if (ptId is null)
{
ptId = await conn.QuerySingleAsync("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion)
VALUES ('PT_Prices_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""");
}
_productTypeId = ptId.Value;
}
public Task DisposeAsync() => Task.CompletedTask;
// ── Auth helpers ──────────────────────────────────────────────────────────
/// Generates a bearer token for admin (has catalogo:productos:gestionar via 'admin' role).
private string GetAdminToken()
{
var jwt = _factory.Services.GetRequiredService();
return jwt.GenerateAccessToken(new Usuario(
id: 1, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
}
/// Generates a bearer token for cajero (does NOT have catalogo:productos:gestionar).
private string GetCajeroToken()
{
var jwt = _factory.Services.GetRequiredService();
return jwt.GenerateAccessToken(new Usuario(
id: 9999, username: "cajero_test", passwordHash: "x",
nombre: "Cajero", apellido: "Test", email: null,
rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
}
private HttpRequestMessage BuildRequest(
HttpMethod method, string url, object? body = null, string? token = null)
{
var req = new HttpRequestMessage(method, url);
if (token is not null)
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null)
req.Content = JsonContent.Create(body);
return req;
}
// ── DB seed helpers ───────────────────────────────────────────────────────
/// Seeds a unique product and returns its Id.
private async Task SeedProductAsync(bool activo = true)
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var nombre = $"PP_{Guid.NewGuid():N}"[..35];
// dbo.Product uses 'IsActive' column (PRD-002 convention)
return await conn.QuerySingleAsync("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion)
VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, @IsActive, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""",
new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId, IsActive = activo ? 1 : 0 });
}
///
/// Inserts a ProductPrice row directly (bypasses SP forward-only guard) to set up test scenarios.
///
private async Task SeedPriceDirectAsync(
int productId, decimal price, DateOnly pvf, DateOnly? pvt)
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
return await conn.QuerySingleAsync("""
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion)
VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""",
new
{
ProductId = productId,
Price = price,
PriceValidFrom = pvf.ToDateTime(TimeOnly.MinValue),
PriceValidTo = pvt.HasValue ? (DateTime?)pvt.Value.ToDateTime(TimeOnly.MinValue) : null
});
}
// ── GET /api/v1/products/{id}/prices ─────────────────────────────────────
[Fact]
public async Task GetPrices_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/1/prices");
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task GetPrices_EmptyHistory_Returns200WithEmptyArray()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var json = await resp.Content.ReadFromJsonAsync();
json.ValueKind.Should().Be(JsonValueKind.Array);
json.GetArrayLength().Should().Be(0);
}
[Fact]
public async Task GetPrices_WithHistory_Returns200OrderedDescending()
{
var productId = await SeedProductAsync();
// Seed 3 prices: 2 closed + 1 active (in ascending order to verify API returns DESC)
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28));
await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 3, 1), null);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var items = await resp.Content.ReadFromJsonAsync();
items.GetArrayLength().Should().Be(3);
// First item = most recent (active, March)
var first = items[0];
first.GetProperty("priceValidFrom").GetString().Should().Be("2026-03-01");
first.GetProperty("isActive").GetBoolean().Should().BeTrue();
first.GetProperty("priceValidTo").ValueKind.Should().Be(JsonValueKind.Null);
// Last item = oldest (January)
var last = items[2];
last.GetProperty("priceValidFrom").GetString().Should().Be("2026-01-01");
last.GetProperty("isActive").GetBoolean().Should().BeFalse();
last.GetProperty("priceValidTo").GetString().Should().Be("2026-01-31");
}
[Fact]
public async Task GetPrices_ProductNotFound_Returns404()
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/999999999/prices", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
var body = await resp.Content.ReadFromJsonAsync();
body.GetProperty("error").GetString().Should().Be("product_not_found");
}
// ── POST /api/v1/admin/products/{id}/prices ───────────────────────────────
[Fact]
public async Task PostPrice_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/1/prices",
body: new { price = 200m, priceValidFrom = "2026-05-01" });
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PostPrice_WithoutPermission_Returns403()
{
var productId = await SeedProductAsync();
var token = GetCajeroToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 200m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task PostPrice_FirstPrice_Returns201WithClosedNull()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
// Use tomorrow to ensure >= hoy_AR passes FluentValidation
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 250m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync();
body.TryGetProperty("created", out var created).Should().BeTrue();
body.TryGetProperty("closed", out var closed).Should().BeTrue();
created.GetProperty("price").GetDecimal().Should().Be(250m);
created.GetProperty("priceValidFrom").GetString().Should().Be(pvf);
created.GetProperty("isActive").GetBoolean().Should().BeTrue();
closed.ValueKind.Should().Be(JsonValueKind.Null);
// Location header must be present
resp.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task PostPrice_SecondPrice_Returns201WithClosedNotNull()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
// Seed an existing active price at a past date (direct insert bypasses SP)
await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 4, 1), null);
// New price must be strictly greater than active PVF
var newPvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(2).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 300m, priceValidFrom = newPvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync();
var closed = body.GetProperty("closed");
closed.ValueKind.Should().NotBe(JsonValueKind.Null);
closed.GetProperty("price").GetDecimal().Should().Be(100m);
// PriceValidTo of closed = newPvf - 1 day
var expectedPvt = DateOnly.ParseExact(newPvf, "yyyy-MM-dd").AddDays(-1).ToString("yyyy-MM-dd");
closed.GetProperty("priceValidTo").GetString().Should().Be(expectedPvt);
var created = body.GetProperty("created");
created.GetProperty("price").GetDecimal().Should().Be(300m);
created.GetProperty("priceValidFrom").GetString().Should().Be(newPvf);
created.GetProperty("isActive").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task PostPrice_PriceZero_Returns400()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 0m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PostPrice_NegativePrice_Returns400()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = -5m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PostPrice_PastDate_Returns400()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
// Clearly in the past
const string pastDate = "2020-01-01";
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 100m, priceValidFrom = pastDate },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PostPrice_ProductNotFound_Returns404()
{
var token = GetAdminToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/999999999/prices",
body: new { price = 100m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
var body = await resp.Content.ReadFromJsonAsync();
body.GetProperty("error").GetString().Should().Be("product_not_found");
}
[Fact]
public async Task PostPrice_ForwardOnlyViolation_Returns409()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
// Seed active price at a far-future date
await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null);
// Try to add price with a date BEFORE the active one
const string retroPvf = "2099-11-01";
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 150m, priceValidFrom = retroPvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync();
body.GetProperty("error").GetString().Should().Be("product_price_forward_only");
}
[Fact]
public async Task PostPrice_SamePvfAsActive_Returns409()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
// Seed active price at a far-future date
await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null);
// Try to add price with the SAME date — also rejected (forward-only: must be strictly greater)
const string samePvf = "2099-12-01";
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 250m, priceValidFrom = samePvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync();
body.GetProperty("error").GetString().Should().Be("product_price_forward_only");
}
// ── Audit Event ───────────────────────────────────────────────────────────
[Fact]
public async Task PostPrice_Success_CreatesAuditEvent()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 500m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync();
var newId = body.GetProperty("created").GetProperty("id").GetInt64();
// Verify audit event row in SIGCM2_Test_Api
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var auditCount = await conn.QuerySingleAsync("""
SELECT COUNT(1) FROM dbo.AuditEvent
WHERE Action = 'product_price.created'
AND TargetType = 'ProductPrice'
AND TargetId = @TargetId
""", new { TargetId = newId.ToString() });
auditCount.Should().Be(1, because: "IAuditLogger must record product_price.created after a successful POST");
}
// ── DateOnly JSON format ──────────────────────────────────────────────────
[Fact]
public async Task PostPrice_DateOnly_SerializesAsYyyyMmDd()
{
var productId = await SeedProductAsync();
var token = GetAdminToken();
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
body: new { price = 999m, priceValidFrom = pvf },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync();
var returnedPvf = body.GetProperty("created").GetProperty("priceValidFrom").GetString();
// DateOnlyJsonConverter must produce "yyyy-MM-dd" — no time, no TZ suffix
returnedPvf.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}$",
because: "DateOnlyJsonConverter (UDT-011) must serialize DateOnly as yyyy-MM-dd");
returnedPvf.Should().Be(pvf);
}
}