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()); + } +}