Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
dmolinari f7fb76219a refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
2026-04-21 10:54:47 -03:00

753 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// </summary>
[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 ──────────────────────────────────────────────────────────
/// <summary>Admin token — has 'tasacion:caracteres_especiales:gestionar' via 'admin' role.</summary>
private string GetAdminToken()
{
var jwt = _factory.Services.GetRequiredService<IJwtService>();
return jwt.GenerateAccessToken(new Usuario(
id: 1, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
}
/// <summary>Cajero token — does NOT have 'tasacion:caracteres_especiales:gestionar'.</summary>
private string GetCajeroToken()
{
var jwt = _factory.Services.GetRequiredService<IJwtService>();
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 ───────────────────────────────────────────────────────
/// <summary>Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.</summary>
private async Task<long> 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<long>("""
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 ───────────────────────────────────
/// <summary>PRC-001-R3.1 — GET list returns paged result.</summary>
[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<JsonElement>();
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} ──────────────────────────────
/// <summary>PRC-001-R3.1 — GET by id returns 200 + DTO.</summary>
[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<JsonElement>();
dto.GetProperty("id").GetInt64().Should().Be(id);
dto.GetProperty("symbol").GetString().Should().Be("€");
dto.GetProperty("isActive").GetBoolean().Should().BeTrue();
}
/// <summary>PRC-001-R3.1 — GET by non-existent id returns 404.</summary>
[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 ──────────────────────────────────
/// <summary>PRC-001-R3.2 — POST valid payload returns 201 + Location header.</summary>
[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<JsonElement>();
body.GetProperty("id").GetInt64().Should().BeGreaterThan(0);
body.GetProperty("symbol").GetString().Should().Be("¥");
body.GetProperty("validFrom").GetString().Should().Be(validFrom);
}
/// <summary>PRC-001-R3.5 — POST without auth returns 401.</summary>
[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);
}
/// <summary>PRC-001-R3.5 — POST without permission returns 403.</summary>
[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);
}
/// <summary>PRC-001-R3.2 — POST invalid price returns 400 validation failure.</summary>
[Fact]
public async Task Post_InvalidPrice_Returns400ValidationFailure()
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
/// <summary>PRC-001-R2.7 — POST with symbol too long returns 400.</summary>
[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);
}
/// <summary>PRC-001-R2.6 — POST with past validFrom returns 400.</summary>
[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);
}
/// <summary>
/// PRC-001-R2.7 — Emoji symbols are explicitly DEFERRED per spec.
/// The ChargeableCharConfig Symbol field accepts any 14 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).
/// </summary>
[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 { productTypeId = (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 ────────────────────────
/// <summary>PRC-001-R3.3 — PUT schedules price change, returns 200.</summary>
[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<JsonElement>();
body.GetProperty("newId").GetInt64().Should().BeGreaterThan(0);
body.GetProperty("newValidFrom").GetString().Should().Be(newValidFrom);
}
/// <summary>PRC-001-R3.3 — PUT with retroactive date returns 409 ForwardOnly.</summary>
[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<JsonElement>();
body.GetProperty("error").GetString().Should().Be("chargeable_char_forward_only");
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ────────────────
/// <summary>PRC-001-R3.4 — PATCH deactivate returns 200 OK.</summary>
[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);
}
/// <summary>PRC-001 — PATCH deactivate on already-inactive row is idempotent → 200 OK.</summary>
[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 ─────────────────
/// <summary>PRC-001 — PATCH reactivate on last closed row returns 200.</summary>
[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<JsonElement>();
body.GetProperty("id").GetInt64().Should().Be(id);
body.GetProperty("isActive").GetBoolean().Should().BeTrue();
}
/// <summary>PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE.</summary>
[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<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE");
}
/// <summary>PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS.</summary>
[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<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS");
}
/// <summary>PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST.</summary>
[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<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST");
}
/// <summary>PRC-001 — PATCH reactivate without auth returns 401.</summary>
[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);
}
/// <summary>PRC-001 — PATCH reactivate without permission returns 403.</summary>
[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);
}
/// <summary>PRC-001 — PATCH reactivate emits audit event.</summary>
[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<int>("""
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} ───────────────────────────
/// <summary>PRC-001 — DELETE existing row returns 200.</summary>
[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<JsonElement>();
body.GetProperty("id").GetInt64().Should().Be(id);
}
/// <summary>PRC-001 — DELETE non-existent row returns 404.</summary>
[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);
}
/// <summary>PRC-001 — DELETE emits audit event.</summary>
[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<int>("""
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");
}
/// <summary>PRC-001 — DELETE without auth returns 401.</summary>
[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);
}
/// <summary>PRC-001 — DELETE without permission returns 403.</summary>
[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 ─────────────────────────────────────────────────────────────────
/// <summary>PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.</summary>
[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<JsonElement>();
var newId = body.GetProperty("id").GetInt64();
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var auditCount = await conn.QuerySingleAsync<int>("""
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");
}
/// <summary>PRC-001-R3.6 — PUT price change emits audit event.</summary>
[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<JsonElement>();
var newId = body.GetProperty("newId").GetInt64();
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var auditCount = await conn.QuerySingleAsync<int>("""
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");
}
/// <summary>PRC-001-R3.6 — PATCH deactivate emits audit event.</summary>
[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<int>("""
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");
}
/// <summary>PRC-001-R3.7 — Audit failure rolls back insert (fail-closed).</summary>
[Fact]
public async Task Audit_Rollback_OnLoggerFailure()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var countBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ChargeableCharConfig");
// Build a mock IAuditLogger that throws
var throwingAudit = Substitute.For<IAuditLogger>();
throwingAudit
.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("audit down — simulated failure"));
using var client = _factory.CreateClientWithOverrides(services =>
{
services.RemoveAll<IAuditLogger>();
services.AddScoped<IAuditLogger>(_ => throwingAudit);
});
var jwt = _factory.Services.GetRequiredService<IJwtService>();
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<int>(
"SELECT COUNT(1) FROM dbo.ChargeableCharConfig");
countAfter.Should().Be(countBefore,
because: "TransactionScope must roll back ChargeableCharConfig insert when audit fails");
}
}