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 /// /// 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? medioId, 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 (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion) VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME()); SELECT CAST(SCOPE_IDENTITY() AS BIGINT); """, new { MedioId = medioId.HasValue ? (object)(int)medioId.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 2 active rows with unique symbols to avoid conflicts var sym1 = $"L{Guid.NewGuid():N}"[..1]; 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 { medioId = (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 { medioId = (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 { medioId = (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() { var token = GetAdminToken(); using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, 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 { medioId = (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 { medioId = (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-R2.7 — Emoji symbols are explicitly DEFERRED per spec. /// The ChargeableCharConfig Symbol field accepts any 1–4 char value including emojis. /// "😀" in C# has string.Length = 2 (UTF-16 surrogate pair), so it passes MaximumLength(4). /// This test documents the deferred behavior: emoji in Symbol is accepted at config level. /// The EmojiDetectedException applies only to WordCounterService (ad text, not config symbols). /// [Fact] public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred() { var token = GetAdminToken(); // "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4). // Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7. using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", body: new { medioId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, token: token); var resp = await _client.SendAsync(req); // Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test. resp.StatusCode.Should().Be(HttpStatusCode.Created, because: "emoji symbol rejection is deferred (spec R2.7). Symbol '😀' has length 2 in C# (UTF-16) → passes MaximumLength(4)"); } // ── 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); } // ── 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 { medioId = (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 { medioId = (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"); } }