From 8fc7b363d53b4663ef1f1b60eb6bdd44a6c6a057 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:46:07 -0300 Subject: [PATCH] feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001) --- .../ChargeableCharConfigController.cs | 201 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 62 ++ .../ChargeableCharConfigControllerTests.cs | 528 ++++++++++++++++++ 3 files changed, 791 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs create mode 100644 tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs new file mode 100644 index 0000000..7bd1adc --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs @@ -0,0 +1,201 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Application.Pricing.ChargeableChars.Create; +using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Application.Pricing.ChargeableChars.GetById; +using SIGCM2.Application.Pricing.ChargeableChars.List; +using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRC-001: Admin endpoints for ChargeableCharConfig management. +/// All endpoints require 'tasacion:caracteres_especiales:gestionar'. +/// Route base: api/v1/admin/chargeable-chars +/// +[ApiController] +[Route("api/v1/admin/chargeable-chars")] +public sealed class ChargeableCharConfigController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _scheduleValidator; + + public ChargeableCharConfigController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator scheduleValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _scheduleValidator = scheduleValidator; + } + + // ── GET /api/v1/admin/chargeable-chars ──────────────────────────────────── + + /// + /// Returns a paginated list of ChargeableCharConfig rows. + /// Filters: medioId (optional, long?), activeOnly (bool, default true). + /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly. + /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200. + /// + [HttpGet] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task List( + [FromQuery] long? medioId, + [FromQuery] bool activeOnly = true, + [FromQuery] int? page = null, + [FromQuery] int? pageSize = null, + [FromQuery] int? skip = null, + [FromQuery] int? take = null) + { + // Support both page/pageSize and skip/take query patterns + int resolvedPage; + int resolvedPageSize; + + if (skip is not null || take is not null) + { + // Convert skip/take to page/pageSize + resolvedPageSize = Math.Min(take ?? 50, 200); + resolvedPage = resolvedPageSize > 0 + ? ((skip ?? 0) / resolvedPageSize) + 1 + : 1; + } + else + { + resolvedPage = page ?? 1; + resolvedPageSize = Math.Min(pageSize ?? 20, 200); + } + + var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + // ── GET /api/v1/admin/chargeable-chars/{id} ─────────────────────────────── + + /// + /// Returns a single ChargeableCharConfig by Id. Returns 404 if not found. + /// + [HttpGet("{id:long}")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById([FromRoute] long id) + { + var result = await _dispatcher.Send( + new GetChargeableCharConfigByIdQuery(id)); + return result is null ? NotFound() : Ok(result); + } + + // ── POST /api/v1/admin/chargeable-chars ─────────────────────────────────── + + /// + /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists. + /// Returns 201 Created with Location header pointing to GET /{id}. + /// + [HttpPost] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateChargeableCharConfigRequest request) + { + var command = new CreateChargeableCharConfigCommand( + request.MedioId, + request.Symbol, + request.Category, + request.PricePerUnit, + request.ValidFrom); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + + // ── PUT /api/v1/admin/chargeable-chars/{id}/price ──────────────────────── + + /// + /// Schedules a price change for an existing ChargeableCharConfig. + /// Closes the current active row and opens a new one with the new price + ValidFrom. + /// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only). + /// + [HttpPut("{id:long}/price")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task SchedulePriceChange( + [FromRoute] long id, + [FromBody] SchedulePriceChangeRequest request) + { + var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom); + + var validation = await _scheduleValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ───────────────── + + /// + /// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR). + /// Idempotent: calling on an already-inactive row is a no-op. + /// + [HttpPatch("{id:long}/deactivate")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Deactivate([FromRoute] long id) + { + var result = await _dispatcher.Send( + new DeactivateChargeableCharConfigCommand(id)); + return Ok(result); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRC-001: Create ChargeableCharConfig request body. +public sealed record CreateChargeableCharConfigRequest( + long? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateOnly ValidFrom); + +/// PRC-001: Schedule price change request body. +public sealed record SchedulePriceChangeRequest( + decimal PricePerUnit, + DateOnly ValidFrom); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 9b3602e..3df5936 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Data.SqlClient; using SIGCM2.Domain.Exceptions; +using SIGCM2.Domain.Pricing.Exceptions; namespace SIGCM2.Api.Filters; @@ -645,6 +646,67 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRC-001: WordCounter + ChargeableCharConfig exceptions + case EmojiDetectedException emojiEx: + context.Result = new ObjectResult(new + { + error = "emoji_not_allowed", + code = "EMOJI_NOT_ALLOWED", + message = emojiEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case WordCountValidationException wordEx: + context.Result = new ObjectResult(new + { + error = "word_count_validation", + code = "WORD_COUNT_VALIDATION", + field = wordEx.Field, + reason = wordEx.Reason, + message = wordEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case ChargeableCharConfigInvalidException configInvalidEx: + context.Result = new ObjectResult(new + { + error = "chargeable_char_invalid", + code = "CHARGEABLE_CHAR_INVALID", + field = configInvalidEx.Field, + reason = configInvalidEx.Reason, + message = configInvalidEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx: + context.Result = new ObjectResult(new + { + error = "chargeable_char_forward_only", + code = "CHARGEABLE_CHAR_FORWARD_ONLY", + medioId = forwardOnlyCharEx.MedioId, + symbol = forwardOnlyCharEx.Symbol, + newValidFrom = forwardOnlyCharEx.NewValidFrom, + activeValidFrom = forwardOnlyCharEx.ActiveValidFrom, + message = forwardOnlyCharEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs new file mode 100644 index 0000000..a2f9132 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs @@ -0,0 +1,528 @@ +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"); + } +}