From 3e7c4bfde90c71e753a457372fdcb47bb0beee4c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 13:27:54 -0300 Subject: [PATCH] =?UTF-8?q?chore(prc-001):=20followups=20#54=20#55=20#57?= =?UTF-8?q?=20#58=20=E2=80=94=20emoji=20validator=20+=20opt-in=20pricing?= =?UTF-8?q?=20+=20demo=20seed=20+=20tsconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- database/README.md | 1 + database/migrations/V025_ROLLBACK.sql | 20 ++++ ...5__seed_chargeable_char_overrides_demo.sql | 101 ++++++++++++++++++ ...ateChargeableCharConfigCommandValidator.cs | 9 +- .../Pricing/WordCounter/WordCounterService.cs | 16 +++ src/web/tsconfig.json | 1 + .../ChargeableCharConfigControllerTests.cs | 23 ++-- ...argeableCharConfigCommandValidatorTests.cs | 36 ++++++- 8 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 database/migrations/V025_ROLLBACK.sql create mode 100644 database/migrations/V025__seed_chargeable_char_overrides_demo.sql diff --git a/database/README.md b/database/README.md index a8f0932..1126d38 100644 --- a/database/README.md +++ b/database/README.md @@ -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 diff --git a/database/migrations/V025_ROLLBACK.sql b/database/migrations/V025_ROLLBACK.sql new file mode 100644 index 0000000..c74d050 --- /dev/null +++ b/database/migrations/V025_ROLLBACK.sql @@ -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 diff --git a/database/migrations/V025__seed_chargeable_char_overrides_demo.sql b/database/migrations/V025__seed_chargeable_char_overrides_demo.sql new file mode 100644 index 0000000..0f06fbb --- /dev/null +++ b/database/migrations/V025__seed_chargeable_char_overrides_demo.sql @@ -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 diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs index 9fa3789..271df38 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs @@ -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) diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs index 81530ad..199a123 100644 --- a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs +++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs @@ -96,6 +96,22 @@ public sealed class WordCounterService return new WordCountResult(tokens.Length, counts); } + /// + /// 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(). + /// + 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; + } + /// /// Returns true if the given rune is an emoji codepoint. /// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ. diff --git a/src/web/tsconfig.json b/src/web/tsconfig.json index fec8c8e..a5b41e9 100644 --- a/src/web/tsconfig.json +++ b/src/web/tsconfig.json @@ -6,6 +6,7 @@ ], "compilerOptions": { "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@/*": ["./src/*"] } diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs index ce14d3e..50a64da 100644 --- a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs @@ -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 } /// - /// 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. /// [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 ──────────────────────── diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs index d6ad20e..8ff9c59 100644 --- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs @@ -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]