From 9144c2e89e8bdd4487267b406e53800bfc78a779 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:01:49 -0300 Subject: [PATCH 01/14] =?UTF-8?q?feat(bd):=20V021=20crea=20dbo.ChargeableC?= =?UTF-8?q?harConfig=20+=20SPs=20+=20=C3=ADndices=20(PRC-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/migrations/V021_ROLLBACK.sql | 79 ++++ .../V021__create_chargeable_char_config.sql | 256 ++++++++++++ .../Auth/AuthControllerTests.cs | 5 +- .../Permisos/PermisosEndpointTests.cs | 10 +- .../ChargeableCharConfigMigrationTests.cs | 365 ++++++++++++++++++ .../Integration/PermisoRepositoryTests.cs | 5 +- .../Integration/RolPermisoRepositoryTests.cs | 5 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 252 +++++++++++- 8 files changed, 966 insertions(+), 11 deletions(-) create mode 100644 database/migrations/V021_ROLLBACK.sql create mode 100644 database/migrations/V021__create_chargeable_char_config.sql create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs diff --git a/database/migrations/V021_ROLLBACK.sql b/database/migrations/V021_ROLLBACK.sql new file mode 100644 index 0000000..28a7370 --- /dev/null +++ b/database/migrations/V021_ROLLBACK.sql @@ -0,0 +1,79 @@ +-- V021_ROLLBACK.sql +-- PRC-001: Reversa de V021__create_chargeable_char_config.sql. +-- +-- Pasos: +-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE). +-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime. +-- 3. Drop de dbo.ChargeableCharConfig_History. +-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada). +-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose. +-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio. +-- +-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal). +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF); + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.'; +END +GO + +-- 2. Elimina el PERIOD y las hidden cols. +IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL +BEGIN + ALTER TABLE dbo.ChargeableCharConfig + DROP PERIOD FOR SYSTEM_TIME; + + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime') + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime; + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime') + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime; + + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime; + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime; + PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.'; +END +GO + +-- 3. Drop de la history table. +IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ChargeableCharConfig_History; + PRINT 'Table dbo.ChargeableCharConfig_History dropped.'; +END +GO + +-- 4. Drop de la tabla principal. +IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ChargeableCharConfig; + PRINT 'Table dbo.ChargeableCharConfig dropped.'; +END +GO + +-- 5. Drop del SP InsertWithClose. +IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; + PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.'; +END +GO + +-- 6. Drop del SP GetActiveForMedio. +IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.'; +END +GO + +PRINT ''; +PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.'; +GO diff --git a/database/migrations/V021__create_chargeable_char_config.sql b/database/migrations/V021__create_chargeable_char_config.sql new file mode 100644 index 0000000..867d403 --- /dev/null +++ b/database/migrations/V021__create_chargeable_char_config.sql @@ -0,0 +1,256 @@ +-- V021__create_chargeable_char_config.sql +-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil. +-- +-- Cambios: +-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio. +-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only). +-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global). +-- +-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V021_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- Notas: +-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4). +-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8). +-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6). +-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9). +-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7). +-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn). +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.ChargeableCharConfig +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ChargeableCharConfig ( + Id BIGINT IDENTITY(1,1) NOT NULL + CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY, + MedioId INT NULL, -- NULL = global fallback + Symbol NVARCHAR(4) NOT NULL, + Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other + PricePerUnit DECIMAL(18,4) NOT NULL, + ValidFrom DATE NOT NULL, + ValidTo DATE NULL, + IsActive BIT NOT NULL + CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL + CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()), + CONSTRAINT FK_ChargeableCharConfig_Medio + FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT CK_ChargeableCharConfig_Price_Positive + CHECK (PricePerUnit > 0), + CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty + CHECK (LEN(Symbol) > 0), + CONSTRAINT CK_ChargeableCharConfig_ValidRange + CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom) + ); + PRINT 'Table dbo.ChargeableCharConfig created.'; +END +ELSE + PRINT 'Table dbo.ChargeableCharConfig already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — ChargeableCharConfig +-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4). +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL +BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD + SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()), + SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); + PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).'; +END +ELSE + PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'ChargeableCharConfig_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Un único vigente por (MedioId, Symbol). +-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo +-- → enforza exactamente 1 vigente global por símbolo (D7). +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig') +) +BEGIN + CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (MedioId, Symbol) + WHERE ValidTo IS NULL; + PRINT 'Index UX_ChargeableCharConfig_Vigente created.'; +END +GO + +-- Cover para GetActiveForMedio y List. +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig') +) +BEGIN + CREATE INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + PRINT 'Index IX_ChargeableCharConfig_Query created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose +-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. +-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL. +-- THROW 50404: Medio not found. +-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom. +-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio). +-- ═══════════════════════════════════════════════════════════════════════ +GO +CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @MedioId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido). + IF @MedioId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId) + BEGIN + ROLLBACK; + THROW 50404, 'Medio not found', 1; + END + + -- Lee el vigente actual con bloqueo de rango para serialización. + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@MedioId IS NULL AND MedioId IS NULL) + OR (@MedioId IS NOT NULL AND MedioId = @MedioId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + -- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo. + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + -- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día. + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + -- Inserta el nuevo vigente. + INSERT INTO dbo.ChargeableCharConfig + (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio +-- Resolución per-medio + global fallback: 1 fila por Symbol. +-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1). +-- ═══════════════════════════════════════════════════════════════════════ +GO +CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio + @MedioId INT, + @AsOfDate DATE +AS +BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (MedioId = @MedioId OR MedioId IS NULL) + ) + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; +END +GO + +PRINT ''; +PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.'; +PRINT 'Next migration: V022 (seed ChargeableCharConfig).'; +GO diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 7eeb4b8..a7a644b 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -52,8 +52,9 @@ public class AuthControllerTests // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, permisos.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index a363606..70d5952 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -142,8 +142,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, list.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, list.GetArrayLength()); } [Fact] @@ -199,8 +200,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 - // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total - Assert.Equal(27, list.GetArrayLength()); + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 + // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total + Assert.Equal(28, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs new file mode 100644 index 0000000..821662e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs @@ -0,0 +1,365 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Pricing; + +/// +/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. +/// These tests verify the BD schema applied against SIGCM2_Test_App: +/// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. +/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. +/// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. +/// +/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). +/// After GREEN, all tests in this class should pass. +/// +[Collection("Database")] +public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime +{ + private SqlConnection _connection = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(TestConnectionStrings.AppTestDb); + await _connection.OpenAsync(); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── V021: Table existence ───────────────────────────────────────────── + + [Fact] + public async Task V021_Table_ChargeableCharConfig_Exists() + { + var exists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "V021 debe crear dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V021_Table_ChargeableCharConfig_HasSystemVersioning() + { + var temporalType = await _connection.ExecuteScalarAsync(@" + SELECT temporal_type + FROM sys.tables + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + temporalType.Should().Be(2, "SYSTEM_VERSIONING = ON requiere temporal_type = 2"); + } + + [Fact] + public async Task V021_HistoryTable_ChargeableCharConfigHistory_Exists() + { + var exists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')"); + + exists.Should().Be(1, "SYSTEM_VERSIONING debe crear dbo.ChargeableCharConfig_History"); + } + + [Fact] + public async Task V021_FilteredUniqueIndex_UX_ChargeableCharConfig_Vigente_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.indexes + WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "UX_ChargeableCharConfig_Vigente filtered index debe existir"); + } + + [Fact] + public async Task V021_CoverIndex_IX_ChargeableCharConfig_Query_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.indexes + WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "IX_ChargeableCharConfig_Query covering index debe existir"); + } + + // ── V021: SP — usp_ChargeableCharConfig_InsertWithClose ────────────── + + [Fact] + public async Task V021_SP_InsertWithClose_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose') + AND type = 'P'"); + + exists.Should().Be(1, "usp_ChargeableCharConfig_InsertWithClose debe existir"); + } + + [Fact] + public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() + { + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", "@", System.Data.DbType.String); + p.Add("@Category", "Other",System.Data.DbType.String); + p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p.Get("@NewId"); + var closedId = p.Get("@ClosedId"); + + newId.Should().BeGreaterThan(0, "primer insert debe devolver NewId > 0"); + closedId.Should().BeNull("primer insert no cierra nada — ClosedId debe ser NULL"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_HappyPath_ClosesAndCreates() + { + // Seed primer activo + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "€", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + var firstId = p1.Get("@NewId")!.Value; + + // Insertar segundo (debe cerrar el primero) + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "€", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p2.Get("@NewId")!.Value; + var closedId = p2.Get("@ClosedId"); + + newId.Should().BeGreaterThan(firstId); + closedId.Should().Be(firstId, "el primer activo debe ser cerrado"); + + // Verify el activo cerrado tiene ValidTo = 2026-01-31 + var closedValidTo = await _connection.ExecuteScalarAsync( + "SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id", + new { Id = firstId }); + closedValidTo.Should().Be(new DateTime(2026, 1, 31), + "ValidTo del cerrado = ValidFrom(nuevo) - 1 día"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "£", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + + // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "£", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + var act = async () => await _connection.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + await act.Should().ThrowAsync() + .Where(ex => ex.Number == 50409, + "ValidFrom igual al activo debe generar THROW 50409 (forward-only)"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); + } + + [Fact] + public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", "¥", System.Data.DbType.String); + p.Add("@Category", "Currency", System.Data.DbType.String); + p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p, + commandType: System.Data.CommandType.StoredProcedure); + + var newId = p.Get("@NewId"); + newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); + } + + [Fact] + public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() + { + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); + + var p1 = new DynamicParameters(); + p1.Add("@MedioId", null, System.Data.DbType.Int32); + p1.Add("@Symbol", "#", System.Data.DbType.String); + p1.Add("@Category", "Other", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, + commandType: System.Data.CommandType.StoredProcedure); + var firstId = p1.Get("@NewId")!.Value; + + // Close it by inserting a newer version + var p2 = new DynamicParameters(); + p2.Add("@MedioId", null, System.Data.DbType.Int32); + p2.Add("@Symbol", "#", System.Data.DbType.String); + p2.Add("@Category", "Other", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + direction: System.Data.ParameterDirection.Output); + + await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, + commandType: System.Data.CommandType.StoredProcedure); + + // The UPDATE on the closed row should produce a history row + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id", + new { Id = firstId }); + + historyCount.Should().BeGreaterThanOrEqualTo(1, + "UPDATE en SYSTEM_VERSIONED table debe producir al menos 1 fila en history"); + + // Cleanup + await _connection.ExecuteAsync( + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); + } + + // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── + + [Fact] + public async Task V021_SP_GetActiveForMedio_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') + AND type = 'P'"); + + exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); + } + + // ── V020: Permission existence ──────────────────────────────────────── + + [Fact] + public async Task V020_Permission_tasacion_caracteres_especiales_gestionar_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.Permiso + WHERE Codigo = 'tasacion:caracteres_especiales:gestionar'"); + + exists.Should().Be(1, + "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); + } + + // ── V022: Seed rows ─────────────────────────────────────────────────── + + [Fact] + public async Task V022_Seeds_AtLeastFourGlobalRows() + { + var count = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL AND IsActive = 1"); + + count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); + } + + [Fact] + public async Task V022_AllSeedRowsHaveIsActive_True() + { + var inactiveCount = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + AND IsActive = 0"); + + inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index 1f1f9e4..961b8c0 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -83,8 +83,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' - // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total - Assert.Equal(27, list.Count); + // + V018 (PRD-002) adds 'catalogo:productos:gestionar' + // + V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' = 28 total + Assert.Equal(28, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index dc21349..9a2b887 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -181,10 +181,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' // + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' - // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total + // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' + // + 1 from V020 (PRC-001): 'tasacion:caracteres_especiales:gestionar' = 28 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(27, permisos.Count); + Assert.Equal(28, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index f2bf05a..775cdaa 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -69,6 +69,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice. await EnsureV019SchemaAsync(); + // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed. + await EnsureV021SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -101,6 +104,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "Product_History"), // PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING. new Respawn.Graph.Table("dbo", "ProductPrices_History"), + // PRC-001 (V021): ChargeableCharConfig es temporal — history protegida por SYSTEM_VERSIONING. + new Respawn.Graph.Table("dbo", "ChargeableCharConfig_History"), ] }); @@ -122,6 +127,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); + await SeedChargeableCharConfigCanonicalAsync(); } private async Task SeedRolCanonicalAsync() @@ -227,7 +233,9 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V017 (PRD-001): permiso para gestionar tipos de producto ('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'), -- V018 (PRD-002): permiso para gestionar productos del catálogo - ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo') + ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo'), + -- V020 (PRC-001): permiso para gestionar caracteres tasables + ('tasacion:caracteres_especiales:gestionar', N'Gestionar caracteres tasables', N'Crear, editar precio y desactivar la configuracion de caracteres especiales para tasacion.', 'tasacion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -279,6 +287,8 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'catalogo:tipos:gestionar'), -- V018 (PRD-002) ('admin', 'catalogo:productos:gestionar'), + -- V020 (PRC-001) + ('admin', 'tasacion:caracteres_especiales:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -563,6 +573,34 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(sql); } + /// + /// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. + /// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente). + /// The table itself is never added to TablesToIgnore because per-medio test rows + /// must be reset between test classes — only the 4 global defaults are reseeded. + /// + private async Task SeedChargeableCharConfigCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + BEGIN + MERGE dbo.ChargeableCharConfig AS t + USING (VALUES + (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) + ) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) + ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) + WHEN NOT MATCHED THEN + INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); + END + """; + await _connection.ExecuteAsync(sql); + } + /// /// UDT-010 (V010): verifies that the audit infrastructure is present. /// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition @@ -1267,4 +1305,216 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(createSp); await _connection.ExecuteAsync(alterSp); } + + /// + /// PRC-001 (V020/V021/V022): applies dbo.ChargeableCharConfig schema + SYSTEM_VERSIONING + /// + filtered UX + SPs + permission 'tasacion:caracteres_especiales:gestionar' + seed data. + /// Mirrors V020+V021+V022 migrations (idempotente). + /// Permission y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync. + /// IMPORTANT: dbo.ChargeableCharConfig_History must be in TablesToIgnore — SYSTEM_VERSIONING + /// prevents Respawn from directly truncating history tables (engine rejects). + /// + private async Task EnsureV021SchemaAsync() + { + const string createTable = """ + IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL + BEGIN + CREATE TABLE dbo.ChargeableCharConfig ( + Id BIGINT IDENTITY(1,1) NOT NULL + CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY, + MedioId INT NULL, + Symbol NVARCHAR(4) NOT NULL, + Category NVARCHAR(32) NOT NULL, + PricePerUnit DECIMAL(18,4) NOT NULL, + ValidFrom DATE NOT NULL, + ValidTo DATE NULL, + IsActive BIT NOT NULL + CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL + CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()), + CONSTRAINT FK_ChargeableCharConfig_Medio + FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT CK_ChargeableCharConfig_Price_Positive + CHECK (PricePerUnit > 0), + CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty + CHECK (LEN(Symbol) > 0), + CONSTRAINT CK_ChargeableCharConfig_ValidRange + CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom) + ); + END + """; + + const string addPeriod = """ + IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD + SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()), + SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); + END + """; + + const string setVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string createVigenteIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (MedioId, Symbol) + WHERE ValidTo IS NULL; + END + """; + + const string createQueryIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + CREATE INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + END + """; + + const string createInsertSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); + """; + + const string alterInsertSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @MedioId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF @MedioId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId) + BEGIN + ROLLBACK; + THROW 50404, 'Medio not found', 1; + END + + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@MedioId IS NULL AND MedioId IS NULL) + OR (@MedioId IS NOT NULL AND MedioId = @MedioId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ChargeableCharConfig + (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + const string createGetActiveSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0'); + """; + + const string alterGetActiveSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio + @MedioId INT, + @AsOfDate DATE + AS + BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (MedioId = @MedioId OR MedioId IS NULL) + ) + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; + END + """; + + const string seedV022 = """ + MERGE dbo.ChargeableCharConfig AS t + USING (VALUES + (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) + ) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) + ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) + WHEN NOT MATCHED THEN + INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); + """; + + await _connection.ExecuteAsync(createTable); + await _connection.ExecuteAsync(addPeriod); + await _connection.ExecuteAsync(setVersioning); + await _connection.ExecuteAsync(createVigenteIndex); + await _connection.ExecuteAsync(createQueryIndex); + await _connection.ExecuteAsync(createInsertSp); + await _connection.ExecuteAsync(alterInsertSp); + await _connection.ExecuteAsync(createGetActiveSp); + await _connection.ExecuteAsync(alterGetActiveSp); + await _connection.ExecuteAsync(seedV022); + // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment + // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + } } From 8ac91a13aa7697bc2fa7332a25d97cbb7c98b9f2 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:01:55 -0300 Subject: [PATCH 02/14] feat(bd): V020 permiso + V022 seed ChargeableCharConfig (PRC-001) --- database/migrations/V020_ROLLBACK.sql | 33 ++++++++++++ .../V020__add_chargeable_chars_permission.sql | 54 +++++++++++++++++++ database/migrations/V022_ROLLBACK.sql | 23 ++++++++ .../V022__seed_chargeable_char_config.sql | 44 +++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 database/migrations/V020_ROLLBACK.sql create mode 100644 database/migrations/V020__add_chargeable_chars_permission.sql create mode 100644 database/migrations/V022_ROLLBACK.sql create mode 100644 database/migrations/V022__seed_chargeable_char_config.sql diff --git a/database/migrations/V020_ROLLBACK.sql b/database/migrations/V020_ROLLBACK.sql new file mode 100644 index 0000000..b305604 --- /dev/null +++ b/database/migrations/V020_ROLLBACK.sql @@ -0,0 +1,33 @@ +-- V020_ROLLBACK.sql +-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql. +-- +-- Pasos: +-- 1. Elimina la asignación del permiso al rol 'admin'. +-- 2. Elimina el permiso del catálogo. +-- +-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente, +-- la FK de RolPermiso causará error. Limpiar RolPermiso primero. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. Eliminar asignaciones del permiso a cualquier rol. +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar'; +PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.'; +GO + +-- 2. Eliminar el permiso del catálogo. +DELETE FROM dbo.Permiso +WHERE Codigo = 'tasacion:caracteres_especiales:gestionar'; +PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.'; +GO + +PRINT ''; +PRINT 'V020 rollback complete.'; +GO diff --git a/database/migrations/V020__add_chargeable_chars_permission.sql b/database/migrations/V020__add_chargeable_chars_permission.sql new file mode 100644 index 0000000..6fabeca --- /dev/null +++ b/database/migrations/V020__add_chargeable_chars_permission.sql @@ -0,0 +1,54 @@ +-- V020__add_chargeable_chars_permission.sql +-- PRC-001: permiso RBAC para ABM de caracteres tasables. +-- +-- Cambios: +-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo. +-- 2. Asigna el permiso al rol 'admin'. +-- +-- Convención RBAC: modulo:recurso:accion. +-- Patrón: V007 (MERGE idempotente). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V020_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir +-- antes de que la API arranque con [RequirePermission(...)]. +-- V021 crea la tabla dbo.ChargeableCharConfig. +-- V022 siembra las 4 filas globales por defecto. +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Agregar permiso al catálogo (idempotente via MERGE). +MERGE dbo.Permiso AS t +USING (VALUES + ('tasacion:caracteres_especiales:gestionar', + N'Gestionar caracteres tasables', + N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.', + 'tasacion') +) AS s (Codigo, Nombre, Descripcion, Modulo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +-- Asignar a rol 'admin' (idempotente via MERGE). +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM dbo.Rol r + CROSS JOIN dbo.Permiso p + WHERE r.Codigo = 'admin' + AND p.Codigo = 'tasacion:caracteres_especiales:gestionar' +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); +GO + +PRINT 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.'; +GO diff --git a/database/migrations/V022_ROLLBACK.sql b/database/migrations/V022_ROLLBACK.sql new file mode 100644 index 0000000..efe087a --- /dev/null +++ b/database/migrations/V022_ROLLBACK.sql @@ -0,0 +1,23 @@ +-- V022_ROLLBACK.sql +-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql. +-- +-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL). +-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal. +-- +-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora +-- (ya no son vigentes). La historia temporal queda intacta. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +DELETE FROM dbo.ChargeableCharConfig +WHERE MedioId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; +GO + +PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.'; +GO diff --git a/database/migrations/V022__seed_chargeable_char_config.sql b/database/migrations/V022__seed_chargeable_char_config.sql new file mode 100644 index 0000000..084265e --- /dev/null +++ b/database/migrations/V022__seed_chargeable_char_config.sql @@ -0,0 +1,44 @@ +-- V022__seed_chargeable_char_config.sql +-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto. +-- +-- Cambios: +-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000. +-- El equipo de negocio seteará los valores reales desde el CMS. +-- +-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V022_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- Depends on: V021 (dbo.ChargeableCharConfig must exist). +-- +-- Notas: +-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista +-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio). +-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026. +-- - ValidTo NULL = vigente (sin fecha de cierre). +-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación. +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +MERGE dbo.ChargeableCharConfig AS t +USING (VALUES + (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) +) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) +ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) +WHEN NOT MATCHED THEN + INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); +GO + +PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).'; +PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.'; +GO From ded76fcdc70856eac8cf8fffa1b9a1909783db88 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:13:06 -0300 Subject: [PATCH 03/14] feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordCounterService: pure domain service, 7-step algorithm (null/empty fast path → length check → emoji detection via Rune.EnumerateRunes → count specials before replace → replace specials+hyphens → collapse whitespace → tokenize) - WordCountResult: sealed record with TotalWords + IReadOnlyDictionary SpecialCharCounts - 4 domain exceptions extending DomainException: EmojiDetectedException, WordCountValidationException, ChargeableCharConfigInvalidException, ChargeableCharConfigForwardOnlyException - ChargeableCharConfig: rich entity with Create factory (invariants), Rehydrate reconstructor, ScheduleNewPrice (forward-only, returns new entity), Deactivate (idempotent) - ChargeableCharCategories: enum-as-string constants (Currency, Percentage, Exclamation, Question, Other) - DomainTimeProviderExtensions: internal GetArgentinaToday helper (mirrors Application.Common without creating Domain→Application dependency) - 60 new tests: 25 golden cases all GREEN, 12 entity invariant tests, 12 exception tests, 5 WordCountResult tests, 6 ChargeableCharConfig entity tests --- .../ChargeableCharCategories.cs | 22 ++ .../ChargeableChars/ChargeableCharConfig.cs | 112 ++++++ ...hargeableCharConfigForwardOnlyException.cs | 28 ++ .../ChargeableCharConfigInvalidException.cs | 22 ++ .../Exceptions/EmojiDetectedException.cs | 19 + .../WordCountValidationException.cs | 21 ++ .../Pricing/TimeProviderExtensions.cs | 34 ++ .../Pricing/WordCounter/WordCountResult.cs | 10 + .../Pricing/WordCounter/WordCounterService.cs | 128 +++++++ .../ChargeableCharConfigTests.cs | 233 +++++++++++++ .../Exceptions/PricingExceptionTests.cs | 146 ++++++++ .../WordCounter/WordCountResultTests.cs | 56 +++ .../WordCounterGoldenCasesTests.cs | 328 ++++++++++++++++++ 13 files changed, 1159 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs new file mode 100644 index 0000000..3088257 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs @@ -0,0 +1,22 @@ +namespace SIGCM2.Domain.Pricing.ChargeableChars; + +/// +/// PRC-001 — Canonical category names for chargeable characters. +/// Persisted as nvarchar(32) in the database (enum-as-string). +/// +public static class ChargeableCharCategories +{ + public const string Currency = "Currency"; + public const string Percentage = "Percentage"; + public const string Exclamation = "Exclamation"; + public const string Question = "Question"; + public const string Other = "Other"; + + private static readonly HashSet Valid = new(StringComparer.Ordinal) + { + Currency, Percentage, Exclamation, Question, Other + }; + + /// Returns true if the given category string is a known valid category. + public static bool IsValid(string? category) => category != null && Valid.Contains(category); +} diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs new file mode 100644 index 0000000..5d4337a --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs @@ -0,0 +1,112 @@ +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Domain.Pricing.ChargeableChars; + +/// +/// PRC-001 — Rich domain entity for chargeable character configuration. +/// Represents a price-per-occurrence for a special character in classified ad text, +/// scoped to a Medio (MedioId) or global (MedioId = null). +/// +/// Forward-only price history: each new price schedules a NEW row; the current row +/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate +/// this instance — it returns a new one. The actual close+insert happens in the repository. +/// +/// MedioId = null → global default (lowest priority, overridden by per-medio row). +/// +public sealed class ChargeableCharConfig +{ + public long Id { get; } + public int? MedioId { get; } + public string Symbol { get; } + public string Category { get; } + public decimal PricePerUnit { get; private set; } + public DateOnly ValidFrom { get; } + public DateOnly? ValidTo { get; private set; } + public bool IsActive { get; private set; } + + private ChargeableCharConfig( + long id, int? medioId, string symbol, string category, + decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive) + { + Id = id; + MedioId = medioId; + Symbol = symbol; + Category = category; + PricePerUnit = price; + ValidFrom = validFrom; + ValidTo = validTo; + IsActive = isActive; + } + + /// + /// Factory for new configs. Enforces all domain invariants. + /// Id is set to 0 until the entity is persisted. + /// + public static ChargeableCharConfig Create( + int? medioId, string symbol, string category, decimal price, DateOnly validFrom) + { + if (string.IsNullOrWhiteSpace(symbol)) + throw new ChargeableCharConfigInvalidException( + nameof(Symbol), "Symbol no puede estar vacío."); + + if (symbol.Length > 4) + throw new ChargeableCharConfigInvalidException( + nameof(Symbol), "Symbol no puede exceder 4 caracteres."); + + if (price <= 0m) + throw new ChargeableCharConfigInvalidException( + nameof(PricePerUnit), "PricePerUnit debe ser > 0."); + + if (!ChargeableCharCategories.IsValid(category)) + throw new ChargeableCharConfigInvalidException( + nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other."); + + return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true); + } + + /// + /// Reconstructor from database (no validation). Used by the repository mapper only. + /// Allows creating entities with any state (e.g., IsActive=false, ValidTo set). + /// + public static ChargeableCharConfig Rehydrate( + long id, int? medioId, string symbol, string category, + decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive) + => new(id, medioId, symbol, category, price, validFrom, validTo, isActive); + + /// + /// Schedules a new price (forward-only semantics). + /// Returns a NEW ChargeableCharConfig instance with the updated price and validFrom. + /// This instance is NOT mutated — the close+insert of rows happens in the repository via SP. + /// + /// Validates: + /// - newValidFrom >= today (Argentina) via TimeProvider + /// - newValidFrom > current ValidFrom (strictly greater — forward-only) + /// - newPrice > 0 + /// + public ChargeableCharConfig ScheduleNewPrice(decimal newPrice, DateOnly newValidFrom, TimeProvider timeProvider) + { + var today = timeProvider.GetArgentinaToday(); + + if (newValidFrom < today) + throw new ChargeableCharConfigInvalidException( + nameof(ValidFrom), + $"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd})."); + + if (newValidFrom <= ValidFrom) + throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom); + + // Create validates price > 0 and category — reuse factory + return Create(MedioId, Symbol, Category, newPrice, newValidFrom); + } + + /// + /// Deactivates this config row. Sets IsActive = false and ValidTo = today. + /// Idempotent: no-op if already inactive. + /// + public void Deactivate(DateOnly today) + { + if (!IsActive) return; // idempotent + IsActive = false; + ValidTo = today; + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs new file mode 100644 index 0000000..e82c1a9 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs @@ -0,0 +1,28 @@ +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — Thrown when attempting to schedule a new price with a ValidFrom that is +/// not strictly greater than the currently active row's ValidFrom. → HTTP 409 +/// +public sealed class ChargeableCharConfigForwardOnlyException : DomainException +{ + public int? MedioId { get; } + public string Symbol { get; } + public DateOnly NewValidFrom { get; } + public DateOnly ActiveValidFrom { get; } + + public ChargeableCharConfigForwardOnlyException( + int? medioId, + string symbol, + DateOnly newValidFrom, + DateOnly activeValidFrom) + : base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).") + { + MedioId = medioId; + Symbol = symbol; + NewValidFrom = newValidFrom; + ActiveValidFrom = activeValidFrom; + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs new file mode 100644 index 0000000..3334780 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs @@ -0,0 +1,22 @@ +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — Thrown when a ChargeableCharConfig value fails domain business validation +/// (e.g., PricePerUnit ≤ 0, Symbol empty/too long, Category unknown, ValidFrom in the past). +/// → HTTP 400 +/// Used as defense-in-depth alongside FluentValidation in the Application layer. +/// +public sealed class ChargeableCharConfigInvalidException : DomainException +{ + public string Field { get; } + public string Reason { get; } + + public ChargeableCharConfigInvalidException(string field, string reason) + : base($"Valor inválido para {field}: {reason}") + { + Field = field; + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs new file mode 100644 index 0000000..17c68bf --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs @@ -0,0 +1,19 @@ +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — Thrown when the input text contains any Unicode emoji codepoint. +/// Emoji detection occurs BEFORE normalization. → HTTP 400 +/// +public sealed class EmojiDetectedException : DomainException +{ + /// The Unicode codepoint value of the first detected emoji rune. + public int DetectedCodepoint { get; } + + public EmojiDetectedException(int detectedCodepoint) + : base($"El texto contiene emojis (U+{detectedCodepoint:X4}), que no son tarifables. Eliminálos antes de continuar.") + { + DetectedCodepoint = detectedCodepoint; + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs new file mode 100644 index 0000000..ed80bd3 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs @@ -0,0 +1,21 @@ +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — Thrown when WordCounterService input fails validation +/// (e.g., exceeds maximum length of 2000 chars). → HTTP 400 +/// Used as defense-in-depth alongside FluentValidation in the Application layer. +/// +public sealed class WordCountValidationException : DomainException +{ + public string Field { get; } + public string Reason { get; } + + public WordCountValidationException(string field, string reason) + : base($"Valor inválido para {field}: {reason}") + { + Field = field; + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs b/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs new file mode 100644 index 0000000..8534324 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs @@ -0,0 +1,34 @@ +namespace SIGCM2.Domain.Pricing; + +/// +/// Domain-layer extension for TimeProvider: returns Argentina civil date. +/// Mirrors SIGCM2.Application.Common.TimeProviderArgentinaExtensions +/// but lives in Domain to avoid a Domain → Application dependency. +/// Domain layer is pure — no Application references allowed. +/// +internal static class DomainTimeProviderExtensions +{ + private const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires"; + private const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time"; + + private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz(); + + internal static DateOnly GetArgentinaToday(this TimeProvider timeProvider) + { + var utcNow = timeProvider.GetUtcNow(); + var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz); + return DateOnly.FromDateTime(argentinaNow.DateTime); + } + + private static TimeZoneInfo LoadArgentinaTz() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows); + } + } +} diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs new file mode 100644 index 0000000..9d9e9ec --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Pricing.WordCounter; + +/// +/// PRC-001 — Immutable value object representing the result of a word count operation. +/// TotalWords: number of whitespace-separated tokens after normalization. +/// SpecialCharCounts: map of category name → occurrence count in the ORIGINAL (pre-normalization) text. +/// +public sealed record WordCountResult( + int TotalWords, + IReadOnlyDictionary SpecialCharCounts); diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs new file mode 100644 index 0000000..81530ad --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs @@ -0,0 +1,128 @@ +using System.Text; +using System.Text.RegularExpressions; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Domain.Pricing.WordCounter; + +/// +/// PRC-001 — Pure domain service for counting words in classified ad text. +/// +/// Algorithm (in order): +/// 1. Null/empty → WordCountResult(0, empty) +/// 2. Length check: rawText.Length > 2000 → WordCountValidationException +/// 3. Emoji detection via Rune.EnumerateRunes + IsEmojiRune → EmojiDetectedException +/// 4. Count special chars by category (BEFORE replacement — anti-fraud ordering) +/// 5. Replace specials with space; normalize line breaks; collapse whitespace; trim +/// 6. Split(' ', RemoveEmptyEntries) → token count +/// 7. Return WordCountResult +/// +/// Tildes (á é í ó ú ñ etc.) are regular word letters — NOT specials. +/// Hyphens are NOT specials — they split words naturally via whitespace split only when +/// they appear as separators between non-whitespace chars (e.g. "buen-estado" → the hyphen +/// itself becomes a word boundary because Split splits on spaces only, so hyphenated words +/// are NOT split by default). Wait — spec GC-18: "buen-estado casi-nuevo" → TotalWords=4. +/// This means hyphen DOES split. The tokenizer must split on hyphen too. +/// +/// Design resolution: after normalization, split on whitespace OR hyphen. +/// Hyphens are treated as word boundaries (split token), not as specials counted in SpecialCharCounts. +/// +public sealed class WordCounterService +{ + public const int MaxInputLength = 2000; + + // Category patterns — order matters for counting (BEFORE replacement) + private static readonly (string Category, Regex Pattern)[] CategoryPatterns = + [ + ("Currency", new Regex(@"[\$€¥£]", RegexOptions.Compiled)), + ("Percentage", new Regex(@"%", RegexOptions.Compiled)), + ("Exclamation", new Regex(@"[!¡]", RegexOptions.Compiled)), + ("Question", new Regex(@"[?¿]", RegexOptions.Compiled)), + ]; + + // Collapses any run of spaces/tabs into a single space + private static readonly Regex WhitespaceCollapseRegex = new(@"[ \t]+", RegexOptions.Compiled); + + public WordCountResult Count(string? rawText) + { + // Step 1: null/empty fast path + if (string.IsNullOrEmpty(rawText)) + return new WordCountResult(0, new Dictionary()); + + // Step 2: length check (on raw input, pre-normalization) + if (rawText.Length > MaxInputLength) + throw new WordCountValidationException( + nameof(rawText), + $"El texto supera el máximo de {MaxInputLength} caracteres."); + + // Step 3: emoji detection — fail-fast on first emoji rune found + foreach (var rune in rawText.EnumerateRunes()) + { + if (IsEmojiRune(rune)) + throw new EmojiDetectedException(rune.Value); + } + + // Step 4: count specials by category on ORIGINAL text (anti-fraud ordering) + var counts = new Dictionary(); + foreach (var (category, pattern) in CategoryPatterns) + { + var matchCount = pattern.Matches(rawText).Count; + if (matchCount > 0) + counts[category] = matchCount; + } + + // Step 5: normalize + // 5a. Replace specials with space (each occurrence → space, enabling anti-fraud split) + var normalized = rawText; + foreach (var (_, pattern) in CategoryPatterns) + normalized = pattern.Replace(normalized, " "); + + // 5b. Normalize line endings to space + normalized = normalized.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' '); + + // 5c. Replace hyphens with space (GC-18: "buen-estado" → 2 tokens) + // Hyphens are word-boundary separators, not special counted chars. + normalized = normalized.Replace('-', ' '); + + // 5d. Collapse consecutive spaces/tabs to single space, then trim + normalized = WhitespaceCollapseRegex.Replace(normalized, " ").Trim(); + + // Step 6: tokenize on space + if (string.IsNullOrEmpty(normalized)) + return new WordCountResult(0, counts); + + var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // Step 7: return result + return new WordCountResult(tokens.Length, counts); + } + + /// + /// Returns true if the given rune is an emoji codepoint. + /// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ. + /// + internal static bool IsEmojiRune(Rune r) + { + int v = r.Value; + + // Main emoji blocks + if (v >= 0x1F300 && v <= 0x1F5FF) return true; // Misc Symbols & Pictographs + if (v >= 0x1F600 && v <= 0x1F64F) return true; // Emoticons + if (v >= 0x1F680 && v <= 0x1F6FF) return true; // Transport & Map + if (v >= 0x1F700 && v <= 0x1F77F) return true; // Alchemical Symbols + if (v >= 0x1F900 && v <= 0x1F9FF) return true; // Supplemental Symbols & Pictographs + if (v >= 0x1FA00 && v <= 0x1FAFF) return true; // Symbols and Pictographs Extended-A + + // Misc Symbols and Dingbats (conditional — many non-emoji chars here too, + // but the design includes these ranges) + if (v >= 0x2600 && v <= 0x26FF) return true; // Misc Symbols (⚡☀️etc.) + if (v >= 0x2700 && v <= 0x27BF) return true; // Dingbats (✂️✈️etc.) + + // Variation Selector-16 (U+FE0F) — used to force emoji presentation + if (v == 0xFE0F) return true; + + // Zero Width Joiner (U+200D) — used in compound emoji (👨‍👩‍👧, 🧑‍🤝‍🧑) + if (v == 0x200D) return true; + + return false; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs new file mode 100644 index 0000000..1f74e64 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs @@ -0,0 +1,233 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars; + +/// +/// PRC-001 — T2.3 Domain unit tests for ChargeableCharConfig entity. +/// Covers: factory invariants, ScheduleNewPrice forward-only, Deactivate idempotency. +/// +public sealed class ChargeableCharConfigTests +{ + // Fixed "today" for Argentina: 2026-04-20 + private static FakeTimeProvider MakeFakeTimeProvider(DateOnly argentinaToday) + { + var fp = new FakeTimeProvider(); + // Argentina is UTC-3. Set UTC to 12:00 of that day → Argentina 09:00 = same day. + fp.SetUtcNow(new DateTimeOffset( + argentinaToday.Year, argentinaToday.Month, argentinaToday.Day, + 12, 0, 0, TimeSpan.Zero)); + return fp; + } + + private static DateOnly Today => new DateOnly(2026, 4, 20); + private static FakeTimeProvider FakeToday => MakeFakeTimeProvider(Today); + + // ── Create factory — invariant violations ───────────────────────────────── + + [Fact] + public void Create_ThrowsInvalid_WhenSymbolIsEmpty() + { + var act = () => ChargeableCharConfig.Create(null, "", "Currency", 1.0m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("Symbol"); + } + + [Fact] + public void Create_ThrowsInvalid_WhenSymbolIsWhitespace() + { + var act = () => ChargeableCharConfig.Create(null, " ", "Currency", 1.0m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("Symbol"); + } + + [Fact] + public void Create_ThrowsInvalid_WhenSymbolExceedsFourChars() + { + var act = () => ChargeableCharConfig.Create(null, "$$$$$", "Currency", 1.0m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("Symbol"); + } + + [Fact] + public void Create_ThrowsInvalid_WhenPricePerUnitIsZero() + { + var act = () => ChargeableCharConfig.Create(null, "$", "Currency", 0m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("PricePerUnit"); + } + + [Fact] + public void Create_ThrowsInvalid_WhenPricePerUnitIsNegative() + { + var act = () => ChargeableCharConfig.Create(null, "$", "Currency", -1.5m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("PricePerUnit"); + } + + [Fact] + public void Create_ThrowsInvalid_WhenCategoryIsUnknown() + { + var act = () => ChargeableCharConfig.Create(null, "$", "INVALID_CAT", 1.0m, Today); + + act.Should().Throw() + .Which.Field.Should().Be("Category"); + } + + // ── Create factory — happy path ─────────────────────────────────────────── + + [Fact] + public void Create_HappyPath_ReturnsEntityWithIsActiveTrue_AndIdZero() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.5m, Today); + + entity.Id.Should().Be(0); + entity.IsActive.Should().BeTrue(); + entity.ValidTo.Should().BeNull(); + entity.Symbol.Should().Be("$"); + entity.Category.Should().Be("Currency"); + entity.PricePerUnit.Should().Be(1.5m); + entity.ValidFrom.Should().Be(Today); + entity.MedioId.Should().BeNull(); + } + + [Fact] + public void Create_WithMedioId_SetsCorrectly() + { + var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today); + + entity.MedioId.Should().Be(5); + } + + [Fact] + public void Create_FourCharSymbol_IsValid() + { + var act = () => ChargeableCharConfig.Create(null, "$$$$", "Currency", 1.0m, Today); + + act.Should().NotThrow(); + } + + // ── ScheduleNewPrice — forward-only ─────────────────────────────────────── + + [Fact] + public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromEqualToCurrent() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + + var act = () => entity.ScheduleNewPrice(2.0m, Today, FakeToday); + + act.Should().Throw(); + } + + [Fact] + public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromBeforeCurrent() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + var pastDate = Today.AddDays(-1); + var fp = MakeFakeTimeProvider(Today.AddDays(-2)); // today is 2 days ago so pastDate is still "future" relative to fake today + // But we want to test forward-only (new <= current ValidFrom), not past date + // Use a fake today that makes pastDate pass the ">=today" check, but still fail forward-only + var fpPast = MakeFakeTimeProvider(Today.AddDays(-10)); // today = Apr 10, pastDate = Apr 19 is future + + var act = () => entity.ScheduleNewPrice(2.0m, pastDate, fpPast); + + act.Should().Throw(); + } + + [Fact] + public void ScheduleNewPrice_ThrowsInvalid_WhenNewValidFromBeforeTodayAR() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today.AddDays(-10)); + // today in fake = Apr 20; newValidFrom = Apr 18 < today → invalid (past date) + var pastDate = Today.AddDays(-2); + + var act = () => entity.ScheduleNewPrice(2.0m, pastDate, FakeToday); + + act.Should().Throw() + .Which.Field.Should().Be("ValidFrom"); + } + + [Fact] + public void ScheduleNewPrice_HappyPath_ReturnsNewEntity_DoesNotMutateThis() + { + var original = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + var futureDate = Today.AddDays(30); + + var newEntity = original.ScheduleNewPrice(2.0m, futureDate, FakeToday); + + // new entity has updated price and validFrom + newEntity.PricePerUnit.Should().Be(2.0m); + newEntity.ValidFrom.Should().Be(futureDate); + newEntity.Symbol.Should().Be("$"); + newEntity.Category.Should().Be("Currency"); + newEntity.IsActive.Should().BeTrue(); + + // original is unchanged (forward-only semantics) + original.PricePerUnit.Should().Be(1.0m); + original.ValidFrom.Should().Be(Today); + } + + [Fact] + public void ScheduleNewPrice_ThrowsInvalid_WhenNewPriceIsZero() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + var futureDate = Today.AddDays(5); + + var act = () => entity.ScheduleNewPrice(0m, futureDate, FakeToday); + + act.Should().Throw(); + } + + // ── Deactivate ──────────────────────────────────────────────────────────── + + [Fact] + public void Deactivate_SetsIsActiveFalseAndValidToToday() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + + entity.Deactivate(Today); + + entity.IsActive.Should().BeFalse(); + entity.ValidTo.Should().Be(Today); + } + + [Fact] + public void Deactivate_Idempotent_WhenAlreadyInactive() + { + var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today); + entity.Deactivate(Today); + + // Second deactivate on same entity — no exception, no change + var act = () => entity.Deactivate(Today); + + act.Should().NotThrow(); + entity.IsActive.Should().BeFalse(); + } + + // ── Rehydrate (reconstructor) ───────────────────────────────────────────── + + [Fact] + public void Rehydrate_SetsAllPropertiesWithoutValidation() + { + // Rehydrate can create entities that would fail Create (e.g., IsActive=false) + var entity = ChargeableCharConfig.Rehydrate( + id: 42, medioId: 5, symbol: "$", category: "Currency", + price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false); + + entity.Id.Should().Be(42); + entity.MedioId.Should().Be(5); + entity.Symbol.Should().Be("$"); + entity.Category.Should().Be("Currency"); + entity.PricePerUnit.Should().Be(1.5m); + entity.ValidFrom.Should().Be(Today); + entity.ValidTo.Should().Be(Today.AddDays(30)); + entity.IsActive.Should().BeFalse(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs new file mode 100644 index 0000000..a70b012 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs @@ -0,0 +1,146 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Application.Tests.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — T2.1 Domain unit tests for Pricing exceptions. +/// Verifies constructor props, message content, and DomainException inheritance. +/// +public sealed class PricingExceptionTests +{ + // ── EmojiDetectedException ──────────────────────────────────────────────── + + [Fact] + public void EmojiDetectedException_SetsDetectedCodepoint() + { + var ex = new EmojiDetectedException(0x1F697); // 🚗 + + ex.DetectedCodepoint.Should().Be(0x1F697); + } + + [Fact] + public void EmojiDetectedException_MessageContainsCodepoint() + { + var ex = new EmojiDetectedException(0x1F697); + + ex.Message.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void EmojiDetectedException_InheritsFromDomainException() + { + var ex = new EmojiDetectedException(0x1F697); + + ex.Should().BeAssignableTo(); + } + + // ── WordCountValidationException ───────────────────────────────────────── + + [Fact] + public void WordCountValidationException_SetsFieldAndReason() + { + var ex = new WordCountValidationException("rawText", "El texto supera el máximo de 2000 caracteres."); + + ex.Field.Should().Be("rawText"); + ex.Reason.Should().Be("El texto supera el máximo de 2000 caracteres."); + } + + [Fact] + public void WordCountValidationException_MessageContainsFieldAndReason() + { + var ex = new WordCountValidationException("rawText", "supera el máximo"); + + ex.Message.Should().Contain("rawText"); + ex.Message.Should().Contain("supera el máximo"); + } + + [Fact] + public void WordCountValidationException_InheritsFromDomainException() + { + var ex = new WordCountValidationException("field", "reason"); + + ex.Should().BeAssignableTo(); + } + + // ── ChargeableCharConfigInvalidException ───────────────────────────────── + + [Fact] + public void ChargeableCharConfigInvalidException_SetsFieldAndReason() + { + var ex = new ChargeableCharConfigInvalidException("PricePerUnit", "debe ser > 0"); + + ex.Field.Should().Be("PricePerUnit"); + ex.Reason.Should().Be("debe ser > 0"); + } + + [Fact] + public void ChargeableCharConfigInvalidException_MessageContainsFieldAndReason() + { + var ex = new ChargeableCharConfigInvalidException("Symbol", "no puede estar vacío"); + + ex.Message.Should().Contain("Symbol"); + ex.Message.Should().Contain("no puede estar vacío"); + } + + [Fact] + public void ChargeableCharConfigInvalidException_InheritsFromDomainException() + { + var ex = new ChargeableCharConfigInvalidException("field", "reason"); + + ex.Should().BeAssignableTo(); + } + + // ── ChargeableCharConfigForwardOnlyException ────────────────────────────── + + [Fact] + public void ChargeableCharConfigForwardOnlyException_SetsAllProperties() + { + var newVf = new DateOnly(2026, 3, 1); + var activeVf = new DateOnly(2026, 4, 1); + + var ex = new ChargeableCharConfigForwardOnlyException( + medioId: 5, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf); + + ex.MedioId.Should().Be(5); + ex.Symbol.Should().Be("$"); + ex.NewValidFrom.Should().Be(newVf); + ex.ActiveValidFrom.Should().Be(activeVf); + } + + [Fact] + public void ChargeableCharConfigForwardOnlyException_NullMedioId_IsAllowed() + { + var ex = new ChargeableCharConfigForwardOnlyException( + medioId: null, symbol: "$", + newValidFrom: new DateOnly(2026, 3, 1), + activeValidFrom: new DateOnly(2026, 4, 1)); + + ex.MedioId.Should().BeNull(); + } + + [Fact] + public void ChargeableCharConfigForwardOnlyException_MessageContainsKeyDates() + { + var newVf = new DateOnly(2026, 3, 1); + var activeVf = new DateOnly(2026, 4, 1); + + var ex = new ChargeableCharConfigForwardOnlyException( + medioId: null, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf); + + ex.Message.Should().Contain("2026-03-01"); + ex.Message.Should().Contain("2026-04-01"); + } + + [Fact] + public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException() + { + var ex = new ChargeableCharConfigForwardOnlyException( + medioId: null, symbol: "$", + newValidFrom: new DateOnly(2026, 3, 1), + activeValidFrom: new DateOnly(2026, 4, 1)); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs new file mode 100644 index 0000000..9ea3f42 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using SIGCM2.Domain.Pricing.WordCounter; + +namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter; + +/// +/// PRC-001 — T2.2 Domain unit tests for WordCountResult value object. +/// +public sealed class WordCountResultTests +{ + [Fact] + public void WordCountResult_RecordEquality_SameValues_AreEqual() + { + var dictA = new Dictionary { ["Currency"] = 1 }; + var dictB = new Dictionary { ["Currency"] = 1 }; + + var a = new WordCountResult(3, dictA); + var b = new WordCountResult(3, dictB); + + // Records compare by value — TotalWords and SpecialCharCounts ref equality + // (not deep dict equality for records), but TotalWords equality is guaranteed. + a.TotalWords.Should().Be(b.TotalWords); + } + + [Fact] + public void WordCountResult_SpecialCharCounts_IsIReadOnlyDictionary() + { + var result = new WordCountResult(0, new Dictionary()); + + result.SpecialCharCounts.Should().BeAssignableTo>(); + } + + [Fact] + public void WordCountResult_MissingKey_ReturnsZeroViaGetValueOrDefault() + { + var result = new WordCountResult(3, new Dictionary { ["Currency"] = 1 }); + + result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(0); + } + + [Fact] + public void WordCountResult_EmptySpecialCharCounts_ReturnsEmpty() + { + var result = new WordCountResult(0, new Dictionary()); + + result.SpecialCharCounts.Should().BeEmpty(); + } + + [Fact] + public void WordCountResult_TotalWords_IsCorrect() + { + var result = new WordCountResult(5, new Dictionary()); + + result.TotalWords.Should().Be(5); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs new file mode 100644 index 0000000..eccd9c3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs @@ -0,0 +1,328 @@ +using FluentAssertions; +using SIGCM2.Domain.Pricing.Exceptions; +using SIGCM2.Domain.Pricing.WordCounter; + +namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter; + +/// +/// PRC-001 — C5 Golden Cases Suite. +/// 25 canonical test cases that constitute the SPIKE acceptance gate. +/// Each golden case is a separate [Theory] row or [Fact] — individually identified. +/// +public sealed class WordCounterGoldenCasesTests +{ + private static readonly WordCounterService _svc = new(); + + // ───────────────────────────────────────────────────────────────────────── + // GC-01: plain text — 4 words, no specials + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC01_PlainText_FourWords_NoSpecials() + { + var result = _svc.Count("vendo auto ford 2005"); + + result.TotalWords.Should().Be(4); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-02: dollar splits a token — 3 words, Currency=1 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC02_DollarSign_SplitsToken_ThreeWords_CurrencyOne() + { + var result = _svc.Count("vendo auto $5000"); + + result.TotalWords.Should().Be(3); + result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-03: percentage — 2 words, Percentage=1 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC03_Percentage_TwoWords_PercentageOne() + { + var result = _svc.Count("descuento 20%"); + + result.TotalWords.Should().Be(2); + result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-04: exclamation mark — 5 words, Exclamation=1 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC04_Exclamation_FiveWords_ExclamationOne() + { + var result = _svc.Count("OFERTA! no te la pierdas"); + + result.TotalWords.Should().Be(5); + result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(1); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-05: inverse + normal exclamation — 1 word, Exclamation=2 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC05_InverseAndNormalExclamation_OneWord_ExclamationTwo() + { + var result = _svc.Count("¡Oferta!"); + + result.TotalWords.Should().Be(1); + result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(2); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-06: anti-fraud pattern P$a$l$a$b$r$a → 7 words, Currency=6 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC06_AntiFraudDollarPattern_SevenWords_CurrencySix() + { + var result = _svc.Count("P$a$l$a$b$r$a"); + + result.TotalWords.Should().Be(7); + result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-07: mixed specials + hyphen split + // VENDO | auto | 5000 | usado | 90 | buen | estado = 7 + // Exclamation=1, Currency=1, Percentage=1 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC07_MixedSpecialsAndHyphen_SevenWords_OneEachCategory() + { + var result = _svc.Count("VENDO! auto $5000 usado %90 buen-estado"); + + result.TotalWords.Should().Be(7); + result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(1); + result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); + result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-08: multi-space collapses — 3 words, no specials + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC08_MultiSpace_Collapses_ThreeWords_NoSpecials() + { + var result = _svc.Count("vendo auto ford"); + + result.TotalWords.Should().Be(3); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-09: CRLF becomes space — 4 words + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC09_CRLF_BecomesSpace_FourWords() + { + var result = _svc.Count("vendo auto\r\nbuen estado"); + + result.TotalWords.Should().Be(4); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-10: leading/trailing whitespace stripped — 2 words + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC10_LeadingTrailingWhitespace_Stripped_TwoWords() + { + var result = _svc.Count(" vendo auto "); + + result.TotalWords.Should().Be(2); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-11: tildes are regular letters — 4 words, no specials + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC11_Tildes_AreRegularLetters_FourWords_NoSpecials() + { + var result = _svc.Count("vendo máquina niño año"); + + result.TotalWords.Should().Be(4); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-12: ñ in words — 3 words, no specials + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC12_EnneInWords_ThreeWords_NoSpecials() + { + var result = _svc.Count("año mañana niño"); + + result.TotalWords.Should().Be(3); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-13: empty string — 0 words, empty dict + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC13_EmptyString_ZeroWords_EmptyDict() + { + var result = _svc.Count(""); + + result.TotalWords.Should().Be(0); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-14: whitespace-only — 0 words, empty dict + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC14_WhitespaceOnly_ZeroWords_EmptyDict() + { + var result = _svc.Count(" "); + + result.TotalWords.Should().Be(0); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-15: single word — 1 word + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC15_SingleWord_OneWord() + { + var result = _svc.Count("auto"); + + result.TotalWords.Should().Be(1); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-16: numbers and tilde — 3 words + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC16_NumbersAndTilde_ThreeWords() + { + var result = _svc.Count("1978 2005 año"); + + result.TotalWords.Should().Be(3); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-17: URL treated as single token — 2 words + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC17_Url_TreatedAsSingleToken_TwoWords() + { + var result = _svc.Count("visita www.example.com"); + + result.TotalWords.Should().Be(2); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-18: hyphenated compounds split — 4 words + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC18_HyphenatedCompounds_SplitIntoFourWords() + { + var result = _svc.Count("buen-estado casi-nuevo"); + + result.TotalWords.Should().Be(4); + result.SpecialCharCounts.Should().BeEmpty(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-19: emoji at end — throws EmojiDetectedException + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC19_EmojiAtEnd_ThrowsEmojiDetectedException() + { + var act = () => _svc.Count("vendo 🚗"); + + act.Should().Throw(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-20: emoji in middle — throws EmojiDetectedException + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC20_EmojiInMiddle_ThrowsEmojiDetectedException() + { + var act = () => _svc.Count("vendo auto 🚗 2005"); + + act.Should().Throw(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-21: only emoji — throws EmojiDetectedException + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC21_OnlyEmoji_ThrowsEmojiDetectedException() + { + var act = () => _svc.Count("🚗"); + + act.Should().Throw(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-22: exactly 2000 chars — passes (no exception), TotalWords >= 1 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC22_Exactly2000Chars_PassesValidation() + { + // Build a 2000-char string of "a " repeated (1000 times = 2000 chars) + var input = string.Concat(Enumerable.Repeat("a ", 1000)).TrimEnd(); // 1999 chars — add one more + // Ensure exactly 2000: "a " x 999 = 1998 + "aa" = 2000 + input = string.Concat(Enumerable.Repeat("a ", 999)) + "aa"; // 999*2 + 2 = 2000 + + var act = () => _svc.Count(input); + + act.Should().NotThrow(); + var result = _svc.Count(input); + result.TotalWords.Should().BeGreaterThanOrEqualTo(1); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-23: 2001 chars — throws WordCountValidationException + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC23_TwoThousandAndOneChars_ThrowsWordCountValidationException() + { + var input = new string('a', 2001); + + var act = () => _svc.Count(input); + + act.Should().Throw(); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-24: all specials replaced → 0 words + // "$$ %% !! ¡¡" → Currency=2, Percentage=2, Exclamation=4, TotalWords=0 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC24_AllSpecials_ZeroWordsRemain_CorrectCounts() + { + var result = _svc.Count("$$ %% !! ¡¡"); + + result.TotalWords.Should().Be(0); + result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(2); + result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(2); + result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(4); + } + + // ───────────────────────────────────────────────────────────────────────── + // GC-25: anti-fraud embedded in sentence — 9 words, Currency=6 + // ───────────────────────────────────────────────────────────────────────── + [Fact] + public void GC25_AntiFraudEmbedded_NineWords_CurrencySix() + { + // "vendo P$a$l$a$b$r$a usado" + // P$a$l$a$b$r$a → P a l a b r a = 7 tokens; + vendo + usado = 9 + var result = _svc.Count("vendo P$a$l$a$b$r$a usado"); + + result.TotalWords.Should().Be(9); + result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6); + } +} From f1b38cd9ce6952ce04c3291e74cb4c9356955a95 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:24:06 -0300 Subject: [PATCH 04/14] feat(application): commands/queries + IChargeableCharConfigService (PRC-001) --- .../IChargeableCharConfigRepository.cs | 79 ++++++++++ .../SIGCM2.Application/DependencyInjection.cs | 14 ++ .../ChargeableCharConfigDto.cs | 14 ++ .../ChargeableCharConfigService.cs | 43 ++++++ .../ChargeableChars/ChargeableCharSnapshot.cs | 10 ++ .../CreateChargeableCharConfigCommand.cs | 12 ++ ...reateChargeableCharConfigCommandHandler.cs | 69 +++++++++ ...ateChargeableCharConfigCommandValidator.cs | 38 +++++ .../CreateChargeableCharConfigResponse.cs | 10 ++ .../DeactivateChargeableCharConfigCommand.cs | 6 + ...ivateChargeableCharConfigCommandHandler.cs | 70 +++++++++ .../DeactivateChargeableCharConfigResponse.cs | 9 ++ .../GetChargeableCharConfigByIdQuery.cs | 7 + ...GetChargeableCharConfigByIdQueryHandler.cs | 37 +++++ .../IChargeableCharConfigService.cs | 21 +++ .../List/ListChargeableCharConfigQuery.cs | 11 ++ .../ListChargeableCharConfigQueryHandler.cs | 46 ++++++ .../SchedulePriceChangeCommand.cs | 11 ++ .../SchedulePriceChangeCommandHandler.cs | 80 ++++++++++ .../SchedulePriceChangeCommandValidator.cs | 30 ++++ .../SchedulePriceChangeResponse.cs | 9 ++ .../ChargeableCharConfigServiceTests.cs | 113 ++++++++++++++ ...argeableCharConfigCommandValidatorTests.cs | 145 ++++++++++++++++++ .../CreateChargeableCharConfigHandlerTests.cs | 138 +++++++++++++++++ ...ctivateChargeableCharConfigHandlerTests.cs | 114 ++++++++++++++ ...GetChargeableCharConfigByIdHandlerTests.cs | 58 +++++++ .../ListChargeableCharConfigHandlerTests.cs | 119 ++++++++++++++ ...chedulePriceChangeCommandValidatorTests.cs | 101 ++++++++++++ .../SchedulePriceChangeHandlerTests.cs | 124 +++++++++++++++ 29 files changed, 1538 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeactivateChargeableCharConfigHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/GetChargeableCharConfigByIdHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeCommandValidatorTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeHandlerTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs new file mode 100644 index 0000000..000ed94 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs @@ -0,0 +1,79 @@ +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// PRC-001 — Write + query access to dbo.ChargeableCharConfig. +/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure. +/// +/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically +/// closes any active row for (MedioId, Symbol) and inserts the new row. +/// +/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns +/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate. +/// The Application service applies the per-medio > global priority rule. +/// +public interface IChargeableCharConfigRepository +{ + /// + /// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope. + /// Closes any active row matching (MedioId, Symbol) and inserts a new one. + /// Returns the Id of the newly inserted row. + /// Throws: + /// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409 + /// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard) + /// + Task InsertWithCloseAsync( + long? medioId, + string symbol, + string category, + decimal price, + DateOnly validFrom, + CancellationToken ct = default); + + /// + /// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate + /// for the specified medio, including global rows (MedioId IS NULL). + /// The SP returns both per-medio AND global rows — callers apply priority. + /// + Task> GetActiveForMedioAsync( + long medioId, + DateOnly asOfDate, + CancellationToken ct = default); + + /// + /// Returns paginated rows filtered by MedioId and IsActive. + /// Skip = (page - 1) * pageSize computed by the caller. + /// + Task> ListAsync( + long? medioId, + bool activeOnly, + int skip, + int take, + CancellationToken ct = default); + + /// + /// Returns total row count for the given filters (used for pagination metadata). + /// + Task CountAsync( + long? medioId, + bool activeOnly, + CancellationToken ct = default); + + /// + /// Returns the row with the given Id, or null if not found. + /// + Task GetByIdAsync( + long id, + CancellationToken ct = default); + + /// + /// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today. + /// Idempotent: no-op if already inactive. + /// Called inside the ambient TransactionScope of the handler. + /// + Task DeactivateAsync( + long id, + DateOnly today, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index c5d7470..79ed5e3 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -83,6 +83,12 @@ using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Deactivate; using SIGCM2.Application.ProductTypes.List; using SIGCM2.Application.ProductTypes.GetById; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Application.Pricing.ChargeableChars.Create; +using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; +using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Application.Pricing.ChargeableChars.List; +using SIGCM2.Application.Pricing.ChargeableChars.GetById; namespace SIGCM2.Application; @@ -200,6 +206,14 @@ public static class DependencyInjection services.AddScoped>, ListProductTypesQueryHandler>(); services.AddScoped, GetProductTypeByIdQueryHandler>(); + // ChargeableCharConfig (PRC-001) + services.AddScoped, CreateChargeableCharConfigCommandHandler>(); + services.AddScoped, SchedulePriceChangeCommandHandler>(); + services.AddScoped, DeactivateChargeableCharConfigCommandHandler>(); + services.AddScoped>, ListChargeableCharConfigQueryHandler>(); + services.AddScoped, GetChargeableCharConfigByIdQueryHandler>(); + services.AddScoped(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs new file mode 100644 index 0000000..a2e85b4 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars; + +/// +/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses. +/// +public sealed record ChargeableCharConfigDto( + long Id, + long? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateOnly ValidFrom, + DateOnly? ValidTo, + bool IsActive); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs new file mode 100644 index 0000000..6de3f85 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs @@ -0,0 +1,43 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Pricing.ChargeableChars; + +/// +/// PRC-001 — Implements IChargeableCharConfigService. +/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies +/// the per-medio > global priority rule in memory. +/// +/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and +/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application. +/// +public sealed class ChargeableCharConfigService : IChargeableCharConfigService +{ + private readonly IChargeableCharConfigRepository _repo; + + public ChargeableCharConfigService(IChargeableCharConfigRepository repo) + { + _repo = repo; + } + + /// + public async Task> GetActiveConfigForMedioAsync( + long medioId, + DateOnly asOf, + CancellationToken ct = default) + { + var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct); + + // Build a dictionary keyed by Symbol. + // Per-medio rows (MedioId != null) take priority over global rows (MedioId == null). + var result = new Dictionary(StringComparer.Ordinal); + + // Two-pass: first add global rows, then overwrite with per-medio rows. + foreach (var row in allRows.Where(r => r.MedioId is null)) + result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit); + + foreach (var row in allRows.Where(r => r.MedioId is not null)) + result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit); + + return result; + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs new file mode 100644 index 0000000..b9e2848 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars; + +/// +/// PRC-001 — Lightweight value snapshot for the active chargeable-char config +/// at the time of word counting. Used by IChargeableCharConfigService. +/// Keyed by Symbol in the returned dictionary. +/// +public sealed record ChargeableCharSnapshot( + string Category, + decimal PricePerUnit); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs new file mode 100644 index 0000000..0bf24a4 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Create; + +/// +/// PRC-001 — Command to create a new ChargeableCharConfig. +/// MedioId = null → global config. MedioId set → per-medio config. +/// +public sealed record CreateChargeableCharConfigCommand( + long? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateOnly ValidFrom); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs new file mode 100644 index 0000000..be112a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs @@ -0,0 +1,69 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; + +namespace SIGCM2.Application.Pricing.ChargeableChars.Create; + +/// +/// PRC-001 — Handler for CreateChargeableCharConfigCommand. +/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete(). +/// +public sealed class CreateChargeableCharConfigCommandHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public CreateChargeableCharConfigCommandHandler( + IChargeableCharConfigRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(CreateChargeableCharConfigCommand command) + { + long newId; + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + newId = await _repo.InsertWithCloseAsync( + command.MedioId, + command.Symbol, + command.Category, + command.PricePerUnit, + command.ValidFrom); + + await _audit.LogAsync( + action: "tasacion.chargeable_char.create", + targetType: "ChargeableCharConfig", + targetId: newId.ToString(), + metadata: new + { + after = new + { + command.MedioId, + command.Symbol, + command.Category, + command.PricePerUnit, + validFrom = command.ValidFrom.ToString("yyyy-MM-dd"), + } + }); + + tx.Complete(); + } + + return new CreateChargeableCharConfigResponse( + newId, + command.Symbol, + command.PricePerUnit, + command.ValidFrom); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs new file mode 100644 index 0000000..9fa3789 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Pricing.ChargeableChars.Create; + +/// +/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand. +/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now). +/// +public sealed class CreateChargeableCharConfigCommandValidator + : AbstractValidator +{ + public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider) + { + var today = timeProvider.GetArgentinaToday(); + + RuleFor(x => x.Symbol) + .NotEmpty() + .WithMessage("Symbol no puede estar vacío.") + .MaximumLength(4) + .WithMessage("Symbol no puede exceder 4 caracteres."); + + RuleFor(x => x.Category) + .NotEmpty() + .WithMessage("Category no puede estar vacío.") + .Must(ChargeableCharCategories.IsValid) + .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."); + + RuleFor(x => x.ValidFrom) + .GreaterThanOrEqualTo(today) + .WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva."); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs new file mode 100644 index 0000000..07e9205 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Create; + +/// +/// PRC-001 — Response for CreateChargeableCharConfigCommand. +/// +public sealed record CreateChargeableCharConfigResponse( + long Id, + string Symbol, + decimal PricePerUnit, + DateOnly ValidFrom); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs new file mode 100644 index 0000000..a7c35c5 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate; + +/// +/// PRC-001 — Command to deactivate an existing ChargeableCharConfig. +/// +public sealed record DeactivateChargeableCharConfigCommand(long Id); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs new file mode 100644 index 0000000..1eda637 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs @@ -0,0 +1,70 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate; + +/// +/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand. +/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete(). +/// +public sealed class DeactivateChargeableCharConfigCommandHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public DeactivateChargeableCharConfigCommandHandler( + IChargeableCharConfigRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle( + DeactivateChargeableCharConfigCommand command) + { + var today = _timeProvider.GetArgentinaToday(); + + // 1. Load existing — ensures the row exists. + var existing = await _repo.GetByIdAsync(command.Id) + ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe."); + + // 2. TX + deactivate + audit (fail-closed). + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + await _repo.DeactivateAsync(command.Id, today); + + await _audit.LogAsync( + action: "tasacion.chargeable_char.deactivate", + targetType: "ChargeableCharConfig", + targetId: command.Id.ToString(), + metadata: new + { + before = new + { + id = existing.Id, + symbol = existing.Symbol, + medioId = existing.MedioId, + validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"), + }, + deactivatedOn = today.ToString("yyyy-MM-dd"), + }); + + tx.Complete(); + } + + return new DeactivateChargeableCharConfigResponse( + Id: command.Id, + ValidTo: today); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs new file mode 100644 index 0000000..c8286b6 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate; + +/// +/// PRC-001 — Response for DeactivateChargeableCharConfigCommand. +/// ValidTo is the date the config was deactivated (= today_AR at time of operation). +/// +public sealed record DeactivateChargeableCharConfigResponse( + long Id, + DateOnly ValidTo); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs new file mode 100644 index 0000000..0c084c4 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.GetById; + +/// +/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id. +/// Returns null if not found (caller decides whether to 404). +/// +public sealed record GetChargeableCharConfigByIdQuery(long Id); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs new file mode 100644 index 0000000..10710a6 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs @@ -0,0 +1,37 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Pricing.ChargeableChars.GetById; + +/// +/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery. +/// Returns null DTO when not found (API layer maps to 404). +/// +public sealed class GetChargeableCharConfigByIdQueryHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + + public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetChargeableCharConfigByIdQuery query) + { + var entity = await _repo.GetByIdAsync(query.Id); + return entity is null ? null : ToDto(entity); + } + + private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( + c.Id, + c.MedioId, + c.Symbol, + c.Category, + c.PricePerUnit, + c.ValidFrom, + c.ValidTo, + c.IsActive); +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs new file mode 100644 index 0000000..c73d1e4 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs @@ -0,0 +1,21 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars; + +/// +/// PRC-001 — Application service for resolving active chargeable-char config for a Medio. +/// +/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol. +/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing. +/// +public interface IChargeableCharConfigService +{ + /// + /// Returns the resolved active config for the given medio as of the given date. + /// Per-medio rows take priority over global rows for the same Symbol. + /// Global rows are used as fallback when no per-medio row exists for that Symbol. + /// Returns an empty dictionary if no config exists at all. + /// + Task> GetActiveConfigForMedioAsync( + long medioId, + DateOnly asOf, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs new file mode 100644 index 0000000..a54efd7 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.List; + +/// +/// PRC-001 — Paginated list query for ChargeableCharConfig rows. +/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]). +/// +public sealed record ListChargeableCharConfigQuery( + long? MedioId, + bool ActiveOnly, + int Page = 1, + int PageSize = 20); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs new file mode 100644 index 0000000..8510c3a --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs @@ -0,0 +1,46 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Pricing.ChargeableChars.List; + +/// +/// PRC-001 — Handler for ListChargeableCharConfigQuery. +/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto. +/// +public sealed class ListChargeableCharConfigQueryHandler + : ICommandHandler> +{ + private readonly IChargeableCharConfigRepository _repo; + + public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListChargeableCharConfigQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + var skip = (page - 1) * pageSize; + + var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize); + var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly); + + var dtos = items.Select(ToDto).ToList(); + + return new PagedResult(dtos, page, pageSize, total); + } + + private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( + c.Id, + c.MedioId, + c.Symbol, + c.Category, + c.PricePerUnit, + c.ValidFrom, + c.ValidTo, + c.IsActive); +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs new file mode 100644 index 0000000..1cd7cc3 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +/// +/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig. +/// Id: the existing row whose price should be superseded. +/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler). +/// +public sealed record SchedulePriceChangeCommand( + long Id, + decimal PricePerUnit, + DateOnly ValidFrom); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs new file mode 100644 index 0000000..2b0c1f3 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs @@ -0,0 +1,80 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; + +namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +/// +/// PRC-001 — Handler for SchedulePriceChangeCommand. +/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete(). +/// +public sealed class SchedulePriceChangeCommandHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public SchedulePriceChangeCommandHandler( + IChargeableCharConfigRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(SchedulePriceChangeCommand command) + { + // 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category. + var existing = await _repo.GetByIdAsync(command.Id) + ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe."); + + // 2. Domain entity validates forward-only rule and builds the new entity value. + // ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward. + var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider); + + // 3. TX + SP + audit (fail-closed). + long newId; + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + newId = await _repo.InsertWithCloseAsync( + newEntity.MedioId, + newEntity.Symbol, + newEntity.Category, + newEntity.PricePerUnit, + newEntity.ValidFrom); + + await _audit.LogAsync( + action: "tasacion.chargeable_char.price_change", + targetType: "ChargeableCharConfig", + targetId: newId.ToString(), + metadata: new + { + before = new + { + id = existing.Id, + pricePerUnit = existing.PricePerUnit, + validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"), + }, + after = new + { + pricePerUnit = newEntity.PricePerUnit, + validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"), + } + }); + + tx.Complete(); + } + + return new SchedulePriceChangeResponse( + NewId: newId, + PreviousValidFrom: existing.ValidFrom, + NewValidFrom: newEntity.ValidFrom); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs new file mode 100644 index 0000000..722473c --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +/// +/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand. +/// Surface validation only (price > 0, validFrom >= today_AR, id > 0). +/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler +/// where the existing entity is loaded. +/// +public sealed class SchedulePriceChangeCommandValidator : AbstractValidator +{ + public SchedulePriceChangeCommandValidator(TimeProvider timeProvider) + { + var today = timeProvider.GetArgentinaToday(); + + RuleFor(x => x.Id) + .GreaterThan(0L) + .WithMessage("Id debe ser un entero positivo."); + + RuleFor(x => x.PricePerUnit) + .GreaterThan(0m) + .WithMessage("PricePerUnit debe ser > 0."); + + RuleFor(x => x.ValidFrom) + .GreaterThanOrEqualTo(today) + .WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART)."); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs new file mode 100644 index 0000000..21afc9e --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +/// +/// PRC-001 — Response for SchedulePriceChangeCommand. +/// +public sealed record SchedulePriceChangeResponse( + long NewId, + DateOnly PreviousValidFrom, + DateOnly NewValidFrom); diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs new file mode 100644 index 0000000..4a6cbfb --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — ChargeableCharConfigService tests. +/// Covers: per-medio wins over global, global fallback when no per-medio, +/// empty result when no config at all. +/// +public class ChargeableCharConfigServiceTests +{ + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly ChargeableCharConfigService _service; + + private static readonly DateOnly AsOf = new(2026, 4, 20); + + public ChargeableCharConfigServiceTests() + { + _service = new ChargeableCharConfigService(_repo); + } + + private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) => + ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); + + private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) => + ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); + + // ── Global fallback ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs() + { + _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + .Returns(new List + { + GlobalConfig("$", 1.0m), + GlobalConfig("%", 0.5m), + }); + + var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + + result.Should().ContainKey("$"); + result["$"].PricePerUnit.Should().Be(1.0m); + result.Should().ContainKey("%"); + } + + // ── Per-medio wins over global ─────────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol() + { + _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) + .Returns(new List + { + GlobalConfig("$", 1.0m), // global price = 1.0 + MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins + GlobalConfig("%", 0.5m), // global only + }); + + var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); + + result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins + result["%"].PricePerUnit.Should().Be(0.5m); // global only + } + + [Fact] + public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory() + { + _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) + .Returns(new List + { + MedioConfig(20L, 5, "$", 3.0m), + }); + + var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); + + result["$"].Category.Should().Be(ChargeableCharCategories.Currency); + } + + // ── Empty result ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary() + { + _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + .Returns(new List()); + + var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + + result.Should().BeEmpty(); + } + + // ── Key: Symbol, Value: snapshot ───────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfig_KeyIsSymbol() + { + _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + .Returns(new List + { + GlobalConfig("!", 2.0m), + }); + + var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + + result.Should().ContainKey("!"); + result["!"].PricePerUnit.Should().Be(2.0m); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs new file mode 100644 index 0000000..d8e0893 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs @@ -0,0 +1,145 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Application.Pricing.ChargeableChars.Create; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — Validator tests for CreateChargeableCharConfigCommand. +/// Covers: Symbol length, Category enum, PricePerUnit > 0, ValidFrom >= today_AR. +/// +public class CreateChargeableCharConfigCommandValidatorTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly CreateChargeableCharConfigCommandValidator _validator; + + private static readonly DateOnly Today = new(2026, 4, 20); + private static readonly DateOnly Yesterday = new(2026, 4, 19); + private static readonly DateOnly Tomorrow = new(2026, 4, 21); + + public CreateChargeableCharConfigCommandValidatorTests() + { + _validator = new CreateChargeableCharConfigCommandValidator(_time); + } + + private static CreateChargeableCharConfigCommand ValidCmd() => new( + MedioId: null, + Symbol: "$", + Category: ChargeableCharCategories.Currency, + PricePerUnit: 1.0m, + ValidFrom: Today); + + // ── Symbol ─────────────────────────────────────────────────────────────────── + + [Fact] + public void Symbol_Empty_FailsValidation() + { + var cmd = ValidCmd() with { Symbol = "" }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol); + } + + [Fact] + public void Symbol_TooLong_FailsValidation() + { + // max 4 chars + var cmd = ValidCmd() with { Symbol = "ABCDE" }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol); + } + + [Fact] + public void Symbol_SingleChar_Passes() + { + var cmd = ValidCmd() with { Symbol = "$" }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol); + } + + [Fact] + public void Symbol_FourChars_Passes() + { + var cmd = ValidCmd() with { Symbol = "ABCD" }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol); + } + + // ── Category ───────────────────────────────────────────────────────────────── + + [Fact] + public void Category_Invalid_FailsValidation() + { + var cmd = ValidCmd() with { Category = "Unknown" }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category); + } + + [Fact] + public void Category_Empty_FailsValidation() + { + var cmd = ValidCmd() with { Category = "" }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category); + } + + [Theory] + [InlineData("Currency")] + [InlineData("Percentage")] + [InlineData("Exclamation")] + [InlineData("Question")] + [InlineData("Other")] + public void Category_ValidValues_Pass(string category) + { + var cmd = ValidCmd() with { Category = category }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Category); + } + + // ── PricePerUnit ───────────────────────────────────────────────────────────── + + [Fact] + public void PricePerUnit_Zero_FailsValidation() + { + var cmd = ValidCmd() with { PricePerUnit = 0m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit); + } + + [Fact] + public void PricePerUnit_Negative_FailsValidation() + { + var cmd = ValidCmd() with { PricePerUnit = -1m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit); + } + + [Fact] + public void PricePerUnit_Positive_Passes() + { + var cmd = ValidCmd() with { PricePerUnit = 0.01m }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit); + } + + // ── ValidFrom ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidFrom_InPast_FailsValidation() + { + var cmd = ValidCmd() with { ValidFrom = Yesterday }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom); + } + + [Fact] + public void ValidFrom_Today_Passes() + { + var cmd = ValidCmd() with { ValidFrom = Today }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom); + } + + [Fact] + public void ValidFrom_Future_Passes() + { + var cmd = ValidCmd() with { ValidFrom = Tomorrow }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom); + } + + // ── Happy path ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidCommand_PassesAllRules() + { + _validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs new file mode 100644 index 0000000..77121e2 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs @@ -0,0 +1,138 @@ +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Pricing.ChargeableChars.Create; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — CreateChargeableCharConfigCommandHandler tests. +/// Covers: happy path, audit emit, audit fail → rollback, validator chain. +/// NSubstitute + FakeTimeProvider. +/// +public class CreateChargeableCharConfigHandlerTests +{ + // Hoy en ART: 2026-04-20T12:00:00 UTC → 2026-04-20T09:00:00 ART + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateChargeableCharConfigCommandHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + private static readonly DateOnly Tomorrow = new(2026, 4, 21); + + public CreateChargeableCharConfigHandlerTests() + { + _repo.InsertWithCloseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(42L); + + _handler = new CreateChargeableCharConfigCommandHandler(_repo, _audit, _time); + } + + private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new( + MedioId: null, + Symbol: "$", + Category: ChargeableCharCategories.Currency, + PricePerUnit: 1.5m, + ValidFrom: validFrom ?? Today); + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsCreateResponse() + { + var result = await _handler.Handle(ValidCmd()); + + result.Should().NotBeNull(); + result.Id.Should().Be(42L); + result.Symbol.Should().Be("$"); + result.PricePerUnit.Should().Be(1.5m); + result.ValidFrom.Should().Be(Today); + } + + [Fact] + public async Task Handle_HappyPath_CallsInsertWithCloseAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).InsertWithCloseAsync( + null, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_EmitsAuditEvent() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "tasacion.chargeable_char.create", + targetType: "ChargeableCharConfig", + targetId: "42", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_WithMedioId_PassesMedioIdToRepo() + { + var cmd = ValidCmd() with { MedioId = 7 }; + + await _handler.Handle(cmd); + + await _repo.Received(1).InsertWithCloseAsync( + 7L, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any()); + } + + [Fact] + public async Task Handle_FutureDateValidFrom_Succeeds() + { + var cmd = ValidCmd(validFrom: Tomorrow); + + var result = await _handler.Handle(cmd); + + result.ValidFrom.Should().Be(Tomorrow); + } + + // ── Audit fail → rollback ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit DB error")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit DB error"); + } + + [Fact] + public async Task Handle_AuditThrows_RepoWasCalled_ButTransactionNotCompleted() + { + // Audit fail is fail-closed: the TransactionScope was NOT completed + // (we can observe the exception propagating; if TX were committed, no exception would reach caller) + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit DB error")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + + // Repo was called (within the TX) but TX never completed + await _repo.Received(1).InsertWithCloseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeactivateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeactivateChargeableCharConfigHandlerTests.cs new file mode 100644 index 0000000..52f4297 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeactivateChargeableCharConfigHandlerTests.cs @@ -0,0 +1,114 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — DeactivateChargeableCharConfigCommandHandler tests. +/// Covers: happy path, not-found, audit emit, audit fail → rollback. +/// +public class DeactivateChargeableCharConfigHandlerTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateChargeableCharConfigCommandHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + + private static ChargeableCharConfig ActiveConfig() => + ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true); + + public DeactivateChargeableCharConfigHandlerTests() + { + _repo.GetByIdAsync(1L, Arg.Any()) + .Returns(ActiveConfig()); + + _handler = new DeactivateChargeableCharConfigCommandHandler(_repo, _audit, _time); + } + + private static DeactivateChargeableCharConfigCommand ValidCmd() => new(Id: 1L); + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDeactivateResponse() + { + var result = await _handler.Handle(ValidCmd()); + + result.Should().NotBeNull(); + result.Id.Should().Be(1L); + result.ValidTo.Should().Be(Today); + } + + [Fact] + public async Task Handle_HappyPath_CallsDeactivateAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_EmitsAuditDeactivate() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "tasacion.chargeable_char.deactivate", + targetType: "ChargeableCharConfig", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Not found ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException() + { + _repo.GetByIdAsync(99L, Arg.Any()) + .Returns((ChargeableCharConfig?)null); + + var act = async () => await _handler.Handle(ValidCmd() with { Id = 99L }); + + await act.Should().ThrowAsync(); + } + + // ── Audit fail → rollback ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit error")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit error"); + } + + [Fact] + public async Task Handle_AuditThrows_DeactivateWasCalled_TransactionNotCompleted() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit error")); + + var act = async () => await _handler.Handle(ValidCmd()); + await act.Should().ThrowAsync(); + + // Repo.DeactivateAsync was called but TX not completed (exception propagated) + await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/GetChargeableCharConfigByIdHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/GetChargeableCharConfigByIdHandlerTests.cs new file mode 100644 index 0000000..2233939 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/GetChargeableCharConfigByIdHandlerTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Pricing.ChargeableChars.GetById; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — GetChargeableCharConfigByIdQueryHandler tests. +/// Covers: found → returns DTO, not-found → returns null. +/// +public class GetChargeableCharConfigByIdHandlerTests +{ + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly GetChargeableCharConfigByIdQueryHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + + public GetChargeableCharConfigByIdHandlerTests() + { + _handler = new GetChargeableCharConfigByIdQueryHandler(_repo); + } + + private static ChargeableCharConfig MakeConfig(long id) => + ChargeableCharConfig.Rehydrate(id, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true); + + // ── Found ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_Found_ReturnsDto() + { + _repo.GetByIdAsync(1L, Arg.Any()) + .Returns(MakeConfig(1L)); + + var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(1L)); + + result.Should().NotBeNull(); + result!.Id.Should().Be(1L); + result.Symbol.Should().Be("$"); + result.PricePerUnit.Should().Be(1.0m); + result.ValidFrom.Should().Be(Today); + result.IsActive.Should().BeTrue(); + } + + // ── Not found ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ReturnsNull() + { + _repo.GetByIdAsync(99L, Arg.Any()) + .Returns((ChargeableCharConfig?)null); + + var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(99L)); + + result.Should().BeNull(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs new file mode 100644 index 0000000..91dc941 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Application.Pricing.ChargeableChars.List; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — ListChargeableCharConfigQueryHandler tests. +/// Covers: happy path with items, empty page, projection to DTO, pagination metadata. +/// +public class ListChargeableCharConfigHandlerTests +{ + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly ListChargeableCharConfigQueryHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + + private static ChargeableCharConfig MakeConfig(long id, string symbol, decimal price) => + ChargeableCharConfig.Rehydrate(id, null, symbol, ChargeableCharCategories.Currency, price, Today, null, true); + + public ListChargeableCharConfigHandlerTests() + { + _handler = new ListChargeableCharConfigQueryHandler(_repo); + } + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_WithItems_ReturnsPagedDtos() + { + var items = new List + { + MakeConfig(1, "$", 1.0m), + MakeConfig(2, "%", 0.5m), + }; + + _repo.ListAsync(null, true, 0, 20, Arg.Any()) + .Returns(items); + _repo.CountAsync(null, true, Arg.Any()) + .Returns(2); + + var query = new ListChargeableCharConfigQuery(MedioId: null, ActiveOnly: true, Page: 1, PageSize: 20); + var result = await _handler.Handle(query); + + result.Items.Should().HaveCount(2); + result.Total.Should().Be(2); + result.Page.Should().Be(1); + result.PageSize.Should().Be(20); + } + + [Fact] + public async Task Handle_WithItems_ProjectsToDto() + { + var items = new List { MakeConfig(5, "$", 1.5m) }; + + _repo.ListAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(items); + _repo.CountAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(1); + + var query = new ListChargeableCharConfigQuery(null, true, 1, 20); + var result = await _handler.Handle(query); + + var dto = result.Items[0]; + dto.Id.Should().Be(5); + dto.Symbol.Should().Be("$"); + dto.PricePerUnit.Should().Be(1.5m); + dto.ValidFrom.Should().Be(Today); + dto.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Handle_EmptyPage_ReturnsEmptyList() + { + _repo.ListAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List()); + _repo.CountAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(0); + + var query = new ListChargeableCharConfigQuery(null, true, 1, 20); + var result = await _handler.Handle(query); + + result.Items.Should().BeEmpty(); + result.Total.Should().Be(0); + } + + [Fact] + public async Task Handle_SkipIsComputed_FromPageAndPageSize() + { + // Page 3, PageSize 10 → skip = 20 + _repo.ListAsync(null, false, 20, 10, Arg.Any()) + .Returns(new List()); + _repo.CountAsync(null, false, Arg.Any()) + .Returns(0); + + var query = new ListChargeableCharConfigQuery(null, false, 3, 10); + await _handler.Handle(query); + + await _repo.Received(1).ListAsync(null, false, 20, 10, Arg.Any()); + } + + [Fact] + public async Task Handle_FiltersByMedioId_WhenProvided() + { + _repo.ListAsync(7L, true, 0, 20, Arg.Any()) + .Returns(new List()); + _repo.CountAsync(7L, true, Arg.Any()) + .Returns(0); + + var query = new ListChargeableCharConfigQuery(7L, true, 1, 20); + await _handler.Handle(query); + + await _repo.Received(1).ListAsync(7L, true, 0, 20, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeCommandValidatorTests.cs new file mode 100644 index 0000000..4604dd2 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeCommandValidatorTests.cs @@ -0,0 +1,101 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — Validator tests for SchedulePriceChangeCommand. +/// Covers: PricePerUnit > 0, ValidFrom >= today_AR. +/// Forward-only check (ValidFrom > existing.ValidFrom) is done in handler, not validator. +/// +public class SchedulePriceChangeCommandValidatorTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly SchedulePriceChangeCommandValidator _validator; + + private static readonly DateOnly Today = new(2026, 4, 20); + private static readonly DateOnly Yesterday = new(2026, 4, 19); + private static readonly DateOnly Tomorrow = new(2026, 4, 21); + + public SchedulePriceChangeCommandValidatorTests() + { + _validator = new SchedulePriceChangeCommandValidator(_time); + } + + private static SchedulePriceChangeCommand ValidCmd() => new( + Id: 1L, + PricePerUnit: 2.0m, + ValidFrom: Tomorrow); + + // ── PricePerUnit ───────────────────────────────────────────────────────────── + + [Fact] + public void PricePerUnit_Zero_FailsValidation() + { + var cmd = ValidCmd() with { PricePerUnit = 0m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit); + } + + [Fact] + public void PricePerUnit_Negative_FailsValidation() + { + var cmd = ValidCmd() with { PricePerUnit = -1m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit); + } + + [Fact] + public void PricePerUnit_Positive_Passes() + { + var cmd = ValidCmd() with { PricePerUnit = 0.01m }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit); + } + + // ── ValidFrom ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidFrom_InPast_FailsValidation() + { + var cmd = ValidCmd() with { ValidFrom = Yesterday }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom); + } + + [Fact] + public void ValidFrom_Today_Passes() + { + // today is valid — forward-only check vs existing row is done in handler + var cmd = ValidCmd() with { ValidFrom = Today }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom); + } + + [Fact] + public void ValidFrom_Future_Passes() + { + var cmd = ValidCmd() with { ValidFrom = Tomorrow }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom); + } + + // ── Id ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void Id_Zero_FailsValidation() + { + var cmd = ValidCmd() with { Id = 0L }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id); + } + + [Fact] + public void Id_Positive_Passes() + { + var cmd = ValidCmd() with { Id = 1L }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Id); + } + + // ── Happy path ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidCommand_PassesAllRules() + { + _validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeHandlerTests.cs new file mode 100644 index 0000000..7d290ca --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/SchedulePriceChangeHandlerTests.cs @@ -0,0 +1,124 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — SchedulePriceChangeCommandHandler tests. +/// Covers: happy path, forward-only validation, audit emit, audit fail → rollback. +/// +public class SchedulePriceChangeHandlerTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly SchedulePriceChangeCommandHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + private static readonly DateOnly NextMonth = new(2026, 5, 1); + + private static ChargeableCharConfig ExistingConfig(DateOnly validFrom) => + ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, validFrom, null, true); + + public SchedulePriceChangeHandlerTests() + { + _repo.GetByIdAsync(1L, Arg.Any()) + .Returns(ExistingConfig(Today)); + + _repo.InsertWithCloseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(99L); + + _handler = new SchedulePriceChangeCommandHandler(_repo, _audit, _time); + } + + private static SchedulePriceChangeCommand ValidCmd() => new( + Id: 1L, + PricePerUnit: 2.5m, + ValidFrom: NextMonth); + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsScheduleResponse() + { + var result = await _handler.Handle(ValidCmd()); + + result.Should().NotBeNull(); + result.NewId.Should().Be(99L); + result.PreviousValidFrom.Should().Be(Today); + result.NewValidFrom.Should().Be(NextMonth); + } + + [Fact] + public async Task Handle_HappyPath_CallsInsertWithCloseAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).InsertWithCloseAsync( + null, "$", ChargeableCharCategories.Currency, 2.5m, NextMonth, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_EmitsAuditPriceChange() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "tasacion.chargeable_char.price_change", + targetType: "ChargeableCharConfig", + targetId: "99", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Not found ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException() + { + _repo.GetByIdAsync(99L, Arg.Any()) + .Returns((ChargeableCharConfig?)null); + + var act = async () => await _handler.Handle(ValidCmd() with { Id = 99L }); + + await act.Should().ThrowAsync(); + } + + // ── Forward-only enforcement ───────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidFromNotGreaterThanCurrent_ThrowsForwardOnlyException() + { + // The existing config has ValidFrom = Today; scheduling for Today is not > Today + var cmd = ValidCmd() with { ValidFrom = Today }; + + var act = async () => await _handler.Handle(cmd); + + await act.Should().ThrowAsync(); + } + + // ── Audit fail → rollback ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit error")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit error"); + } +} From 3b1edfd696be896056ee54d50aa9fddc7a389006 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:32:17 -0300 Subject: [PATCH 05/14] feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper - InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params; maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException - GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows (global + per-medio) — Application service handles priority resolution - ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter - GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation - DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository - Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI - 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass --- .../DependencyInjection.cs | 2 + .../ChargeableCharConfigRepository.cs | 248 ++++++++++ ...bleCharConfigRepositoryIntegrationTests.cs | 450 ++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 20e5090..24e06a3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -45,6 +45,8 @@ public static class DependencyInjection services.AddScoped(); // PRD-003: ProductPrices históricos services.AddScoped(); + // PRC-001: ChargeableCharConfig — caracteres especiales tasables + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs new file mode 100644 index 0000000..1440cd4 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs @@ -0,0 +1,248 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig. +/// +/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps: +/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found) +/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException +/// +/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio. +/// Returns all rows (global + per-medio) — the Application service applies priority. +/// +/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via +/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository. +/// +/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer. +/// +public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository +{ + private readonly SqlConnectionFactory _factory; + + public ChargeableCharConfigRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + /// + public async Task InsertWithCloseAsync( + long? medioId, + string symbol, + string category, + decimal price, + DateOnly validFrom, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + // SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021) + p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32); + p.Add("@Symbol", symbol, DbType.String, size: 4); + p.Add("@Category", category, DbType.String, size: 32); + p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date); + p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output); + p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + } + catch (SqlException ex) when (ex.Number == 50404) + { + // Medio not found (SP validates MedioId when not null) + throw new ChargeableCharConfigInvalidException( + nameof(medioId), + $"Medio with Id={medioId} not found."); + } + catch (SqlException ex) when (ex.Number == 50409) + { + // Forward-only violation: new ValidFrom <= active.ValidFrom + throw new ChargeableCharConfigForwardOnlyException( + medioId.HasValue ? (int?)checked((int)medioId.Value) : null, + symbol, + validFrom, + DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder + } + + return p.Get("@NewId"); + } + + /// + public async Task> GetActiveForMedioAsync( + long medioId, + DateOnly asOfDate, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + // SP @MedioId is INT + p.Add("@MedioId", checked((int)medioId), DbType.Int32); + p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync( + new CommandDefinition( + "dbo.usp_ChargeableCharConfig_GetActiveForMedio", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + + return rows.Select(MapRow).ToList(); + } + + /// + public async Task> ListAsync( + long? medioId, + bool activeOnly, + int skip, + int take, + CancellationToken ct = default) + { + // NULL-aware MedioId filter: + // - medioId provided → filter to that medio only + // - medioId null → return all rows regardless of medio + // activeOnly filters by IsActive = 1. + const string sql = """ + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM dbo.ChargeableCharConfig + WHERE (@MedioId IS NULL OR MedioId = @MedioId) + AND (@ActiveOnly = 0 OR IsActive = 1) + ORDER BY ValidFrom DESC, Id DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync( + new CommandDefinition( + sql, + new + { + MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0, + Skip = skip, + Take = take + }, + cancellationToken: ct)); + + return rows.Select(MapRow).ToList(); + } + + /// + public async Task CountAsync( + long? medioId, + bool activeOnly, + CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE (@MedioId IS NULL OR MedioId = @MedioId) + AND (@ActiveOnly = 0 OR IsActive = 1) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync( + new CommandDefinition( + sql, + new + { + MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0 + }, + cancellationToken: ct)); + } + + /// + public async Task GetByIdAsync( + long id, + CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM dbo.ChargeableCharConfig + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + + return row is null ? null : MapRow(row); + } + + /// + public async Task DeactivateAsync( + long id, + DateOnly today, + CancellationToken ct = default) + { + // Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive. + const string sql = """ + UPDATE dbo.ChargeableCharConfig + SET IsActive = 0, + ValidTo = @Today + WHERE Id = @Id + AND IsActive = 1 + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync( + new CommandDefinition( + sql, + new + { + Id = id, + Today = today.ToDateTime(TimeOnly.MinValue) + }, + cancellationToken: ct)); + } + + // ── Row mapper ──────────────────────────────────────────────────────────── + // Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here. + // Same pattern as ProductPriceRepository. + + private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r) + => ChargeableCharConfig.Rehydrate( + id: r.Id, + medioId: r.MedioId, + symbol: r.Symbol, + category: r.Category, + price: r.PricePerUnit, + validFrom: DateOnly.FromDateTime(r.ValidFrom), + validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null, + isActive: r.IsActive); + + private sealed record ChargeableCharConfigRow( + long Id, + int? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateTime ValidFrom, + DateTime? ValidTo, + bool IsActive); +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs new file mode 100644 index 0000000..63a4a6d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -0,0 +1,450 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Pricing; + +/// +/// PRC-001 — Integration tests for ChargeableCharConfigRepository (Dapper) against SIGCM2_Test_App. +/// +/// All tests run against the real DB via SqlTestFixture (Database collection). +/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). +/// Tests that mutate specific (MedioId, Symbol) pairs clean their own state before mutating. +/// +/// Spec coverage: +/// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id +/// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new +/// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException +/// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation +/// T4.5 GetActiveForMedioAsync — medio has override → returns both medio and global rows +/// T4.6 GetActiveForMedioAsync — no medio override → returns only global rows +/// T4.7 ListAsync — paginates (skip/take) +/// T4.8 CountAsync — filters by activeOnly +/// T4.9 GetByIdAsync — missing → returns null +/// T4.10 GetByIdAsync — exists → returns entity +/// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today +/// T4.12 DeactivateAsync — already inactive → idempotent (no-op) +/// +[Collection("Database")] +public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private int _medioId; + + public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + + // Create a dedicated Medio for per-medio tests + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + _medioId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1) + """); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ───────────────────────────────────────────────────────────────────────── + // T4.1 — InsertWithCloseAsync: first insert for new symbol → row created, returns Id > 0 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId() + { + // NEW symbol not in canonical seed — use per-medio so it doesn't conflict + const string symbol = "@"; + var repo = BuildRepository(); + + var newId = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Other", + price: 2.5000m, + validFrom: new DateOnly(2026, 1, 1)); + + newId.Should().BeGreaterThan(0, "first insert must return the new row's Id"); + + // Verify the row exists in DB + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var row = await conn.QuerySingleOrDefaultAsync( + "SELECT Id, Symbol, IsActive, ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id", + new { Id = newId }); + + ((object?)row).Should().NotBeNull(); + ((string)row!.Symbol).Should().Be(symbol); + ((bool)row.IsActive).Should().BeTrue(); + ((object?)row.ValidTo).Should().BeNull("first insert has no ValidTo — still active"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.2 — InsertWithCloseAsync: with existing vigente → closes previous, inserts new + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task InsertWithCloseAsync_WithExistingVigente_ClosesPreviousAndInsertsNew() + { + const string symbol = "#"; + var repo = BuildRepository(); + + // First insert — becomes the vigente + var firstId = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 3, 1)); + + // Second insert (forward) — must close the first + var secondValidFrom = new DateOnly(2026, 6, 1); + var secondId = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Other", + price: 2.0000m, + validFrom: secondValidFrom); + + secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id"); + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // First row must now have ValidTo = secondValidFrom - 1 day = 2026-05-31 + var firstRow = await conn.QuerySingleAsync( + "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id", + new { Id = firstId }); + + ((DateTime)firstRow.ValidTo).Date.Should().Be(new DateTime(2026, 5, 31), + "SP closes the vigente with ValidTo = new ValidFrom - 1 day"); + + // Second row must be the new vigente (ValidTo IS NULL) + var secondRow = await conn.QuerySingleAsync( + "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id", + new { Id = secondId }); + + ((object?)secondRow.ValidTo).Should().BeNull("new vigente has ValidTo = NULL"); + ((bool)secondRow.IsActive).Should().BeTrue(); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.3 — InsertWithCloseAsync: backdate attempt → ThrowsForwardOnlyException + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task InsertWithCloseAsync_BackdateAttempt_ThrowsChargeableCharConfigForwardOnlyException() + { + const string symbol = "€"; + var repo = BuildRepository(); + + // Establish a vigente at 2026-04-01 + await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Currency", + price: 1.5000m, + validFrom: new DateOnly(2026, 4, 1)); + + // Try to insert retroactively — SP will THROW 50409 + var act = async () => await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Currency", + price: 1.2000m, + validFrom: new DateOnly(2026, 3, 1)); + + await act.Should() + .ThrowAsync( + "SQL THROW 50409 must be mapped to ChargeableCharConfigForwardOnlyException"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.4 — InsertWithCloseAsync: SYSTEM_VERSIONING captures history row after close + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task InsertWithCloseAsync_SystemVersioningCaptures_HistoryHasRowAfterClose() + { + const string symbol = "£"; + var repo = BuildRepository(); + + // Insert the first row + var firstId = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Currency", + price: 3.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + // Insert a second row — this triggers an UPDATE on the first row → history + await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: symbol, + category: "Currency", + price: 4.0000m, + validFrom: new DateOnly(2026, 7, 1)); + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var histCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id", + new { Id = firstId }); + + histCount.Should().BeGreaterThanOrEqualTo(1, + "SYSTEM_VERSIONING must create a history row when the vigente row is closed via UPDATE"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows + // Note: SP returns ALL rows (global + per-medio); service does priority resolution. + // This test verifies the REPOSITORY returns both, not just one. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows() + { + var repo = BuildRepository(); + var asOf = new DateOnly(2026, 6, 1); + + // Add a per-medio override for symbol '$' + // Canonical seed already has global '$' from ResetAndSeedAsync + await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: "$", + category: "Currency", + price: 5.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf); + + // The SP returns both the per-medio '$' AND global rows for other symbols + // (at minimum: global '$' was replaced by per-medio; other globals still present) + // SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio. + // So we should get exactly one row per symbol that is active as of asOf. + rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed"); + + var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); + dollarRow.Should().NotBeNull("the SP must return a row for '$'"); + dollarRow!.MedioId.Should().Be(_medioId, + "per-medio row takes priority over global in the SP's ROW_NUMBER ordering"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows() + { + var repo = BuildRepository(); + var asOf = new DateOnly(2026, 6, 1); + + // Use a DIFFERENT medioId that has no per-medio rows + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + var otherMedioId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1) + """); + + var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf); + + rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01"); + rows.Should().AllSatisfy(r => + r.MedioId.Should().BeNull("all returned rows must be global (MedioId = NULL)")); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.7 — ListAsync: paginates via skip/take + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ListAsync_Paginates_ReturnsCorrectSubset() + { + var repo = BuildRepository(); + + // Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2). + var page1 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 0, take: 2); + var page2 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 2, take: 2); + + page1.Should().HaveCount(2, "take=2 with at least 4 rows"); + page2.Should().HaveCount(2, "second page of 4 rows"); + + // No overlap + page1.Select(r => r.Id).Intersect(page2.Select(r => r.Id)) + .Should().BeEmpty("two non-overlapping pages must not share any row"); + } + + [Fact] + public async Task ListAsync_PageBeyondTotal_ReturnsEmpty() + { + var repo = BuildRepository(); + var result = await repo.ListAsync(medioId: null, activeOnly: false, skip: 1000, take: 10); + result.Should().BeEmpty("skip far beyond available data must return empty"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.8 — CountAsync: filters by activeOnly + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CountAsync_FiltersByActiveOnly_CountsOnlyActiveRows() + { + var repo = BuildRepository(); + + // Canonical seed: 4 active global rows + var countAll = await repo.CountAsync(medioId: null, activeOnly: false); + var countActive = await repo.CountAsync(medioId: null, activeOnly: true); + + countAll.Should().BeGreaterThanOrEqualTo(4, + "canonical seed provides at least 4 rows (may have more if other tests ran)"); + countActive.Should().BeGreaterThanOrEqualTo(4, + "all canonical rows are active"); + countActive.Should().BeLessThanOrEqualTo(countAll, + "active-only count must be <= total count"); + } + + [Fact] + public async Task CountAsync_AfterDeactivation_ActiveCountDecreases() + { + var repo = BuildRepository(); + + // Insert a row, then deactivate it — active count should decrease by 1 + var id = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: "~", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var today = new DateOnly(2026, 4, 20); + var beforeDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true); + + await repo.DeactivateAsync(id, today); + + var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true); + + afterDeactivate.Should().Be(beforeDeactivate - 1, + "deactivating one row must decrease the active count by 1"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.9 — GetByIdAsync: missing → returns null + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_Missing_ReturnsNull() + { + var repo = BuildRepository(); + var result = await repo.GetByIdAsync(999_999_999L); + result.Should().BeNull("non-existent Id must return null"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.10 — GetByIdAsync: exists → returns entity with all fields correct + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_Exists_ReturnsEntityWithCorrectFields() + { + var repo = BuildRepository(); + + var expectedValidFrom = new DateOnly(2026, 2, 1); + var id = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: "^", + category: "Other", + price: 7.5000m, + validFrom: expectedValidFrom); + + var entity = await repo.GetByIdAsync(id); + + entity.Should().NotBeNull(); + entity!.Id.Should().Be(id); + entity.MedioId.Should().Be(_medioId); + entity.Symbol.Should().Be("^"); + entity.Category.Should().Be("Other"); + entity.PricePerUnit.Should().Be(7.5000m); + entity.ValidFrom.Should().Be(expectedValidFrom); + entity.ValidTo.Should().BeNull("freshly inserted row has no ValidTo"); + entity.IsActive.Should().BeTrue(); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.11 — DeactivateAsync: sets IsActive = false and ValidTo = today + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateAsync_SetsIsActiveFalseAndValidToToday() + { + var repo = BuildRepository(); + + var id = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: "&", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var today = new DateOnly(2026, 4, 20); + await repo.DeactivateAsync(id, today); + + var entity = await repo.GetByIdAsync(id); + + entity.Should().NotBeNull(); + entity!.IsActive.Should().BeFalse("deactivated row must have IsActive = false"); + entity.ValidTo.Should().Be(today, "ValidTo must be set to the provided today date"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.12 — DeactivateAsync: already inactive → idempotent (no error, row unchanged) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateAsync_AlreadyInactive_IsIdempotent() + { + var repo = BuildRepository(); + + var id = await repo.InsertWithCloseAsync( + medioId: (long?)_medioId, + symbol: "*", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var today = new DateOnly(2026, 4, 20); + + // Deactivate once + await repo.DeactivateAsync(id, today); + + // Deactivate again — must be a no-op, no exception + var act = async () => await repo.DeactivateAsync(id, today); + await act.Should().NotThrowAsync("re-deactivating an already-inactive row must be idempotent"); + + // State must remain the same + var entity = await repo.GetByIdAsync(id); + entity!.IsActive.Should().BeFalse(); + entity.ValidTo.Should().Be(today); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + private static ChargeableCharConfigRepository BuildRepository() + => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); +} From 8fc7b363d53b4663ef1f1b60eb6bdd44a6c6a057 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:46:07 -0300 Subject: [PATCH 06/14] feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001) --- .../ChargeableCharConfigController.cs | 201 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 62 ++ .../ChargeableCharConfigControllerTests.cs | 528 ++++++++++++++++++ 3 files changed, 791 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs create mode 100644 tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs new file mode 100644 index 0000000..7bd1adc --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs @@ -0,0 +1,201 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Application.Pricing.ChargeableChars.Create; +using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Application.Pricing.ChargeableChars.GetById; +using SIGCM2.Application.Pricing.ChargeableChars.List; +using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRC-001: Admin endpoints for ChargeableCharConfig management. +/// All endpoints require 'tasacion:caracteres_especiales:gestionar'. +/// Route base: api/v1/admin/chargeable-chars +/// +[ApiController] +[Route("api/v1/admin/chargeable-chars")] +public sealed class ChargeableCharConfigController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _scheduleValidator; + + public ChargeableCharConfigController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator scheduleValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _scheduleValidator = scheduleValidator; + } + + // ── GET /api/v1/admin/chargeable-chars ──────────────────────────────────── + + /// + /// Returns a paginated list of ChargeableCharConfig rows. + /// Filters: medioId (optional, long?), activeOnly (bool, default true). + /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly. + /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200. + /// + [HttpGet] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task List( + [FromQuery] long? medioId, + [FromQuery] bool activeOnly = true, + [FromQuery] int? page = null, + [FromQuery] int? pageSize = null, + [FromQuery] int? skip = null, + [FromQuery] int? take = null) + { + // Support both page/pageSize and skip/take query patterns + int resolvedPage; + int resolvedPageSize; + + if (skip is not null || take is not null) + { + // Convert skip/take to page/pageSize + resolvedPageSize = Math.Min(take ?? 50, 200); + resolvedPage = resolvedPageSize > 0 + ? ((skip ?? 0) / resolvedPageSize) + 1 + : 1; + } + else + { + resolvedPage = page ?? 1; + resolvedPageSize = Math.Min(pageSize ?? 20, 200); + } + + var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + // ── GET /api/v1/admin/chargeable-chars/{id} ─────────────────────────────── + + /// + /// Returns a single ChargeableCharConfig by Id. Returns 404 if not found. + /// + [HttpGet("{id:long}")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById([FromRoute] long id) + { + var result = await _dispatcher.Send( + new GetChargeableCharConfigByIdQuery(id)); + return result is null ? NotFound() : Ok(result); + } + + // ── POST /api/v1/admin/chargeable-chars ─────────────────────────────────── + + /// + /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists. + /// Returns 201 Created with Location header pointing to GET /{id}. + /// + [HttpPost] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateChargeableCharConfigRequest request) + { + var command = new CreateChargeableCharConfigCommand( + request.MedioId, + request.Symbol, + request.Category, + request.PricePerUnit, + request.ValidFrom); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + + // ── PUT /api/v1/admin/chargeable-chars/{id}/price ──────────────────────── + + /// + /// Schedules a price change for an existing ChargeableCharConfig. + /// Closes the current active row and opens a new one with the new price + ValidFrom. + /// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only). + /// + [HttpPut("{id:long}/price")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task SchedulePriceChange( + [FromRoute] long id, + [FromBody] SchedulePriceChangeRequest request) + { + var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom); + + var validation = await _scheduleValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ───────────────── + + /// + /// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR). + /// Idempotent: calling on an already-inactive row is a no-op. + /// + [HttpPatch("{id:long}/deactivate")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Deactivate([FromRoute] long id) + { + var result = await _dispatcher.Send( + new DeactivateChargeableCharConfigCommand(id)); + return Ok(result); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRC-001: Create ChargeableCharConfig request body. +public sealed record CreateChargeableCharConfigRequest( + long? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateOnly ValidFrom); + +/// PRC-001: Schedule price change request body. +public sealed record SchedulePriceChangeRequest( + decimal PricePerUnit, + DateOnly ValidFrom); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 9b3602e..3df5936 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Data.SqlClient; using SIGCM2.Domain.Exceptions; +using SIGCM2.Domain.Pricing.Exceptions; namespace SIGCM2.Api.Filters; @@ -645,6 +646,67 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRC-001: WordCounter + ChargeableCharConfig exceptions + case EmojiDetectedException emojiEx: + context.Result = new ObjectResult(new + { + error = "emoji_not_allowed", + code = "EMOJI_NOT_ALLOWED", + message = emojiEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case WordCountValidationException wordEx: + context.Result = new ObjectResult(new + { + error = "word_count_validation", + code = "WORD_COUNT_VALIDATION", + field = wordEx.Field, + reason = wordEx.Reason, + message = wordEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case ChargeableCharConfigInvalidException configInvalidEx: + context.Result = new ObjectResult(new + { + error = "chargeable_char_invalid", + code = "CHARGEABLE_CHAR_INVALID", + field = configInvalidEx.Field, + reason = configInvalidEx.Reason, + message = configInvalidEx.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx: + context.Result = new ObjectResult(new + { + error = "chargeable_char_forward_only", + code = "CHARGEABLE_CHAR_FORWARD_ONLY", + medioId = forwardOnlyCharEx.MedioId, + symbol = forwardOnlyCharEx.Symbol, + newValidFrom = forwardOnlyCharEx.NewValidFrom, + activeValidFrom = forwardOnlyCharEx.ActiveValidFrom, + message = forwardOnlyCharEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs new file mode 100644 index 0000000..a2f9132 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs @@ -0,0 +1,528 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Common; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Domain.Entities; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — E2E integration tests for: +/// GET /api/v1/admin/chargeable-chars +/// GET /api/v1/admin/chargeable-chars/{id} +/// POST /api/v1/admin/chargeable-chars +/// PUT /api/v1/admin/chargeable-chars/{id}/price +/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate +/// +/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory). +/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission. +/// +[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 ────────────────────────────────────────────────────────── + + /// Admin token — has 'tasacion:caracteres_especiales:gestionar' via 'admin' role. + private string GetAdminToken() + { + var jwt = _factory.Services.GetRequiredService(); + return jwt.GenerateAccessToken(new Usuario( + id: 1, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + } + + /// Cajero token — does NOT have 'tasacion:caracteres_especiales:gestionar'. + private string GetCajeroToken() + { + var jwt = _factory.Services.GetRequiredService(); + 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 ─────────────────────────────────────────────────────── + + /// Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup. + private async Task SeedConfigDirectAsync( + long? medioId, string symbol, string category, decimal pricePerUnit, + DateOnly validFrom, DateOnly? validTo, bool isActive = true) + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync(""" + INSERT INTO dbo.ChargeableCharConfig + (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion) + VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS BIGINT); + """, + new + { + MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value, + Symbol = symbol, + Category = category, + PricePerUnit = pricePerUnit, + ValidFrom = validFrom.ToDateTime(TimeOnly.MinValue), + ValidTo = validTo.HasValue ? (object)validTo.Value.ToDateTime(TimeOnly.MinValue) : DBNull.Value, + IsActive = isActive ? 1 : 0 + }); + } + + private static string TomorrowStr() => + DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + private static string FutureDateStr(int daysAhead = 60) => + DateOnly.FromDateTime(DateTime.UtcNow).AddDays(daysAhead).ToString("yyyy-MM-dd"); + + // ── GET /api/v1/admin/chargeable-chars ─────────────────────────────────── + + /// PRC-001-R3.1 — GET list returns paged result. + [Fact] + public async Task Get_List_ReturnsPagedResult() + { + // Seed 2 active rows with unique symbols to avoid conflicts + var sym1 = $"L{Guid.NewGuid():N}"[..1]; + await SeedConfigDirectAsync(null, "§", "Currency", 1.50m, + new DateOnly(2026, 1, 1), null, true); + + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Get, + "/api/v1/admin/chargeable-chars?activeOnly=true", token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resp.Content.ReadFromJsonAsync(); + 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} ────────────────────────────── + + /// PRC-001-R3.1 — GET by id returns 200 + DTO. + [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(); + dto.GetProperty("id").GetInt64().Should().Be(id); + dto.GetProperty("symbol").GetString().Should().Be("€"); + dto.GetProperty("isActive").GetBoolean().Should().BeTrue(); + } + + /// PRC-001-R3.1 — GET by non-existent id returns 404. + [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 ────────────────────────────────── + + /// PRC-001-R3.2 — POST valid payload returns 201 + Location header. + [Fact] + public async Task Post_WithValidPayload_Returns201WithLocation() + { + var token = GetAdminToken(); + var validFrom = TomorrowStr(); + + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new + { + medioId = (long?)null, + symbol = "¥", + category = "Currency", + pricePerUnit = 1.75m, + validFrom + }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + resp.Headers.Location.Should().NotBeNull("201 must include Location header"); + + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("id").GetInt64().Should().BeGreaterThan(0); + body.GetProperty("symbol").GetString().Should().Be("¥"); + body.GetProperty("validFrom").GetString().Should().Be(validFrom); + } + + /// PRC-001-R3.5 — POST without auth returns 401. + [Fact] + public async Task Post_Unauthenticated_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + /// PRC-001-R3.5 — POST without permission returns 403. + [Fact] + public async Task Post_WithoutPermission_Returns403() + { + var token = GetCajeroToken(); + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + /// PRC-001-R3.2 — POST invalid price returns 400 validation failure. + [Fact] + public async Task Post_InvalidPrice_Returns400ValidationFailure() + { + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// PRC-001-R2.7 — POST with symbol too long returns 400. + [Fact] + public async Task Post_SymbolTooLong_Returns400() + { + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// PRC-001-R2.6 — POST with past validFrom returns 400. + [Fact] + public async Task Post_WithPastValidFrom_Returns400() + { + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// 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). + /// + [Fact] + public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred() + { + var token = GetAdminToken(); + // "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4). + // Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7. + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() }, + token: token); + var resp = await _client.SendAsync(req); + // Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test. + resp.StatusCode.Should().Be(HttpStatusCode.Created, + because: "emoji symbol rejection is deferred (spec R2.7). Symbol '😀' has length 2 in C# (UTF-16) → passes MaximumLength(4)"); + } + + // ── PUT /api/v1/admin/chargeable-chars/{id}/price ──────────────────────── + + /// PRC-001-R3.3 — PUT schedules price change, returns 200. + [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(); + body.GetProperty("newId").GetInt64().Should().BeGreaterThan(0); + body.GetProperty("newValidFrom").GetString().Should().Be(newValidFrom); + } + + /// PRC-001-R3.3 — PUT with retroactive date returns 409 ForwardOnly. + [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(); + body.GetProperty("error").GetString().Should().Be("chargeable_char_forward_only"); + } + + // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ──────────────── + + /// PRC-001-R3.4 — PATCH deactivate returns 200 OK. + [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); + } + + /// PRC-001 — PATCH deactivate on already-inactive row is idempotent → 200 OK. + [Fact] + public async Task Patch_Deactivate_AlreadyInactive_Returns200() + { + // Seed an already-inactive row + var id = await SeedConfigDirectAsync(null, "▼", "Currency", 1.00m, + new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31), false); + + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Patch, + $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token); + var resp = await _client.SendAsync(req); + + // Idempotent — no exception thrown + resp.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ── Audit ───────────────────────────────────────────────────────────────── + + /// PRC-001-R3.6 — POST emits audit event chargeable_char_config.created. + [Fact] + public async Task AuditEvent_EmittedOnCreate() + { + var token = GetAdminToken(); + var validFrom = TomorrowStr(); + + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", + body: new { medioId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Created, + because: "POST must succeed before we can verify audit"); + + var body = await resp.Content.ReadFromJsonAsync(); + var newId = body.GetProperty("id").GetInt64(); + + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var auditCount = await conn.QuerySingleAsync(""" + 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"); + } + + /// PRC-001-R3.6 — PUT price change emits audit event. + [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(); + var newId = body.GetProperty("newId").GetInt64(); + + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var auditCount = await conn.QuerySingleAsync(""" + 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"); + } + + /// PRC-001-R3.6 — PATCH deactivate emits audit event. + [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(""" + 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"); + } + + /// PRC-001-R3.7 — Audit failure rolls back insert (fail-closed). + [Fact] + public async Task Audit_Rollback_OnLoggerFailure() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + var countBefore = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ChargeableCharConfig"); + + // Build a mock IAuditLogger that throws + var throwingAudit = Substitute.For(); + throwingAudit + .LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("audit down — simulated failure")); + + using var client = _factory.CreateClientWithOverrides(services => + { + services.RemoveAll(); + services.AddScoped(_ => throwingAudit); + }); + + var jwt = _factory.Services.GetRequiredService(); + var token = jwt.GenerateAccessToken(new Usuario( + id: 1, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/chargeable-chars"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Content = JsonContent.Create(new + { + medioId = (long?)null, + symbol = "←", + category = "Currency", + pricePerUnit = 1.50m, + validFrom = TomorrowStr() + }); + + // Act + var resp = await client.SendAsync(req); + + // Audit throws → unhandled → 500 + resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError, + because: "audit failure must propagate as 500 (fail-closed)"); + + // DB: no row was inserted + await using var verifyConn = new SqlConnection(ConnectionString); + await verifyConn.OpenAsync(); + var countAfter = await verifyConn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ChargeableCharConfig"); + + countAfter.Should().Be(countBefore, + because: "TransactionScope must roll back ChargeableCharConfig insert when audit fails"); + } +} From c2a0612a70f62f56eb5974e972c731090b0f7284 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:59:27 -0300 Subject: [PATCH 07/14] =?UTF-8?q?feat(frontend):=20chargeableChars=20featu?= =?UTF-8?q?re=20=E2=80=94=20table=20+=20dialog=20+=20copy-to-all=20(PRC-00?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: ChargeableCharConfig, PagedResult, requests (validFrom/validTo as yyyy-MM-dd strings, UDT-011) - categories.ts: CHARGEABLE_CHAR_CATEGORIES + CATEGORY_LABELS - api/: 5 functions (list, getById, create, schedulePriceChange, deactivate) via axiosClient - hooks/: 5 TanStack Query hooks; mutations invalidate ['chargeableChars','list'] + byId - SymbolInput.tsx: emoji-blocking input (/\p{Extended_Pictographic}/u), max 4 chars - ChargeableCharsTable.tsx: shadcn DataTable; medio filter + activeOnly toggle; Vigente/Cerrada badges; formatCivilDate (UDT-011) - ChargeableCharFormDialog.tsx: dual-mode create/schedulePrice; Zod schema; todayArgentina() min date; 409 inline error - CopyToAllMediaDialog.tsx: Promise.allSettled over active medios; preview symbol/price/date - ChargeableCharsPage.tsx: orchestrates table + dialogs + state - routes.tsx: path/permission constants - router.tsx: route /admin/tasacion/chargeable-chars registered - AppSidebar.tsx: nav item "Caracteres Tasables" with Hash icon - Tests: 22 new RTL/vitest tests (5 test files) — strict TDD RED→GREEN→REFACTOR --- src/web/src/components/layout/AppSidebar.tsx | 7 + .../ChargeableCharFormDialog.test.tsx | 226 ++++++++++ .../__tests__/ChargeableCharsTable.test.tsx | 91 ++++ .../__tests__/CopyToAllMediaDialog.test.tsx | 106 +++++ .../__tests__/SymbolInput.test.tsx | 78 ++++ .../chargeableChars/__tests__/hooks.test.ts | 92 ++++ .../api/createChargeableCharConfig.ts | 12 + .../api/deactivateChargeableCharConfig.ts | 5 + .../api/getChargeableCharConfig.ts | 9 + .../api/listChargeableCharConfigs.ts | 18 + .../api/schedulePriceChange.ts | 13 + .../features/chargeableChars/categories.ts | 18 + .../components/ChargeableCharFormDialog.tsx | 420 ++++++++++++++++++ .../components/ChargeableCharsTable.tsx | 229 ++++++++++ .../components/CopyToAllMediaDialog.tsx | 139 ++++++ .../components/SymbolInput.tsx | 62 +++ .../hooks/useChargeableCharConfig.ts | 11 + .../hooks/useChargeableCharConfigs.ts | 14 + .../hooks/useCreateChargeableCharConfig.ts | 14 + .../useDeactivateChargeableCharConfig.ts | 13 + .../hooks/useSchedulePriceChange.ts | 14 + .../pages/ChargeableCharsPage.tsx | 113 +++++ .../src/features/chargeableChars/routes.tsx | 9 + src/web/src/features/chargeableChars/types.ts | 55 +++ src/web/src/router.tsx | 11 + 25 files changed, 1779 insertions(+) create mode 100644 src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx create mode 100644 src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx create mode 100644 src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx create mode 100644 src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx create mode 100644 src/web/src/features/chargeableChars/__tests__/hooks.test.ts create mode 100644 src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts create mode 100644 src/web/src/features/chargeableChars/api/schedulePriceChange.ts create mode 100644 src/web/src/features/chargeableChars/categories.ts create mode 100644 src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx create mode 100644 src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx create mode 100644 src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx create mode 100644 src/web/src/features/chargeableChars/components/SymbolInput.tsx create mode 100644 src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts create mode 100644 src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts create mode 100644 src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx create mode 100644 src/web/src/features/chargeableChars/routes.tsx create mode 100644 src/web/src/features/chargeableChars/types.ts diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index aa89dcf..785aeb7 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -18,6 +18,7 @@ import { Tag, Layers, Package, + Hash, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -89,6 +90,12 @@ const adminItems: NavItem[] = [ icon: Package, requiredPermission: 'catalogo:productos:gestionar', }, + { + label: 'Caracteres Tasables', + href: '/admin/tasacion/chargeable-chars', + icon: Hash, + requiredPermission: 'tasacion:caracteres_especiales:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx new file mode 100644 index 0000000..9f23d6c --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog' +import type { ChargeableCharConfig } from '../types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks(); vi.useRealTimers() }) +afterAll(() => server.close()) + +function setupFakeTimers() { + // Fix today to 2026-04-20 ART + // 2026-04-20T15:00:00-03:00 = 2026-04-20T18:00:00Z + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(new Date('2026-04-20T18:00:00.000Z')) +} + +function renderDialog( + mode: 'create' | 'schedulePrice' = 'create', + config?: ChargeableCharConfig, + onOpenChange = vi.fn(), +) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + , + ) +} + +describe('ChargeableCharFormDialog — create mode', () => { + beforeEach(() => setupFakeTimers()) + + it('shows validation error when pricePerUnit is 0', async () => { + renderDialog('create') + + const priceInput = screen.getByRole('spinbutton', { name: /precio/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '0') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + + await waitFor(() => + expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(), + { timeout: 3000 }) + }) + + it('shows validation error when validFrom is in the past', async () => { + renderDialog('create') + + const priceInput = screen.getByRole('spinbutton', { name: /precio/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '1.5') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-19') + + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + + await waitFor(() => + expect(screen.getByText(/anterior a hoy/i)).toBeInTheDocument(), + { timeout: 3000 }) + }) + + it('happy path calls mutation with correct yyyy-MM-dd string payload', async () => { + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json( + { + id: 1, medioId: null, symbol: '$', category: 'Currency', + pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true, + }, + { status: 201 }, + ) + }), + ) + + const onOpenChange = vi.fn() + renderDialog('create', undefined, onOpenChange) + + // Fill symbol + const symbolInput = screen.getByRole('textbox', { name: /símbolo/i }) + await userEvent.clear(symbolInput) + await userEvent.type(symbolInput, '$') + + // Select category via Radix Select + await userEvent.click(screen.getByRole('combobox', { name: /categoría/i })) + await userEvent.click(screen.getByRole('option', { name: /moneda/i })) + + // Fill price + const priceInput = screen.getByRole('spinbutton', { name: /precio/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '1.5') + + // Fill date + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor(() => { + expect(capturedBody).toBeTruthy() + const body = capturedBody as Record + expect(body['validFrom']).toBe('2026-04-25') + expect(typeof body['validFrom']).toBe('string') + }, { timeout: 8000 }) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 }) + }, 15000) + + it('shows inline message on server 409 (ForwardOnly)', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, () => + HttpResponse.json( + { error: 'chargeable_char_forward_only', message: 'No se pueden retrodatar precios.' }, + { status: 409 }, + ), + ), + ) + + renderDialog('create') + + // Fill symbol + const symbolInput = screen.getByRole('textbox', { name: /símbolo/i }) + await userEvent.clear(symbolInput) + await userEvent.type(symbolInput, '$') + + // Select category + await userEvent.click(screen.getByRole('combobox', { name: /categoría/i })) + await userEvent.click(screen.getByRole('option', { name: /moneda/i })) + + const priceInput = screen.getByRole('spinbutton', { name: /precio/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '1.5') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor(() => + expect(screen.getByText(/retrodatar/i)).toBeInTheDocument(), + { timeout: 8000 }) + }, 15000) +}) + +describe('ChargeableCharFormDialog — schedulePrice mode', () => { + beforeEach(() => setupFakeTimers()) + + it('hides symbol and category inputs (read-only mode)', () => { + const existingConfig: ChargeableCharConfig = { + id: 5, medioId: null, symbol: '%', category: 'Percentage', + pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, + } + renderDialog('schedulePrice', existingConfig) + + // Should not show editable symbol/category inputs in schedulePrice mode + expect(screen.queryByLabelText(/símbolo/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/categoría/i)).not.toBeInTheDocument() + + // Price and date should still be present + expect(screen.getByRole('spinbutton', { name: /precio/i })).toBeInTheDocument() + expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument() + }) + + it('happy path schedulePrice calls PUT endpoint with correct payload', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json( + { created: { id: 6, medioId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null }, + ) + }), + ) + + const existingConfig: ChargeableCharConfig = { + id: 5, medioId: null, symbol: '%', category: 'Percentage', + pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, + } + const onOpenChange = vi.fn() + renderDialog('schedulePrice', existingConfig, onOpenChange) + + const priceInput = screen.getByRole('spinbutton', { name: /precio/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '2') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + + await waitFor(() => { + expect(capturedBody).toBeTruthy() + const body = capturedBody as Record + expect(body['newValidFrom']).toBe('2026-04-25') + }, { timeout: 5000 }) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 }) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx new file mode 100644 index 0000000..1147d5e --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { ChargeableCharsTable } from '../components/ChargeableCharsTable' +import type { ChargeableCharConfig } from '../types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +function makeConfig(overrides: Partial = {}): ChargeableCharConfig { + return { + id: 1, + medioId: null, + symbol: '$', + category: 'Currency', + pricePerUnit: 1.5, + validFrom: '2026-01-01', + validTo: null, + isActive: true, + ...overrides, + } +} + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderTable( + configs: ChargeableCharConfig[], + onSchedulePrice = vi.fn(), + onDeactivate = vi.fn(), +) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +describe('ChargeableCharsTable', () => { + it('renders rows from query result — symbol and category visible', () => { + renderTable([ + makeConfig({ id: 1, symbol: '$', category: 'Currency' }), + makeConfig({ id: 2, symbol: '%', category: 'Percentage' }), + ]) + + expect(screen.getByText('$')).toBeInTheDocument() + expect(screen.getByText('%')).toBeInTheDocument() + // Category is displayed with localized label "Moneda ($)" + expect(screen.getByText('Moneda ($)')).toBeInTheDocument() + }) + + it('displays "Global" when medioId is null', () => { + renderTable([makeConfig({ medioId: null })]) + expect(screen.getByText('Global')).toBeInTheDocument() + }) + + it('shows "Vigente" badge for rows with validTo === null', () => { + renderTable([makeConfig({ validTo: null, isActive: true })]) + expect(screen.getByText('Vigente')).toBeInTheDocument() + }) + + it('shows "Cerrada" badge for rows with validTo set', () => { + renderTable([makeConfig({ validTo: '2026-03-31', isActive: false })]) + expect(screen.getByText('Cerrada')).toBeInTheDocument() + }) + + it('formats validFrom using formatCivilDate — shows dd/MM/yyyy', () => { + renderTable([makeConfig({ validFrom: '2026-01-15' })]) + expect(screen.getByText('15/01/2026')).toBeInTheDocument() + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx new file mode 100644 index 0000000..33a9231 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' +import type { MedioListItem } from '../../medios/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +function makeMedio(id: number, nombre: string): MedioListItem { + return { id, codigo: `M${id}`, nombre, tipo: 1, plataformaEmpresaId: null, activo: true } +} + +const medios = [makeMedio(1, 'La Nación'), makeMedio(2, 'Clarín'), makeMedio(3, 'Infobae')] + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderDialog(onOpenChange = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: medios, page: 1, pageSize: 100, total: 3 }), + ), + ) + + return render( + + + , + ) +} + +describe('CopyToAllMediaDialog', () => { + it('shows preview of symbol, price, and validFrom', async () => { + renderDialog() + + // Wait for dialog to render + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Preview info + expect(screen.getByText('$')).toBeInTheDocument() + expect(screen.getByText('1.5')).toBeInTheDocument() + expect(screen.getByText('25/04/2026')).toBeInTheDocument() + }) + + it('confirm with 3 medios selected calls create mutation 3 times', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + const body = await request.json() + createCalls.push(body) + return HttpResponse.json( + { id: createCalls.length, medioId: (body as Record)['medioId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, + { status: 201 }, + ) + }), + ) + + renderDialog() + + await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument()) + + // All checkboxes are selected by default; click confirm + const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i }) + await userEvent.click(confirmBtn) + + await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 }) + }) + + it('cancel button closes without making API calls', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + createCalls.push(await request.json()) + return HttpResponse.json({}, { status: 201 }) + }), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const cancelBtn = screen.getByRole('button', { name: /cancelar/i }) + await userEvent.click(cancelBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(createCalls.length).toBe(0) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx b/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx new file mode 100644 index 0000000..36c0531 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SymbolInput } from '../components/SymbolInput' + +afterEach(() => vi.clearAllMocks()) + +describe('SymbolInput — emoji blocking', () => { + it('typing ASCII chars updates value via onChange', async () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await userEvent.type(input, '$') + + expect(onChange).toHaveBeenCalledWith('$') + }) + + it('typing an emoji does NOT call onChange with emoji content (emoji blocked)', async () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + // userEvent.type fires change events for each char; emoji chars may be split into surrogates. + // The contract: onChange must never be called with a value containing an Extended_Pictographic char. + await userEvent.type(input, '😀') + + const calls = onChange.mock.calls.map(([v]: [string]) => v) + const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v)) + expect(hasEmoji).toBe(false) + }) + + it('pasting a string with emoji — onChange NOT called with emoji content', async () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await userEvent.click(input) + // userEvent.paste triggers onPaste handler with the given text + await userEvent.paste('😀') + + // onChange must NOT have been called with an emoji + const calls = onChange.mock.calls.map(([v]: [string]) => v) + const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v)) + expect(hasEmoji).toBe(false) + }) + + it('pasting normal text (no emoji) allows value update', async () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await userEvent.click(input) + // Paste normal ASCII — should go through onChange + await userEvent.paste('$') + + // onChange may be called with '$' or the merged result + // The key assertion: no rejection for non-emoji + const calls = onChange.mock.calls.map(([v]: [string]) => v) + const allNonEmoji = calls.every((v) => !/\p{Extended_Pictographic}/u.test(v)) + expect(allNonEmoji).toBe(true) + }) + + it('value is capped at 4 characters — 5th char is rejected via onChange not called with 5+ chars', async () => { + // Start with value of 4 chars already set + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + // DOM value is controlled at 4 chars; any additional char should be blocked + await userEvent.type(input, '$') + + // onChange should NOT be called with a 5-char string + const calls = onChange.mock.calls.map(([v]: [string]) => v) + const tooLong = calls.some((v) => v.length > 4) + expect(tooLong).toBe(false) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts new file mode 100644 index 0000000..0e38ec3 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' +import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return { + qc, + wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children), + } +} + +describe('useChargeableCharConfigs', () => { + it('fetches list and returns paged result', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () => + HttpResponse.json({ + items: [{ id: 1, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }], + page: 1, pageSize: 20, total: 1, + }), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useChargeableCharConfigs({}), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.items).toHaveLength(1) + expect(result.current.data?.items[0].symbol).toBe('$') + }) + + it('sends medioId and activeOnly as query params', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook( + () => useChargeableCharConfigs({ medioId: 3, activeOnly: true }), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(capturedUrl).toContain('medioId=3') + expect(capturedUrl).toContain('activeOnly=true') + }) +}) + +describe('useSchedulePriceChange', () => { + it('on success invalidates both list and byId query keys', async () => { + server.use( + http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () => + HttpResponse.json({ + created: { id: 6, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true }, + closed: null, + }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + + const { result } = renderHook(() => useSchedulePriceChange(5), { wrapper }) + + await act(async () => { + result.current.mutate({ newPricePerUnit: 2.0, newValidFrom: '2026-05-01' }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + // Should invalidate the list query and the byId query + const listInvalidated = invalidateSpy.mock.calls.some( + ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'), + ) + expect(listInvalidated).toBe(true) + }) +}) diff --git a/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts new file mode 100644 index 0000000..36f2167 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig, CreateChargeableCharConfigRequest } from '../types' + +export async function createChargeableCharConfig( + payload: CreateChargeableCharConfigRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/chargeable-chars', + payload, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts new file mode 100644 index 0000000..5b41c47 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateChargeableCharConfig(id: number): Promise { + await axiosClient.patch(`/api/v1/admin/chargeable-chars/${id}/deactivate`) +} diff --git a/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts new file mode 100644 index 0000000..23fdb11 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts @@ -0,0 +1,9 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig } from '../types' + +export async function getChargeableCharConfig(id: number): Promise { + const response = await axiosClient.get( + `/api/v1/admin/chargeable-chars/${id}`, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts new file mode 100644 index 0000000..cd1e0b3 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts @@ -0,0 +1,18 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig, ChargeableCharConfigsQuery, PagedResult } from '../types' + +export async function listChargeableCharConfigs( + query: ChargeableCharConfigsQuery, +): Promise> { + const params = new URLSearchParams() + if (query.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly)) + if (query.page !== undefined) params.set('page', String(query.page)) + if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) + + const response = await axiosClient.get>( + '/api/v1/admin/chargeable-chars', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/schedulePriceChange.ts b/src/web/src/features/chargeableChars/api/schedulePriceChange.ts new file mode 100644 index 0000000..0bc4250 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/schedulePriceChange.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SchedulePriceChangeRequest, SchedulePriceChangeResponse } from '../types' + +export async function schedulePriceChange( + id: number, + payload: SchedulePriceChangeRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/chargeable-chars/${id}/price`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/categories.ts b/src/web/src/features/chargeableChars/categories.ts new file mode 100644 index 0000000..498aaa0 --- /dev/null +++ b/src/web/src/features/chargeableChars/categories.ts @@ -0,0 +1,18 @@ +// PRC-001 — ChargeableCharCategory constants +import type { ChargeableCharCategory } from './types' + +export const CHARGEABLE_CHAR_CATEGORIES: ChargeableCharCategory[] = [ + 'Currency', + 'Percentage', + 'Exclamation', + 'Question', + 'Other', +] + +export const CATEGORY_LABELS: Record = { + Currency: 'Moneda ($)', + Percentage: 'Porcentaje (%)', + Exclamation: 'Exclamación (!)', + Question: 'Pregunta (?)', + Other: 'Otro', +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx new file mode 100644 index 0000000..1d650ca --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx @@ -0,0 +1,420 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { todayArgentina } from '@/lib/formatters' +import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories' +import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' +import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' +import { SymbolInput } from './SymbolInput' +import type { ChargeableCharConfig } from '../types' + +// ─── Emoji regex (same as SymbolInput) ─────────────────────────────────────── +const EMOJI_REGEX = /\p{Extended_Pictographic}/u + +// ─── Schemas ───────────────────────────────────────────────────────────────── + +const createSchema = z.object({ + medioId: z.number().int().positive().nullable().optional(), + symbol: z + .string() + .min(1, 'El símbolo es requerido.') + .max(4, 'Máximo 4 caracteres.') + .refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'), + category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], { + required_error: 'La categoría es requerida.', + }), + pricePerUnit: z.coerce + .number('Debe ser un número.') + .positive('El precio debe ser mayor a cero.'), + validFrom: z + .string() + .min(1, 'La fecha es requerida.') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.') + .refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'), +}) + +const schedulePriceSchema = z.object({ + pricePerUnit: z.coerce + .number('Debe ser un número.') + .positive('El precio debe ser mayor a cero.'), + validFrom: z + .string() + .min(1, 'La fecha es requerida.') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.') + .refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'), +}) + +type CreateFormRaw = { + medioId?: string + symbol: string + category: string + pricePerUnit: string + validFrom: string +} + +type SchedulePriceFormRaw = { + pricePerUnit: string + validFrom: string +} + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string; code?: string } + if (err.response.status === 409) { + return data.message ?? 'No se pueden retrodatar precios. Elegí una fecha posterior.' + } + if (err.response.status === 400 && data.code === 'CHARGEABLE_CHAR_FORWARD_ONLY') { + return 'No se pueden retrodatar precios.' + } + return data.message ?? data.error ?? 'Error al guardar.' + } + return 'Error al guardar. Intentá de nuevo.' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ChargeableCharFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + mode: 'create' | 'schedulePrice' + /** Required when mode is 'schedulePrice' */ + config?: ChargeableCharConfig +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ChargeableCharFormDialog({ + open, + onOpenChange, + mode, + config, +}: ChargeableCharFormDialogProps) { + const createMutation = useCreateChargeableCharConfig() + const scheduleMutation = useSchedulePriceChange(config?.id ?? 0) + + const isSchedule = mode === 'schedulePrice' + const activeMutation = isSchedule ? scheduleMutation : createMutation + + // ── Create form ────────────────────────────────────────────────────────── + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createForm = useForm({ + resolver: zodResolver(createSchema) as any, + defaultValues: { medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }, + mode: 'onSubmit', + }) + + // ── SchedulePrice form ─────────────────────────────────────────────────── + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scheduleForm = useForm({ + resolver: zodResolver(schedulePriceSchema) as any, + defaultValues: { pricePerUnit: '', validFrom: '' }, + mode: 'onSubmit', + }) + + useEffect(() => { + if (open) { + createForm.reset({ medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }) + scheduleForm.reset({ pricePerUnit: '', validFrom: '' }) + createMutation.reset() + scheduleMutation.reset() + } + }, [open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(activeMutation.error) + const today = todayArgentina() + + function handleCreateSubmit(values: z.infer) { + createMutation.mutate( + { + medioId: values.medioId ?? null, + symbol: values.symbol, + category: values.category as ChargeableCharConfig['category'], + pricePerUnit: values.pricePerUnit, + validFrom: values.validFrom, + }, + { + onSuccess: () => onOpenChange(false), + }, + ) + } + + function handleScheduleSubmit(values: z.infer) { + scheduleMutation.mutate( + { + newPricePerUnit: values.pricePerUnit, + newValidFrom: values.validFrom, + }, + { + onSuccess: () => onOpenChange(false), + }, + ) + } + + const isPending = activeMutation.isPending + + return ( + + + + + {isSchedule ? 'Programar cambio de precio' : 'Nuevo carácter tasable'} + + + {isSchedule + ? `Programá un nuevo precio para "${config?.symbol}" a partir de la fecha elegida.` + : 'Completá los datos para crear un nuevo carácter tasable.'} + + + + {backendError && ( + + + {backendError} + + )} + + {/* ── CREATE MODE ─────────────────────────────────────────────────── */} + {!isSchedule && ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Símbolo */} + ( + + Símbolo + + + + {!fieldState.error && } + + )} + /> + + {/* Categoría */} + ( + + Categoría + + + + + + )} + /> + + {/* Precio */} + ( + + Precio por unidad + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + + + + + + )} + + {/* ── SCHEDULE PRICE MODE ─────────────────────────────────────────── */} + {isSchedule && ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Read-only info */} + {config && ( +
+
+ Símbolo: + {config.symbol} +
+
+ Categoría: + {CATEGORY_LABELS[config.category]} +
+
+ )} + + {/* Nuevo precio */} + ( + + Nuevo precio por unidad + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + + + + + + )} +
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx new file mode 100644 index 0000000..e05abb3 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx @@ -0,0 +1,229 @@ +import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { MoreHorizontal } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { formatCivilDate } from '@/lib/formatters' +import type { ChargeableCharConfig } from '../types' +import { CATEGORY_LABELS } from '../categories' +import { useMediosList } from '../../medios/hooks/useMediosList' + +interface ChargeableCharsTableProps { + configs: ChargeableCharConfig[] + total: number + page: number + pageSize: number + onPageChange: (page: number) => void + medioId: number | undefined + activeOnly: boolean + onMedioChange: (medioId: number | undefined) => void + onActiveOnlyChange: (value: boolean) => void + onSchedulePrice: (config: ChargeableCharConfig) => void + onDeactivate: (config: ChargeableCharConfig) => void +} + +export function ChargeableCharsTable({ + configs, + total, + page, + pageSize, + onPageChange, + medioId, + activeOnly, + onMedioChange, + onActiveOnlyChange, + onSchedulePrice, + onDeactivate, +}: ChargeableCharsTableProps) { + const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) + const medios = mediosData?.items ?? [] + + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const hasPrev = page > 1 + const hasNext = page < totalPages + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'medioId', + header: 'Medio', + cell: ({ row }) => { + const mid = row.original.medioId + if (mid === null) return Global + const medio = medios.find((m) => m.id === mid) + return {medio?.nombre ?? `Medio ${mid}`} + }, + }, + { + accessorKey: 'symbol', + header: 'Símbolo', + cell: ({ row }) => ( + {row.original.symbol} + ), + }, + { + accessorKey: 'category', + header: 'Categoría', + cell: ({ row }) => ( + + {CATEGORY_LABELS[row.original.category] ?? row.original.category} + + ), + }, + { + accessorKey: 'pricePerUnit', + header: 'Precio/unidad', + cell: ({ row }) => ( + + {new Intl.NumberFormat('es-AR', { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(row.original.pricePerUnit)} + + ), + }, + { + accessorKey: 'validFrom', + header: 'Desde', + cell: ({ row }) => {formatCivilDate(row.original.validFrom)}, + }, + { + accessorKey: 'validTo', + header: 'Hasta', + cell: ({ row }) => ( + + {row.original.validTo ? formatCivilDate(row.original.validTo) : '—'} + + ), + }, + { + accessorKey: 'isActive', + header: 'Estado', + cell: ({ row }) => + row.original.isActive ? ( + + Vigente + + ) : ( + + Cerrada + + ), + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + + + onSchedulePrice(row.original)}> + Programar cambio de precio + + onDeactivate(row.original)} + className="text-destructive" + > + Desactivar + + + +
+ ), + }, + ], + [medios, onSchedulePrice, onDeactivate], + ) + + return ( +
+ {/* Filters */} +
+ + +
+ + +
+
+ + String(row.id)} + emptyMessage="Todavía no hay caracteres tasables configurados." + /> + + {/* Pagination */} +
+ + {total} resultado{total !== 1 ? 's' : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx new file mode 100644 index 0000000..04b3894 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { formatCivilDate } from '@/lib/formatters' +import { useMediosList } from '../../medios/hooks/useMediosList' +import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' +import type { ChargeableCharCategory } from '../types' + +interface CopyToAllMediaDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + symbol: string + pricePerUnit: number + /** yyyy-MM-dd */ + validFrom: string + category: ChargeableCharCategory +} + +/** + * Confirmation dialog that creates rows for all active medios + * with the same symbol/price/validFrom/category. + * + * Uses Promise.allSettled — if one medio fails, the rest still proceed. + * Summary toast shows success/failure counts. + */ +export function CopyToAllMediaDialog({ + open, + onOpenChange, + symbol, + pricePerUnit, + validFrom, + category, +}: CopyToAllMediaDialogProps) { + const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) + const medios = mediosData?.items ?? [] + const createMutation = useCreateChargeableCharConfig() + const [isProcessing, setIsProcessing] = useState(false) + + async function handleConfirm() { + if (medios.length === 0) return + setIsProcessing(true) + try { + // Promise.allSettled: partial failure doesn't block the rest + const results = await Promise.allSettled( + medios.map((m) => + createMutation.mutateAsync({ + medioId: m.id, + symbol, + category, + pricePerUnit, + validFrom, + }), + ), + ) + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed === 0) { + toast.success(`Copiado a ${succeeded} medio${succeeded !== 1 ? 's' : ''} exitosamente.`) + } else { + toast.error( + `${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`, + ) + } + onOpenChange(false) + } finally { + setIsProcessing(false) + } + } + + return ( + + + + Copiar a todos los medios + + Se creará una configuración para cada medio activo con los siguientes datos. + + + + {/* Preview */} +
+
+ Símbolo: + {symbol} +
+
+ Precio/unidad: + {pricePerUnit} +
+
+ Vigente desde: + {formatCivilDate(validFrom)} +
+
+ + {/* List of medios */} + {medios.length > 0 ? ( +
+ {medios.map((m) => ( +
+ + {m.nombre} +
+ ))} +
+ ) : ( +

Cargando medios...

+ )} + + + + + +
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/SymbolInput.tsx b/src/web/src/features/chargeableChars/components/SymbolInput.tsx new file mode 100644 index 0000000..585000a --- /dev/null +++ b/src/web/src/features/chargeableChars/components/SymbolInput.tsx @@ -0,0 +1,62 @@ +import { Input } from '@/components/ui/input' + +// PRC-001 — UDT: emoji blocking regex (Unicode Extended_Pictographic) +const EMOJI_REGEX = /\p{Extended_Pictographic}/u + +interface SymbolInputProps { + value: string + onChange: (value: string) => void + error?: string + disabled?: boolean + placeholder?: string + id?: string + 'aria-label'?: string + name?: string +} + +/** + * Controlled text input that blocks emoji characters. + * Emoji detection via /\p{Extended_Pictographic}/u (spec R4.4). + * Max length 4 chars. Server-side validation remains authoritative. + */ +export function SymbolInput({ value, onChange, error, disabled, placeholder, id, 'aria-label': ariaLabel, name }: SymbolInputProps) { + function handleChange(e: React.ChangeEvent) { + const v = e.target.value + // Block emojis + if (EMOJI_REGEX.test(v)) return + // Enforce max length + if (v.length > 4) return + onChange(v) + } + + function handlePaste(e: React.ClipboardEvent) { + const pasted = e.clipboardData.getData('text') + if (EMOJI_REGEX.test(pasted)) { + e.preventDefault() + } + // Normal paste proceeds (length clamping happens via onChange) + } + + return ( +
+ + {error && ( + + {error} + + )} +
+ ) +} diff --git a/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts new file mode 100644 index 0000000..802f92f --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getChargeableCharConfig } from '../api/getChargeableCharConfig' + +export function useChargeableCharConfig(id: number) { + return useQuery({ + queryKey: ['chargeableChars', id] as const, + queryFn: () => getChargeableCharConfig(id), + enabled: id > 0, + staleTime: 30_000, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts new file mode 100644 index 0000000..0ada98e --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { listChargeableCharConfigs } from '../api/listChargeableCharConfigs' +import type { ChargeableCharConfigsQuery } from '../types' + +export const chargeableCharConfigsQueryKey = (query: ChargeableCharConfigsQuery) => + ['chargeableChars', 'list', query] as const + +export function useChargeableCharConfigs(query: ChargeableCharConfigsQuery) { + return useQuery({ + queryKey: chargeableCharConfigsQueryKey(query), + queryFn: () => listChargeableCharConfigs(query), + staleTime: 30_000, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts new file mode 100644 index 0000000..c21897a --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createChargeableCharConfig } from '../api/createChargeableCharConfig' +import type { CreateChargeableCharConfigRequest } from '../types' + +export function useCreateChargeableCharConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateChargeableCharConfigRequest) => + createChargeableCharConfig(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts new file mode 100644 index 0000000..48c4e71 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateChargeableCharConfig } from '../api/deactivateChargeableCharConfig' + +export function useDeactivateChargeableCharConfig(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => deactivateChargeableCharConfig(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts b/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts new file mode 100644 index 0000000..d35673e --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { schedulePriceChange } from '../api/schedulePriceChange' +import type { SchedulePriceChangeRequest } from '../types' + +export function useSchedulePriceChange(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: SchedulePriceChangeRequest) => schedulePriceChange(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx new file mode 100644 index 0000000..d2120f3 --- /dev/null +++ b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' +import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig' +import { ChargeableCharsTable } from '../components/ChargeableCharsTable' +import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog' +import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' +import type { ChargeableCharConfig } from '../types' + +const PERMISSION = 'tasacion:caracteres_especiales:gestionar' +const DEFAULT_PAGE_SIZE = 20 + +export function ChargeableCharsPage() { + // ── Filter / pagination state ────────────────────────────────────────────── + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [activeOnly, setActiveOnly] = useState(true) + + // ── Dialog state ────────────────────────────────────────────────────────── + const [createOpen, setCreateOpen] = useState(false) + const [scheduleConfig, setScheduleConfig] = useState(null) + const [deactivateId, setDeactivateId] = useState(null) + const [copyFromConfig, setCopyFromConfig] = useState(null) + + // ── Data ────────────────────────────────────────────────────────────────── + const { data, isLoading } = useChargeableCharConfigs({ + medioId, + activeOnly, + page, + pageSize: DEFAULT_PAGE_SIZE, + }) + + const deactivateMutation = useDeactivateChargeableCharConfig(deactivateId ?? 0) + + function handleDeactivate(config: ChargeableCharConfig) { + setDeactivateId(config.id) + // Trigger deactivation immediately (idempotent) + deactivateMutation.mutate() + } + + return ( +
+
+

Caracteres Tasables

+ +
+ + +
+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( + { setMedioId(v); setPage(1) }} + onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }} + onSchedulePrice={setScheduleConfig} + onDeactivate={handleDeactivate} + /> + )} + + {/* Create dialog */} + + + {/* Schedule price dialog */} + { if (!open) setScheduleConfig(null) }} + /> + + {/* Copy to all medios dialog */} + {copyFromConfig && ( + { if (!open) setCopyFromConfig(null) }} + symbol={copyFromConfig.symbol} + pricePerUnit={copyFromConfig.pricePerUnit} + validFrom={copyFromConfig.validFrom} + category={copyFromConfig.category} + /> + )} +
+ ) +} diff --git a/src/web/src/features/chargeableChars/routes.tsx b/src/web/src/features/chargeableChars/routes.tsx new file mode 100644 index 0000000..6ecbf92 --- /dev/null +++ b/src/web/src/features/chargeableChars/routes.tsx @@ -0,0 +1,9 @@ +// PRC-001 — chargeableChars feature routes +// Route: /admin/tasacion/chargeable-chars +// Permission: tasacion:caracteres_especiales:gestionar +// +// Note: Registration is done in the main router (src/web/src/router.tsx). +// This file exports the route path constant for consistency. + +export const CHARGEABLE_CHARS_PATH = '/admin/tasacion/chargeable-chars' +export const CHARGEABLE_CHARS_PERMISSION = 'tasacion:caracteres_especiales:gestionar' diff --git a/src/web/src/features/chargeableChars/types.ts b/src/web/src/features/chargeableChars/types.ts new file mode 100644 index 0000000..8a487cc --- /dev/null +++ b/src/web/src/features/chargeableChars/types.ts @@ -0,0 +1,55 @@ +// PRC-001 — ChargeableCharConfig feature types + +export type ChargeableCharCategory = + | 'Currency' + | 'Percentage' + | 'Exclamation' + | 'Question' + | 'Other' + +export interface ChargeableCharConfig { + id: number + medioId: number | null + symbol: string + category: ChargeableCharCategory + pricePerUnit: number + /** yyyy-MM-dd — Cat2 civil date, NEVER a Date object */ + validFrom: string + /** yyyy-MM-dd | null — null means still active */ + validTo: string | null + isActive: boolean +} + +export interface CreateChargeableCharConfigRequest { + medioId: number | null + symbol: string + category: ChargeableCharCategory + pricePerUnit: number + /** yyyy-MM-dd */ + validFrom: string +} + +export interface SchedulePriceChangeRequest { + newPricePerUnit: number + /** yyyy-MM-dd */ + newValidFrom: string +} + +export interface SchedulePriceChangeResponse { + created: ChargeableCharConfig + closed: ChargeableCharConfig | null +} + +export interface ChargeableCharConfigsQuery { + medioId?: number + activeOnly?: boolean + page?: number + pageSize?: number +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 6ce3ea5..d6e0b2b 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -30,6 +30,7 @@ import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage' import { RubrosPage } from './features/rubros/pages/RubrosPage' import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage' import { ProductsPage } from './features/products/pages/ProductsPage' +import { ChargeableCharsPage } from './features/chargeableChars/pages/ChargeableCharsPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -331,6 +332,16 @@ export function AppRoutes() { } /> + {/* ChargeableChars routes — PRC-001 */} + + + + } + /> + } /> ) From 5175cc1ecebcd45540e2e820e0d11ef419dfbf40 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 13:21:59 -0300 Subject: [PATCH 08/14] test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRC-001) Batch 7 hardening tests: - T7.1 Concurrency: SemaphoreSlim barrier + Task.WhenAll; exactly 1 winner, 2 losers receive SqlException; post-race vigente count = 1. - T7.2 SYSTEM_VERSIONING: exact 0-before / 1-after history row count on close; history captures pre-close state (ValidTo was NULL at snapshot). - T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot at T0 returns row as it existed before the close UPDATE (ValidTo=NULL, original price). - T7.4 Per-medio + global fallback: ELDIA override for % wins over global; ELPLATA falls back to V022 global seed at 1.00; service-layer priority verified. - T7.6 WordCounterService x ChargeableCharConfig integration contract (pure unit): documents PRC-002+ billing pattern; asserts charge computation for 6 scenarios. Total .NET tests: 1603 (was 1591; +12 new). --- .../ChargeableCharConfigHardeningTests.cs | 386 ++++++++++++++++++ ...rdCounterChargeableCharIntegrationTests.cs | 280 +++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs new file mode 100644 index 0000000..2e7cde7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -0,0 +1,386 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Pricing; + +/// +/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig. +/// Covers cross-cutting concerns not addressed by individual layer batches: +/// +/// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on +/// usp_ChargeableCharConfig_InsertWithClose — only 1 winner, SERIALIZABLE guard holds. +/// +/// T7.2 SYSTEM_VERSIONING: exact row count before/after close (0 → 1). +/// +/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state. +/// +/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync: +/// - ELDIA override for '$' → per-medio row returned at priority +/// - ELPLATA (no override) → global fallback returned +/// +/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture). +/// Each test seeds its own unique symbols to avoid cross-test interference. +/// +[Collection("Database")] +public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private int _eldiaId; + private int _elplataId; + + public ChargeableCharConfigHardeningTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override). + _eldiaId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1) + """); + + _elplataId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1) + """); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ───────────────────────────────────────────────────────────────────────── + // T7.1 — Concurrency: only one winner survives the race + // + // Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol). + // The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit. + // The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205). + // + // After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol). + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Concurrency_ThreeParallelInserts_ExactlyOneWins() + { + // Unique symbol so this test doesn't conflict with other tests or seed data. + const string symbol = "¢"; + const string category = "Currency"; + + // Barrier: all tasks must wait until released simultaneously to maximize the race. + var barrier = new SemaphoreSlim(0, 3); + + async Task TryInsert(decimal price) + { + await barrier.WaitAsync(); + try + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2027, 9, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + + await conn.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + return null; // winner + } + catch (SqlException ex) + { + // Expected losers: 50409 (forward-only), 2601/2627 (unique index), 1205 (deadlock) + return ex; + } + } + + var t1 = Task.Run(() => TryInsert(1.00m)); + var t2 = Task.Run(() => TryInsert(2.00m)); + var t3 = Task.Run(() => TryInsert(3.00m)); + + // Release all three simultaneously to create a genuine race. + barrier.Release(3); + + var results = await Task.WhenAll(t1, t2, t3); + + var successes = results.Count(r => r is null); + var failures = results.Count(r => r is not null); + + successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed"); + failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException"); + + // Verify post-race state: exactly 1 vigente row for (NULL, '¢') + await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await verifyConn.OpenAsync(); + + var vigente = await verifyConn.ExecuteScalarAsync(""" + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL + AND Symbol = @Symbol + AND ValidTo IS NULL + AND IsActive = 1 + """, new { Symbol = symbol }); + + vigente.Should().Be(1, + "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)"); + + // No duplicates: total row count must also be 1 (only the winner was inserted) + var total = await verifyConn.ExecuteScalarAsync(""" + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL + AND Symbol = @Symbol + """, new { Symbol = symbol }); + + total.Should().Be(1, "no duplicate rows must exist — SERIALIZABLE guard ensures only one insert commits"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.2 — SYSTEM_VERSIONING: exact history row count before and after close + // + // Before any UPDATE: history table has 0 rows for the new Id. + // After InsertWithClose closes the previous row (UPDATE): exactly 1 history row. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SystemVersioning_HistoryCount_IsZeroBeforeClose_AndOneAfter() + { + const string symbol = "₽"; + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Insert first row (becomes vigente) + var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 1.0m, new DateTime(2026, 1, 1)); + + // Before any UPDATE: history table must have 0 rows for firstId + var histBefore = await conn.ExecuteScalarAsync(""" + SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + histBefore.Should().Be(0, + "SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE — INSERT produces no history row"); + + // Insert second row which closes (UPDATEs) the first + await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 2.0m, new DateTime(2026, 7, 1)); + + // After the UPDATE (close): exactly 1 history row for firstId + var histAfter = await conn.ExecuteScalarAsync(""" + SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + histAfter.Should().Be(1, + "SYSTEM_VERSIONING must produce exactly one history row when the vigente row is closed via UPDATE"); + + // Verify: the history row captured the pre-close state (ValidTo was NULL before the UPDATE) + var histRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + ((object?)histRow).Should().NotBeNull("history row must exist after close"); + + // The history captures the state as it was BEFORE the UPDATE: + // before close, the row had ValidTo = NULL and IsActive = 1. + ((object?)histRow!.ValidTo).Should().BeNull( + "the history row captures the state before the close UPDATE — ValidTo was NULL at that point"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.3 — FOR SYSTEM_TIME AS OF: temporal snapshot returns pre-close state + // + // Create row at T0 → query FOR SYSTEM_TIME AS OF T0 → returns row with ValidTo = NULL. + // After close → current query → row has ValidTo != NULL. + // This validates that SYSTEM_VERSIONING preserves immutable history. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0_BeforeClose() + { + const string symbol = "₿"; + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Insert first row and capture the UTC timestamp immediately after + var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 5.0m, new DateTime(2026, 3, 1)); + + // Capture T0: the SYSUTCDATETIME() right after INSERT (row is still active) + var t0 = await conn.ExecuteScalarAsync("SELECT SYSUTCDATETIME()"); + + // Wait 200ms so the SYSTEM_VERSIONING SysEndTime is strictly after T0 + // (SQL Server DATETIME2 has ~100ns precision — 200ms is more than sufficient) + await Task.Delay(200); + + // Insert second row — this closes (UPDATEs) the first, sending it to history + await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 6.0m, new DateTime(2026, 8, 1)); + + // FOR SYSTEM_TIME AS OF T0: must return the first row in its pre-close state + var snapshotRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT Id, PricePerUnit, ValidTo + FROM dbo.ChargeableCharConfig + FOR SYSTEM_TIME AS OF @T0 + WHERE Id = @Id + """, new { T0 = t0, Id = firstId }); + + ((object?)snapshotRow).Should().NotBeNull( + "FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before the close UPDATE)"); + + ((decimal)snapshotRow!.PricePerUnit).Should().Be(5.0m, + "snapshot must reflect the original price before the close"); + + ((object?)snapshotRow.ValidTo).Should().BeNull( + "at T0 the row was vigente (ValidTo IS NULL) — snapshot must preserve this"); + + // Current state: first row must now have ValidTo != NULL (it was closed) + var currentRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id + """, new { Id = firstId }); + + ((object?)currentRow).Should().NotBeNull("the closed row still exists in the current table"); + ((object?)currentRow!.ValidTo).Should().NotBeNull( + "after the close, the current row has ValidTo set — it is no longer vigente"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync + // + // Scenario: + // - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed) + // - ELDIA-specific '$' at price 5.00 effective from today (per-medio override) + // - ELPLATA has no override for '$' + // + // GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins) + // GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal() + { + var asOf = new DateOnly(2026, 6, 1); + + // Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01 + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); + + // Build the repository + service (same as application layer usage) + var repo = BuildRepository(); + var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf); + + // The per-medio '$' must be returned + var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); + dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it"); + + dollarRow!.MedioId.Should().Be(_eldiaId, + "the per-medio row (MedioId = ELDIA) must take priority over the global row"); + + dollarRow.PricePerUnit.Should().Be(5.0000m, + "ELDIA override has price 5.00, not the global 1.00"); + } + + [Fact] + public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal() + { + var asOf = new DateOnly(2026, 6, 1); + + // ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync + // provides '$' at global price. + var repo = BuildRepository(); + var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf); + + // Must have at least the global '$' from seed + rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); + + var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); + dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)"); + + dollarRow!.MedioId.Should().BeNull( + "ELPLATA has no override — the returned row must be the global row (MedioId = NULL)"); + } + + [Fact] + public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly() + { + // End-to-end: IChargeableCharConfigService resolves the final dictionary. + // Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed). + var asOf = new DateOnly(2026, 6, 1); + + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); + + // Build the service (wraps repo with priority resolution) + var service = BuildService(); + + var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf); + var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf); + + // ELDIA: '%' must come from per-medio override at 3.00 + eldiaConfig.Should().ContainKey("%", + "ELDIA has a per-medio override for '%'"); + eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m, + "per-medio '%' at 3.00 must override the global 2.00 for ELDIA"); + + // ELPLATA: '%' must come from global fallback + // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId). + // We verify via price: global '%' is seeded at 2.00 by V022. + elplataConfig.Should().ContainKey("%", + "global '%' from canonical seed must appear in ELPLATA's resolved config"); + // V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql) + elplataConfig["%"].PricePerUnit.Should().Be(1.0000m, + "ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static async Task ExecInsertWithCloseAsync( + SqlConnection conn, + int? medioId, + string symbol, + string category, + decimal pricePerUnit, + DateTime validFrom) + { + var p = new DynamicParameters(); + p.Add("@MedioId", medioId, System.Data.DbType.Int32); + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + + await conn.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + return p.Get("@NewId"); + } + + private static ChargeableCharConfigRepository BuildRepository() + => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); + + private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService() + => new(BuildRepository()); +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs new file mode 100644 index 0000000..055f317 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs @@ -0,0 +1,280 @@ +using FluentAssertions; +using SIGCM2.Application.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.WordCounter; +using Xunit; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 Batch 7 — T7.6: WordCounterService × ChargeableCharConfig integration contract. +/// +/// Documents the integration pattern that PRC-002+ will follow when computing +/// special-character billing charges for classified ads. +/// +/// Contract: +/// 1. WordCounterService.Count(text) → WordCountResult with SpecialCharCounts keyed by Category. +/// 2. IChargeableCharConfigService.GetActiveConfigForMedioAsync(medioId, today) +/// → IReadOnlyDictionary<string, ChargeableCharSnapshot> keyed by Symbol. +/// 3. A charge calculator maps SpecialCharCounts (by Category) against resolved config +/// (by Symbol) to compute the total special-character billing amount. +/// +/// This test is a PURE UNIT TEST — no DB, no HTTP, no DI container. +/// It exercises the in-memory integration point using the real WordCounterService +/// and a hand-built resolved config dictionary. +/// +/// Spec reference: PRC-001-R1 + C5 golden cases + design §11.1. +/// +public sealed class WordCounterChargeableCharIntegrationTests +{ + private readonly WordCounterService _svc = new(); + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.a — Basic integration contract + // + // Text: "vendo $5000 %50" + // WordCount result: { Currency: 1, Percentage: 1 } + // Resolved config: { "$": 2.00, "%": 3.00 } + // Expected charge: 1 × 2.00 + 1 × 3.00 = 5.00 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void BasicIntegration_CurrencyAndPercentage_ComputesCorrectCharge() + { + const string text = "vendo $5000 %50"; + + // Step 1: count specials + var countResult = _svc.Count(text); + + countResult.TotalWords.Should().Be(3, "vendo + 5000 + 50 after special replacement"); + countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); + countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); + + // Step 2: resolved config (simulates IChargeableCharConfigService output) + // Key is Symbol; ChargeableCharSnapshot holds Category + PricePerUnit. + var resolvedConfig = BuildResolvedConfig(new[] + { + ("$", "Currency", 2.0000m), + ("%", "Percentage", 3.0000m), + }); + + // Step 3: compute charge using the integration helper + var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); + + charge.Should().Be(5.0000m, + "1 × $2.00 (Currency) + 1 × $3.00 (Percentage) = $5.00"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.b — Anti-fraud: multiple specials per word + // + // Text: "P$a$l$a$b$r$a" (anti-fraud: 7 currency symbols embedded in word) + // WordCount: TotalWords = 7, Currency = 6 + // Config: { "$": 1.50 } + // Charge: 6 × 1.50 = 9.00 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void AntiFraud_MultipleSpecialsEmbedded_CountsEachOccurrence() + { + const string text = "P$a$l$a$b$r$a"; + + var countResult = _svc.Count(text); + + countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6, + "anti-fraud: 6 '$' embedded in the word — each is counted before replacement"); + + var resolvedConfig = BuildResolvedConfig(new[] + { + ("$", "Currency", 1.5000m), + }); + + var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); + + charge.Should().Be(9.0000m, "6 × $1.50 = $9.00"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.c — Symbol not in config → 0 charge (free special) + // + // Text: "¿Que tal?" + // WordCount: { Question: 2 } + // Config: only "$" and "%" configured — "?" and "¿" are NOT priced symbols. + // Charge: 0.00 (no config entry for Question symbols) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void SymbolNotInConfig_ProducesZeroCharge() + { + const string text = "¿Que tal?"; + + var countResult = _svc.Count(text); + + countResult.SpecialCharCounts.GetValueOrDefault("Question").Should().Be(2, + "¿ and ? each count as 1 Question"); + + // Config has no entry for "?" or "¿" — Questions are not billable in this scenario + var resolvedConfig = BuildResolvedConfig(new[] + { + ("$", "Currency", 2.0000m), + ("%", "Percentage", 3.0000m), + }); + + var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); + + charge.Should().Be(0.0000m, + "Question symbols are not in the config — no charge should apply"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.d — Combined scenario: multiple categories, all billable + // + // Text: "¡Oferta! $500 -20% hoy" + // From GC-23: TotalWords=4, Currency=1, Percentage=1, Exclamation=2 + // Config: { "$": 2.00, "%": 3.00, "!": 0.50, "¡": 0.50 } + // Charge: 1×2.00 + 1×3.00 + 2×0.50 = 6.00 + // + // Note: "!" and "¡" are two distinct symbols but both are Exclamation category. + // The charge is computed per-symbol-occurrence, not per-category. + // WordCountResult gives us Exclamation=2 total; we price them the same since both + // symbols have the same configured price (0.50 each). + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void CombinedCategories_AllBillable_ComputesTotalCorrectly() + { + const string text = "¡Oferta! $500 -20% hoy"; + + var countResult = _svc.Count(text); + + // Verify WordCounterService counts (GC-23 equivalent) + countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); + countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); + countResult.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(2, + "both '¡' and '!' count as Exclamation (GC-23)"); + + // Resolved config: all four symbols priced + var resolvedConfig = BuildResolvedConfig(new[] + { + ("$", "Currency", 2.0000m), + ("%", "Percentage", 3.0000m), + ("!", "Exclamation", 0.5000m), + ("¡", "Exclamation", 0.5000m), + }); + + // The charge calculator works at the CATEGORY level: + // for each category in SpecialCharCounts, find the first config entry for that category, + // use its price × occurrence count. (This simplification is valid for PRC-001 spike; + // PRC-002 will introduce per-symbol pricing if symbols in same category have different prices.) + var chargePerCategory = ComputeSpecialCharChargeByCategory(countResult, resolvedConfig); + + // 1×2.00 + 1×3.00 + 2×0.50 = 6.00 + chargePerCategory.Should().Be(6.0000m, + "Currency 1×2.00 + Percentage 1×3.00 + Exclamation 2×0.50 = 6.00"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.e — Empty text → zero charge + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void EmptyText_ProducesZeroWordsAndZeroCharge() + { + var countResult = _svc.Count(""); + + countResult.TotalWords.Should().Be(0); + countResult.SpecialCharCounts.Should().BeEmpty(); + + var resolvedConfig = BuildResolvedConfig(new[] + { + ("$", "Currency", 2.0000m), + }); + + var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); + charge.Should().Be(0.0000m, "no specials in empty text → zero charge"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.6.f — Null text → zero charge (WordCounterService null-safe) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void NullText_ProducesZeroWordsAndZeroCharge() + { + var countResult = _svc.Count(null); + + countResult.TotalWords.Should().Be(0); + countResult.SpecialCharCounts.Should().BeEmpty(); + + var resolvedConfig = BuildResolvedConfig(Array.Empty<(string, string, decimal)>()); + var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); + charge.Should().Be(0.0000m); + } + + // ───────────────────────────────────────────────────────────────────────── + // Integration contract: charge calculator helpers + // + // These helpers document the PRC-002+ integration contract. + // A real implementation would live in a pricing service or use case handler. + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Builds a resolved config dictionary keyed by Symbol (simulates + /// IChargeableCharConfigService.GetActiveConfigForMedioAsync result). + /// + private static IReadOnlyDictionary BuildResolvedConfig( + IEnumerable<(string Symbol, string Category, decimal PricePerUnit)> entries) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (symbol, category, price) in entries) + dict[symbol] = new ChargeableCharSnapshot(category, price); + return dict; + } + + /// + /// Computes the total special-character billing charge. + /// + /// Algorithm (per-symbol lookup): + /// For each symbol in the resolved config where the config category matches a + /// SpecialCharCounts category, multiply the category occurrence count by the symbol price. + /// + /// This is the SIMPLEST form of the integration contract: one price per category. + /// PRC-002+ can extend this to per-symbol pricing if different symbols in the same + /// category have different prices. + /// + private static decimal ComputeSpecialCharCharge( + WordCountResult countResult, + IReadOnlyDictionary resolvedConfig) + { + if (countResult.SpecialCharCounts.Count == 0) return 0m; + + var total = 0m; + + // For each configured symbol, check if there are occurrences of its category. + // Use the first symbol in each category as the representative price. + // (Sufficient for PRC-001 where symbols within the same category share the same price.) + var chargedCategories = new HashSet(StringComparer.Ordinal); + + foreach (var (symbol, snapshot) in resolvedConfig) + { + if (chargedCategories.Contains(snapshot.Category)) continue; + if (countResult.SpecialCharCounts.TryGetValue(snapshot.Category, out var count)) + { + total += count * snapshot.PricePerUnit; + chargedCategories.Add(snapshot.Category); + } + } + + return total; + } + + /// + /// Category-level charge computation: resolves price by finding any configured symbol + /// for each category, then multiplies by occurrence count. + /// Explicit version of ComputeSpecialCharCharge for clarity in documentation tests. + /// + private static decimal ComputeSpecialCharChargeByCategory( + WordCountResult countResult, + IReadOnlyDictionary resolvedConfig) + => ComputeSpecialCharCharge(countResult, resolvedConfig); +} From 5c1675e59a01f8b44e828ec37dde1470b4d4d9b5 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 10:35:38 -0300 Subject: [PATCH 09/14] refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001) BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet; subsequent commits in this PR restore compilation. Acceptable only because feature/PRC-001 is not yet merged to main. - V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and GetActiveForProductType (renamed from GetActiveForMedio). NEW SP ReactivateWithGuard (A+guard pattern for feature 3 of scope delta). Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing). - V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing). - V023_ROLLBACK + V024_ROLLBACK scripts. - SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed method signature (ProductTypeId=NULL + PricePerUnit=0), history table TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio). - MigrationTests: updated SP existence + column + FK + price assertions. - RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated; C# method/property renames deferred to Agent 2 (backend refactor). --- database/migrations/V023_ROLLBACK.sql | 246 +++++++++++ ...chargeable_char_config_to_product_type.sql | 406 ++++++++++++++++++ database/migrations/V024_ROLLBACK.sql | 22 + .../V024__reseed_global_with_zero_price.sql | 34 ++ .../ChargeableCharConfigHardeningTests.cs | 150 ++++--- .../ChargeableCharConfigMigrationTests.cs | 278 ++++++++---- ...bleCharConfigRepositoryIntegrationTests.cs | 10 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 404 ++++++++++++++++- 8 files changed, 1390 insertions(+), 160 deletions(-) create mode 100644 database/migrations/V023_ROLLBACK.sql create mode 100644 database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql create mode 100644 database/migrations/V024_ROLLBACK.sql create mode 100644 database/migrations/V024__reseed_global_with_zero_price.sql diff --git a/database/migrations/V023_ROLLBACK.sql b/database/migrations/V023_ROLLBACK.sql new file mode 100644 index 0000000..4e9735a --- /dev/null +++ b/database/migrations/V023_ROLLBACK.sql @@ -0,0 +1,246 @@ +-- V023_ROLLBACK.sql +-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql. +-- +-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId. +-- - Todos los datos de ProductTypeId se pierden. +-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL). +-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después. +-- +-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ─── 1. Drop new SPs ──────────────────────────────────────────────────────── + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.'; +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.'; +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.'; +END +GO + +-- ─── 2. Reverse table alterations if ProductTypeId column exists ───────────── + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') +BEGIN + -- 2a. Turn off SYSTEM_VERSIONING + ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF); + PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.'; + + -- 2b. Drop indexes on ProductTypeId + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.'; + END + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.'; + END + + -- 2c. Drop FK to ProductType + DECLARE @fk_pt sysname; + SELECT @fk_pt = name + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.ProductType'); + IF @fk_pt IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt); + PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.'; + END + + -- 2d. Drop NonNegative price check; restore Positive check + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative; + PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.'; + END + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0); + PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.'; + END + + -- 2e. Drop ProductTypeId column from main + history + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId; + PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.'; + + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'ProductTypeId') + BEGIN + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId; + PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.'; + END + + -- 2f. Restore MedioId column + ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL; + ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL; + PRINT 'V023 ROLLBACK: MedioId restored.'; + + -- 2g. Restore FK to Medio + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_Medio + FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION; + PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.'; + + -- 2h. Restore indexes on MedioId + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (MedioId, Symbol) + WHERE ValidTo IS NULL; + PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).'; + + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).'; + + -- 2i. Restore SYSTEM_VERSIONING + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.'; +END +ELSE + PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.'; +GO + +-- ─── 3. Restore original SPs ──────────────────────────────────────────────── + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); +GO + +ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @MedioId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF @MedioId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId) + BEGIN + ROLLBACK; + THROW 50404, 'Medio not found', 1; + END + + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@MedioId IS NULL AND MedioId IS NULL) + OR (@MedioId IS NOT NULL AND MedioId = @MedioId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ChargeableCharConfig + (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0'); +GO + +ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio + @MedioId INT, + @AsOfDate DATE +AS +BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (MedioId = @MedioId OR MedioId IS NULL) + ) + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; +END +GO + +PRINT ''; +PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.'; +GO diff --git a/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql b/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql new file mode 100644 index 0000000..5724118 --- /dev/null +++ b/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql @@ -0,0 +1,406 @@ +-- V023__refactor_chargeable_char_config_to_product_type.sql +-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio). +-- +-- Cambios: +-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian. +-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType. +-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query). +-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId). +-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType. +-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3). +-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing). +-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0). +-- +-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia +-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta. +-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention). +-- Depende de: V017 (dbo.ProductType debe existir). +-- Reversa: V023_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design +-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1 + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId +-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo). +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId') +BEGIN + PRINT 'V023: MedioId column found — proceeding with refactor.'; + + -- ─── 1. Turn OFF SYSTEM_VERSIONING ───────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF); + PRINT 'V023: SYSTEM_VERSIONING = OFF.'; + + -- ─── 2. Drop indexes that reference MedioId ──────────────────────── + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.'; + END + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + PRINT 'V023: IX_ChargeableCharConfig_Query dropped.'; + END + + -- ─── 3. Drop FK to Medio ──────────────────────────────────────────── + DECLARE @fk_name sysname; + SELECT @fk_name = name + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.Medio'); + IF @fk_name IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name); + PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.'; + END + + -- ─── 4. Drop MedioId column (drop DF constraint first if present) ─── + DECLARE @df_medio sysname; + SELECT @df_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND c.name = 'MedioId'; + IF @df_medio IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio); + PRINT 'V023: Default constraint on MedioId dropped.'; + END + + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId; + PRINT 'V023: MedioId column dropped from ChargeableCharConfig.'; + + -- Drop MedioId from history table if present + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'MedioId') + BEGIN + -- Drop default constraint on history MedioId if any + DECLARE @df_hist_medio sysname; + SELECT @df_hist_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND c.name = 'MedioId'; + IF @df_hist_medio IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio); + + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId; + PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.'; + END + + -- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ── + -- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it. + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive; + PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.'; + END + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative + CHECK (PricePerUnit >= 0); + PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).'; + END + + -- ─── 6. Add ProductTypeId column ──────────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + ADD ProductTypeId INT NULL; -- NULL = global fallback + PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.'; + + ALTER TABLE dbo.ChargeableCharConfig_History + ADD ProductTypeId INT NULL; + PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.'; + + -- ─── 7. Add FK to ProductType ──────────────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_ProductType + FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION; + PRINT 'V023: FK_ChargeableCharConfig_ProductType added.'; + + -- ─── 8. Recreate filtered unique index with ProductTypeId ──────────── + -- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback. + -- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global. + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol) + WHERE ValidTo IS NULL; + PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).'; + + -- ─── 9. Recreate cover index with ProductTypeId ────────────────────── + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).'; + + -- ─── 10. Turn SYSTEM_VERSIONING back ON ────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).'; +END +ELSE +BEGIN + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.'; + ELSE + PRINT 'V023: MedioId column not found — table already refactored, skipping.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId) +-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. +-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity). +-- THROW 50404: ProductType not found. +-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom. +-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol). +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; +GO + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @ProductTypeId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Validate ProductTypeId only when provided (NULL = global fallback, always valid). + -- FK constraint handles referential integrity; we throw 50404 explicitly for better UX. + IF @ProductTypeId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId) + BEGIN + ROLLBACK; + THROW 50404, 'ProductType not found', 1; + END + + -- Read current vigente with range lock for serialization. + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL) + OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + -- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom. + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + -- Close the current vigente: ValidTo = new ValidFrom - 1 day. + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + -- Insert the new vigente. + INSERT INTO dbo.ChargeableCharConfig + (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_GetActiveForProductType +-- Resolución per-ProductType + global fallback: 1 fila por Symbol. +-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1). +-- @ProductTypeId: the specific product type to resolve for. +-- @AsOfDate: resolve active rows as of this date (for pricing snapshot). +-- ═══════════════════════════════════════════════════════════════════════ + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType + @ProductTypeId INT, + @AsOfDate DATE +AS +BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL) + ) + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta) +-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol). +-- Guards: +-- - Row must exist → THROW 50404 +-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active +-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411 +-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412 +-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo). +-- Preserves forward-only invariant and maintains clean history. +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard; +GO + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard + @Id BIGINT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Step 1: Lock + load target row. + DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT; + SELECT @ProductTypeId = ProductTypeId, + @Symbol = Symbol, + @ValidTo = ValidTo, + @IsActive = IsActive + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK) + WHERE Id = @Id; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + THROW 50404, 'ChargeableCharConfig row not found', 1; + END + + -- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0). + -- If it is currently active (ValidTo IS NULL), reactivation is nonsensical. + IF @ValidTo IS NULL + BEGIN + ROLLBACK TRANSACTION; + THROW 50410, 'Row is already active — reactivation not needed', 1; + END + + -- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol). + -- Prevents re-opening a row while another is already vigente. + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidTo IS NULL + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1; + END + + -- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo. + -- Ensures this is the LAST closed row; reactivating an older row would violate + -- forward-only ordering of the temporal chain. + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidFrom > @ValidTo + AND Id <> @Id + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1; + END + + -- Step 5: Literal undo — re-open the row. + UPDATE dbo.ChargeableCharConfig + SET IsActive = 1, + ValidTo = NULL + WHERE Id = @Id; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +PRINT ''; +PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:'; +PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)'; +PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated'; +PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId'; +PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped'; +PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created'; +PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)'; +PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)'; +PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).'; +GO diff --git a/database/migrations/V024_ROLLBACK.sql b/database/migrations/V024_ROLLBACK.sql new file mode 100644 index 0000000..d46fd31 --- /dev/null +++ b/database/migrations/V024_ROLLBACK.sql @@ -0,0 +1,22 @@ +-- V024_ROLLBACK.sql +-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql. +-- +-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022). +-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo. +-- +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + +PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.'; +PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10)); +GO diff --git a/database/migrations/V024__reseed_global_with_zero_price.sql b/database/migrations/V024__reseed_global_with_zero_price.sql new file mode 100644 index 0000000..70f37bf --- /dev/null +++ b/database/migrations/V024__reseed_global_with_zero_price.sql @@ -0,0 +1,34 @@ +-- V024__reseed_global_with_zero_price.sql +-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000. +-- +-- Cambios: +-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000. +-- +-- Decisión: UPDATE directo (no forward-only close+insert) porque: +-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio. +-- - No existe historial de facturación con el valor 1.0000. +-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales. +-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix +-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún). +-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1. +-- +-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL. +-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado). +-- Reversa: V024_ROLLBACK.sql. +-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required). +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + +PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).'; +PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10)); +GO diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs index 2e7cde7..54514d2 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -8,7 +8,7 @@ using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// -/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig. +/// PRC-001 — Integration hardening tests for ChargeableCharConfig. /// Covers cross-cutting concerns not addressed by individual layer batches: /// /// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on @@ -18,9 +18,11 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state. /// -/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync: -/// - ELDIA override for '$' → per-medio row returned at priority -/// - ELPLATA (no override) → global fallback returned +/// T7.4 Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync: +/// - ProductType1 override for '$' → per-PT row returned at priority +/// - ProductType2 (no override) → global fallback returned +/// +/// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows. /// /// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture). /// Each test seeds its own unique symbols to avoid cross-test interference. @@ -29,8 +31,10 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime { private readonly SqlTestFixture _db; - private int _eldiaId; - private int _elplataId; + // V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs. + // These are ProductType IDs (FK to dbo.ProductType), not Medio IDs. + private int _productType1Id; // has per-PT override (was ELDIA) + private int _productType2Id; // no override, falls back to global (was ELPLATA) public ChargeableCharConfigHardeningTests(SqlTestFixture db) { @@ -44,17 +48,18 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); - // Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override). - _eldiaId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + // Seed two dedicated ProductTypes for override/fallback resolution tests. + // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). + _productType1Id = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, IsActive) OUTPUT INSERTED.Id - VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1) + VALUES ('Hardening PT1 (override)', 1) """); - _elplataId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + _productType2Id = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, IsActive) OUTPUT INSERTED.Id - VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1) + VALUES ('Hardening PT2 (fallback)', 1) """); } @@ -126,27 +131,28 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed"); failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException"); - // Verify post-race state: exactly 1 vigente row for (NULL, '¢') + // Verify post-race state: exactly 1 vigente row for (ProductTypeId=NULL, '¢') + // V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); await verifyConn.OpenAsync(); var vigente = await verifyConn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL + WHERE ProductTypeId IS NULL AND Symbol = @Symbol AND ValidTo IS NULL AND IsActive = 1 """, new { Symbol = symbol }); vigente.Should().Be(1, - "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)"); + "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (ProductTypeId, Symbol)"); // No duplicates: total row count must also be 1 (only the winner was inserted) var total = await verifyConn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL + WHERE ProductTypeId IS NULL AND Symbol = @Symbol """, new { Symbol = symbol }); @@ -260,115 +266,127 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime } // ───────────────────────────────────────────────────────────────────────── - // T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync + // T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync + // + // V023 scope delta: MedioId → ProductTypeId in table + SP. // // Scenario: - // - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed) - // - ELDIA-specific '$' at price 5.00 effective from today (per-medio override) - // - ELPLATA has no override for '$' + // - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed) + // - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override) + // - ProductType2 has no override for '$' // - // GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins) - // GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback) + // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins) + // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback) + // + // NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be + // renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2. + // SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already + // updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is. // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal() + public async Task GetActiveConfigForProductType_Override_WinsOverGlobal() { var asOf = new DateOnly(2026, 6, 1); - // Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01 + // Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01 await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); - await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); + await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); - // Build the repository + service (same as application layer usage) + // Build the repository + service (C# method will be renamed in Agent 2) var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf); + var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync - // The per-medio '$' must be returned + // The per-PT '$' must be returned var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); - dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it"); + dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it"); - dollarRow!.MedioId.Should().Be(_eldiaId, - "the per-medio row (MedioId = ELDIA) must take priority over the global row"); + dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId + "the per-PT row (ProductTypeId = PT1) must take priority over the global row"); dollarRow.PricePerUnit.Should().Be(5.0000m, - "ELDIA override has price 5.00, not the global 1.00"); + "ProductType1 override has price 5.00, not the global 0.00"); } [Fact] - public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal() + public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal() { var asOf = new DateOnly(2026, 6, 1); - // ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync - // provides '$' at global price. + // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync + // provides '$' at global price (0.0000 after V024). var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf); + var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync // Must have at least the global '$' from seed rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); - dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)"); + dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)"); - dollarRow!.MedioId.Should().BeNull( - "ELPLATA has no override — the returned row must be the global row (MedioId = NULL)"); + dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId + "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)"); } [Fact] - public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly() + public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly() { // End-to-end: IChargeableCharConfigService resolves the final dictionary. - // Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed). + // Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed). var asOf = new DateOnly(2026, 6, 1); await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); - await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); + await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); // Build the service (wraps repo with priority resolution) + // TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync var service = BuildService(); - var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf); - var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf); + var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2 + var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2 - // ELDIA: '%' must come from per-medio override at 3.00 - eldiaConfig.Should().ContainKey("%", - "ELDIA has a per-medio override for '%'"); - eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m, - "per-medio '%' at 3.00 must override the global 2.00 for ELDIA"); + // ProductType1: '%' must come from per-PT override at 3.00 + pt1Config.Should().ContainKey("%", + "ProductType1 has a per-PT override for '%'"); + pt1Config["%"].PricePerUnit.Should().Be(3.0000m, + "per-PT '%' at 3.00 must override the global 0.00 for ProductType1"); - // ELPLATA: '%' must come from global fallback - // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId). - // We verify via price: global '%' is seeded at 2.00 by V022. - elplataConfig.Should().ContainKey("%", - "global '%' from canonical seed must appear in ELPLATA's resolved config"); - // V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql) - elplataConfig["%"].PricePerUnit.Should().Be(1.0000m, - "ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)"); + // ProductType2: '%' must come from global fallback + // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId). + // We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022). + pt2Config.Should().ContainKey("%", + "global '%' from canonical seed must appear in ProductType2's resolved config"); + // V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced) + pt2Config["%"].PricePerUnit.Should().Be(0.0000m, + "ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)"); } // ── Helpers ─────────────────────────────────────────────────────────────── + /// + /// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL. + /// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId. + /// private static async Task ExecInsertWithCloseAsync( SqlConnection conn, - int? medioId, + int? productTypeId, string symbol, string category, decimal pricePerUnit, DateTime validFrom) { var p = new DynamicParameters(); - p.Add("@MedioId", medioId, System.Data.DbType.Int32); - p.Add("@Symbol", symbol, System.Data.DbType.String); - p.Add("@Category", category, System.Data.DbType.String); - p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ProductTypeId", productTypeId, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await conn.ExecuteAsync( "dbo.usp_ChargeableCharConfig_InsertWithClose", diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs index 821662e..2d54482 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs @@ -6,14 +6,15 @@ using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// -/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. +/// PRC-001 — Integration tests for V020/V021/V022/V023/V024 migrations. /// These tests verify the BD schema applied against SIGCM2_Test_App: /// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. -/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. +/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + SPs (initial MedioId shape). /// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. +/// - V023 (scope delta): MedioId → ProductTypeId refactor + ReactivateWithGuard SP. +/// - V024 (scope delta): global seed PricePerUnit reset to 0.0000 (opt-in billing). /// -/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). -/// After GREEN, all tests in this class should pass. +/// After GREEN all tests pass. SqlTestFixture applies V023+V024 during initialization. /// [Collection("Database")] public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime @@ -104,19 +105,19 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime [Fact] public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() { - // Cleanup + // Cleanup (use ProductTypeId IS NULL after V023 refactor) await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'@'"); var p = new DynamicParameters(); - p.Add("@MedioId", null, System.Data.DbType.Int32); - p.Add("@Symbol", "@", System.Data.DbType.String); - p.Add("@Category", "Other",System.Data.DbType.String); - p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, + p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", "@", System.Data.DbType.String); + p.Add("@Category", "Other",System.Data.DbType.String); + p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync( @@ -132,7 +133,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND ProductTypeId IS NULL"); } [Fact] @@ -140,17 +141,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime { // Seed primer activo await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'€'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "€", System.Data.DbType.String); - p1.Add("@Category", "Currency", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "€", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -159,14 +160,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Insertar segundo (debe cerrar el primero) var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "€", System.Data.DbType.String); - p2.Add("@Category", "Currency", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "€", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, @@ -187,24 +188,24 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND ProductTypeId IS NULL"); } [Fact] public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() { await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'£'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "£", System.Data.DbType.String); - p1.Add("@Category", "Currency", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "£", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -212,14 +213,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "£", System.Data.DbType.String); - p2.Add("@Category", "Currency", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "£", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); var act = async () => await _connection.ExecuteAsync( @@ -232,52 +233,53 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND ProductTypeId IS NULL"); } [Fact] - public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() + public async Task usp_InsertWithClose_ProductTypeNull_GlobalFallback_Works() { + // V023: global fallback is now ProductTypeId IS NULL (was MedioId IS NULL) await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'¥'"); var p = new DynamicParameters(); - p.Add("@MedioId", null, System.Data.DbType.Int32); - p.Add("@Symbol", "¥", System.Data.DbType.String); - p.Add("@Category", "Currency", System.Data.DbType.String); - p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, + p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", "¥", System.Data.DbType.String); + p.Add("@Category", "Currency", System.Data.DbType.String); + p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p, commandType: System.Data.CommandType.StoredProcedure); var newId = p.Get("@NewId"); - newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); + newId.Should().BeGreaterThan(0, "global insert (ProductTypeId NULL) debe funcionar"); // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND ProductTypeId IS NULL"); } [Fact] public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() { await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'#'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "#", System.Data.DbType.String); - p1.Add("@Category", "Other", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "#", System.Data.DbType.String); + p1.Add("@Category", "Other", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -286,14 +288,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Close it by inserting a newer version var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "#", System.Data.DbType.String); - p2.Add("@Category", "Other", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "#", System.Data.DbType.String); + p2.Add("@Category", "Other", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, @@ -309,13 +311,25 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND ProductTypeId IS NULL"); } - // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── + // ── V023 scope delta: SP — usp_ChargeableCharConfig_GetActiveForProductType ──────────── [Fact] - public async Task V021_SP_GetActiveForMedio_Exists() + public async Task V023_SP_GetActiveForProductType_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType') + AND type = 'P'"); + + exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_GetActiveForProductType (renamed from GetActiveForMedio)"); + } + + [Fact] + public async Task V023_SP_GetActiveForMedio_NoLongerExists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) @@ -323,7 +337,81 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') AND type = 'P'"); - exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); + exists.Should().Be(0, "V023 debe eliminar usp_ChargeableCharConfig_GetActiveForMedio (renamed to GetActiveForProductType)"); + } + + [Fact] + public async Task V023_SP_ReactivateWithGuard_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard') + AND type = 'P'"); + + exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_ReactivateWithGuard (new SP for feature 3)"); + } + + // ── V023 scope delta: column ProductTypeId replaces MedioId ───────── + + [Fact] + public async Task V023_Column_ProductTypeId_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId'"); + + exists.Should().Be(1, "V023 debe agregar columna ProductTypeId a dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V023_Column_MedioId_NoLongerExists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId'"); + + exists.Should().Be(0, "V023 debe eliminar columna MedioId de dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V023_FK_ProductType_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.ProductType')"); + + exists.Should().Be(1, "V023 debe crear FK de ChargeableCharConfig a ProductType"); + } + + [Fact] + public async Task V023_Check_Price_NonNegative_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "V023 debe crear CK_ChargeableCharConfig_Price_NonNegative (>= 0 para opt-in billing)"); + } + + [Fact] + public async Task V023_Check_Price_Positive_NoLongerExists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(0, "V023 debe eliminar CK_ChargeableCharConfig_Price_Positive (reemplazado por NonNegative)"); } // ── V020: Permission existence ──────────────────────────────────────── @@ -339,17 +427,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); } - // ── V022: Seed rows ─────────────────────────────────────────────────── + // ── V022: Seed rows (after V023 refactor: use ProductTypeId IS NULL) ───── [Fact] public async Task V022_Seeds_AtLeastFourGlobalRows() { var count = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL AND IsActive = 1"); - count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); + count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡ (global = ProductTypeId IS NULL tras V023)"); } [Fact] @@ -357,9 +445,39 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime { var inactiveCount = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND IsActive = 0"); inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); } + + // ── V024 scope delta: global seed prices = 0.0000 (opt-in billing) ─────── + + [Fact] + public async Task V024_GlobalSeedRows_HaveZeroPrice() + { + var nonZeroCount = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL + AND PricePerUnit <> 0.0000"); + + nonZeroCount.Should().Be(0, + "V024 debe resetear todas las filas de seed global a PricePerUnit = 0.0000 (opt-in billing)"); + } + + [Fact] + public async Task V024_GlobalSeedRows_AllHaveZeroPriceExact() + { + var rows = await _connection.QueryAsync(@" + SELECT PricePerUnit FROM dbo.ChargeableCharConfig + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL"); + + rows.Should().AllSatisfy(price => + price.Should().Be(0.0000m, + "V024 seed: cada fila global debe tener PricePerUnit = 0.0000 exacto")); + } } diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs index 63a4a6d..16acd8b 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -14,15 +14,19 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// All tests run against the real DB via SqlTestFixture (Database collection). /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). -/// Tests that mutate specific (MedioId, Symbol) pairs clean their own state before mutating. +/// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating. +/// +/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync +/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor). +/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected. /// /// Spec coverage: /// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id /// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new /// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException /// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation -/// T4.5 GetActiveForMedioAsync — medio has override → returns both medio and global rows -/// T4.6 GetActiveForMedioAsync — no medio override → returns only global rows +/// T4.5 GetActiveForProductTypeAsync — PT has override → returns both PT and global rows +/// T4.6 GetActiveForProductTypeAsync — no PT override → returns only global rows /// T4.7 ListAsync — paginates (skip/take) /// T4.8 CountAsync — filters by activeOnly /// T4.9 GetByIdAsync — missing → returns null diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 775cdaa..4b07492 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -72,6 +72,12 @@ public sealed class SqlTestFixture : IAsyncLifetime // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed. await EnsureV021SchemaAsync(); + // V023 (PRC-001 scope delta): refactor ChargeableCharConfig — MedioId → ProductTypeId + ReactivateWithGuard SP. + await EnsureV023SchemaAsync(); + + // V024 (PRC-001 scope delta): reseed global rows with PricePerUnit = 0.0000 (opt-in billing). + await EnsureV024SeedAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -127,6 +133,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); + // PRC-001 scope delta: ChargeableCharConfig re-seeded with ProductTypeId-based canonical seed. await SeedChargeableCharConfigCanonicalAsync(); } @@ -574,28 +581,34 @@ public sealed class SqlTestFixture : IAsyncLifetime } /// - /// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. - /// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente). - /// The table itself is never added to TablesToIgnore because per-medio test rows + /// PRC-001 scope delta (V022+V024): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. + /// Uses ProductTypeId NULL (global fallback) and PricePerUnit = 0.0000 (opt-in billing — V024 decision). + /// Mirrors V022 MERGE pattern, adapted for ProductTypeId column (V023 refactor). + /// The table itself is never added to TablesToIgnore because per-productType test rows /// must be reset between test classes — only the 4 global defaults are reseeded. + /// NOTE: seed price is 0.0000 (not 1.0000). Tests asserting price must use 0.0000 unless + /// they have explicitly seeded their own rows at a different price. /// private async Task SeedChargeableCharConfigCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') BEGIN MERGE dbo.ChargeableCharConfig AS t USING (VALUES - (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) - ) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) - ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) + (NULL, N'$', N'Currency', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'%', N'Percentage', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'!', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'¡', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) + ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom) + ON (t.ProductTypeId IS NULL AND s.ProductTypeId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) WHEN NOT MATCHED THEN - INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) - VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); + INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); END """; await _connection.ExecuteAsync(sql); @@ -1517,4 +1530,373 @@ public sealed class SqlTestFixture : IAsyncLifetime // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). } + + /// + /// PRC-001 scope delta (V023): refactors dbo.ChargeableCharConfig from MedioId to ProductTypeId. + /// Mirrors V023__refactor_chargeable_char_config_to_product_type.sql (idempotente). + /// + /// Steps (only run if MedioId column still exists — guard for idempotence): + /// 1. SYSTEM_VERSIONING OFF + /// 2. Drop UX_Vigente + IX_Query (MedioId-based) + /// 3. Drop FK_ChargeableCharConfig_Medio + /// 4. Drop MedioId column from main + history + /// 5. Drop CK_Price_Positive; add CK_Price_NonNegative (>= 0 for opt-in billing) + /// 6. Add ProductTypeId column (nullable) to main + history + /// 7. Add FK_ChargeableCharConfig_ProductType + /// 8. Recreate UX_Vigente + IX_Query (ProductTypeId-based) + /// 9. SYSTEM_VERSIONING ON + /// 10. Drop+Create usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId) + /// 11. Drop usp_ChargeableCharConfig_GetActiveForMedio + /// 12. Create usp_ChargeableCharConfig_GetActiveForProductType + /// 13. Create usp_ChargeableCharConfig_ReactivateWithGuard (NEW) + /// + private async Task EnsureV023SchemaAsync() + { + // ── Guard: only run the ALTER block if MedioId still exists ────────── + // SPs are always idempotently recreated (create-if-not-exists + alter pattern). + const string checkMedioId = """ + SELECT CAST( + CASE WHEN EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId' + ) THEN 1 ELSE 0 END + AS BIT) + """; + + var hasMedioId = await _connection.ExecuteScalarAsync(checkMedioId); + + if (hasMedioId) + { + // ── 1. SYSTEM_VERSIONING OFF ────────────────────────────────────── + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF)"); + + // ── 2. Drop MedioId-based indexes ───────────────────────────────── + const string dropIndexes = """ + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + """; + await _connection.ExecuteAsync(dropIndexes); + + // ── 3. Drop FK to Medio ──────────────────────────────────────────── + const string dropFkMedio = """ + DECLARE @fk_name sysname; + SELECT @fk_name = name FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.Medio'); + IF @fk_name IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name); + """; + await _connection.ExecuteAsync(dropFkMedio); + + // ── 4. Drop MedioId from main + history ──────────────────────────── + const string dropMedioIdMain = """ + DECLARE @df_medio sysname; + SELECT @df_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND c.name = 'MedioId'; + IF @df_medio IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio); + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId; + """; + await _connection.ExecuteAsync(dropMedioIdMain); + + const string dropMedioIdHistory = """ + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'MedioId') + BEGIN + DECLARE @df_hist sysname; + SELECT @df_hist = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND c.name = 'MedioId'; + IF @df_hist IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist); + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId; + END + """; + await _connection.ExecuteAsync(dropMedioIdHistory); + + // ── 5. Replace price check constraint ──────────────────────────────── + const string replacePriceCheck = """ + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive; + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative CHECK (PricePerUnit >= 0); + """; + await _connection.ExecuteAsync(replacePriceCheck); + + // ── 6. Add ProductTypeId to main + history ───────────────────────── + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig ADD ProductTypeId INT NULL"); + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig_History ADD ProductTypeId INT NULL"); + + // ── 7. Add FK to ProductType ─────────────────────────────────────── + await _connection.ExecuteAsync(""" + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_ProductType + FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION + """); + + // ── 8. Recreate ProductTypeId-based indexes ──────────────────────── + await _connection.ExecuteAsync(""" + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol) + WHERE ValidTo IS NULL + """); + + await _connection.ExecuteAsync(""" + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category) + """); + + // ── 9. SYSTEM_VERSIONING ON ──────────────────────────────────────── + await _connection.ExecuteAsync(""" + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )) + """); + } + + // ── 10. Recreate InsertWithClose SP (always: idempotent via drop+create) ── + const string createInsertSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); + """; + + // Only ALTER if ProductTypeId column exists (meaning table was already refactored or we just did it) + const string alterInsertSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @ProductTypeId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF @ProductTypeId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId) + BEGIN + ROLLBACK; + THROW 50404, 'ProductType not found', 1; + END + + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL) + OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ChargeableCharConfig + (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + await _connection.ExecuteAsync(createInsertSp); + await _connection.ExecuteAsync(alterInsertSp); + + // ── 11. Drop GetActiveForMedio SP ────────────────────────────────────── + await _connection.ExecuteAsync(""" + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + """); + + // ── 12. Create GetActiveForProductType SP ────────────────────────────── + const string createGetForPtSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForProductType', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType AS RETURN 0'); + """; + + const string alterGetForPtSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType + @ProductTypeId INT, + @AsOfDate DATE + AS + BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL) + ) + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; + END + """; + + await _connection.ExecuteAsync(createGetForPtSp); + await _connection.ExecuteAsync(alterGetForPtSp); + + // ── 13. Create ReactivateWithGuard SP (NEW) ──────────────────────────── + const string createReactivateSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_ReactivateWithGuard', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard AS RETURN 0'); + """; + + const string alterReactivateSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard + @Id BIGINT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT; + SELECT @ProductTypeId = ProductTypeId, + @Symbol = Symbol, + @ValidTo = ValidTo, + @IsActive = IsActive + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK) + WHERE Id = @Id; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + THROW 50404, 'ChargeableCharConfig row not found', 1; + END + + IF @ValidTo IS NULL + BEGIN + ROLLBACK TRANSACTION; + THROW 50410, 'Row is already active — reactivation not needed', 1; + END + + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidTo IS NULL + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1; + END + + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidFrom > @ValidTo + AND Id <> @Id + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1; + END + + UPDATE dbo.ChargeableCharConfig + SET IsActive = 1, + ValidTo = NULL + WHERE Id = @Id; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + await _connection.ExecuteAsync(createReactivateSp); + await _connection.ExecuteAsync(alterReactivateSp); + } + + /// + /// PRC-001 scope delta (V024): reseeds global ChargeableCharConfig rows to PricePerUnit = 0.0000. + /// Direct UPDATE — V022 seed price 1.0000 was always a placeholder, no business history exists. + /// Safe to re-run: already-zero rows are unchanged. + /// Requires ProductTypeId column to exist (V023 must have run). + /// + private async Task EnsureV024SeedAsync() + { + const string sql = """ + IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') + BEGIN + UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + END + """; + await _connection.ExecuteAsync(sql); + } } From f7fb76219a1fa30f5b6054e00e4b82b7ccc6bb80 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 10:54:47 -0300 Subject: [PATCH 10/14] refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ChargeableCharConfigController.cs | 58 +++- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 29 +- .../IChargeableCharConfigRepository.cs | 51 +++- .../SIGCM2.Application/DependencyInjection.cs | 4 + .../ChargeableCharConfigDto.cs | 2 +- .../ChargeableCharConfigService.cs | 22 +- .../CreateChargeableCharConfigCommand.cs | 4 +- ...reateChargeableCharConfigCommandHandler.cs | 4 +- ...ivateChargeableCharConfigCommandHandler.cs | 2 +- .../DeleteChargeableCharConfigCommand.cs | 10 + ...eleteChargeableCharConfigCommandHandler.cs | 75 +++++ .../DeleteChargeableCharConfigResponse.cs | 6 + ...GetChargeableCharConfigByIdQueryHandler.cs | 2 +- .../IChargeableCharConfigService.cs | 14 +- .../List/ListChargeableCharConfigQuery.cs | 2 +- .../ListChargeableCharConfigQueryHandler.cs | 6 +- .../ReactivateChargeableCharConfigCommand.cs | 7 + ...ivateChargeableCharConfigCommandHandler.cs | 71 +++++ .../ReactivateChargeableCharConfigResponse.cs | 14 + .../SchedulePriceChangeCommandHandler.cs | 4 +- .../ChargeableChars/ChargeableCharConfig.cs | 22 +- ...hargeableCharConfigForwardOnlyException.cs | 6 +- ...arConfigReactivationNotAllowedException.cs | 29 ++ .../ChargeableCharConfigRepository.cs | 172 ++++++++--- .../ChargeableCharConfigControllerTests.cs | 254 ++++++++++++++- ...figReactivationNotAllowedExceptionTests.cs | 32 ++ .../ChargeableCharConfigTests.cs | 10 +- .../ChargeableCharConfigHardeningTests.cs | 31 +- ...bleCharConfigRepositoryIntegrationTests.cs | 288 ++++++++++++------ .../ChargeableCharConfigServiceTests.cs | 42 +-- ...argeableCharConfigCommandValidatorTests.cs | 2 +- .../CreateChargeableCharConfigHandlerTests.cs | 6 +- .../DeleteChargeableCharConfigHandlerTests.cs | 113 +++++++ .../ListChargeableCharConfigHandlerTests.cs | 4 +- ...ctivateChargeableCharConfigHandlerTests.cs | 148 +++++++++ 35 files changed, 1273 insertions(+), 273 deletions(-) create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ReactivateChargeableCharConfigHandlerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs index 7bd1adc..ebea321 100644 --- a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs +++ b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs @@ -6,8 +6,10 @@ using SIGCM2.Application.Common; using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Application.Pricing.ChargeableChars.Create; using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Application.Pricing.ChargeableChars.Delete; using SIGCM2.Application.Pricing.ChargeableChars.GetById; using SIGCM2.Application.Pricing.ChargeableChars.List; +using SIGCM2.Application.Pricing.ChargeableChars.Reactivate; using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; namespace SIGCM2.Api.Controllers; @@ -39,7 +41,7 @@ public sealed class ChargeableCharConfigController : ControllerBase /// /// Returns a paginated list of ChargeableCharConfig rows. - /// Filters: medioId (optional, long?), activeOnly (bool, default true). + /// Filters: productTypeId (optional, long?), activeOnly (bool, default true). /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly. /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200. /// @@ -49,7 +51,7 @@ public sealed class ChargeableCharConfigController : ControllerBase [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task List( - [FromQuery] long? medioId, + [FromQuery] long? productTypeId, [FromQuery] bool activeOnly = true, [FromQuery] int? page = null, [FromQuery] int? pageSize = null, @@ -74,7 +76,7 @@ public sealed class ChargeableCharConfigController : ControllerBase resolvedPageSize = Math.Min(pageSize ?? 20, 200); } - var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize); + var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize); var result = await _dispatcher.Send>(query); return Ok(result); } @@ -100,7 +102,7 @@ public sealed class ChargeableCharConfigController : ControllerBase // ── POST /api/v1/admin/chargeable-chars ─────────────────────────────────── /// - /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists. + /// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists. /// Returns 201 Created with Location header pointing to GET /{id}. /// [HttpPost] @@ -113,7 +115,7 @@ public sealed class ChargeableCharConfigController : ControllerBase public async Task Create([FromBody] CreateChargeableCharConfigRequest request) { var command = new CreateChargeableCharConfigCommand( - request.MedioId, + request.ProductTypeId, request.Symbol, request.Category, request.PricePerUnit, @@ -183,13 +185,57 @@ public sealed class ChargeableCharConfigController : ControllerBase new DeactivateChargeableCharConfigCommand(id)); return Ok(result); } + + // ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ───────────────── + + /// + /// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation). + /// Guard rules (enforced by SP): + /// - ALREADY_ACTIVE: target row is already active → 409 + /// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409 + /// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409 + /// + [HttpPatch("{id:long}/reactivate")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Reactivate([FromRoute] long id) + { + var result = await _dispatcher.Send( + new ReactivateChargeableCharConfigCommand(id)); + return Ok(result); + } + + // ── DELETE /api/v1/admin/chargeable-chars/{id} ─────────────────────────── + + /// + /// Deletes a ChargeableCharConfig row. + /// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved). + /// The row disappears from all current-state queries. + /// Guard for "used in invoicing" is deferred to FAC-001 followup issue. + /// Returns 200 + { id } consistent with the Deactivate pattern. + /// + [HttpDelete("{id:long}")] + [RequirePermission("tasacion:caracteres_especiales:gestionar")] + [ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete([FromRoute] long id) + { + var result = await _dispatcher.Send( + new DeleteChargeableCharConfigCommand(id)); + return Ok(result); + } } // ── Request body records ────────────────────────────────────────────────────── /// PRC-001: Create ChargeableCharConfig request body. public sealed record CreateChargeableCharConfigRequest( - long? MedioId, + long? ProductTypeId, string Symbol, string Category, decimal PricePerUnit, diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 3df5936..68c6b4c 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -695,7 +695,7 @@ public sealed class ExceptionFilter : IExceptionFilter { error = "chargeable_char_forward_only", code = "CHARGEABLE_CHAR_FORWARD_ONLY", - medioId = forwardOnlyCharEx.MedioId, + productTypeId = forwardOnlyCharEx.ProductTypeId, symbol = forwardOnlyCharEx.Symbol, newValidFrom = forwardOnlyCharEx.NewValidFrom, activeValidFrom = forwardOnlyCharEx.ActiveValidFrom, @@ -707,6 +707,33 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + case ChargeableCharConfigReactivationNotAllowedException reactivationEx: + context.Result = new ObjectResult(new + { + error = "chargeable_char_reactivation_not_allowed", + code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED", + id = reactivationEx.Id, + reason = reactivationEx.Reason, + message = reactivationEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case KeyNotFoundException keyNotFoundEx: + context.Result = new ObjectResult(new + { + error = "not_found", + message = keyNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs index 000ed94..24a787c 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs @@ -7,24 +7,24 @@ namespace SIGCM2.Application.Abstractions.Persistence; /// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure. /// /// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically -/// closes any active row for (MedioId, Symbol) and inserts the new row. +/// closes any active row for (ProductTypeId, Symbol) and inserts the new row. /// -/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns -/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate. -/// The Application service applies the per-medio > global priority rule. +/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which +/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate. +/// The Application service applies the per-ProductType > global priority rule. /// public interface IChargeableCharConfigRepository { /// /// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope. - /// Closes any active row matching (MedioId, Symbol) and inserts a new one. + /// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one. /// Returns the Id of the newly inserted row. /// Throws: /// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409 /// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard) /// Task InsertWithCloseAsync( - long? medioId, + long? productTypeId, string symbol, string category, decimal price, @@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository /// /// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate - /// for the specified medio, including global rows (MedioId IS NULL). - /// The SP returns both per-medio AND global rows — callers apply priority. + /// for the specified ProductType, including global rows (ProductTypeId IS NULL). + /// The SP returns both per-ProductType AND global rows — callers apply priority. /// - Task> GetActiveForMedioAsync( - long medioId, + Task> GetActiveForProductTypeAsync( + long productTypeId, DateOnly asOfDate, CancellationToken ct = default); /// - /// Returns paginated rows filtered by MedioId and IsActive. + /// Returns paginated rows filtered by ProductTypeId and IsActive. /// Skip = (page - 1) * pageSize computed by the caller. /// Task> ListAsync( - long? medioId, + long? productTypeId, bool activeOnly, int skip, int take, @@ -56,7 +56,7 @@ public interface IChargeableCharConfigRepository /// Returns total row count for the given filters (used for pagination metadata). ///
Task CountAsync( - long? medioId, + long? productTypeId, bool activeOnly, CancellationToken ct = default); @@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository long id, DateOnly today, CancellationToken ct = default); + + /// + /// Invokes usp_ChargeableCharConfig_ReactivateWithGuard. + /// Guard rules (enforced by SP): + /// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE) + /// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS) + /// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST) + /// 50404 → row not found → ChargeableCharConfigInvalidException + /// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity. + /// + Task ReactivateAsync( + long id, + CancellationToken ct = default); + + /// + /// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state). + /// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with + /// SysEndTime set to the delete time. The row disappears from all current-state queries but + /// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved. + /// Future guard for "used in invoicing" is deferred to FAC-001 followup issue. + /// Throws KeyNotFoundException if the row does not exist. + /// + Task DeleteAsync( + long id, + CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 79ed5e3..4352129 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -87,6 +87,8 @@ using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Application.Pricing.ChargeableChars.Create; using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; +using SIGCM2.Application.Pricing.ChargeableChars.Reactivate; +using SIGCM2.Application.Pricing.ChargeableChars.Delete; using SIGCM2.Application.Pricing.ChargeableChars.List; using SIGCM2.Application.Pricing.ChargeableChars.GetById; @@ -210,6 +212,8 @@ public static class DependencyInjection services.AddScoped, CreateChargeableCharConfigCommandHandler>(); services.AddScoped, SchedulePriceChangeCommandHandler>(); services.AddScoped, DeactivateChargeableCharConfigCommandHandler>(); + services.AddScoped, ReactivateChargeableCharConfigCommandHandler>(); + services.AddScoped, DeleteChargeableCharConfigCommandHandler>(); services.AddScoped>, ListChargeableCharConfigQueryHandler>(); services.AddScoped, GetChargeableCharConfigByIdQueryHandler>(); services.AddScoped(); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs index a2e85b4..6481639 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs @@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars; /// public sealed record ChargeableCharConfigDto( long Id, - long? MedioId, + long? ProductTypeId, string Symbol, string Category, decimal PricePerUnit, diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs index 6de3f85..4cb580e 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs @@ -4,11 +4,11 @@ namespace SIGCM2.Application.Pricing.ChargeableChars; /// /// PRC-001 — Implements IChargeableCharConfigService. -/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies -/// the per-medio > global priority rule in memory. +/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies +/// the per-ProductType > global priority rule in memory. /// -/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and -/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application. +/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and +/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application. /// public sealed class ChargeableCharConfigService : IChargeableCharConfigService { @@ -20,22 +20,22 @@ public sealed class ChargeableCharConfigService : IChargeableCharConfigService } /// - public async Task> GetActiveConfigForMedioAsync( - long medioId, + public async Task> GetActiveConfigForProductTypeAsync( + long productTypeId, DateOnly asOf, CancellationToken ct = default) { - var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct); + var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct); // Build a dictionary keyed by Symbol. - // Per-medio rows (MedioId != null) take priority over global rows (MedioId == null). + // Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == null). var result = new Dictionary(StringComparer.Ordinal); - // Two-pass: first add global rows, then overwrite with per-medio rows. - foreach (var row in allRows.Where(r => r.MedioId is null)) + // Two-pass: first add global rows, then overwrite with per-ProductType rows. + foreach (var row in allRows.Where(r => r.ProductTypeId is null)) result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit); - foreach (var row in allRows.Where(r => r.MedioId is not null)) + foreach (var row in allRows.Where(r => r.ProductTypeId is not null)) result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit); return result; diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs index 0bf24a4..14da49e 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs @@ -2,10 +2,10 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.Create; /// /// PRC-001 — Command to create a new ChargeableCharConfig. -/// MedioId = null → global config. MedioId set → per-medio config. +/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config. /// public sealed record CreateChargeableCharConfigCommand( - long? MedioId, + long? ProductTypeId, string Symbol, string Category, decimal PricePerUnit, diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs index be112a8..f9f6d80 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs @@ -35,7 +35,7 @@ public sealed class CreateChargeableCharConfigCommandHandler TransactionScopeAsyncFlowOption.Enabled)) { newId = await _repo.InsertWithCloseAsync( - command.MedioId, + command.ProductTypeId, command.Symbol, command.Category, command.PricePerUnit, @@ -49,7 +49,7 @@ public sealed class CreateChargeableCharConfigCommandHandler { after = new { - command.MedioId, + command.ProductTypeId, command.Symbol, command.Category, command.PricePerUnit, diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs index 1eda637..509f208 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs @@ -54,7 +54,7 @@ public sealed class DeactivateChargeableCharConfigCommandHandler { id = existing.Id, symbol = existing.Symbol, - medioId = existing.MedioId, + productTypeId = existing.ProductTypeId, validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"), }, deactivatedOn = today.ToString("yyyy-MM-dd"), diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs new file mode 100644 index 0000000..0b8b7c3 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Delete; + +/// +/// PRC-001 — Command to physically delete a ChargeableCharConfig row. +/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table +/// (SysEndTime = delete time). The row disappears from all current-state queries but +/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred +/// to the FAC-001 followup issue. +/// +public sealed record DeleteChargeableCharConfigCommand(long Id); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs new file mode 100644 index 0000000..3b1b5ee --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs @@ -0,0 +1,75 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Pricing.ChargeableChars.Delete; + +/// +/// PRC-001 — Handler for DeleteChargeableCharConfigCommand. +/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete(). +/// +/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with +/// SysEndTime = deletion timestamp. This means: +/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted". +/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact. +/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here. +/// +/// Future FAC-001 will add a guard to block delete if the row was used in invoicing. +/// +public sealed class DeleteChargeableCharConfigCommandHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public DeleteChargeableCharConfigCommandHandler( + IChargeableCharConfigRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle( + DeleteChargeableCharConfigCommand command) + { + // 1. Load existing — ensures the row exists before opening TX. + var existing = await _repo.GetByIdAsync(command.Id) + ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe."); + + // 2. TX + delete + audit (fail-closed). + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + await _repo.DeleteAsync(command.Id); + + await _audit.LogAsync( + action: "tasacion.chargeable_char.delete", + targetType: "ChargeableCharConfig", + targetId: command.Id.ToString(), + metadata: new + { + before = new + { + id = existing.Id, + symbol = existing.Symbol, + productTypeId = existing.ProductTypeId, + isActive = existing.IsActive, + validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"), + }, + deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"), + }); + + tx.Complete(); + } + + return new DeleteChargeableCharConfigResponse(Id: command.Id); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs new file mode 100644 index 0000000..fba1fc9 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Delete; + +/// +/// PRC-001 — Response for a successful delete operation. +/// +public sealed record DeleteChargeableCharConfigResponse(long Id); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs index 10710a6..65481c7 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs @@ -27,7 +27,7 @@ public sealed class GetChargeableCharConfigByIdQueryHandler private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( c.Id, - c.MedioId, + c.ProductTypeId, c.Symbol, c.Category, c.PricePerUnit, diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs index c73d1e4..15a66b3 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs @@ -1,21 +1,21 @@ namespace SIGCM2.Application.Pricing.ChargeableChars; /// -/// PRC-001 — Application service for resolving active chargeable-char config for a Medio. +/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType. /// -/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol. +/// Priority rule: per-ProductType row overrides global (ProductTypeId IS NULL) for the same Symbol. /// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing. /// public interface IChargeableCharConfigService { /// - /// Returns the resolved active config for the given medio as of the given date. - /// Per-medio rows take priority over global rows for the same Symbol. - /// Global rows are used as fallback when no per-medio row exists for that Symbol. + /// Returns the resolved active config for the given ProductType as of the given date. + /// Per-ProductType rows take priority over global rows for the same Symbol. + /// Global rows are used as fallback when no per-ProductType row exists for that Symbol. /// Returns an empty dictionary if no config exists at all. /// - Task> GetActiveConfigForMedioAsync( - long medioId, + Task> GetActiveConfigForProductTypeAsync( + long productTypeId, DateOnly asOf, CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs index a54efd7..cd9d6f0 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs @@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.List; /// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]). /// public sealed record ListChargeableCharConfigQuery( - long? MedioId, + long? ProductTypeId, bool ActiveOnly, int Page = 1, int PageSize = 20); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs index 8510c3a..13467e3 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs @@ -26,8 +26,8 @@ public sealed class ListChargeableCharConfigQueryHandler var pageSize = Math.Clamp(query.PageSize, 1, 100); var skip = (page - 1) * pageSize; - var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize); - var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly); + var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize); + var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly); var dtos = items.Select(ToDto).ToList(); @@ -36,7 +36,7 @@ public sealed class ListChargeableCharConfigQueryHandler private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( c.Id, - c.MedioId, + c.ProductTypeId, c.Symbol, c.Category, c.PricePerUnit, diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs new file mode 100644 index 0000000..541c48a --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate; + +/// +/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row. +/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST). +/// +public sealed record ReactivateChargeableCharConfigCommand(long Id); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs new file mode 100644 index 0000000..474859d --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs @@ -0,0 +1,71 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate; + +/// +/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand. +/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete(). +/// +/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the +/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the +/// ExceptionFilter which maps them to HTTP 409. +/// +public sealed class ReactivateChargeableCharConfigCommandHandler + : ICommandHandler +{ + private readonly IChargeableCharConfigRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public ReactivateChargeableCharConfigCommandHandler( + IChargeableCharConfigRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle( + ReactivateChargeableCharConfigCommand command) + { + // Open TX before calling SP so that audit failure rolls back the SP work. + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + // SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure. + // Returns the reactivated entity so we can populate the response and the audit log. + var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None); + + await _audit.LogAsync( + action: "tasacion.chargeable_char.reactivate", + targetType: "ChargeableCharConfig", + targetId: command.Id.ToString(), + metadata: new + { + id = reactivated.Id, + symbol = reactivated.Symbol, + productTypeId = reactivated.ProductTypeId, + validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"), + reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"), + }); + + tx.Complete(); + + return new ReactivateChargeableCharConfigResponse( + Id: reactivated.Id, + ProductTypeId: reactivated.ProductTypeId, + Symbol: reactivated.Symbol, + Category: reactivated.Category, + PricePerUnit: reactivated.PricePerUnit, + ValidFrom: reactivated.ValidFrom, + IsActive: reactivated.IsActive); + } +} diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs new file mode 100644 index 0000000..6c8b141 --- /dev/null +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate; + +/// +/// PRC-001 — Response for a successful reactivation. +/// Returns the current state of the row after it has been re-opened. +/// +public sealed record ReactivateChargeableCharConfigResponse( + long Id, + long? ProductTypeId, + string Symbol, + string Category, + decimal PricePerUnit, + DateOnly ValidFrom, + bool IsActive); diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs index 2b0c1f3..8f78d76 100644 --- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs +++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs @@ -28,7 +28,7 @@ public sealed class SchedulePriceChangeCommandHandler public async Task Handle(SchedulePriceChangeCommand command) { - // 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category. + // 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category. var existing = await _repo.GetByIdAsync(command.Id) ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe."); @@ -44,7 +44,7 @@ public sealed class SchedulePriceChangeCommandHandler TransactionScopeAsyncFlowOption.Enabled)) { newId = await _repo.InsertWithCloseAsync( - newEntity.MedioId, + newEntity.ProductTypeId, newEntity.Symbol, newEntity.Category, newEntity.PricePerUnit, diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs index 5d4337a..0cb5049 100644 --- a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs +++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs @@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars; /// /// PRC-001 — Rich domain entity for chargeable character configuration. /// Represents a price-per-occurrence for a special character in classified ad text, -/// scoped to a Medio (MedioId) or global (MedioId = null). +/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null). /// /// Forward-only price history: each new price schedules a NEW row; the current row /// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate /// this instance — it returns a new one. The actual close+insert happens in the repository. /// -/// MedioId = null → global default (lowest priority, overridden by per-medio row). +/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row). /// public sealed class ChargeableCharConfig { public long Id { get; } - public int? MedioId { get; } + public int? ProductTypeId { get; } public string Symbol { get; } public string Category { get; } public decimal PricePerUnit { get; private set; } @@ -25,11 +25,11 @@ public sealed class ChargeableCharConfig public bool IsActive { get; private set; } private ChargeableCharConfig( - long id, int? medioId, string symbol, string category, + long id, int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive) { Id = id; - MedioId = medioId; + ProductTypeId = productTypeId; Symbol = symbol; Category = category; PricePerUnit = price; @@ -43,7 +43,7 @@ public sealed class ChargeableCharConfig /// Id is set to 0 until the entity is persisted. /// public static ChargeableCharConfig Create( - int? medioId, string symbol, string category, decimal price, DateOnly validFrom) + int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom) { if (string.IsNullOrWhiteSpace(symbol)) throw new ChargeableCharConfigInvalidException( @@ -61,7 +61,7 @@ public sealed class ChargeableCharConfig throw new ChargeableCharConfigInvalidException( nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other."); - return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true); + return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true); } /// @@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig /// Allows creating entities with any state (e.g., IsActive=false, ValidTo set). /// public static ChargeableCharConfig Rehydrate( - long id, int? medioId, string symbol, string category, + long id, int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive) - => new(id, medioId, symbol, category, price, validFrom, validTo, isActive); + => new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive); /// /// Schedules a new price (forward-only semantics). @@ -93,10 +93,10 @@ public sealed class ChargeableCharConfig $"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd})."); if (newValidFrom <= ValidFrom) - throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom); + throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom); // Create validates price > 0 and category — reuse factory - return Create(MedioId, Symbol, Category, newPrice, newValidFrom); + return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom); } /// diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs index e82c1a9..0928a39 100644 --- a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs @@ -8,19 +8,19 @@ namespace SIGCM2.Domain.Pricing.Exceptions; /// public sealed class ChargeableCharConfigForwardOnlyException : DomainException { - public int? MedioId { get; } + public int? ProductTypeId { get; } public string Symbol { get; } public DateOnly NewValidFrom { get; } public DateOnly ActiveValidFrom { get; } public ChargeableCharConfigForwardOnlyException( - int? medioId, + int? productTypeId, string symbol, DateOnly newValidFrom, DateOnly activeValidFrom) : base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).") { - MedioId = medioId; + ProductTypeId = productTypeId; Symbol = symbol; NewValidFrom = newValidFrom; ActiveValidFrom = activeValidFrom; diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs new file mode 100644 index 0000000..3c09fd5 --- /dev/null +++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs @@ -0,0 +1,29 @@ +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Pricing.Exceptions; + +/// +/// PRC-001 — Thrown when a reactivation attempt is blocked by a guard rule. +/// Maps to HTTP 409. +/// +/// Reason codes: +/// ALREADY_ACTIVE — target row is currently active (50410) +/// VIGENTE_EXISTS — a different active row exists for (ProductTypeId, Symbol) (50411) +/// POSTERIOR_ROWS_EXIST — rows with higher ValidFrom exist after the target row (50412) +/// +public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException +{ + public long Id { get; } + + /// + /// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST" + /// + public string Reason { get; } + + public ChargeableCharConfigReactivationNotAllowedException(long id, string reason) + : base($"Reactivation not allowed for config {id}: {reason}") + { + Id = id; + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs index 1440cd4..dc5885e 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs @@ -11,16 +11,27 @@ namespace SIGCM2.Infrastructure.Persistence; /// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig. /// /// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps: -/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found) +/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found) /// - SqlException 50409 → ChargeableCharConfigForwardOnlyException /// -/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio. -/// Returns all rows (global + per-medio) — the Application service applies priority. +/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType. +/// Returns all rows (global + per-ProductType) — the Application service applies priority. +/// +/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps: +/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE) +/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS) +/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST) +/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found) +/// +/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException. +/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current +/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the +/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved. /// /// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via /// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository. /// -/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer. +/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer. /// public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository { @@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi /// public async Task InsertWithCloseAsync( - long? medioId, + long? productTypeId, string symbol, string category, decimal price, @@ -41,14 +52,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi CancellationToken ct = default) { var p = new DynamicParameters(); - // SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021) - p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32); - p.Add("@Symbol", symbol, DbType.String, size: 4); - p.Add("@Category", category, DbType.String, size: 32); - p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date); - p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output); - p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output); + // SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023) + p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32); + p.Add("@Symbol", symbol, DbType.String, size: 4); + p.Add("@Category", category, DbType.String, size: 32); + p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date); + p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output); + p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output); await using var connection = _factory.CreateConnection(); await connection.OpenAsync(ct); @@ -64,16 +75,16 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi } catch (SqlException ex) when (ex.Number == 50404) { - // Medio not found (SP validates MedioId when not null) + // ProductType not found (SP validates ProductTypeId when not null) throw new ChargeableCharConfigInvalidException( - nameof(medioId), - $"Medio with Id={medioId} not found."); + nameof(productTypeId), + $"ProductType with Id={productTypeId} not found."); } catch (SqlException ex) when (ex.Number == 50409) { // Forward-only violation: new ValidFrom <= active.ValidFrom throw new ChargeableCharConfigForwardOnlyException( - medioId.HasValue ? (int?)checked((int)medioId.Value) : null, + productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, symbol, validFrom, DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder @@ -83,22 +94,22 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi } /// - public async Task> GetActiveForMedioAsync( - long medioId, + public async Task> GetActiveForProductTypeAsync( + long productTypeId, DateOnly asOfDate, CancellationToken ct = default) { var p = new DynamicParameters(); - // SP @MedioId is INT - p.Add("@MedioId", checked((int)medioId), DbType.Int32); - p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date); + // SP @ProductTypeId is INT + p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32); + p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date); await using var connection = _factory.CreateConnection(); await connection.OpenAsync(ct); var rows = await connection.QueryAsync( new CommandDefinition( - "dbo.usp_ChargeableCharConfig_GetActiveForMedio", + "dbo.usp_ChargeableCharConfig_GetActiveForProductType", p, commandType: CommandType.StoredProcedure, cancellationToken: ct)); @@ -108,20 +119,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi /// public async Task> ListAsync( - long? medioId, + long? productTypeId, bool activeOnly, int skip, int take, CancellationToken ct = default) { - // NULL-aware MedioId filter: - // - medioId provided → filter to that medio only - // - medioId null → return all rows regardless of medio + // NULL-aware ProductTypeId filter: + // - productTypeId provided → filter to that ProductType only + // - productTypeId null → return all rows regardless of ProductType // activeOnly filters by IsActive = 1. const string sql = """ - SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive FROM dbo.ChargeableCharConfig - WHERE (@MedioId IS NULL OR MedioId = @MedioId) + WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId) AND (@ActiveOnly = 0 OR IsActive = 1) ORDER BY ValidFrom DESC, Id DESC OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY @@ -135,10 +146,10 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi sql, new { - MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, - ActiveOnly = activeOnly ? 1 : 0, - Skip = skip, - Take = take + ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0, + Skip = skip, + Take = take }, cancellationToken: ct)); @@ -147,14 +158,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi /// public async Task CountAsync( - long? medioId, + long? productTypeId, bool activeOnly, CancellationToken ct = default) { const string sql = """ SELECT COUNT(1) FROM dbo.ChargeableCharConfig - WHERE (@MedioId IS NULL OR MedioId = @MedioId) + WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId) AND (@ActiveOnly = 0 OR IsActive = 1) """; @@ -166,8 +177,8 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi sql, new { - MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, - ActiveOnly = activeOnly ? 1 : 0 + ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0 }, cancellationToken: ct)); } @@ -178,7 +189,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi CancellationToken ct = default) { const string sql = """ - SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id """; @@ -221,24 +232,91 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi cancellationToken: ct)); } + /// + public async Task ReactivateAsync( + long id, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + p.Add("@Id", id, DbType.Int64); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + "dbo.usp_ChargeableCharConfig_ReactivateWithGuard", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + } + catch (SqlException ex) when (ex.Number == 50404) + { + throw new ChargeableCharConfigInvalidException( + nameof(id), $"ChargeableCharConfig with Id={id} not found."); + } + catch (SqlException ex) when (ex.Number == 50410) + { + throw new ChargeableCharConfigReactivationNotAllowedException(id, "ALREADY_ACTIVE"); + } + catch (SqlException ex) when (ex.Number == 50411) + { + throw new ChargeableCharConfigReactivationNotAllowedException(id, "VIGENTE_EXISTS"); + } + catch (SqlException ex) when (ex.Number == 50412) + { + throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST"); + } + + // Fetch the reactivated row to return its current state. + var reactivated = await GetByIdAsync(id, ct); + return reactivated + ?? throw new ChargeableCharConfigInvalidException( + nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected)."); + } + + /// + public async Task DeleteAsync( + long id, + CancellationToken ct = default) + { + // NOTE: With SYSTEM_VERSIONING ON on dbo.ChargeableCharConfig, this DELETE moves + // the row to dbo.ChargeableCharConfig_History (SysEndTime = deletion timestamp). + // The row disappears from current-state queries but is still queryable via + // FOR SYSTEM_TIME. Temporal audit trail is preserved. + // Future FAC-001 will add a guard to block delete if the row was used in invoicing. + const string sql = "DELETE FROM dbo.ChargeableCharConfig WHERE Id = @Id"; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rowsAffected = await connection.ExecuteAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + + if (rowsAffected == 0) + throw new KeyNotFoundException($"ChargeableCharConfig with Id={id} not found."); + } + // ── Row mapper ──────────────────────────────────────────────────────────── // Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here. // Same pattern as ProductPriceRepository. private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r) => ChargeableCharConfig.Rehydrate( - id: r.Id, - medioId: r.MedioId, - symbol: r.Symbol, - category: r.Category, - price: r.PricePerUnit, - validFrom: DateOnly.FromDateTime(r.ValidFrom), - validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null, - isActive: r.IsActive); + id: r.Id, + productTypeId: r.ProductTypeId, + symbol: r.Symbol, + category: r.Category, + price: r.PricePerUnit, + validFrom: DateOnly.FromDateTime(r.ValidFrom), + validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null, + isActive: r.IsActive); private sealed record ChargeableCharConfigRow( long Id, - int? MedioId, + int? ProductTypeId, string Symbol, string Category, decimal PricePerUnit, diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs index a2f9132..ce14d3e 100644 --- a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs @@ -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 /// Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup. private async Task 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(""" 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 ───────────────── + + /// PRC-001 — PATCH reactivate on last closed row returns 200. + [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(); + body.GetProperty("id").GetInt64().Should().Be(id); + body.GetProperty("isActive").GetBoolean().Should().BeTrue(); + } + + /// PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE. + [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(); + body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); + body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE"); + } + + /// PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS. + [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(); + body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); + body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS"); + } + + /// PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST. + [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(); + body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED"); + body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST"); + } + + /// PRC-001 — PATCH reactivate without auth returns 401. + [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); + } + + /// PRC-001 — PATCH reactivate without permission returns 403. + [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); + } + + /// PRC-001 — PATCH reactivate emits audit event. + [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(""" + 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} ─────────────────────────── + + /// PRC-001 — DELETE existing row returns 200. + [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(); + body.GetProperty("id").GetInt64().Should().Be(id); + } + + /// PRC-001 — DELETE non-existent row returns 404. + [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); + } + + /// PRC-001 — DELETE emits audit event. + [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(""" + 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"); + } + + /// PRC-001 — DELETE without auth returns 401. + [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); + } + + /// PRC-001 — DELETE without permission returns 403. + [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 ───────────────────────────────────────────────────────────────── /// PRC-001-R3.6 — POST emits audit event chargeable_char_config.created. @@ -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, diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs new file mode 100644 index 0000000..44d96d9 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars; + +/// +/// PRC-001 — Unit tests for ChargeableCharConfigReactivationNotAllowedException. +/// +public sealed class ChargeableCharConfigReactivationNotAllowedExceptionTests +{ + [Theory] + [InlineData("ALREADY_ACTIVE")] + [InlineData("VIGENTE_EXISTS")] + [InlineData("POSTERIOR_ROWS_EXIST")] + public void Constructor_SetsIdAndReason(string reason) + { + var ex = new ChargeableCharConfigReactivationNotAllowedException(42L, reason); + + ex.Id.Should().Be(42L); + ex.Reason.Should().Be(reason); + ex.Message.Should().Contain("42"); + ex.Message.Should().Contain(reason); + } + + [Fact] + public void Exception_IsDomainException() + { + var ex = new ChargeableCharConfigReactivationNotAllowedException(1L, "ALREADY_ACTIVE"); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs index 1f74e64..c518a76 100644 --- a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs @@ -95,15 +95,15 @@ public sealed class ChargeableCharConfigTests entity.Category.Should().Be("Currency"); entity.PricePerUnit.Should().Be(1.5m); entity.ValidFrom.Should().Be(Today); - entity.MedioId.Should().BeNull(); + entity.ProductTypeId.Should().BeNull(); } [Fact] - public void Create_WithMedioId_SetsCorrectly() + public void Create_WithProductTypeId_SetsCorrectly() { var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today); - entity.MedioId.Should().Be(5); + entity.ProductTypeId.Should().Be(5); } [Fact] @@ -218,11 +218,11 @@ public sealed class ChargeableCharConfigTests { // Rehydrate can create entities that would fail Create (e.g., IsActive=false) var entity = ChargeableCharConfig.Rehydrate( - id: 42, medioId: 5, symbol: "$", category: "Currency", + id: 42, productTypeId: 5, symbol: "$", category: "Currency", price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false); entity.Id.Should().Be(42); - entity.MedioId.Should().Be(5); + entity.ProductTypeId.Should().Be(5); entity.Symbol.Should().Be("$"); entity.Category.Should().Be("Currency"); entity.PricePerUnit.Should().Be(1.5m); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs index 54514d2..8d06c96 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // Seed two dedicated ProductTypes for override/fallback resolution tests. // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). _productType1Id = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, IsActive) + INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id - VALUES ('Hardening PT1 (override)', 1) + VALUES ('Hardening PT1 (override)', 'H_PT1', 1) """); _productType2Id = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, IsActive) + INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id - VALUES ('Hardening PT2 (fallback)', 1) + VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1) """); } @@ -68,11 +68,11 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // ───────────────────────────────────────────────────────────────────────── // T7.1 — Concurrency: only one winner survives the race // - // Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol). + // Three parallel connections try to InsertWithClose for the same (ProductTypeId=null, Symbol). // The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit. // The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205). // - // After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol). + // After resolution: exactly 1 vigente row exists for (ProductTypeId=NULL, Symbol). // ───────────────────────────────────────────────────────────────────────── [Fact] @@ -94,7 +94,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime await conn.OpenAsync(); var p = new DynamicParameters(); - p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@ProductTypeId", null, System.Data.DbType.Int32); p.Add("@Symbol", symbol, System.Data.DbType.String); p.Add("@Category", category, System.Data.DbType.String); p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4); @@ -278,10 +278,6 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins) // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback) // - // NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be - // renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2. - // SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already - // updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is. // ───────────────────────────────────────────────────────────────────────── [Fact] @@ -297,13 +293,13 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // Build the repository + service (C# method will be renamed in Agent 2) var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync + var rows = await repo.GetActiveForProductTypeAsync((long)_productType1Id, asOf); // The per-PT '$' must be returned var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it"); - dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId + dollarRow!.ProductTypeId.Should().Be(_productType1Id, "the per-PT row (ProductTypeId = PT1) must take priority over the global row"); dollarRow.PricePerUnit.Should().Be(5.0000m, @@ -318,7 +314,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync // provides '$' at global price (0.0000 after V024). var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync + var rows = await repo.GetActiveForProductTypeAsync((long)_productType2Id, asOf); // Must have at least the global '$' from seed rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); @@ -326,7 +322,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)"); - dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId + dollarRow!.ProductTypeId.Should().BeNull( "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)"); } @@ -343,11 +339,10 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); // Build the service (wraps repo with priority resolution) - // TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync var service = BuildService(); - var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2 - var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2 + var pt1Config = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf); + var pt2Config = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf); // ProductType1: '%' must come from per-PT override at 3.00 pt1Config.Should().ContainKey("%", diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs index 16acd8b..8a70c9a 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -16,9 +16,8 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). /// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating. /// -/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync -/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor). -/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected. +/// V023 scope delta: MedioId → ProductTypeId. Uses dbo.ProductType for per-PT override tests. +/// Uses unique name "RepoIntegration PT1" to avoid uniqueness conflicts with HardeningTests. /// /// Spec coverage: /// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id @@ -33,12 +32,16 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// T4.10 GetByIdAsync — exists → returns entity /// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today /// T4.12 DeactivateAsync — already inactive → idempotent (no-op) +/// T4.13 ReactivateAsync — last closed row → returns reactivated entity +/// T4.14 ReactivateAsync — already active → throws ALREADY_ACTIVE +/// T4.15 DeleteAsync — row exists → deleted (0 rows after) +/// T4.16 DeleteAsync — row not found → throws KeyNotFoundException /// [Collection("Database")] public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime { private readonly SqlTestFixture _db; - private int _medioId; + private int _productTypeId; public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db) { @@ -49,14 +52,15 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime { await _db.ResetAndSeedAsync(); - // Create a dedicated Medio for per-medio tests + // Create a dedicated ProductType for per-PT tests. + // Unique name to avoid conflicts with HardeningTests ("RepoIntegration PT1"). await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); - _medioId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + _productTypeId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id - VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1) + VALUES ('RepoIntegration PT1', 'RI_PT1', 1) """); } @@ -69,16 +73,16 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime [Fact] public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId() { - // NEW symbol not in canonical seed — use per-medio so it doesn't conflict + // NEW symbol not in canonical seed — use per-PT so it doesn't conflict const string symbol = "@"; var repo = BuildRepository(); var newId = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Other", - price: 2.5000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 2.5000m, + validFrom: new DateOnly(2026, 1, 1)); newId.Should().BeGreaterThan(0, "first insert must return the new row's Id"); @@ -108,20 +112,20 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime // First insert — becomes the vigente var firstId = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Other", - price: 1.0000m, - validFrom: new DateOnly(2026, 3, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 3, 1)); // Second insert (forward) — must close the first var secondValidFrom = new DateOnly(2026, 6, 1); var secondId = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Other", - price: 2.0000m, - validFrom: secondValidFrom); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 2.0000m, + validFrom: secondValidFrom); secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id"); @@ -157,19 +161,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime // Establish a vigente at 2026-04-01 await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Currency", - price: 1.5000m, - validFrom: new DateOnly(2026, 4, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Currency", + price: 1.5000m, + validFrom: new DateOnly(2026, 4, 1)); // Try to insert retroactively — SP will THROW 50409 var act = async () => await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Currency", - price: 1.2000m, - validFrom: new DateOnly(2026, 3, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Currency", + price: 1.2000m, + validFrom: new DateOnly(2026, 3, 1)); await act.Should() .ThrowAsync( @@ -188,19 +192,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime // Insert the first row var firstId = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Currency", - price: 3.0000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Currency", + price: 3.0000m, + validFrom: new DateOnly(2026, 1, 1)); // Insert a second row — this triggers an UPDATE on the first row → history await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: symbol, - category: "Currency", - price: 4.0000m, - validFrom: new DateOnly(2026, 7, 1)); + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Currency", + price: 4.0000m, + validFrom: new DateOnly(2026, 7, 1)); await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); @@ -214,64 +218,61 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime } // ───────────────────────────────────────────────────────────────────────── - // T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows - // Note: SP returns ALL rows (global + per-medio); service does priority resolution. + // T4.5 — GetActiveForProductTypeAsync: PT has override → returns both PT and global rows + // Note: SP returns ALL rows (global + per-PT); service does priority resolution. // This test verifies the REPOSITORY returns both, not just one. // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows() + public async Task GetActiveForProductTypeAsync_PTHasOverride_ReturnsBothPTAndGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); - // Add a per-medio override for symbol '$' + // Add a per-PT override for symbol '$' // Canonical seed already has global '$' from ResetAndSeedAsync await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: "$", - category: "Currency", - price: 5.0000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: "$", + category: "Currency", + price: 5.0000m, + validFrom: new DateOnly(2026, 1, 1)); - var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf); + var rows = await repo.GetActiveForProductTypeAsync((long)_productTypeId, asOf); - // The SP returns both the per-medio '$' AND global rows for other symbols - // (at minimum: global '$' was replaced by per-medio; other globals still present) - // SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio. - // So we should get exactly one row per symbol that is active as of asOf. + // The SP returns both the per-PT '$' AND global rows for other symbols rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); dollarRow.Should().NotBeNull("the SP must return a row for '$'"); - dollarRow!.MedioId.Should().Be(_medioId, - "per-medio row takes priority over global in the SP's ROW_NUMBER ordering"); + dollarRow!.ProductTypeId.Should().Be(_productTypeId, + "per-PT row takes priority over global in the SP's ROW_NUMBER ordering"); } // ───────────────────────────────────────────────────────────────────────── - // T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows + // T4.6 — GetActiveForProductTypeAsync: no PT override → returns only global rows // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows() + public async Task GetActiveForProductTypeAsync_NoPTOverride_ReturnsOnlyGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); - // Use a DIFFERENT medioId that has no per-medio rows + // Use a DIFFERENT productTypeId that has no per-PT rows await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); - var otherMedioId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + var otherPTId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id - VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1) + VALUES ('RepoIntegration PT2 NoOverride', 'RI_PT2', 1) """); - var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf); + var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf); rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01"); rows.Should().AllSatisfy(r => - r.MedioId.Should().BeNull("all returned rows must be global (MedioId = NULL)")); + r.ProductTypeId.Should().BeNull("all returned rows must be global (ProductTypeId = NULL)")); } // ───────────────────────────────────────────────────────────────────────── @@ -284,8 +285,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime var repo = BuildRepository(); // Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2). - var page1 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 0, take: 2); - var page2 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 2, take: 2); + var page1 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 0, take: 2); + var page2 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 2, take: 2); page1.Should().HaveCount(2, "take=2 with at least 4 rows"); page2.Should().HaveCount(2, "second page of 4 rows"); @@ -299,7 +300,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime public async Task ListAsync_PageBeyondTotal_ReturnsEmpty() { var repo = BuildRepository(); - var result = await repo.ListAsync(medioId: null, activeOnly: false, skip: 1000, take: 10); + var result = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 1000, take: 10); result.Should().BeEmpty("skip far beyond available data must return empty"); } @@ -313,8 +314,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime var repo = BuildRepository(); // Canonical seed: 4 active global rows - var countAll = await repo.CountAsync(medioId: null, activeOnly: false); - var countActive = await repo.CountAsync(medioId: null, activeOnly: true); + var countAll = await repo.CountAsync(productTypeId: null, activeOnly: false); + var countActive = await repo.CountAsync(productTypeId: null, activeOnly: true); countAll.Should().BeGreaterThanOrEqualTo(4, "canonical seed provides at least 4 rows (may have more if other tests ran)"); @@ -331,18 +332,18 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime // Insert a row, then deactivate it — active count should decrease by 1 var id = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: "~", - category: "Other", - price: 1.0000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: "~", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); - var beforeDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true); + var beforeDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true); await repo.DeactivateAsync(id, today); - var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true); + var afterDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true); afterDeactivate.Should().Be(beforeDeactivate - 1, "deactivating one row must decrease the active count by 1"); @@ -371,17 +372,17 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime var expectedValidFrom = new DateOnly(2026, 2, 1); var id = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: "^", - category: "Other", - price: 7.5000m, - validFrom: expectedValidFrom); + productTypeId: (long?)_productTypeId, + symbol: "^", + category: "Other", + price: 7.5000m, + validFrom: expectedValidFrom); var entity = await repo.GetByIdAsync(id); entity.Should().NotBeNull(); entity!.Id.Should().Be(id); - entity.MedioId.Should().Be(_medioId); + entity.ProductTypeId.Should().Be(_productTypeId); entity.Symbol.Should().Be("^"); entity.Category.Should().Be("Other"); entity.PricePerUnit.Should().Be(7.5000m); @@ -400,11 +401,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime var repo = BuildRepository(); var id = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: "&", - category: "Other", - price: 1.0000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: "&", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); await repo.DeactivateAsync(id, today); @@ -426,11 +427,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime var repo = BuildRepository(); var id = await repo.InsertWithCloseAsync( - medioId: (long?)_medioId, - symbol: "*", - category: "Other", - price: 1.0000m, - validFrom: new DateOnly(2026, 1, 1)); + productTypeId: (long?)_productTypeId, + symbol: "*", + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); @@ -447,6 +448,101 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime entity.ValidTo.Should().Be(today); } + // ───────────────────────────────────────────────────────────────────────── + // T4.13 — ReactivateAsync: last closed row → returns reactivated entity + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ReactivateAsync_LastClosedRow_ReturnsReactivatedEntity() + { + var repo = BuildRepository(); + + // Insert a row, then deactivate it — it becomes "last closed" for this symbol + const string symbol = "≈"; + var id = await repo.InsertWithCloseAsync( + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 2.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var today = new DateOnly(2026, 4, 20); + await repo.DeactivateAsync(id, today); + + // Reactivate — no posterior rows, no vigente + var reactivated = await repo.ReactivateAsync(id); + + reactivated.Should().NotBeNull(); + reactivated.Id.Should().Be(id); + reactivated.IsActive.Should().BeTrue("row must be active after reactivation"); + reactivated.ValidTo.Should().BeNull("ValidTo must be NULL after reactivation"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.14 — ReactivateAsync: already active → throws ALREADY_ACTIVE + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ReactivateAsync_AlreadyActive_ThrowsAlreadyActive() + { + var repo = BuildRepository(); + + // Insert an active row — do NOT deactivate it + const string symbol = "≠"; + var id = await repo.InsertWithCloseAsync( + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 3.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + var act = async () => await repo.ReactivateAsync(id); + + await act.Should() + .ThrowAsync() + .Where(e => e.Reason == "ALREADY_ACTIVE", + "SP 50410 → ALREADY_ACTIVE reason"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.15 — DeleteAsync: row exists → deleted (0 rows after) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteAsync_ExistingRow_RowIsGone() + { + var repo = BuildRepository(); + + const string symbol = "∞"; + var id = await repo.InsertWithCloseAsync( + productTypeId: (long?)_productTypeId, + symbol: symbol, + category: "Other", + price: 1.0000m, + validFrom: new DateOnly(2026, 1, 1)); + + await repo.DeleteAsync(id); + + // Row must be gone from current state + var entity = await repo.GetByIdAsync(id); + entity.Should().BeNull("deleted row must not appear in GetByIdAsync"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T4.16 — DeleteAsync: row not found → throws KeyNotFoundException + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteAsync_NotFound_ThrowsKeyNotFoundException() + { + var repo = BuildRepository(); + + var act = async () => await repo.DeleteAsync(999_999_997L); + + await act.Should().ThrowAsync( + "non-existent Id must throw KeyNotFoundException"); + } + // ── Helper ─────────────────────────────────────────────────────────────── private static ChargeableCharConfigRepository BuildRepository() diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs index 4a6cbfb..cf74493 100644 --- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs @@ -26,57 +26,57 @@ public class ChargeableCharConfigServiceTests private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) => ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); - private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) => - ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); + private static ChargeableCharConfig ProductTypeConfig(long id, int productTypeId, string symbol, decimal price) => + ChargeableCharConfig.Rehydrate(id, productTypeId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); // ── Global fallback ───────────────────────────────────────────────────────── [Fact] - public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs() + public async Task GetActiveConfig_NoPerProductType_ReturnsGlobalConfigs() { - _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any()) .Returns(new List { GlobalConfig("$", 1.0m), GlobalConfig("%", 0.5m), }); - var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None); result.Should().ContainKey("$"); result["$"].PricePerUnit.Should().Be(1.0m); result.Should().ContainKey("%"); } - // ── Per-medio wins over global ─────────────────────────────────────────────── + // ── Per-ProductType wins over global ───────────────────────────────────────── [Fact] - public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol() + public async Task GetActiveConfig_PerProductTypeExists_OverridesGlobalForSameSymbol() { - _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) + _repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any()) .Returns(new List { - GlobalConfig("$", 1.0m), // global price = 1.0 - MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins - GlobalConfig("%", 0.5m), // global only + GlobalConfig("$", 1.0m), // global price = 1.0 + ProductTypeConfig(20L, 5, "$", 3.0m), // per-PT price = 3.0 → wins + GlobalConfig("%", 0.5m), // global only }); - var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); + var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None); - result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins + result["$"].PricePerUnit.Should().Be(3.0m); // per-PT wins result["%"].PricePerUnit.Should().Be(0.5m); // global only } [Fact] - public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory() + public async Task GetActiveConfig_PerProductTypeExists_IncludesCorrectCategory() { - _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) + _repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any()) .Returns(new List { - MedioConfig(20L, 5, "$", 3.0m), + ProductTypeConfig(20L, 5, "$", 3.0m), }); - var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); + var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None); result["$"].Category.Should().Be(ChargeableCharCategories.Currency); } @@ -86,10 +86,10 @@ public class ChargeableCharConfigServiceTests [Fact] public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary() { - _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any()) .Returns(new List()); - var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None); result.Should().BeEmpty(); } @@ -99,13 +99,13 @@ public class ChargeableCharConfigServiceTests [Fact] public async Task GetActiveConfig_KeyIsSymbol() { - _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) + _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any()) .Returns(new List { GlobalConfig("!", 2.0m), }); - var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); + var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None); result.Should().ContainKey("!"); result["!"].PricePerUnit.Should().Be(2.0m); diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs index d8e0893..d6ad20e 100644 --- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs @@ -24,7 +24,7 @@ public class CreateChargeableCharConfigCommandValidatorTests } private static CreateChargeableCharConfigCommand ValidCmd() => new( - MedioId: null, + ProductTypeId: null, Symbol: "$", Category: ChargeableCharCategories.Currency, PricePerUnit: 1.0m, diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs index 77121e2..a532a2e 100644 --- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs @@ -37,7 +37,7 @@ public class CreateChargeableCharConfigHandlerTests } private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new( - MedioId: null, + ProductTypeId: null, Symbol: "$", Category: ChargeableCharCategories.Currency, PricePerUnit: 1.5m, @@ -80,9 +80,9 @@ public class CreateChargeableCharConfigHandlerTests } [Fact] - public async Task Handle_WithMedioId_PassesMedioIdToRepo() + public async Task Handle_WithProductTypeId_PassesProductTypeIdToRepo() { - var cmd = ValidCmd() with { MedioId = 7 }; + var cmd = ValidCmd() with { ProductTypeId = 7 }; await _handler.Handle(cmd); diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs new file mode 100644 index 0000000..2d30a76 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Pricing.ChargeableChars.Delete; +using SIGCM2.Domain.Pricing.ChargeableChars; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — DeleteChargeableCharConfigCommandHandler tests. +/// Strict TDD — RED written before implementation. +/// Covers: happy path, not-found, audit emission, audit fail-closed. +/// +public class DeleteChargeableCharConfigHandlerTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeleteChargeableCharConfigCommandHandler _handler; + + private static ChargeableCharConfig SomeConfig() => + ChargeableCharConfig.Rehydrate( + id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency, + price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true); + + public DeleteChargeableCharConfigHandlerTests() + { + _repo.GetByIdAsync(1L, Arg.Any()) + .Returns(SomeConfig()); + + _handler = new DeleteChargeableCharConfigCommandHandler(_repo, _audit, _time); + } + + private static DeleteChargeableCharConfigCommand ValidCmd() => new(Id: 1L); + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsResponse() + { + var result = await _handler.Handle(ValidCmd()); + + result.Should().NotBeNull(); + result.Id.Should().Be(1L); + } + + [Fact] + public async Task Handle_HappyPath_CallsDeleteAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).DeleteAsync(1L, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_EmitsAuditDelete() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "tasacion.chargeable_char.delete", + targetType: "ChargeableCharConfig", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Not found ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException() + { + _repo.GetByIdAsync(99L, Arg.Any()) + .Returns((ChargeableCharConfig?)null); + + var act = async () => await _handler.Handle(new DeleteChargeableCharConfigCommand(Id: 99L)); + + await act.Should().ThrowAsync(); + } + + // ── Audit fail → rollback (fail-closed) ───────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit down")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit down"); + } + + [Fact] + public async Task Handle_AuditThrows_DeleteWasCalled_TransactionNotCompleted() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit down")); + + var act = async () => await _handler.Handle(ValidCmd()); + await act.Should().ThrowAsync(); + + await _repo.Received(1).DeleteAsync(1L, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs index 91dc941..201a699 100644 --- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ListChargeableCharConfigHandlerTests.cs @@ -43,7 +43,7 @@ public class ListChargeableCharConfigHandlerTests _repo.CountAsync(null, true, Arg.Any()) .Returns(2); - var query = new ListChargeableCharConfigQuery(MedioId: null, ActiveOnly: true, Page: 1, PageSize: 20); + var query = new ListChargeableCharConfigQuery(ProductTypeId: null, ActiveOnly: true, Page: 1, PageSize: 20); var result = await _handler.Handle(query); result.Items.Should().HaveCount(2); @@ -104,7 +104,7 @@ public class ListChargeableCharConfigHandlerTests } [Fact] - public async Task Handle_FiltersByMedioId_WhenProvided() + public async Task Handle_FiltersByProductTypeId_WhenProvided() { _repo.ListAsync(7L, true, 0, 20, Arg.Any()) .Returns(new List()); diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ReactivateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ReactivateChargeableCharConfigHandlerTests.cs new file mode 100644 index 0000000..8baec92 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ReactivateChargeableCharConfigHandlerTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Pricing.ChargeableChars.Reactivate; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; + +/// +/// PRC-001 — ReactivateChargeableCharConfigCommandHandler tests. +/// Strict TDD — RED written before implementation. +/// Covers: happy path, audit emission, audit fail-closed, repo exception propagation. +/// +public class ReactivateChargeableCharConfigHandlerTests +{ + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + private readonly IChargeableCharConfigRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateChargeableCharConfigCommandHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 20); + + private static ChargeableCharConfig ClosedConfig() => + ChargeableCharConfig.Rehydrate( + id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency, + price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: new DateOnly(2026, 4, 19), isActive: false); + + private static ChargeableCharConfig ActiveConfig() => + ChargeableCharConfig.Rehydrate( + id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency, + price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true); + + public ReactivateChargeableCharConfigHandlerTests() + { + _repo.ReactivateAsync(1L, Arg.Any()) + .Returns(ActiveConfig()); + + _handler = new ReactivateChargeableCharConfigCommandHandler(_repo, _audit, _time); + } + + private static ReactivateChargeableCharConfigCommand ValidCmd() => new(Id: 1L); + + // ── Happy path ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsResponse() + { + var result = await _handler.Handle(ValidCmd()); + + result.Should().NotBeNull(); + result.Id.Should().Be(1L); + result.Symbol.Should().Be("$"); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Handle_HappyPath_CallsReactivateAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).ReactivateAsync(1L, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_EmitsAuditReactivate() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "tasacion.chargeable_char.reactivate", + targetType: "ChargeableCharConfig", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Guard failures propagate ──────────────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyActive_ThrowsReactivationNotAllowed() + { + _repo.ReactivateAsync(2L, Arg.Any()) + .ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(2L, "ALREADY_ACTIVE")); + + var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 2L)); + + await act.Should().ThrowAsync() + .Where(e => e.Reason == "ALREADY_ACTIVE"); + } + + [Fact] + public async Task Handle_VigenteExists_ThrowsReactivationNotAllowed() + { + _repo.ReactivateAsync(3L, Arg.Any()) + .ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(3L, "VIGENTE_EXISTS")); + + var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 3L)); + + await act.Should().ThrowAsync() + .Where(e => e.Reason == "VIGENTE_EXISTS"); + } + + [Fact] + public async Task Handle_PosteriorRowsExist_ThrowsReactivationNotAllowed() + { + _repo.ReactivateAsync(4L, Arg.Any()) + .ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(4L, "POSTERIOR_ROWS_EXIST")); + + var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 4L)); + + await act.Should().ThrowAsync() + .Where(e => e.Reason == "POSTERIOR_ROWS_EXIST"); + } + + // ── Audit fail → rollback (fail-closed) ───────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit down")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit down"); + } + + [Fact] + public async Task Handle_AuditThrows_ReactivateWasCalled_TransactionNotCompleted() + { + _audit.LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit down")); + + var act = async () => await _handler.Handle(ValidCmd()); + await act.Should().ThrowAsync(); + + await _repo.Received(1).ReactivateAsync(1L, Arg.Any()); + } +} From 3eecb05634cc3568dcab1aa0bdcd894dc337a9f3 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 11:08:17 -0300 Subject: [PATCH 11/14] refactor+feat(frontend): chargeableChars por ProductType + Reactivate/Delete/UI condicional (PRC-001) Part of feature/PRC-001 pre-merge refinement. REFACTOR: - types, API client, hooks, components renamed MedioId -> ProductTypeId - CopyToAllMediaDialog -> CopyToAllProductTypesDialog - ProductTypeSelect reused from features/product-types (or created minimal stub) - Form validation + test mocks updated FEATURES: - Conditional action buttons per row.isActive: - Active: Desactivar + Eliminar - Inactive: Reactivar + Eliminar - Reactivate: useReactivateChargeableCharConfig hook, 409 reason surfaces localized error message - Delete: useDeleteChargeableCharConfig hook + DeleteChargeableCharConfigDialog with confirmation warning + FAC-001 disclaimer - ProductType column in ChargeableCharsTable (fallback "Global" when null) Tests: - Conditional rendering tests (5 new) - Reactivate/Delete hook tests (5 new) - Updated mocks for all existing tests - DeleteChargeableCharConfigDialog tests (3 new) - CopyToAllProductTypesDialog tests (3 new) --- .../ChargeableCharFormDialog.test.tsx | 36 +++- .../__tests__/ChargeableCharsTable.test.tsx | 89 ++++++++- .../__tests__/CopyToAllMediaDialog.test.tsx | 45 +++-- .../CopyToAllProductTypesDialog.test.tsx | 116 ++++++++++++ .../DeleteChargeableCharConfigDialog.test.tsx | 84 +++++++++ .../chargeableChars/__tests__/hooks.test.ts | 113 ++++++++++- .../api/deleteChargeableCharConfig.ts | 11 ++ .../api/listChargeableCharConfigs.ts | 2 +- .../api/reactivateChargeableCharConfig.ts | 40 ++++ .../components/ChargeableCharFormDialog.tsx | 37 +++- .../components/ChargeableCharsTable.tsx | 177 +++++++++++------- .../components/CopyToAllMediaDialog.tsx | 141 +------------- .../CopyToAllProductTypesDialog.tsx | 139 ++++++++++++++ .../DeleteChargeableCharConfigDialog.tsx | 92 +++++++++ .../components/ProductTypeSelect.tsx | 83 ++++++++ .../hooks/useDeleteChargeableCharConfig.ts | 13 ++ .../useReactivateChargeableCharConfig.ts | 13 ++ .../pages/ChargeableCharsPage.tsx | 16 +- src/web/src/features/chargeableChars/types.ts | 16 +- 19 files changed, 1009 insertions(+), 254 deletions(-) create mode 100644 src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx create mode 100644 src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx create mode 100644 src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx create mode 100644 src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx create mode 100644 src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx create mode 100644 src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts create mode 100644 src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx index 9f23d6c..669a850 100644 --- a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx @@ -10,6 +10,30 @@ import type { ChargeableCharConfig } from '../types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +// Mock ProductTypeSelect so it renders a simple select with a "Global" option +// This avoids fetching product-types in form dialog tests +vi.mock('../components/ProductTypeSelect', () => ({ + ProductTypeSelect: ({ value, onValueChange, disabled }: { + value: number | null | undefined + onValueChange: (v: number | null | undefined) => void + disabled?: boolean + }) => ( + + ), +})) + const API_URL = 'http://localhost:5000' const server = setupServer() @@ -82,14 +106,14 @@ describe('ChargeableCharFormDialog — create mode', () => { { timeout: 3000 }) }) - it('happy path calls mutation with correct yyyy-MM-dd string payload', async () => { + it('happy path calls mutation with correct yyyy-MM-dd string payload (productTypeId: null)', async () => { let capturedBody: unknown = null server.use( http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json( { - id: 1, medioId: null, symbol: '$', category: 'Currency', + id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true, }, { status: 201 }, @@ -126,6 +150,8 @@ describe('ChargeableCharFormDialog — create mode', () => { const body = capturedBody as Record expect(body['validFrom']).toBe('2026-04-25') expect(typeof body['validFrom']).toBe('string') + // productTypeId defaults to null (Global) + expect(body['productTypeId']).toBeNull() }, { timeout: 8000 }) await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 }) @@ -173,7 +199,7 @@ describe('ChargeableCharFormDialog — schedulePrice mode', () => { it('hides symbol and category inputs (read-only mode)', () => { const existingConfig: ChargeableCharConfig = { - id: 5, medioId: null, symbol: '%', category: 'Percentage', + id: 5, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, } renderDialog('schedulePrice', existingConfig) @@ -193,13 +219,13 @@ describe('ChargeableCharFormDialog — schedulePrice mode', () => { http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json( - { created: { id: 6, medioId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null }, + { created: { id: 6, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null }, ) }), ) const existingConfig: ChargeableCharConfig = { - id: 5, medioId: null, symbol: '%', category: 'Percentage', + id: 5, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, } const onOpenChange = vi.fn() diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx index 1147d5e..ed71340 100644 --- a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx @@ -10,12 +10,34 @@ import type { ChargeableCharConfig } from '../types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +// Mock ProductTypeSelect to avoid fetching product-types in table tests +vi.mock('../components/ProductTypeSelect', () => ({ + ProductTypeSelect: ({ value, onValueChange, 'aria-label': ariaLabel }: { + value: number | null | undefined + onValueChange: (v: number | null | undefined) => void + 'aria-label'?: string + }) => ( + + ), +})) + const API_URL = 'http://localhost:5000' function makeConfig(overrides: Partial = {}): ChargeableCharConfig { return { id: 1, - medioId: null, + productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, @@ -45,9 +67,9 @@ function renderTable( page={1} pageSize={20} onPageChange={vi.fn()} - medioId={undefined} + productTypeId={undefined} activeOnly={true} - onMedioChange={vi.fn()} + onProductTypeChange={vi.fn()} onActiveOnlyChange={vi.fn()} onSchedulePrice={onSchedulePrice} onDeactivate={onDeactivate} @@ -69,9 +91,10 @@ describe('ChargeableCharsTable', () => { expect(screen.getByText('Moneda ($)')).toBeInTheDocument() }) - it('displays "Global" when medioId is null', () => { - renderTable([makeConfig({ medioId: null })]) - expect(screen.getByText('Global')).toBeInTheDocument() + it('displays "Global" when productTypeId is null', () => { + renderTable([makeConfig({ productTypeId: null })]) + // Multiple "Global" texts may exist (table cell + select option) — assert at least one is present + expect(screen.getAllByText('Global').length).toBeGreaterThanOrEqual(1) }) it('shows "Vigente" badge for rows with validTo === null', () => { @@ -88,4 +111,58 @@ describe('ChargeableCharsTable', () => { renderTable([makeConfig({ validFrom: '2026-01-15' })]) expect(screen.getByText('15/01/2026')).toBeInTheDocument() }) + + // ── Conditional buttons ──────────────────────────────────────────────────── + + it('active row shows Desactivar button but NOT Reactivar', () => { + renderTable([makeConfig({ id: 1, isActive: true })]) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /reactivar/i })).not.toBeInTheDocument() + }) + + it('inactive row shows Reactivar button but NOT Desactivar', () => { + renderTable([makeConfig({ id: 1, isActive: false, validTo: '2026-03-31' })]) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /desactivar/i })).not.toBeInTheDocument() + }) + + it('Eliminar button is visible for both active and inactive rows', () => { + renderTable([ + makeConfig({ id: 1, isActive: true }), + makeConfig({ id: 2, isActive: false, validTo: '2026-03-31' }), + ]) + const eliminarBtns = screen.getAllByRole('button', { name: /eliminar/i }) + expect(eliminarBtns).toHaveLength(2) + }) + + it('clicking Desactivar calls onDeactivate with the correct config', async () => { + const onDeactivate = vi.fn() + const config = makeConfig({ id: 5, isActive: true, symbol: '€' }) + renderTable([config], vi.fn(), onDeactivate) + + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + expect(onDeactivate).toHaveBeenCalledWith(expect.objectContaining({ id: 5, symbol: '€' })) + }) + + it('clicking Reactivar calls PATCH /reactivate endpoint', async () => { + const calls: unknown[] = [] + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => { + calls.push(true) + return HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }) + }), + ) + // Also mock the list invalidation re-fetch + server.use( + http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + const config = makeConfig({ id: 7, isActive: false, validTo: '2026-03-31' }) + renderTable([config]) + + await userEvent.click(screen.getByRole('button', { name: /reactivar/i })) + await waitFor(() => expect(calls.length).toBe(1), { timeout: 5000 }) + }) }) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx index 33a9231..16dc606 100644 --- a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx @@ -1,22 +1,40 @@ +/** + * @deprecated This file is kept for reference. Tests have moved to CopyToAllProductTypesDialog.test.tsx + * The CopyToAllMediaDialog component has been renamed to CopyToAllProductTypesDialog. + * This file re-exports the new test suite so the old path doesn't break CI. + */ +// Re-run the same suite but importing the new component import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import React from 'react' -import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' -import type { MedioListItem } from '../../medios/types' +import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' +import type { ProductTypeListItem } from '../../product-types/types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) const API_URL = 'http://localhost:5000' -function makeMedio(id: number, nombre: string): MedioListItem { - return { id, codigo: `M${id}`, nombre, tipo: 1, plataformaEmpresaId: null, activo: true } +function makeProductType(id: number, nombre: string): ProductTypeListItem { + return { + id, + nombre, + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, + } } -const medios = [makeMedio(1, 'La Nación'), makeMedio(2, 'Clarín'), makeMedio(3, 'Infobae')] +const productTypes = [ + makeProductType(1, 'La Nación'), + makeProductType(2, 'Clarín'), + makeProductType(3, 'Infobae'), +] const server = setupServer() beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) @@ -27,14 +45,14 @@ function renderDialog(onOpenChange = vi.fn()) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) server.use( - http.get(`${API_URL}/api/v1/admin/medios`, () => - HttpResponse.json({ items: medios, page: 1, pageSize: 100, total: 3 }), + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }), ), ) return render( - { +describe('CopyToAllMediaDialog (renamed → CopyToAllProductTypesDialog)', () => { it('shows preview of symbol, price, and validFrom', async () => { renderDialog() - // Wait for dialog to render await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - // Preview info expect(screen.getByText('$')).toBeInTheDocument() expect(screen.getByText('1.5')).toBeInTheDocument() expect(screen.getByText('25/04/2026')).toBeInTheDocument() }) - it('confirm with 3 medios selected calls create mutation 3 times', async () => { + it('confirm with 3 product types selected calls create mutation 3 times', async () => { const createCalls: unknown[] = [] server.use( http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { const body = await request.json() createCalls.push(body) return HttpResponse.json( - { id: createCalls.length, medioId: (body as Record)['medioId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, + { id: createCalls.length, productTypeId: (body as Record)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, { status: 201 }, ) }), @@ -76,7 +92,6 @@ describe('CopyToAllMediaDialog', () => { await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument()) - // All checkboxes are selected by default; click confirm const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i }) await userEvent.click(confirmBtn) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx new file mode 100644 index 0000000..8411b50 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' +import type { ProductTypeListItem } from '../../product-types/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +function makeProductType(id: number, nombre: string): ProductTypeListItem { + return { + id, + nombre, + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, + } +} + +const productTypes = [ + makeProductType(1, 'Clasificados'), + makeProductType(2, 'Notables'), + makeProductType(3, 'Fúnebres'), +] + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderDialog(onOpenChange = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }), + ), + ) + + return render( + + + , + ) +} + +describe('CopyToAllProductTypesDialog', () => { + it('shows preview of symbol, price, and validFrom', async () => { + renderDialog() + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Preview info + expect(screen.getByText('$')).toBeInTheDocument() + expect(screen.getByText('1.5')).toBeInTheDocument() + expect(screen.getByText('25/04/2026')).toBeInTheDocument() + }) + + it('confirm with 3 product types calls create mutation 3 times', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + const body = await request.json() + createCalls.push(body) + return HttpResponse.json( + { id: createCalls.length, productTypeId: (body as Record)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, + { status: 201 }, + ) + }), + ) + + renderDialog() + + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + + const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i }) + await userEvent.click(confirmBtn) + + await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 }) + }) + + it('cancel button closes without making API calls', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + createCalls.push(await request.json()) + return HttpResponse.json({}, { status: 201 }) + }), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const cancelBtn = screen.getByRole('button', { name: /cancelar/i }) + await userEvent.click(cancelBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(createCalls.length).toBe(0) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx new file mode 100644 index 0000000..404ee80 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { DeleteChargeableCharConfigDialog } from '../components/DeleteChargeableCharConfigDialog' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderDialog( + configId = 1, + symbol = '$', + onOpenChange = vi.fn(), +) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + , + ) +} + +describe('DeleteChargeableCharConfigDialog', () => { + it('renders dialog with symbol in warning text', async () => { + renderDialog(1, '$') + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(screen.getByText(/eliminará permanentemente/i)).toBeInTheDocument() + expect(screen.getByText(/'\$'/)).toBeInTheDocument() + expect(screen.getByText(/FAC-001/i)).toBeInTheDocument() + }) + + it('confirm button calls delete mutation and shows success toast', async () => { + const { toast } = await import('sonner') + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/5`, () => + HttpResponse.json({ id: 5 }), + ), + ) + const onOpenChange = vi.fn() + renderDialog(5, '%', onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const confirmBtn = screen.getByRole('button', { name: /eliminar/i }) + await userEvent.click(confirmBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 }) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('%')) + }) + + it('cancel button closes dialog without calling mutation', async () => { + const deleteCalls: unknown[] = [] + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/1`, async () => { + deleteCalls.push(true) + return HttpResponse.json({ id: 1 }) + }), + ) + const onOpenChange = vi.fn() + renderDialog(1, '$', onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const cancelBtn = screen.getByRole('button', { name: /cancelar/i }) + await userEvent.click(cancelBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(deleteCalls.length).toBe(0) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts index 0e38ec3..f5e3cff 100644 --- a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts +++ b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts @@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' +import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig' +import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig' +import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) @@ -30,7 +33,7 @@ describe('useChargeableCharConfigs', () => { server.use( http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () => HttpResponse.json({ - items: [{ id: 1, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }], + items: [{ id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }], page: 1, pageSize: 20, total: 1, }), ), @@ -43,7 +46,7 @@ describe('useChargeableCharConfigs', () => { expect(result.current.data?.items[0].symbol).toBe('$') }) - it('sends medioId and activeOnly as query params', async () => { + it('sends productTypeId and activeOnly as query params', async () => { let capturedUrl: string | null = null server.use( http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => { @@ -53,12 +56,12 @@ describe('useChargeableCharConfigs', () => { ) const { wrapper } = makeWrapper() const { result } = renderHook( - () => useChargeableCharConfigs({ medioId: 3, activeOnly: true }), + () => useChargeableCharConfigs({ productTypeId: 3, activeOnly: true }), { wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(capturedUrl).toContain('medioId=3') + expect(capturedUrl).toContain('productTypeId=3') expect(capturedUrl).toContain('activeOnly=true') }) }) @@ -68,7 +71,7 @@ describe('useSchedulePriceChange', () => { server.use( http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () => HttpResponse.json({ - created: { id: 6, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true }, + created: { id: 6, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true }, closed: null, }), ), @@ -83,10 +86,108 @@ describe('useSchedulePriceChange', () => { }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - // Should invalidate the list query and the byId query const listInvalidated = invalidateSpy.mock.calls.some( ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'), ) expect(listInvalidated).toBe(true) }) }) + +describe('useReactivateChargeableCharConfig', () => { + it('happy path — returns response and invalidates list + byId', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => + HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(7) }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(7) + + const listInvalidated = invalidateSpy.mock.calls.some( + ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'), + ) + expect(listInvalidated).toBe(true) + }) + + it('409 ALREADY_ACTIVE — throws ReactivationNotAllowedError with correct reason', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/8/reactivate`, () => + HttpResponse.json( + { code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'ALREADY_ACTIVE', message: 'El registro ya está activo' }, + { status: 409 }, + ), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(8) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError) + expect((result.current.error as ReactivationNotAllowedError).reason).toBe('ALREADY_ACTIVE') + }) + + it('409 VIGENTE_EXISTS — throws ReactivationNotAllowedError with correct reason', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/9/reactivate`, () => + HttpResponse.json( + { code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'VIGENTE_EXISTS', message: 'Ya existe un registro activo' }, + { status: 409 }, + ), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(9) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError) + expect((result.current.error as ReactivationNotAllowedError).reason).toBe('VIGENTE_EXISTS') + }) +}) + +describe('useDeleteChargeableCharConfig', () => { + it('happy path — returns {id} and invalidates list + removes byId', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/10`, () => + HttpResponse.json({ id: 10 }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const removeSpy = vi.spyOn(qc, 'removeQueries') + + const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(10) }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(10) + + const listInvalidated = invalidateSpy.mock.calls.some( + ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'), + ) + expect(listInvalidated).toBe(true) + expect(removeSpy).toHaveBeenCalled() + }) + + it('404 — mutation transitions to error state', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/99`, () => + HttpResponse.json({ message: 'Not found' }, { status: 404 }), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(99) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) diff --git a/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts new file mode 100644 index 0000000..df6e7e0 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts @@ -0,0 +1,11 @@ +import { axiosClient } from '@/api/axiosClient' +import type { DeleteChargeableCharConfigResponse } from '../types' + +export async function deleteChargeableCharConfig( + id: number, +): Promise { + const response = await axiosClient.delete( + `/api/v1/admin/chargeable-chars/${id}`, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts index cd1e0b3..c4c1c9d 100644 --- a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts +++ b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts @@ -5,7 +5,7 @@ export async function listChargeableCharConfigs( query: ChargeableCharConfigsQuery, ): Promise> { const params = new URLSearchParams() - if (query.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.productTypeId !== undefined) params.set('productTypeId', String(query.productTypeId)) if (query.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly)) if (query.page !== undefined) params.set('page', String(query.page)) if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) diff --git a/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts new file mode 100644 index 0000000..6126d76 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts @@ -0,0 +1,40 @@ +import { axiosClient } from '@/api/axiosClient' +import { isAxiosError } from 'axios' +import type { ReactivateChargeableCharConfigResponse } from '../types' + +export type ReactivationNotAllowedReason = + | 'ALREADY_ACTIVE' + | 'VIGENTE_EXISTS' + | 'POSTERIOR_ROWS_EXIST' + +export class ReactivationNotAllowedError extends Error { + reason: ReactivationNotAllowedReason + + constructor(reason: ReactivationNotAllowedReason, message: string) { + super(message) + this.name = 'ReactivationNotAllowedError' + this.reason = reason + } +} + +export async function reactivateChargeableCharConfig( + id: number, +): Promise { + try { + const response = await axiosClient.patch( + `/api/v1/admin/chargeable-chars/${id}/reactivate`, + ) + return response.data + } catch (err) { + if ( + isAxiosError(err) && + err.response?.status === 409 && + err.response.data?.code === 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED' + ) { + const reason: ReactivationNotAllowedReason = err.response.data?.reason ?? 'ALREADY_ACTIVE' + const message: string = err.response.data?.message ?? 'Reactivación no permitida.' + throw new ReactivationNotAllowedError(reason, message) + } + throw err + } +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx index 1d650ca..5152e71 100644 --- a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx +++ b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx @@ -22,6 +22,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { Input } from '@/components/ui/input' import { Select, SelectContent, @@ -29,12 +30,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Input } from '@/components/ui/input' import { todayArgentina } from '@/lib/formatters' import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories' import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' import { SymbolInput } from './SymbolInput' +import { ProductTypeSelect } from './ProductTypeSelect' import type { ChargeableCharConfig } from '../types' // ─── Emoji regex (same as SymbolInput) ─────────────────────────────────────── @@ -43,14 +44,14 @@ const EMOJI_REGEX = /\p{Extended_Pictographic}/u // ─── Schemas ───────────────────────────────────────────────────────────────── const createSchema = z.object({ - medioId: z.number().int().positive().nullable().optional(), + productTypeId: z.number().nullable().optional(), symbol: z .string() .min(1, 'El símbolo es requerido.') .max(4, 'Máximo 4 caracteres.') .refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'), category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], { - required_error: 'La categoría es requerida.', + error: 'La categoría es requerida.', }), pricePerUnit: z.coerce .number('Debe ser un número.') @@ -74,7 +75,7 @@ const schedulePriceSchema = z.object({ }) type CreateFormRaw = { - medioId?: string + productTypeId?: number | null symbol: string category: string pricePerUnit: string @@ -131,7 +132,7 @@ export function ChargeableCharFormDialog({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const createForm = useForm({ resolver: zodResolver(createSchema) as any, - defaultValues: { medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }, + defaultValues: { productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' }, mode: 'onSubmit', }) @@ -145,7 +146,7 @@ export function ChargeableCharFormDialog({ useEffect(() => { if (open) { - createForm.reset({ medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }) + createForm.reset({ productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' }) scheduleForm.reset({ pricePerUnit: '', validFrom: '' }) createMutation.reset() scheduleMutation.reset() @@ -158,7 +159,7 @@ export function ChargeableCharFormDialog({ function handleCreateSubmit(values: z.infer) { createMutation.mutate( { - medioId: values.medioId ?? null, + productTypeId: values.productTypeId ?? null, symbol: values.symbol, category: values.category as ChargeableCharConfig['category'], pricePerUnit: values.pricePerUnit, @@ -215,6 +216,28 @@ export function ChargeableCharFormDialog({ className="space-y-4" noValidate > + {/* Tipo de producto */} + ( + + Tipo de producto + + field.onChange(v ?? null)} + globalOptionLabel="Global (todos los tipos)" + placeholder="Seleccioná un tipo de producto" + disabled={isPending} + aria-label="Tipo de producto" + /> + + + + )} + /> + {/* Símbolo */} void - medioId: number | undefined + productTypeId: number | undefined activeOnly: boolean - onMedioChange: (medioId: number | undefined) => void + onProductTypeChange: (productTypeId: number | undefined) => void onActiveOnlyChange: (value: boolean) => void onSchedulePrice: (config: ChargeableCharConfig) => void onDeactivate: (config: ChargeableCharConfig) => void } +function resolveReactivationError(err: unknown): string { + if (err instanceof ReactivationNotAllowedError) { + switch (err.reason) { + case 'ALREADY_ACTIVE': + return 'El registro ya está activo.' + case 'VIGENTE_EXISTS': + return 'Ya existe un registro activo para este tipo de producto y símbolo. Modificá ese registro en su lugar.' + case 'POSTERIOR_ROWS_EXIST': + return 'Existen cambios posteriores al cierre de este registro. Para modificar el precio, usá "Programar cambio de precio".' + } + } + return 'No se pudo reactivar el símbolo. Intentá de nuevo.' +} + export function ChargeableCharsTable({ configs, total, page, pageSize, onPageChange, - medioId, + productTypeId, activeOnly, - onMedioChange, + onProductTypeChange, onActiveOnlyChange, onSchedulePrice, onDeactivate, }: ChargeableCharsTableProps) { - const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) - const medios = mediosData?.items ?? [] + const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = ptData?.items ?? [] + + const reactivateMutation = useReactivateChargeableCharConfig() + + const [deleteTarget, setDeleteTarget] = useState(null) const totalPages = Math.max(1, Math.ceil(total / pageSize)) const hasPrev = page > 1 const hasNext = page < totalPages + function handleReactivate(config: ChargeableCharConfig) { + reactivateMutation.mutate(config.id, { + onError: (err) => { + toast.error(resolveReactivationError(err)) + }, + }) + } + const columns = useMemo[]>( () => [ { - accessorKey: 'medioId', - header: 'Medio', + accessorKey: 'productTypeId', + header: 'Tipo de Producto', cell: ({ row }) => { - const mid = row.original.medioId - if (mid === null) return Global - const medio = medios.find((m) => m.id === mid) - return {medio?.nombre ?? `Medio ${mid}`} + const ptId = row.original.productTypeId + if (ptId === null) return Global + const pt = productTypes.find((p) => p.id === ptId) + return {pt?.nombre ?? `Tipo ${ptId}`} }, }, { @@ -129,53 +146,71 @@ export function ChargeableCharsTable({ { id: 'acciones', header: 'Acciones', - cell: ({ row }) => ( -
e.stopPropagation()}> - - - - - - onSchedulePrice(row.original)}> - Programar cambio de precio - - onDeactivate(row.original)} - className="text-destructive" + cell: ({ row }) => { + const config = row.original + return ( +
e.stopPropagation()}> + {config.isActive ? ( + <> + + + + ) : ( +
- ), + Reactivar + + )} + +
+ ) + }, }, ], - [medios, onSchedulePrice, onDeactivate], + [productTypes, onSchedulePrice, onDeactivate, reactivateMutation], ) return (
{/* Filters */}
- + onProductTypeChange(v === null ? undefined : v)} + showAllOption={true} + allOptionLabel="Todos los tipos" + globalOptionLabel="Global" + placeholder="Todos los tipos" + aria-label="Tipo de producto" + />
+ + {/* Delete confirmation dialog */} + {deleteTarget && ( + { if (!open) setDeleteTarget(null) }} + configId={deleteTarget.id} + symbol={deleteTarget.symbol} + /> + )}
) } diff --git a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx index 04b3894..77b1e1e 100644 --- a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx +++ b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx @@ -1,139 +1,6 @@ -import { useState } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { formatCivilDate } from '@/lib/formatters' -import { useMediosList } from '../../medios/hooks/useMediosList' -import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' -import type { ChargeableCharCategory } from '../types' - -interface CopyToAllMediaDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - symbol: string - pricePerUnit: number - /** yyyy-MM-dd */ - validFrom: string - category: ChargeableCharCategory -} - /** - * Confirmation dialog that creates rows for all active medios - * with the same symbol/price/validFrom/category. - * - * Uses Promise.allSettled — if one medio fails, the rest still proceed. - * Summary toast shows success/failure counts. + * @deprecated Renamed to CopyToAllProductTypesDialog. + * This file is kept for backwards compatibility — no code imports it. + * @see CopyToAllProductTypesDialog */ -export function CopyToAllMediaDialog({ - open, - onOpenChange, - symbol, - pricePerUnit, - validFrom, - category, -}: CopyToAllMediaDialogProps) { - const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) - const medios = mediosData?.items ?? [] - const createMutation = useCreateChargeableCharConfig() - const [isProcessing, setIsProcessing] = useState(false) - - async function handleConfirm() { - if (medios.length === 0) return - setIsProcessing(true) - try { - // Promise.allSettled: partial failure doesn't block the rest - const results = await Promise.allSettled( - medios.map((m) => - createMutation.mutateAsync({ - medioId: m.id, - symbol, - category, - pricePerUnit, - validFrom, - }), - ), - ) - const succeeded = results.filter((r) => r.status === 'fulfilled').length - const failed = results.filter((r) => r.status === 'rejected').length - - if (failed === 0) { - toast.success(`Copiado a ${succeeded} medio${succeeded !== 1 ? 's' : ''} exitosamente.`) - } else { - toast.error( - `${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`, - ) - } - onOpenChange(false) - } finally { - setIsProcessing(false) - } - } - - return ( - - - - Copiar a todos los medios - - Se creará una configuración para cada medio activo con los siguientes datos. - - - - {/* Preview */} -
-
- Símbolo: - {symbol} -
-
- Precio/unidad: - {pricePerUnit} -
-
- Vigente desde: - {formatCivilDate(validFrom)} -
-
- - {/* List of medios */} - {medios.length > 0 ? ( -
- {medios.map((m) => ( -
- - {m.nombre} -
- ))} -
- ) : ( -

Cargando medios...

- )} - - - - - -
-
- ) -} +export { CopyToAllProductTypesDialog as CopyToAllMediaDialog } from './CopyToAllProductTypesDialog' diff --git a/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx new file mode 100644 index 0000000..737b6bf --- /dev/null +++ b/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { formatCivilDate } from '@/lib/formatters' +import { useProductTypes } from '../../product-types/hooks/useProductTypes' +import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' +import type { ChargeableCharCategory } from '../types' + +interface CopyToAllProductTypesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + symbol: string + pricePerUnit: number + /** yyyy-MM-dd */ + validFrom: string + category: ChargeableCharCategory +} + +/** + * Confirmation dialog that creates rows for all active ProductTypes + * with the same symbol/price/validFrom/category. + * + * Uses Promise.allSettled — if one productType fails, the rest still proceed. + * Summary toast shows success/failure counts. + */ +export function CopyToAllProductTypesDialog({ + open, + onOpenChange, + symbol, + pricePerUnit, + validFrom, + category, +}: CopyToAllProductTypesDialogProps) { + const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = ptData?.items ?? [] + const createMutation = useCreateChargeableCharConfig() + const [isProcessing, setIsProcessing] = useState(false) + + async function handleConfirm() { + if (productTypes.length === 0) return + setIsProcessing(true) + try { + // Promise.allSettled: partial failure doesn't block the rest + const results = await Promise.allSettled( + productTypes.map((pt) => + createMutation.mutateAsync({ + productTypeId: pt.id, + symbol, + category, + pricePerUnit, + validFrom, + }), + ), + ) + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed === 0) { + toast.success(`Copiado a ${succeeded} tipo${succeeded !== 1 ? 's' : ''} exitosamente.`) + } else { + toast.error( + `${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`, + ) + } + onOpenChange(false) + } finally { + setIsProcessing(false) + } + } + + return ( + + + + Copiar a todos los tipos de producto + + Se creará una configuración para cada tipo de producto activo con los siguientes datos. + + + + {/* Preview */} +
+
+ Símbolo: + {symbol} +
+
+ Precio/unidad: + {pricePerUnit} +
+
+ Vigente desde: + {formatCivilDate(validFrom)} +
+
+ + {/* List of product types */} + {productTypes.length > 0 ? ( +
+ {productTypes.map((pt) => ( +
+ + {pt.nombre} +
+ ))} +
+ ) : ( +

Cargando tipos de producto...

+ )} + + + + + +
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx b/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx new file mode 100644 index 0000000..011bf93 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx @@ -0,0 +1,92 @@ +import { toast } from 'sonner' +import { AlertTriangle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig' + +interface DeleteChargeableCharConfigDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** The config id to delete */ + configId: number + /** Symbol label for the warning text */ + symbol: string +} + +export function DeleteChargeableCharConfigDialog({ + open, + onOpenChange, + configId, + symbol, +}: DeleteChargeableCharConfigDialogProps) { + const deleteMutation = useDeleteChargeableCharConfig() + + function handleConfirm() { + deleteMutation.mutate(configId, { + onSuccess: () => { + toast.success(`Símbolo '${symbol}' eliminado correctamente.`) + onOpenChange(false) + deleteMutation.reset() + }, + onError: () => { + toast.error('No se pudo eliminar el símbolo. Intentá de nuevo.') + }, + }) + } + + function handleClose(open: boolean) { + if (!deleteMutation.isPending) { + deleteMutation.reset() + onOpenChange(open) + } + } + + return ( + + + + Eliminar carácter tasable + + Esta acción eliminará permanentemente la tasación del símbolo '{symbol}'. + ¿Estás seguro? + + + + + + + La eliminación es posible porque aún no existe el módulo de facturación (FAC-001). + Cuando se active, no podrán eliminarse símbolos ya usados. + + + + + + + + + + ) +} diff --git a/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx b/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx new file mode 100644 index 0000000..b035eda --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx @@ -0,0 +1,83 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useProductTypes } from '../../product-types/hooks/useProductTypes' + +const GLOBAL_VALUE = '__global__' +const ALL_VALUE = '__all__' + +interface ProductTypeSelectProps { + /** Current selected productTypeId. null = "Global", undefined = "All types" (filter mode) */ + value: number | null | undefined + /** Called with null for "Global (todos los tipos)", undefined for "All types", or id */ + onValueChange: (value: number | null | undefined) => void + /** If true — show an "All types" option (undefined) for filter use-case */ + showAllOption?: boolean + /** Label for "all" option */ + allOptionLabel?: string + /** Label for global/null option */ + globalOptionLabel?: string + placeholder?: string + disabled?: boolean + 'aria-label'?: string +} + +/** + * ProductTypeSelect — renders a shadcn Select populated with active ProductTypes. + * + * - In "filter" mode (showAllOption=true): adds an "All types" option that returns undefined + * - Always includes a "Global (todos los tipos)" option that returns null + * - Product types come from GET /api/v1/product-types with activo=true + */ +export function ProductTypeSelect({ + value, + onValueChange, + showAllOption = false, + allOptionLabel = 'Todos los tipos', + globalOptionLabel = 'Global (todos los tipos)', + placeholder = 'Seleccioná un tipo de producto', + disabled = false, + 'aria-label': ariaLabel, +}: ProductTypeSelectProps) { + const { data } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = data?.items ?? [] + + function toSelectValue(v: number | null | undefined): string { + if (v === undefined) return ALL_VALUE + if (v === null) return GLOBAL_VALUE + return String(v) + } + + function fromSelectValue(sv: string): number | null | undefined { + if (sv === ALL_VALUE) return undefined + if (sv === GLOBAL_VALUE) return null + return Number(sv) + } + + return ( + + ) +} diff --git a/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts new file mode 100644 index 0000000..0a603b4 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deleteChargeableCharConfig } from '../api/deleteChargeableCharConfig' + +export function useDeleteChargeableCharConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deleteChargeableCharConfig(id), + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.removeQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts new file mode 100644 index 0000000..a4196c1 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateChargeableCharConfig } from '../api/reactivateChargeableCharConfig' + +export function useReactivateChargeableCharConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateChargeableCharConfig(id), + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx index d2120f3..3eb686d 100644 --- a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx +++ b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx @@ -5,7 +5,7 @@ import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig' import { ChargeableCharsTable } from '../components/ChargeableCharsTable' import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog' -import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' +import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' import type { ChargeableCharConfig } from '../types' const PERMISSION = 'tasacion:caracteres_especiales:gestionar' @@ -14,7 +14,7 @@ const DEFAULT_PAGE_SIZE = 20 export function ChargeableCharsPage() { // ── Filter / pagination state ────────────────────────────────────────────── const [page, setPage] = useState(1) - const [medioId, setMedioId] = useState(undefined) + const [selectedProductTypeId, setSelectedProductTypeId] = useState(undefined) const [activeOnly, setActiveOnly] = useState(true) // ── Dialog state ────────────────────────────────────────────────────────── @@ -25,7 +25,7 @@ export function ChargeableCharsPage() { // ── Data ────────────────────────────────────────────────────────────────── const { data, isLoading } = useChargeableCharConfigs({ - medioId, + productTypeId: selectedProductTypeId, activeOnly, page, pageSize: DEFAULT_PAGE_SIZE, @@ -51,7 +51,7 @@ export function ChargeableCharsPage() { onClick={() => setCopyFromConfig(data?.items[0] ?? null)} disabled={!data?.items.length} > - Copiar a todos los medios + Copiar a todos los tipos