@@ -41,6 +41,7 @@ database/
|
||||
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
||||
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
||||
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
||||
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
||||
|
||||
## Convenciones
|
||||
|
||||
|
||||
20
database/migrations/V025_ROLLBACK.sql
Normal file
20
database/migrations/V025_ROLLBACK.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- V025_ROLLBACK.sql
|
||||
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
|
||||
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.ChargeableCharConfig
|
||||
WHERE ProductTypeId IS NOT NULL
|
||||
AND ValidTo IS NULL
|
||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||
AND ProductTypeId IN (
|
||||
SELECT Id FROM dbo.ProductType
|
||||
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
|
||||
);
|
||||
|
||||
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
|
||||
GO
|
||||
@@ -0,0 +1,101 @@
|
||||
-- V025__seed_chargeable_char_overrides_demo.sql
|
||||
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
|
||||
--
|
||||
-- Estrategia:
|
||||
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
|
||||
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
|
||||
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
|
||||
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
|
||||
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
|
||||
-- los overrides que falten (MERGE idempotente).
|
||||
--
|
||||
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
|
||||
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
|
||||
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
|
||||
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
|
||||
--
|
||||
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
|
||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Clasificado overrides
|
||||
IF @ClasificadoId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
|
||||
GO
|
||||
|
||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Notables overrides
|
||||
IF @NotablesId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
|
||||
GO
|
||||
|
||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Fúnebres overrides
|
||||
IF @FunebresId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
|
||||
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
|
||||
GO
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.WordCounter;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
@@ -19,7 +20,9 @@ public sealed class CreateChargeableCharConfigCommandValidator
|
||||
.NotEmpty()
|
||||
.WithMessage("Symbol no puede estar vacío.")
|
||||
.MaximumLength(4)
|
||||
.WithMessage("Symbol no puede exceder 4 caracteres.");
|
||||
.WithMessage("Symbol no puede exceder 4 caracteres.")
|
||||
.Must(s => !WordCounterService.ContainsEmoji(s))
|
||||
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
|
||||
|
||||
RuleFor(x => x.Category)
|
||||
.NotEmpty()
|
||||
@@ -28,8 +31,8 @@ public sealed class CreateChargeableCharConfigCommandValidator
|
||||
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
||||
|
||||
RuleFor(x => x.PricePerUnit)
|
||||
.GreaterThan(0m)
|
||||
.WithMessage("PricePerUnit debe ser > 0.");
|
||||
.GreaterThanOrEqualTo(0m)
|
||||
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
|
||||
|
||||
RuleFor(x => x.ValidFrom)
|
||||
.GreaterThanOrEqualTo(today)
|
||||
|
||||
@@ -96,6 +96,22 @@ public sealed class WordCounterService
|
||||
return new WordCountResult(tokens.Length, counts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given string contains any emoji codepoint.
|
||||
/// Used by validators that must reject emojis in user-facing identifiers
|
||||
/// (e.g. ChargeableCharConfig.Symbol) where the frontend blocker can be bypassed
|
||||
/// by direct API calls. Shares the same IsEmojiRune Unicode ranges used by Count().
|
||||
/// </summary>
|
||||
public static bool ContainsEmoji(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return false;
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
if (IsEmojiRune(rune)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given rune is an emoji codepoint.
|
||||
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -243,9 +243,11 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task Post_InvalidPrice_Returns400ValidationFailure()
|
||||
{
|
||||
// PRC-001 followup #57: PricePerUnit >= 0 is now valid (opt-in billing).
|
||||
// Use -1 to still exercise the negative rejection path.
|
||||
var token = GetAdminToken();
|
||||
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
|
||||
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = -1m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
@@ -276,25 +278,22 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001-R2.7 — Emoji symbols are explicitly DEFERRED per spec.
|
||||
/// The ChargeableCharConfig Symbol field accepts any 1–4 char value including emojis.
|
||||
/// "😀" in C# has string.Length = 2 (UTF-16 surrogate pair), so it passes MaximumLength(4).
|
||||
/// This test documents the deferred behavior: emoji in Symbol is accepted at config level.
|
||||
/// The EmojiDetectedException applies only to WordCounterService (ad text, not config symbols).
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred()
|
||||
public async Task Post_WithEmojiSymbol_Returns400()
|
||||
{
|
||||
var token = GetAdminToken();
|
||||
// "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4).
|
||||
// Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7.
|
||||
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
|
||||
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
|
||||
token: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
// Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test.
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created,
|
||||
because: "emoji symbol rejection is deferred (spec R2.7). Symbol '😀' has length 2 in C# (UTF-16) → passes MaximumLength(4)");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest,
|
||||
because: "emoji symbols are rejected by validator via WordCounterService.ContainsEmoji (#55)");
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
||||
|
||||
@@ -92,10 +92,13 @@ public class CreateChargeableCharConfigCommandValidatorTests
|
||||
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Zero_FailsValidation()
|
||||
public void PricePerUnit_Zero_Passes_OptInBilling()
|
||||
{
|
||||
// PRC-001 followup #57: opt-in billing — 0 is a valid price meaning
|
||||
// "this Symbol exists but does NOT charge extra for this ProductType".
|
||||
// Aligned with DB check constraint CK_ChargeableCharConfig_Price_NonNegative (>= 0).
|
||||
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,6 +115,35 @@ public class CreateChargeableCharConfigCommandValidatorTests
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
// ── Symbol emoji rejection (PRC-001 followup #55) ───────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("😀")] // smiley face (U+1F600, Emoticons block)
|
||||
[InlineData("🚗")] // car (U+1F697, Transport block)
|
||||
[InlineData("🔥")] // fire (U+1F525, Misc Symbols & Pictographs)
|
||||
[InlineData("❤️")] // red heart + VS-16 (U+2764 U+FE0F)
|
||||
[InlineData("☀️")] // sun (U+2600 in Misc Symbols block)
|
||||
public void Symbol_WithEmoji_FailsValidation(string emojiSymbol)
|
||||
{
|
||||
var cmd = ValidCmd() with { Symbol = emojiSymbol };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("$")]
|
||||
[InlineData("%")]
|
||||
[InlineData("!")]
|
||||
[InlineData("¡")]
|
||||
[InlineData("@")]
|
||||
[InlineData("€")]
|
||||
[InlineData("##")]
|
||||
[InlineData("ABCD")]
|
||||
public void Symbol_WithoutEmoji_Passes(string plainSymbol)
|
||||
{
|
||||
var cmd = ValidCmd() with { Symbol = plainSymbol };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user