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