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.Application.Common; using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Domain.Entities; using SIGCM2.TestSupport; using Xunit; namespace SIGCM2.Api.Tests.Pricing.ChargeableChars; /// /// PRC-001 — E2E integration tests for: /// GET /api/v1/admin/chargeable-chars /// GET /api/v1/admin/chargeable-chars/{id} /// POST /api/v1/admin/chargeable-chars /// PUT /api/v1/admin/chargeable-chars/{id}/price /// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate /// PATCH /api/v1/admin/chargeable-chars/{id}/reactivate /// DELETE /api/v1/admin/chargeable-chars/{id} /// /// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory). /// All mutations require 'tasacion:caracteres_especiales:gestionar' permission. /// [Collection("ApiIntegration")] public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime { private const string ConnectionString = TestConnectionStrings.ApiTestDb; private readonly TestWebAppFactory _factory; private readonly HttpClient _client; public ChargeableCharConfigControllerTests(TestWebAppFactory factory) { _factory = factory; _client = factory.CreateClient(); } public Task InitializeAsync() => Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask; // ── Auth helpers ────────────────────────────────────────────────────────── /// Admin token — has 'tasacion:caracteres_especiales: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)); } /// Cajero token — does NOT have 'tasacion:caracteres_especiales: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 ─────────────────────────────────────────────────────── /// Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup. private async Task SeedConfigDirectAsync( long? productTypeId, string symbol, string category, decimal pricePerUnit, DateOnly validFrom, DateOnly? validTo, bool isActive = true) { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); return await conn.QuerySingleAsync(""" INSERT INTO dbo.ChargeableCharConfig (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion) VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME()); SELECT CAST(SCOPE_IDENTITY() AS BIGINT); """, new { ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value, Symbol = symbol, Category = category, PricePerUnit = pricePerUnit, ValidFrom = validFrom.ToDateTime(TimeOnly.MinValue), ValidTo = validTo.HasValue ? (object)validTo.Value.ToDateTime(TimeOnly.MinValue) : DBNull.Value, IsActive = isActive ? 1 : 0 }); } private static string TomorrowStr() => DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); private static string FutureDateStr(int daysAhead = 60) => DateOnly.FromDateTime(DateTime.UtcNow).AddDays(daysAhead).ToString("yyyy-MM-dd"); // ── GET /api/v1/admin/chargeable-chars ─────────────────────────────────── /// PRC-001-R3.1 — GET list returns paged result. [Fact] public async Task Get_List_ReturnsPagedResult() { // Seed an active row with unique symbol to avoid conflicts await SeedConfigDirectAsync(null, "§", "Currency", 1.50m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars?activeOnly=true", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.TryGetProperty("items", out _).Should().BeTrue("response must have 'items' property"); body.GetProperty("page").GetInt32().Should().BeGreaterThanOrEqualTo(1); body.GetProperty("pageSize").GetInt32().Should().BeGreaterThanOrEqualTo(1); body.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(1); } [Fact] public async Task Get_List_Unauthenticated_Returns401() { using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars"); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task Get_List_WithoutPermission_Returns403() { var token = GetCajeroToken(); using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } // ── GET /api/v1/admin/chargeable-chars/{id} ────────────────────────────── /// PRC-001-R3.1 — GET by id returns 200 + DTO. [Fact] public async Task Get_ById_Existing_Returns200() { var id = await SeedConfigDirectAsync(null, "€", "Currency", 2.00m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Get, $"/api/v1/admin/chargeable-chars/{id}", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); var dto = await resp.Content.ReadFromJsonAsync(); dto.GetProperty("id").GetInt64().Should().Be(id); dto.GetProperty("symbol").GetString().Should().Be("€"); dto.GetProperty("isActive").GetBoolean().Should().BeTrue(); } /// PRC-001-R3.1 — GET by non-existent id returns 404. [Fact] public async Task Get_ByIdMissing_Returns404() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars/999999999", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.NotFound); } // ── POST /api/v1/admin/chargeable-chars ────────────────────────────────── /// PRC-001-R3.2 — POST valid payload returns 201 + Location header. [Fact] public async Task Post_WithValidPayload_Returns201WithLocation() { var token = GetAdminToken(); var validFrom = TomorrowStr(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "¥", category = "Currency", pricePerUnit = 1.75m, validFrom }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Created); resp.Headers.Location.Should().NotBeNull("201 must include Location header"); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("id").GetInt64().Should().BeGreaterThan(0); body.GetProperty("symbol").GetString().Should().Be("¥"); body.GetProperty("validFrom").GetString().Should().Be(validFrom); } /// PRC-001-R3.5 — POST without auth returns 401. [Fact] public async Task Post_Unauthenticated_Returns401() { using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } /// PRC-001-R3.5 — POST without permission returns 403. [Fact] public async Task Post_WithoutPermission_Returns403() { var token = GetCajeroToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } /// PRC-001-R3.2 — POST invalid price returns 400 validation failure. [Fact] public async Task Post_InvalidPrice_Returns400ValidationFailure() { // PRC-001 followup #57: PricePerUnit >= 0 is now valid (opt-in billing). // Use -1 to still exercise the negative rejection path. var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = -1m, validFrom = TomorrowStr() }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } /// PRC-001-R2.7 — POST with symbol too long returns 400. [Fact] public async Task Post_SymbolTooLong_Returns400() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } /// PRC-001-R2.6 — POST with past validFrom returns 400. [Fact] public async Task Post_WithPastValidFrom_Returns400() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } /// /// PRC-001 followup #55 — Business decision (2026-04-21): emoji Symbols are NOT allowed. /// Validator delegates to WordCounterService.ContainsEmoji which checks every rune against /// the Unicode emoji ranges (Emoticons, Pictographs, Dingbats, VS-16, ZWJ, etc.). /// This provides a defensive check beyond the frontend SymbolInput blocker — direct API /// calls (Postman, adversarial clients) can't bypass it. /// [Fact] public async Task Post_WithEmojiSymbol_Returns400() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest, because: "emoji symbols are rejected by validator via WordCounterService.ContainsEmoji (#55)"); } // ── PUT /api/v1/admin/chargeable-chars/{id}/price ──────────────────────── /// PRC-001-R3.3 — PUT schedules price change, returns 200. [Fact] public async Task Put_PriceChange_Returns200() { // Seed an active row var existingId = await SeedConfigDirectAsync(null, "★", "Currency", 1.00m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); var newValidFrom = FutureDateStr(30); using var req = BuildRequest(HttpMethod.Put, $"/api/v1/admin/chargeable-chars/{existingId}/price", body: new { pricePerUnit = 2.50m, validFrom = newValidFrom }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("newId").GetInt64().Should().BeGreaterThan(0); body.GetProperty("newValidFrom").GetString().Should().Be(newValidFrom); } /// PRC-001-R3.3 — PUT with retroactive date returns 409 ForwardOnly. [Fact] public async Task Put_PriceBackdateAttempt_Returns409() { // Seed an active row with a far-future ValidFrom var existingId = await SeedConfigDirectAsync(null, "♦", "Currency", 1.00m, new DateOnly(2099, 12, 1), null, true); var token = GetAdminToken(); // Try to schedule before the existing ValidFrom using var req = BuildRequest(HttpMethod.Put, $"/api/v1/admin/chargeable-chars/{existingId}/price", body: new { pricePerUnit = 2.00m, validFrom = "2099-11-01" }, 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("chargeable_char_forward_only"); } // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ──────────────── /// PRC-001-R3.4 — PATCH deactivate returns 200 OK. [Fact] public async Task Patch_Deactivate_Returns200() { var id = await SeedConfigDirectAsync(null, "▲", "Currency", 1.00m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); } /// PRC-001 — PATCH deactivate on already-inactive row is idempotent → 200 OK. [Fact] public async Task Patch_Deactivate_AlreadyInactive_Returns200() { // Seed an already-inactive row var id = await SeedConfigDirectAsync(null, "▼", "Currency", 1.00m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31), false); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token); var resp = await _client.SendAsync(req); // Idempotent — no exception thrown resp.StatusCode.Should().Be(HttpStatusCode.OK); } // ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ───────────────── /// PRC-001 — PATCH reactivate on last closed row returns 200. [Fact] public async Task Patch_Reactivate_LastClosed_Returns200() { // Seed a closed row (isActive=false) — no other active row for this symbol // Use a unique symbol to avoid conflicts with other tests var uniqueSymbol = $"R{Guid.NewGuid():N}"[..1]; var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("id").GetInt64().Should().Be(id); body.GetProperty("isActive").GetBoolean().Should().BeTrue(); } /// PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE. [Fact] public async Task Patch_Reactivate_AlreadyActive_Returns409_AlreadyActive() { var uniqueSymbol = $"A{Guid.NewGuid():N}"[..1]; var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), null, true); // already active var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE"); } /// PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS. [Fact] public async Task Patch_Reactivate_VigenteExists_Returns409_VigenteExists() { // Seed: one active (vigente) row + one closed row for the same symbol var uniqueSymbol = $"V{Guid.NewGuid():N}"[..1]; // First: closed row (older) var closedId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false); // Second: active vigente row (newer — this blocks reactivation of closedId) await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m, new DateOnly(2026, 4, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{closedId}/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS"); } /// PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST. [Fact] public async Task Patch_Reactivate_PosteriorRowsExist_Returns409_PosteriorRowsExist() { // Seed: target closed row + a posterior (newer ValidFrom) closed row for the same symbol var uniqueSymbol = $"P{Guid.NewGuid():N}"[..1]; // First: target closed row var targetId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false); // Second: posterior closed row (newer ValidFrom, also closed — blocks reactivation) await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m, new DateOnly(2026, 4, 1), new DateOnly(2026, 6, 30), false); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{targetId}/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST"); } /// PRC-001 — PATCH reactivate without auth returns 401. [Fact] public async Task Patch_Reactivate_Unauthorized_Returns401() { using var req = BuildRequest(HttpMethod.Patch, "/api/v1/admin/chargeable-chars/1/reactivate"); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } /// PRC-001 — PATCH reactivate without permission returns 403. [Fact] public async Task Patch_Reactivate_WithoutPermission_Returns403() { var token = GetCajeroToken(); using var req = BuildRequest(HttpMethod.Patch, "/api/v1/admin/chargeable-chars/1/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } /// PRC-001 — PATCH reactivate emits audit event. [Fact] public async Task Patch_Reactivate_AuditEventEmitted() { var uniqueSymbol = $"Q{Guid.NewGuid():N}"[..1]; var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK, because: "PATCH reactivate must succeed before checking audit"); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var auditCount = await conn.QuerySingleAsync(""" SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'tasacion.chargeable_char.reactivate' AND TargetType = 'ChargeableCharConfig' AND TargetId = @TargetId """, new { TargetId = id.ToString() }); auditCount.Should().Be(1, because: "IAuditLogger must record tasacion.chargeable_char.reactivate after successful PATCH"); } // ── DELETE /api/v1/admin/chargeable-chars/{id} ─────────────────────────── /// PRC-001 — DELETE existing row returns 200. [Fact] public async Task Delete_Existing_Returns200() { var uniqueSymbol = $"D{Guid.NewGuid():N}"[..1]; var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/chargeable-chars/{id}", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("id").GetInt64().Should().Be(id); } /// PRC-001 — DELETE non-existent row returns 404. [Fact] public async Task Delete_NotFound_Returns404() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Delete, "/api/v1/admin/chargeable-chars/999999998", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.NotFound); } /// PRC-001 — DELETE emits audit event. [Fact] public async Task Delete_AuditEventEmitted() { var uniqueSymbol = $"E{Guid.NewGuid():N}"[..1]; var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/chargeable-chars/{id}", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK, because: "DELETE must succeed before checking audit"); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var auditCount = await conn.QuerySingleAsync(""" SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'tasacion.chargeable_char.delete' AND TargetType = 'ChargeableCharConfig' AND TargetId = @TargetId """, new { TargetId = id.ToString() }); auditCount.Should().Be(1, because: "IAuditLogger must record tasacion.chargeable_char.delete after successful DELETE"); } /// PRC-001 — DELETE without auth returns 401. [Fact] public async Task Delete_Unauthorized_Returns401() { using var req = BuildRequest(HttpMethod.Delete, "/api/v1/admin/chargeable-chars/1"); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } /// PRC-001 — DELETE without permission returns 403. [Fact] public async Task Delete_WithoutPermission_Returns403() { var token = GetCajeroToken(); using var req = BuildRequest(HttpMethod.Delete, "/api/v1/admin/chargeable-chars/1", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } // ── Audit ───────────────────────────────────────────────────────────────── /// PRC-001-R3.6 — POST emits audit event chargeable_char_config.created. [Fact] public async Task AuditEvent_EmittedOnCreate() { var token = GetAdminToken(); var validFrom = TomorrowStr(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { productTypeId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.Created, because: "POST must succeed before we can verify audit"); var body = await resp.Content.ReadFromJsonAsync(); var newId = body.GetProperty("id").GetInt64(); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var auditCount = await conn.QuerySingleAsync(""" SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'tasacion.chargeable_char.create' AND TargetType = 'ChargeableCharConfig' AND TargetId = @TargetId """, new { TargetId = newId.ToString() }); auditCount.Should().Be(1, because: "IAuditLogger must record tasacion.chargeable_char.create after successful POST"); } /// PRC-001-R3.6 — PUT price change emits audit event. [Fact] public async Task AuditEvent_EmittedOnPriceChange() { var existingId = await SeedConfigDirectAsync(null, "↗", "Currency", 1.00m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); var newValidFrom = FutureDateStr(45); using var req = BuildRequest(HttpMethod.Put, $"/api/v1/admin/chargeable-chars/{existingId}/price", body: new { pricePerUnit = 3.00m, validFrom = newValidFrom }, token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK, because: "PUT must succeed before we can verify audit"); var body = await resp.Content.ReadFromJsonAsync(); var newId = body.GetProperty("newId").GetInt64(); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var auditCount = await conn.QuerySingleAsync(""" SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'tasacion.chargeable_char.price_change' AND TargetType = 'ChargeableCharConfig' AND TargetId = @TargetId """, new { TargetId = newId.ToString() }); auditCount.Should().Be(1, because: "IAuditLogger must record tasacion.chargeable_char.price_change after successful PUT"); } /// PRC-001-R3.6 — PATCH deactivate emits audit event. [Fact] public async Task AuditEvent_EmittedOnDeactivate() { var id = await SeedConfigDirectAsync(null, "→", "Currency", 1.00m, new DateOnly(2026, 1, 1), null, true); var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Patch, $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token); var resp = await _client.SendAsync(req); resp.StatusCode.Should().Be(HttpStatusCode.OK, because: "PATCH must succeed before we can verify audit"); await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var auditCount = await conn.QuerySingleAsync(""" SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'tasacion.chargeable_char.deactivate' AND TargetType = 'ChargeableCharConfig' AND TargetId = @TargetId """, new { TargetId = id.ToString() }); auditCount.Should().Be(1, because: "IAuditLogger must record tasacion.chargeable_char.deactivate after successful PATCH"); } /// PRC-001-R3.7 — Audit failure rolls back insert (fail-closed). [Fact] public async Task Audit_Rollback_OnLoggerFailure() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var countBefore = await conn.ExecuteScalarAsync( "SELECT COUNT(1) FROM dbo.ChargeableCharConfig"); // Build a mock IAuditLogger that throws var throwingAudit = Substitute.For(); throwingAudit .LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("audit down — simulated failure")); using var client = _factory.CreateClientWithOverrides(services => { services.RemoveAll(); services.AddScoped(_ => throwingAudit); }); 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 req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/chargeable-chars"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Content = JsonContent.Create(new { productTypeId = (long?)null, symbol = "←", category = "Currency", pricePerUnit = 1.50m, validFrom = TomorrowStr() }); // Act var resp = await client.SendAsync(req); // Audit throws → unhandled → 500 resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError, because: "audit failure must propagate as 500 (fail-closed)"); // DB: no row was inserted await using var verifyConn = new SqlConnection(ConnectionString); await verifyConn.OpenAsync(); var countAfter = await verifyConn.ExecuteScalarAsync( "SELECT COUNT(1) FROM dbo.ChargeableCharConfig"); countAfter.Should().Be(countBefore, because: "TransactionScope must roll back ChargeableCharConfig insert when audit fails"); } }