feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001)

This commit is contained in:
2026-04-20 12:46:07 -03:00
parent 3b1edfd696
commit 8fc7b363d5
3 changed files with 791 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
/// Route base: api/v1/admin/chargeable-chars
/// </summary>
[ApiController]
[Route("api/v1/admin/chargeable-chars")]
public sealed class ChargeableCharConfigController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
public ChargeableCharConfigController(
IDispatcher dispatcher,
IValidator<CreateChargeableCharConfigCommand> createValidator,
IValidator<SchedulePriceChangeCommand> scheduleValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_scheduleValidator = scheduleValidator;
}
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpGet]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
return Ok(result);
}
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
/// <summary>
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
/// </summary>
[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<IActionResult> GetById([FromRoute] long id)
{
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
new GetChargeableCharConfigByIdQuery(id));
return result is null ? NotFound() : Ok(result);
}
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
/// <summary>
/// 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}.
/// </summary>
[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<IActionResult> 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<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
/// <summary>
/// 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).
/// </summary>
[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<IActionResult> 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<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
return Ok(result);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
/// <summary>
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
/// Idempotent: calling on an already-inactive row is a no-op.
/// </summary>
[HttpPatch("{id:long}/deactivate")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Deactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
new DeactivateChargeableCharConfigCommand(id));
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
public sealed record CreateChargeableCharConfigRequest(
long? MedioId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom);
/// <summary>PRC-001: Schedule price change request body.</summary>
public sealed record SchedulePriceChangeRequest(
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Api.Filters; namespace SIGCM2.Api.Filters;
@@ -645,6 +646,67 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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: case ValidationException validationEx:
var errors = validationEx.Errors var errors = validationEx.Errors
.GroupBy(e => e.PropertyName) .GroupBy(e => e.PropertyName)

View File

@@ -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;
/// <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
///
/// 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? 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<long>("""
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 ───────────────────────────────────
/// <summary>PRC-001-R3.1 — GET list returns paged result.</summary>
[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<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
{
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<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 { medioId = (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 { 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);
}
/// <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 { medioId = (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 { 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);
}
/// <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 { 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);
}
/// <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 { 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 ────────────────────────
/// <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);
}
// ── 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 { 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<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
{
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<int>(
"SELECT COUNT(1) FROM dbo.ChargeableCharConfig");
countAfter.Should().Be(countBefore,
because: "TransactionScope must roll back ChargeableCharConfig insert when audit fails");
}
}