refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
Domain, Application, Infrastructure, API, all test projects.
Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).
Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
→ ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.
Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
KeyNotFoundException → 404 via ExceptionFilter.
Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
This commit is contained in:
@@ -26,6 +26,8 @@ namespace SIGCM2.Api.Tests.Pricing.ChargeableChars;
|
||||
/// POST /api/v1/admin/chargeable-chars
|
||||
/// PUT /api/v1/admin/chargeable-chars/{id}/price
|
||||
/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate
|
||||
/// PATCH /api/v1/admin/chargeable-chars/{id}/reactivate
|
||||
/// DELETE /api/v1/admin/chargeable-chars/{id}
|
||||
///
|
||||
/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
|
||||
/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission.
|
||||
@@ -84,20 +86,20 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
|
||||
/// <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,
|
||||
long? productTypeId, string symbol, string category, decimal pricePerUnit,
|
||||
DateOnly validFrom, DateOnly? validTo, bool isActive = true)
|
||||
{
|
||||
await using var conn = new SqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<long>("""
|
||||
INSERT INTO dbo.ChargeableCharConfig
|
||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
|
||||
VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
|
||||
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
|
||||
VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
|
||||
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
|
||||
""",
|
||||
new
|
||||
{
|
||||
MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value,
|
||||
ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value,
|
||||
Symbol = symbol,
|
||||
Category = category,
|
||||
PricePerUnit = pricePerUnit,
|
||||
@@ -119,8 +121,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task Get_List_ReturnsPagedResult()
|
||||
{
|
||||
// Seed 2 active rows with unique symbols to avoid conflicts
|
||||
var sym1 = $"L{Guid.NewGuid():N}"[..1];
|
||||
// Seed an active row with unique symbol to avoid conflicts
|
||||
await SeedConfigDirectAsync(null, "§", "Currency", 1.50m,
|
||||
new DateOnly(2026, 1, 1), null, true);
|
||||
|
||||
@@ -198,7 +199,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
||||
body: new
|
||||
{
|
||||
medioId = (long?)null,
|
||||
productTypeId = (long?)null,
|
||||
symbol = "¥",
|
||||
category = "Currency",
|
||||
pricePerUnit = 1.75m,
|
||||
@@ -221,7 +222,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
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() });
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -232,7 +233,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
{
|
||||
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() },
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
@@ -244,7 +245,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
{
|
||||
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() },
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
@@ -256,7 +257,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
{
|
||||
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() },
|
||||
body: new { productTypeId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
@@ -268,7 +269,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
{
|
||||
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" },
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
@@ -288,7 +289,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
// "😀" 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() },
|
||||
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
// Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test.
|
||||
@@ -376,6 +377,229 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate on last closed row returns 200.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_LastClosed_Returns200()
|
||||
{
|
||||
// Seed a closed row (isActive=false) — no other active row for this symbol
|
||||
// Use a unique symbol to avoid conflicts with other tests
|
||||
var uniqueSymbol = $"R{Guid.NewGuid():N}"[..1];
|
||||
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("id").GetInt64().Should().Be(id);
|
||||
body.GetProperty("isActive").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_AlreadyActive_Returns409_AlreadyActive()
|
||||
{
|
||||
var uniqueSymbol = $"A{Guid.NewGuid():N}"[..1];
|
||||
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), null, true); // already active
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
|
||||
body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE");
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_VigenteExists_Returns409_VigenteExists()
|
||||
{
|
||||
// Seed: one active (vigente) row + one closed row for the same symbol
|
||||
var uniqueSymbol = $"V{Guid.NewGuid():N}"[..1];
|
||||
|
||||
// First: closed row (older)
|
||||
var closedId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
|
||||
|
||||
// Second: active vigente row (newer — this blocks reactivation of closedId)
|
||||
await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
|
||||
new DateOnly(2026, 4, 1), null, true);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
$"/api/v1/admin/chargeable-chars/{closedId}/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
|
||||
body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS");
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_PosteriorRowsExist_Returns409_PosteriorRowsExist()
|
||||
{
|
||||
// Seed: target closed row + a posterior (newer ValidFrom) closed row for the same symbol
|
||||
var uniqueSymbol = $"P{Guid.NewGuid():N}"[..1];
|
||||
|
||||
// First: target closed row
|
||||
var targetId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
|
||||
|
||||
// Second: posterior closed row (newer ValidFrom, also closed — blocks reactivation)
|
||||
await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
|
||||
new DateOnly(2026, 4, 1), new DateOnly(2026, 6, 30), false);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
$"/api/v1/admin/chargeable-chars/{targetId}/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
|
||||
body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST");
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate without auth returns 401.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_Unauthorized_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
"/api/v1/admin/chargeable-chars/1/reactivate");
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate without permission returns 403.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_WithoutPermission_Returns403()
|
||||
{
|
||||
var token = GetCajeroToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
"/api/v1/admin/chargeable-chars/1/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — PATCH reactivate emits audit event.</summary>
|
||||
[Fact]
|
||||
public async Task Patch_Reactivate_AuditEventEmitted()
|
||||
{
|
||||
var uniqueSymbol = $"Q{Guid.NewGuid():N}"[..1];
|
||||
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Patch,
|
||||
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK,
|
||||
because: "PATCH reactivate must succeed before checking audit");
|
||||
|
||||
await using var conn = new SqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
var auditCount = await conn.QuerySingleAsync<int>("""
|
||||
SELECT COUNT(1) FROM dbo.AuditEvent
|
||||
WHERE Action = 'tasacion.chargeable_char.reactivate'
|
||||
AND TargetType = 'ChargeableCharConfig'
|
||||
AND TargetId = @TargetId
|
||||
""", new { TargetId = id.ToString() });
|
||||
|
||||
auditCount.Should().Be(1,
|
||||
because: "IAuditLogger must record tasacion.chargeable_char.reactivate after successful PATCH");
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
|
||||
|
||||
/// <summary>PRC-001 — DELETE existing row returns 200.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_Existing_Returns200()
|
||||
{
|
||||
var uniqueSymbol = $"D{Guid.NewGuid():N}"[..1];
|
||||
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), null, true);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Delete,
|
||||
$"/api/v1/admin/chargeable-chars/{id}", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("id").GetInt64().Should().Be(id);
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — DELETE non-existent row returns 404.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_NotFound_Returns404()
|
||||
{
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Delete,
|
||||
"/api/v1/admin/chargeable-chars/999999998", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — DELETE emits audit event.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_AuditEventEmitted()
|
||||
{
|
||||
var uniqueSymbol = $"E{Guid.NewGuid():N}"[..1];
|
||||
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
|
||||
new DateOnly(2026, 1, 1), null, true);
|
||||
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Delete,
|
||||
$"/api/v1/admin/chargeable-chars/{id}", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK,
|
||||
because: "DELETE must succeed before checking audit");
|
||||
|
||||
await using var conn = new SqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
var auditCount = await conn.QuerySingleAsync<int>("""
|
||||
SELECT COUNT(1) FROM dbo.AuditEvent
|
||||
WHERE Action = 'tasacion.chargeable_char.delete'
|
||||
AND TargetType = 'ChargeableCharConfig'
|
||||
AND TargetId = @TargetId
|
||||
""", new { TargetId = id.ToString() });
|
||||
|
||||
auditCount.Should().Be(1,
|
||||
because: "IAuditLogger must record tasacion.chargeable_char.delete after successful DELETE");
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — DELETE without auth returns 401.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_Unauthorized_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Delete,
|
||||
"/api/v1/admin/chargeable-chars/1");
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
/// <summary>PRC-001 — DELETE without permission returns 403.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_WithoutPermission_Returns403()
|
||||
{
|
||||
var token = GetCajeroToken();
|
||||
using var req = BuildRequest(HttpMethod.Delete,
|
||||
"/api/v1/admin/chargeable-chars/1", token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.</summary>
|
||||
@@ -386,7 +610,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
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 },
|
||||
body: new { productTypeId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created,
|
||||
@@ -502,7 +726,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
req.Content = JsonContent.Create(new
|
||||
{
|
||||
medioId = (long?)null,
|
||||
productTypeId = (long?)null,
|
||||
symbol = "←",
|
||||
category = "Currency",
|
||||
pricePerUnit = 1.50m,
|
||||
|
||||
Reference in New Issue
Block a user