2026-04-20 12:46:07 -03:00
|
|
|
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
|
2026-04-21 10:54:47 -03:00
|
|
|
/// PATCH /api/v1/admin/chargeable-chars/{id}/reactivate
|
|
|
|
|
/// DELETE /api/v1/admin/chargeable-chars/{id}
|
2026-04-20 12:46:07 -03:00
|
|
|
///
|
|
|
|
|
/// 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(
|
2026-04-21 10:54:47 -03:00
|
|
|
long? productTypeId, string symbol, string category, decimal pricePerUnit,
|
2026-04-20 12:46:07 -03:00
|
|
|
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
|
2026-04-21 10:54:47 -03:00
|
|
|
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
|
|
|
|
|
VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
|
2026-04-20 12:46:07 -03:00
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
|
|
|
|
|
""",
|
|
|
|
|
new
|
|
|
|
|
{
|
2026-04-21 10:54:47 -03:00
|
|
|
ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value,
|
2026-04-20 12:46:07 -03:00
|
|
|
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()
|
|
|
|
|
{
|
2026-04-21 10:54:47 -03:00
|
|
|
// Seed an active row with unique symbol to avoid conflicts
|
2026-04-20 12:46:07 -03:00
|
|
|
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
|
|
|
|
|
{
|
2026-04-21 10:54:47 -03:00
|
|
|
productTypeId = (long?)null,
|
2026-04-20 12:46:07 -03:00
|
|
|
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",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
|
2026-04-20 12:46:07 -03:00
|
|
|
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",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
2026-04-20 12:46:07 -03:00
|
|
|
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()
|
|
|
|
|
{
|
chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:
#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
- WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
- CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
(Postman, adversariales) mas alla del SymbolInput blocker del frontend.
- Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).
#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
- CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
-> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
- Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
sigue fallando. Api e2e usa -1 para el caso de rechazo.
#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
- src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
el warning TS5101 del baseUrl deprecated en TS 6.x.
- (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
mockean ProductTypeSelect directamente; warning residual no existe.)
#54 — Seeder demo de overrides ficticios per-ProductType (V025).
- database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
MERGE idempotente que crea overrides de ChargeableCharConfig para
ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
- V025_ROLLBACK.sql: elimina overrides demo preservando globales.
- Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
- database/README.md: V025 agregada al indice.
Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.
Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
|
|
|
// PRC-001 followup #57: PricePerUnit >= 0 is now valid (opt-in billing).
|
|
|
|
|
// Use -1 to still exercise the negative rejection path.
|
2026-04-20 12:46:07 -03:00
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:
#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
- WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
- CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
(Postman, adversariales) mas alla del SymbolInput blocker del frontend.
- Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).
#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
- CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
-> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
- Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
sigue fallando. Api e2e usa -1 para el caso de rechazo.
#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
- src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
el warning TS5101 del baseUrl deprecated en TS 6.x.
- (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
mockean ProductTypeSelect directamente; warning residual no existe.)
#54 — Seeder demo de overrides ficticios per-ProductType (V025).
- database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
MERGE idempotente que crea overrides de ChargeableCharConfig para
ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
- V025_ROLLBACK.sql: elimina overrides demo preservando globales.
- Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
- database/README.md: V025 agregada al indice.
Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.
Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = -1m, validFrom = TomorrowStr() },
|
2026-04-20 12:46:07 -03:00
|
|
|
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",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
2026-04-20 12:46:07 -03:00
|
|
|
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",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" },
|
2026-04-20 12:46:07 -03:00
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:
#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
- WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
- CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
(Postman, adversariales) mas alla del SymbolInput blocker del frontend.
- Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).
#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
- CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
-> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
- Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
sigue fallando. Api e2e usa -1 para el caso de rechazo.
#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
- src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
el warning TS5101 del baseUrl deprecated en TS 6.x.
- (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
mockean ProductTypeSelect directamente; warning residual no existe.)
#54 — Seeder demo de overrides ficticios per-ProductType (V025).
- database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
MERGE idempotente que crea overrides de ChargeableCharConfig para
ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
- V025_ROLLBACK.sql: elimina overrides demo preservando globales.
- Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
- database/README.md: V025 agregada al indice.
Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.
Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
|
|
|
/// PRC-001 followup #55 — Business decision (2026-04-21): emoji Symbols are NOT allowed.
|
|
|
|
|
/// Validator delegates to WordCounterService.ContainsEmoji which checks every rune against
|
|
|
|
|
/// the Unicode emoji ranges (Emoticons, Pictographs, Dingbats, VS-16, ZWJ, etc.).
|
|
|
|
|
/// This provides a defensive check beyond the frontend SymbolInput blocker — direct API
|
|
|
|
|
/// calls (Postman, adversarial clients) can't bypass it.
|
2026-04-20 12:46:07 -03:00
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:
#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
- WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
- CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
(Postman, adversariales) mas alla del SymbolInput blocker del frontend.
- Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).
#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
- CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
-> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
- Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
sigue fallando. Api e2e usa -1 para el caso de rechazo.
#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
- src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
el warning TS5101 del baseUrl deprecated en TS 6.x.
- (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
mockean ProductTypeSelect directamente; warning residual no existe.)
#54 — Seeder demo de overrides ficticios per-ProductType (V025).
- database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
MERGE idempotente que crea overrides de ChargeableCharConfig para
ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
- V025_ROLLBACK.sql: elimina overrides demo preservando globales.
- Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
- database/README.md: V025 agregada al indice.
Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.
Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
|
|
|
public async Task Post_WithEmojiSymbol_Returns400()
|
2026-04-20 12:46:07 -03:00
|
|
|
{
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
2026-04-20 12:46:07 -03:00
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:
#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
- WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
- CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
(Postman, adversariales) mas alla del SymbolInput blocker del frontend.
- Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).
#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
- CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
-> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
- Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
sigue fallando. Api e2e usa -1 para el caso de rechazo.
#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
- src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
el warning TS5101 del baseUrl deprecated en TS 6.x.
- (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
mockean ProductTypeSelect directamente; warning residual no existe.)
#54 — Seeder demo de overrides ficticios per-ProductType (V025).
- database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
MERGE idempotente que crea overrides de ChargeableCharConfig para
ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
- V025_ROLLBACK.sql: elimina overrides demo preservando globales.
- Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
- database/README.md: V025 agregada al indice.
Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.
Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest,
|
|
|
|
|
because: "emoji symbols are rejected by validator via WordCounterService.ContainsEmoji (#55)");
|
2026-04-20 12:46:07 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 10:54:47 -03:00
|
|
|
// ── 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:46:07 -03:00
|
|
|
// ── 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",
|
2026-04-21 10:54:47 -03:00
|
|
|
body: new { productTypeId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
|
2026-04-20 12:46:07 -03:00
|
|
|
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
|
|
|
|
|
{
|
2026-04-21 10:54:47 -03:00
|
|
|
productTypeId = (long?)null,
|
2026-04-20 12:46:07 -03:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|