diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs new file mode 100644 index 0000000..ccc63e7 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs @@ -0,0 +1,157 @@ +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 Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests.Products; + +/// +/// PRD-003 — Batch 7 / T7.4: Audit failure e2e test. +/// +/// Verifies that when IAuditLogger.LogAsync throws, the TransactionScope rolls back: +/// - dbo.ProductPrices row is NOT inserted (fail-closed). +/// - dbo.AuditEvent row is NOT created. +/// - HTTP response is 500 (unhandled exception propagates through ExceptionFilter). +/// +/// Uses CreateClientWithOverrides (pattern from issue #36) to inject a throwing IAuditLogger +/// mock without touching the shared factory. +/// +/// DB: SIGCM2_Test_Api (ApiIntegration collection). +/// +[Collection("ApiIntegration")] +public sealed class ProductPricesAuditFailureTests : IAsyncLifetime +{ + private const string ConnectionString = TestConnectionStrings.ApiTestDb; + + private readonly TestWebAppFactory _factory; + private int _medioId; + private int _productTypeId; + + public ProductPricesAuditFailureTests(TestWebAppFactory factory) + { + _factory = factory; + } + + public async Task InitializeAsync() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + _medioId = await conn.QuerySingleAsync( + "SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id"); + + 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_AuditFail_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS INT); + """); + } + _productTypeId = ptId.Value; + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ── T7.4 — Audit failure rolls back the ProductPrice insert ────────────── + + [Fact] + public async Task PostPrice_WhenAuditLoggerThrows_Returns500AndRollsBackInsert() + { + // Arrange: seed a unique product + var productId = await SeedProductAsync(); + + // Count rows before the failing POST + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + var priceCountBefore = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId", + new { ProductId = productId }); + + var auditCountBefore = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'"); + + // Build a mock IAuditLogger that throws on LogAsync + var throwingAuditLogger = Substitute.For(); + throwingAuditLogger + .LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("audit down — simulated failure")); + + // Use CreateClientWithOverrides to inject the mock without touching the shared factory + using var client = _factory.CreateClientWithOverrides(services => + { + services.RemoveAll(); + services.AddScoped(_ => throwingAuditLogger); + }); + + // Generate admin token (use factory services, not child) + var jwt = _factory.Services.GetRequiredService(); + var token = jwt.GenerateAccessToken(new Usuario( + id: 1, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Content = JsonContent.Create(new { price = 999m, priceValidFrom = pvf }); + + // Act + var resp = await client.SendAsync(req); + + // Assert HTTP: audit failure → unhandled exception → 500 + resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError, + because: "IAuditLogger throwing must propagate as 500 (fail-closed)"); + + // Assert DB: NO new ProductPrices row was persisted (TransactionScope rolled back) + await using var verifyConn = new SqlConnection(ConnectionString); + await verifyConn.OpenAsync(); + + var priceCountAfter = await verifyConn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId", + new { ProductId = productId }); + + priceCountAfter.Should().Be(priceCountBefore, + because: "TransactionScope must roll back the ProductPrices INSERT when audit fails"); + + // Assert DB: NO new AuditEvent row for this product + var auditCountAfter = await verifyConn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'"); + + auditCountAfter.Should().Be(auditCountBefore, + because: "no AuditEvent row must be created when the audit logger throws"); + } + + // ── Helper: seed an active product ─────────────────────────────────────── + + private async Task SeedProductAsync() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35]; + return await conn.QuerySingleAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion) + VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, 1, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS INT); + """, + new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId }); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs index b18068c..6459f9e 100644 --- a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs @@ -466,6 +466,283 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime "retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException"); } + // ───────────────────────────────────────────────────────────────────────── + // Batch 7 — T7.1: Concurrency con 3 tasks + SemaphoreSlim barrier + // §REQ-1.2 — Exactamente 1 ganador, 2 perdedores lanzan excepción manejable + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Concurrency_ThreeConcurrentInserts_ExactlyOneSucceeds() + { + // Barrera: todos esperan en el semáforo; cuando se liberan juntos, la race es auténtica. + var barrier = new SemaphoreSlim(0, 3); + var pvf = new DateOnly(2027, 6, 1); + + async Task TryInsert(decimal price) + { + // Cada task espera en la barrera antes de ejecutar + await barrier.WaitAsync(); + try + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + await ExecAddPriceSpAsync(conn, _defaultProductId, price, pvf); + return null; // éxito + } + catch (SqlException ex) + { + return ex; // perdedor: 50409, 2601, 2627 o deadlock 1205 + } + } + + var t1 = Task.Run(() => TryInsert(111.00m)); + var t2 = Task.Run(() => TryInsert(222.00m)); + var t3 = Task.Run(() => TryInsert(333.00m)); + + // Liberar las 3 tasks simultáneamente + barrier.Release(3); + + var results = await Task.WhenAll(t1, t2, t3); + + // Exactamente 1 éxito (null), exactamente 2 fallos + var successes = results.Count(r => r is null); + var failures = results.Count(r => r is not null); + + successes.Should().Be(1, "exactly one concurrent insert must succeed"); + failures.Should().Be(2, "the other two must fail with a SqlException"); + + // Verificar que el estado final es exactamente 1 activo (PriceValidTo IS NULL) + await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await verifyConn.OpenAsync(); + var activeCount = await verifyConn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL", + new { ProductId = _defaultProductId }); + + activeCount.Should().Be(1, "only one active price (PriceValidTo IS NULL) must survive the race"); + + // Sin duplicados: COUNT(*) para este producto debe ser 1 (solo la ganadora) + var totalCount = await verifyConn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId", + new { ProductId = _defaultProductId }); + + totalCount.Should().Be(1, "no duplicate rows must exist for the same ProductId"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Batch 7 — T7.2: SYSTEM_VERSIONING — no history before close, 1 row after + // Verifica que el SP produce exactamente 1 row en dbo.ProductPrices_History + // al cerrar el activo, y que antes del cierre la tabla está vacía para ese Id. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SystemVersioning_BeforeClose_HistoryTableIsEmpty() + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1)); + + // Antes del UPDATE (cierre), history debe estar vacía para este Id + var histCountBefore = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id", + new { Id = firstId }); + + histCountBefore.Should().Be(0, + "SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE, not on INSERT"); + } + + [Fact] + public async Task SystemVersioning_AfterClose_ExactlyOneHistoryRow() + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1)); + + // Cierra el activo con una segunda inserción más futura + await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20)); + + // dbo.ProductPrices_History debe tener exactamente 1 row para el Id cerrado + var histCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id", + new { Id = firstId }); + + histCount.Should().Be(1, + "SYSTEM_VERSIONING must produce exactly one history row when the active price is closed via UPDATE"); + + // El row activo en dbo.ProductPrices debe tener PriceValidTo <> NULL + var pvt = await conn.ExecuteScalarAsync( + "SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", + new { Id = firstId }); + + pvt.Should().NotBeNull("the closed row in dbo.ProductPrices must have PriceValidTo set"); + pvt!.Value.Date.Should().Be(new DateTime(2026, 4, 19), + "PriceValidTo = new PVF - 1 day = 2026-04-19"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Batch 7 — T7.3: FOR SYSTEM_TIME AS OF — snapshot temporal + // Verifica que la history table preserva el estado del activo en el instante + // pre-cierre y que la query temporal devuelve el precio correcto. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0() + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // T0: insertar precio1 y capturar el instante UTC antes de cerrarlo + var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1)); + + // Capturar T0 inmediatamente después del INSERT (SYSTEM_VERSIONING usa DATETIME2 UTC) + var t0 = await conn.ExecuteScalarAsync( + "SELECT SYSUTCDATETIME()"); + + // Esperar 200ms para que DATETIME2(3) avance y el registro de history tenga un rango claro + await Task.Delay(200); + + // Insertar precio2 que cierra precio1 — esto produce el row en history + await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 5, 1)); + + // T7.3.a — Query FOR SYSTEM_TIME AS OF T0: debe devolver precio1 con PriceValidTo = NULL + // (estado del registro tal como estaba en T0, antes del cierre) + var snapshotRow = await conn.QuerySingleOrDefaultAsync( + """ + SELECT Id, Price, PriceValidTo + FROM dbo.ProductPrices + FOR SYSTEM_TIME AS OF @T0 + WHERE ProductId = @ProductId + AND Id = @Id + """, + new { T0 = t0, ProductId = _defaultProductId, Id = firstId }); + + ((object?)snapshotRow).Should().NotBeNull( + "FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before close)"); + ((decimal)snapshotRow!.Price).Should().Be(100.00m); + ((object?)snapshotRow.PriceValidTo).Should().BeNull( + "at T0 the row was still active (PriceValidTo IS NULL)"); + + // T7.3.b — Query actual (sin FOR SYSTEM_TIME): precio1 debe tener PriceValidTo != NULL + var currentRow = await conn.QuerySingleOrDefaultAsync( + "SELECT Id, Price, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", + new { Id = firstId }); + + ((object?)currentRow).Should().NotBeNull(); + ((object?)currentRow!.PriceValidTo).Should().NotBeNull( + "in current state, the first price is closed (PriceValidTo IS NOT NULL)"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Batch 7 — T7.5: GetActiveAsync boundary cases (ventanas civiles inclusivas) + // §REQ-4.4 — Inclusive en ambos extremos (PVF y PVT) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveAsync_BeforeFirstPrice_ReturnsNull() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Precio1: [2026-01-01 .. 2026-03-31] (cerrado) + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31)); + + result.Should().BeNull("date is before the first PriceValidFrom"); + } + + [Fact] + public async Task GetActiveAsync_ExactMatchPvf_ReturnsPrice() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Precio1: [2026-01-01 .. 2026-03-31] + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + // Exact match en PVF + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 1, 1)); + + result.Should().NotBeNull("date equals PriceValidFrom → inclusive lower bound"); + result!.Price.Should().Be(100.00m); + } + + [Fact] + public async Task GetActiveAsync_MiddleOfRange_ReturnsPrice() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..] + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 2, 15)); + + result.Should().NotBeNull("date is in the middle of precio1 window"); + result!.Price.Should().Be(100.00m); + } + + [Fact] + public async Task GetActiveAsync_ExactMatchPvt_ReturnsClosedPrice() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Precio1: [2026-01-01 .. 2026-03-31] — PVT=2026-03-31 (día anterior a 2026-04-01) + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + // Exact match en PVT del precio1 → inclusive upper bound + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 3, 31)); + + result.Should().NotBeNull("date equals PriceValidTo → inclusive upper bound"); + result!.Price.Should().Be(100.00m); + } + + [Fact] + public async Task GetActiveAsync_ExactMatchNextPvf_ReturnsNextPrice() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..] + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + // PVF del precio2 → debe devolver precio2 + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 4, 1)); + + result.Should().NotBeNull("date equals PriceValidFrom of precio2 → inclusive lower bound"); + result!.Price.Should().Be(150.00m); + } + + [Fact] + public async Task GetActiveAsync_FarFuture_ReturnsActivePrice() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Solo precio activo desde 2026-04-01 (PriceValidTo = NULL) + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2099, 12, 31)); + + result.Should().NotBeNull("far-future date should return the open-ended active price"); + result!.Price.Should().Be(200.00m); + result.IsActive.Should().BeTrue(); + result.PriceValidTo.Should().BeNull(); + } + // ── Helper: build a real ProductPriceRepository using the test DB ───────── private static ProductPriceRepository BuildRepository()