refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)

Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
This commit is contained in:
2026-04-21 10:54:47 -03:00
parent 5c1675e59a
commit f7fb76219a
35 changed files with 1273 additions and 273 deletions

View File

@@ -6,8 +6,10 @@ using SIGCM2.Application.Common;
using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create; using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.GetById; using SIGCM2.Application.Pricing.ChargeableChars.GetById;
using SIGCM2.Application.Pricing.ChargeableChars.List; using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
namespace SIGCM2.Api.Controllers; namespace SIGCM2.Api.Controllers;
@@ -39,7 +41,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
/// <summary> /// <summary>
/// Returns a paginated list of ChargeableCharConfig rows. /// 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. /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200. /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
/// </summary> /// </summary>
@@ -49,7 +51,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> List( public async Task<IActionResult> List(
[FromQuery] long? medioId, [FromQuery] long? productTypeId,
[FromQuery] bool activeOnly = true, [FromQuery] bool activeOnly = true,
[FromQuery] int? page = null, [FromQuery] int? page = null,
[FromQuery] int? pageSize = null, [FromQuery] int? pageSize = null,
@@ -74,7 +76,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
resolvedPageSize = Math.Min(pageSize ?? 20, 200); 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<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query); var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
return Ok(result); return Ok(result);
} }
@@ -100,7 +102,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
// ── POST /api/v1/admin/chargeable-chars ─────────────────────────────────── // ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
/// <summary> /// <summary>
/// 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}. /// Returns 201 Created with Location header pointing to GET /{id}.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
@@ -113,7 +115,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request) public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
{ {
var command = new CreateChargeableCharConfigCommand( var command = new CreateChargeableCharConfigCommand(
request.MedioId, request.ProductTypeId,
request.Symbol, request.Symbol,
request.Category, request.Category,
request.PricePerUnit, request.PricePerUnit,
@@ -183,13 +185,57 @@ public sealed class ChargeableCharConfigController : ControllerBase
new DeactivateChargeableCharConfigCommand(id)); new DeactivateChargeableCharConfigCommand(id));
return Ok(result); return Ok(result);
} }
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
/// <summary>
/// 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
/// </summary>
[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<IActionResult> Reactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
new ReactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<IActionResult> Delete([FromRoute] long id)
{
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
new DeleteChargeableCharConfigCommand(id));
return Ok(result);
}
} }
// ── Request body records ────────────────────────────────────────────────────── // ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary> /// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
public sealed record CreateChargeableCharConfigRequest( public sealed record CreateChargeableCharConfigRequest(
long? MedioId, long? ProductTypeId,
string Symbol, string Symbol,
string Category, string Category,
decimal PricePerUnit, decimal PricePerUnit,

View File

@@ -695,7 +695,7 @@ public sealed class ExceptionFilter : IExceptionFilter
{ {
error = "chargeable_char_forward_only", error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY", code = "CHARGEABLE_CHAR_FORWARD_ONLY",
medioId = forwardOnlyCharEx.MedioId, productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol, symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom, newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom, activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
@@ -707,6 +707,33 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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: case ValidationException validationEx:
var errors = validationEx.Errors var errors = validationEx.Errors
.GroupBy(e => e.PropertyName) .GroupBy(e => e.PropertyName)

View File

@@ -7,24 +7,24 @@ namespace SIGCM2.Application.Abstractions.Persistence;
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure. /// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
/// ///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically /// 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 /// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate. /// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
/// The Application service applies the per-medio > global priority rule. /// The Application service applies the per-ProductType > global priority rule.
/// </summary> /// </summary>
public interface IChargeableCharConfigRepository public interface IChargeableCharConfigRepository
{ {
/// <summary> /// <summary>
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope. /// 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. /// Returns the Id of the newly inserted row.
/// Throws: /// Throws:
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409 /// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard) /// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
/// </summary> /// </summary>
Task<long> InsertWithCloseAsync( Task<long> InsertWithCloseAsync(
long? medioId, long? productTypeId,
string symbol, string symbol,
string category, string category,
decimal price, decimal price,
@@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository
/// <summary> /// <summary>
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate /// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
/// for the specified medio, including global rows (MedioId IS NULL). /// for the specified ProductType, including global rows (ProductTypeId IS NULL).
/// The SP returns both per-medio AND global rows — callers apply priority. /// The SP returns both per-ProductType AND global rows — callers apply priority.
/// </summary> /// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync( Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long medioId, long productTypeId,
DateOnly asOfDate, DateOnly asOfDate,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary> /// <summary>
/// Returns paginated rows filtered by MedioId and IsActive. /// Returns paginated rows filtered by ProductTypeId and IsActive.
/// Skip = (page - 1) * pageSize computed by the caller. /// Skip = (page - 1) * pageSize computed by the caller.
/// </summary> /// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync( Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? medioId, long? productTypeId,
bool activeOnly, bool activeOnly,
int skip, int skip,
int take, int take,
@@ -56,7 +56,7 @@ public interface IChargeableCharConfigRepository
/// Returns total row count for the given filters (used for pagination metadata). /// Returns total row count for the given filters (used for pagination metadata).
/// </summary> /// </summary>
Task<int> CountAsync( Task<int> CountAsync(
long? medioId, long? productTypeId,
bool activeOnly, bool activeOnly,
CancellationToken ct = default); CancellationToken ct = default);
@@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository
long id, long id,
DateOnly today, DateOnly today,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<ChargeableCharConfig> ReactivateAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task DeleteAsync(
long id,
CancellationToken ct = default);
} }

View File

@@ -87,6 +87,8 @@ using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create; using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; 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.List;
using SIGCM2.Application.Pricing.ChargeableChars.GetById; using SIGCM2.Application.Pricing.ChargeableChars.GetById;
@@ -210,6 +212,8 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>(); services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>(); services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>(); services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>(); services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>(); services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();

View File

@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
/// </summary> /// </summary>
public sealed record ChargeableCharConfigDto( public sealed record ChargeableCharConfigDto(
long Id, long Id,
long? MedioId, long? ProductTypeId,
string Symbol, string Symbol,
string Category, string Category,
decimal PricePerUnit, decimal PricePerUnit,

View File

@@ -4,11 +4,11 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary> /// <summary>
/// PRC-001 — Implements IChargeableCharConfigService. /// PRC-001 — Implements IChargeableCharConfigService.
/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies /// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
/// the per-medio > global priority rule in memory. /// the per-ProductType > global priority rule in memory.
/// ///
/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and /// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application. /// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
/// </summary> /// </summary>
public sealed class ChargeableCharConfigService : IChargeableCharConfigService public sealed class ChargeableCharConfigService : IChargeableCharConfigService
{ {
@@ -20,22 +20,22 @@ public sealed class ChargeableCharConfigService : IChargeableCharConfigService
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync( public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long medioId, long productTypeId,
DateOnly asOf, DateOnly asOf,
CancellationToken ct = default) 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. // 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<string, ChargeableCharSnapshot>(StringComparer.Ordinal); var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
// Two-pass: first add global rows, then overwrite with per-medio rows. // Two-pass: first add global rows, then overwrite with per-ProductType rows.
foreach (var row in allRows.Where(r => r.MedioId is null)) foreach (var row in allRows.Where(r => r.ProductTypeId is null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit); 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); result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
return result; return result;

View File

@@ -2,10 +2,10 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary> /// <summary>
/// PRC-001 — Command to create a new ChargeableCharConfig. /// 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.
/// </summary> /// </summary>
public sealed record CreateChargeableCharConfigCommand( public sealed record CreateChargeableCharConfigCommand(
long? MedioId, long? ProductTypeId,
string Symbol, string Symbol,
string Category, string Category,
decimal PricePerUnit, decimal PricePerUnit,

View File

@@ -35,7 +35,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
TransactionScopeAsyncFlowOption.Enabled)) TransactionScopeAsyncFlowOption.Enabled))
{ {
newId = await _repo.InsertWithCloseAsync( newId = await _repo.InsertWithCloseAsync(
command.MedioId, command.ProductTypeId,
command.Symbol, command.Symbol,
command.Category, command.Category,
command.PricePerUnit, command.PricePerUnit,
@@ -49,7 +49,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
{ {
after = new after = new
{ {
command.MedioId, command.ProductTypeId,
command.Symbol, command.Symbol,
command.Category, command.Category,
command.PricePerUnit, command.PricePerUnit,

View File

@@ -54,7 +54,7 @@ public sealed class DeactivateChargeableCharConfigCommandHandler
{ {
id = existing.Id, id = existing.Id,
symbol = existing.Symbol, symbol = existing.Symbol,
medioId = existing.MedioId, productTypeId = existing.ProductTypeId,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"), validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
}, },
deactivatedOn = today.ToString("yyyy-MM-dd"), deactivatedOn = today.ToString("yyyy-MM-dd"),

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// 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.
/// </summary>
public sealed record DeleteChargeableCharConfigCommand(long Id);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class DeleteChargeableCharConfigCommandHandler
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
{
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<DeleteChargeableCharConfigResponse> 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);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// PRC-001 — Response for a successful delete operation.
/// </summary>
public sealed record DeleteChargeableCharConfigResponse(long Id);

View File

@@ -27,7 +27,7 @@ public sealed class GetChargeableCharConfigByIdQueryHandler
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id, c.Id,
c.MedioId, c.ProductTypeId,
c.Symbol, c.Symbol,
c.Category, c.Category,
c.PricePerUnit, c.PricePerUnit,

View File

@@ -1,21 +1,21 @@
namespace SIGCM2.Application.Pricing.ChargeableChars; namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary> /// <summary>
/// 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. /// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
/// </summary> /// </summary>
public interface IChargeableCharConfigService public interface IChargeableCharConfigService
{ {
/// <summary> /// <summary>
/// Returns the resolved active config for the given medio as of the given date. /// Returns the resolved active config for the given ProductType as of the given date.
/// Per-medio rows take priority over global rows for the same Symbol. /// Per-ProductType 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. /// 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. /// Returns an empty dictionary if no config exists at all.
/// </summary> /// </summary>
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync( Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long medioId, long productTypeId,
DateOnly asOf, DateOnly asOf,
CancellationToken ct = default); CancellationToken ct = default);
} }

View File

@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]). /// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
/// </summary> /// </summary>
public sealed record ListChargeableCharConfigQuery( public sealed record ListChargeableCharConfigQuery(
long? MedioId, long? ProductTypeId,
bool ActiveOnly, bool ActiveOnly,
int Page = 1, int Page = 1,
int PageSize = 20); int PageSize = 20);

View File

@@ -26,8 +26,8 @@ public sealed class ListChargeableCharConfigQueryHandler
var pageSize = Math.Clamp(query.PageSize, 1, 100); var pageSize = Math.Clamp(query.PageSize, 1, 100);
var skip = (page - 1) * pageSize; var skip = (page - 1) * pageSize;
var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize); var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly); var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
var dtos = items.Select(ToDto).ToList(); var dtos = items.Select(ToDto).ToList();
@@ -36,7 +36,7 @@ public sealed class ListChargeableCharConfigQueryHandler
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new( private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id, c.Id,
c.MedioId, c.ProductTypeId,
c.Symbol, c.Symbol,
c.Category, c.Category,
c.PricePerUnit, c.PricePerUnit,

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// 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).
/// </summary>
public sealed record ReactivateChargeableCharConfigCommand(long Id);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class ReactivateChargeableCharConfigCommandHandler
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
{
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<ReactivateChargeableCharConfigResponse> 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);
}
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// PRC-001 — Response for a successful reactivation.
/// Returns the current state of the row after it has been re-opened.
/// </summary>
public sealed record ReactivateChargeableCharConfigResponse(
long Id,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom,
bool IsActive);

View File

@@ -28,7 +28,7 @@ public sealed class SchedulePriceChangeCommandHandler
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command) public async Task<SchedulePriceChangeResponse> 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) var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe."); ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
@@ -44,7 +44,7 @@ public sealed class SchedulePriceChangeCommandHandler
TransactionScopeAsyncFlowOption.Enabled)) TransactionScopeAsyncFlowOption.Enabled))
{ {
newId = await _repo.InsertWithCloseAsync( newId = await _repo.InsertWithCloseAsync(
newEntity.MedioId, newEntity.ProductTypeId,
newEntity.Symbol, newEntity.Symbol,
newEntity.Category, newEntity.Category,
newEntity.PricePerUnit, newEntity.PricePerUnit,

View File

@@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary> /// <summary>
/// PRC-001 — Rich domain entity for chargeable character configuration. /// PRC-001 — Rich domain entity for chargeable character configuration.
/// Represents a price-per-occurrence for a special character in classified ad text, /// 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 /// 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 /// 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. /// 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).
/// </summary> /// </summary>
public sealed class ChargeableCharConfig public sealed class ChargeableCharConfig
{ {
public long Id { get; } public long Id { get; }
public int? MedioId { get; } public int? ProductTypeId { get; }
public string Symbol { get; } public string Symbol { get; }
public string Category { get; } public string Category { get; }
public decimal PricePerUnit { get; private set; } public decimal PricePerUnit { get; private set; }
@@ -25,11 +25,11 @@ public sealed class ChargeableCharConfig
public bool IsActive { get; private set; } public bool IsActive { get; private set; }
private ChargeableCharConfig( 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) decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
{ {
Id = id; Id = id;
MedioId = medioId; ProductTypeId = productTypeId;
Symbol = symbol; Symbol = symbol;
Category = category; Category = category;
PricePerUnit = price; PricePerUnit = price;
@@ -43,7 +43,7 @@ public sealed class ChargeableCharConfig
/// Id is set to 0 until the entity is persisted. /// Id is set to 0 until the entity is persisted.
/// </summary> /// </summary>
public static ChargeableCharConfig Create( 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)) if (string.IsNullOrWhiteSpace(symbol))
throw new ChargeableCharConfigInvalidException( throw new ChargeableCharConfigInvalidException(
@@ -61,7 +61,7 @@ public sealed class ChargeableCharConfig
throw new ChargeableCharConfigInvalidException( throw new ChargeableCharConfigInvalidException(
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other."); 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);
} }
/// <summary> /// <summary>
@@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set). /// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
/// </summary> /// </summary>
public static ChargeableCharConfig Rehydrate( 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) 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);
/// <summary> /// <summary>
/// Schedules a new price (forward-only semantics). /// 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})."); $"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
if (newValidFrom <= ValidFrom) 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 // Create validates price > 0 and category — reuse factory
return Create(MedioId, Symbol, Category, newPrice, newValidFrom); return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
} }
/// <summary> /// <summary>

View File

@@ -8,19 +8,19 @@ namespace SIGCM2.Domain.Pricing.Exceptions;
/// </summary> /// </summary>
public sealed class ChargeableCharConfigForwardOnlyException : DomainException public sealed class ChargeableCharConfigForwardOnlyException : DomainException
{ {
public int? MedioId { get; } public int? ProductTypeId { get; }
public string Symbol { get; } public string Symbol { get; }
public DateOnly NewValidFrom { get; } public DateOnly NewValidFrom { get; }
public DateOnly ActiveValidFrom { get; } public DateOnly ActiveValidFrom { get; }
public ChargeableCharConfigForwardOnlyException( public ChargeableCharConfigForwardOnlyException(
int? medioId, int? productTypeId,
string symbol, string symbol,
DateOnly newValidFrom, DateOnly newValidFrom,
DateOnly activeValidFrom) DateOnly activeValidFrom)
: base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).") : 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; Symbol = symbol;
NewValidFrom = newValidFrom; NewValidFrom = newValidFrom;
ActiveValidFrom = activeValidFrom; ActiveValidFrom = activeValidFrom;

View File

@@ -0,0 +1,29 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// 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)
/// </summary>
public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
{
public long Id { get; }
/// <summary>
/// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
/// </summary>
public string Reason { get; }
public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
: base($"Reactivation not allowed for config {id}: {reason}")
{
Id = id;
Reason = reason;
}
}

View File

@@ -11,16 +11,27 @@ namespace SIGCM2.Infrastructure.Persistence;
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig. /// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
/// ///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps: /// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found) /// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException /// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
/// ///
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio. /// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
/// Returns all rows (global + per-medio) — the Application service applies priority. /// 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 mapping: SQL DATE columns are received as DateTime by Dapper; converted via
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository. /// 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.
/// </summary> /// </summary>
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{ {
@@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/> /// <inheritdoc/>
public async Task<long> InsertWithCloseAsync( public async Task<long> InsertWithCloseAsync(
long? medioId, long? productTypeId,
string symbol, string symbol,
string category, string category,
decimal price, decimal price,
@@ -41,8 +52,8 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default) CancellationToken ct = default)
{ {
var p = new DynamicParameters(); var p = new DynamicParameters();
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021) // SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32); p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
p.Add("@Symbol", symbol, DbType.String, size: 4); p.Add("@Symbol", symbol, DbType.String, size: 4);
p.Add("@Category", category, DbType.String, size: 32); p.Add("@Category", category, DbType.String, size: 32);
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4); p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
@@ -64,16 +75,16 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
} }
catch (SqlException ex) when (ex.Number == 50404) 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( throw new ChargeableCharConfigInvalidException(
nameof(medioId), nameof(productTypeId),
$"Medio with Id={medioId} not found."); $"ProductType with Id={productTypeId} not found.");
} }
catch (SqlException ex) when (ex.Number == 50409) catch (SqlException ex) when (ex.Number == 50409)
{ {
// Forward-only violation: new ValidFrom <= active.ValidFrom // Forward-only violation: new ValidFrom <= active.ValidFrom
throw new ChargeableCharConfigForwardOnlyException( throw new ChargeableCharConfigForwardOnlyException(
medioId.HasValue ? (int?)checked((int)medioId.Value) : null, productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
symbol, symbol,
validFrom, validFrom,
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
@@ -83,14 +94,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync( public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long medioId, long productTypeId,
DateOnly asOfDate, DateOnly asOfDate,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var p = new DynamicParameters(); var p = new DynamicParameters();
// SP @MedioId is INT // SP @ProductTypeId is INT
p.Add("@MedioId", checked((int)medioId), DbType.Int32); p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date); p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
await using var connection = _factory.CreateConnection(); await using var connection = _factory.CreateConnection();
@@ -98,7 +109,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
var rows = await connection.QueryAsync<ChargeableCharConfigRow>( var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition( new CommandDefinition(
"dbo.usp_ChargeableCharConfig_GetActiveForMedio", "dbo.usp_ChargeableCharConfig_GetActiveForProductType",
p, p,
commandType: CommandType.StoredProcedure, commandType: CommandType.StoredProcedure,
cancellationToken: ct)); cancellationToken: ct));
@@ -108,20 +119,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/> /// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync( public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? medioId, long? productTypeId,
bool activeOnly, bool activeOnly,
int skip, int skip,
int take, int take,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// NULL-aware MedioId filter: // NULL-aware ProductTypeId filter:
// - medioId provided → filter to that medio only // - productTypeId provided → filter to that ProductType only
// - medioId null → return all rows regardless of medio // - productTypeId null → return all rows regardless of ProductType
// activeOnly filters by IsActive = 1. // activeOnly filters by IsActive = 1.
const string sql = """ const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId) WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1) AND (@ActiveOnly = 0 OR IsActive = 1)
ORDER BY ValidFrom DESC, Id DESC ORDER BY ValidFrom DESC, Id DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
@@ -135,7 +146,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql, sql,
new new
{ {
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0, ActiveOnly = activeOnly ? 1 : 0,
Skip = skip, Skip = skip,
Take = take Take = take
@@ -147,14 +158,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/> /// <inheritdoc/>
public async Task<int> CountAsync( public async Task<int> CountAsync(
long? medioId, long? productTypeId,
bool activeOnly, bool activeOnly,
CancellationToken ct = default) CancellationToken ct = default)
{ {
const string sql = """ const string sql = """
SELECT COUNT(1) SELECT COUNT(1)
FROM dbo.ChargeableCharConfig FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId) WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1) AND (@ActiveOnly = 0 OR IsActive = 1)
"""; """;
@@ -166,7 +177,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql, sql,
new new
{ {
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0 ActiveOnly = activeOnly ? 1 : 0
}, },
cancellationToken: ct)); cancellationToken: ct));
@@ -178,7 +189,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default) CancellationToken ct = default)
{ {
const string sql = """ const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig FROM dbo.ChargeableCharConfig
WHERE Id = @Id WHERE Id = @Id
"""; """;
@@ -221,6 +232,73 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
cancellationToken: ct)); cancellationToken: ct));
} }
/// <inheritdoc/>
public async Task<ChargeableCharConfig> 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).");
}
/// <inheritdoc/>
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 ──────────────────────────────────────────────────────────── // ── Row mapper ────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here. // Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
// Same pattern as ProductPriceRepository. // Same pattern as ProductPriceRepository.
@@ -228,7 +306,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r) private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
=> ChargeableCharConfig.Rehydrate( => ChargeableCharConfig.Rehydrate(
id: r.Id, id: r.Id,
medioId: r.MedioId, productTypeId: r.ProductTypeId,
symbol: r.Symbol, symbol: r.Symbol,
category: r.Category, category: r.Category,
price: r.PricePerUnit, price: r.PricePerUnit,
@@ -238,7 +316,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
private sealed record ChargeableCharConfigRow( private sealed record ChargeableCharConfigRow(
long Id, long Id,
int? MedioId, int? ProductTypeId,
string Symbol, string Symbol,
string Category, string Category,
decimal PricePerUnit, decimal PricePerUnit,

View File

@@ -26,6 +26,8 @@ namespace SIGCM2.Api.Tests.Pricing.ChargeableChars;
/// POST /api/v1/admin/chargeable-chars /// POST /api/v1/admin/chargeable-chars
/// PUT /api/v1/admin/chargeable-chars/{id}/price /// PUT /api/v1/admin/chargeable-chars/{id}/price
/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate /// 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). /// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission. /// All mutations require 'tasacion:caracteres_especiales:gestionar' permission.
@@ -84,20 +86,20 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
/// <summary>Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.</summary> /// <summary>Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.</summary>
private async Task<long> SeedConfigDirectAsync( private async Task<long> SeedConfigDirectAsync(
long? medioId, string symbol, string category, decimal pricePerUnit, long? productTypeId, string symbol, string category, decimal pricePerUnit,
DateOnly validFrom, DateOnly? validTo, bool isActive = true) DateOnly validFrom, DateOnly? validTo, bool isActive = true)
{ {
await using var conn = new SqlConnection(ConnectionString); await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync(); await conn.OpenAsync();
return await conn.QuerySingleAsync<long>(""" return await conn.QuerySingleAsync<long>("""
INSERT INTO dbo.ChargeableCharConfig INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion) (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME()); VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS BIGINT); SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""", """,
new new
{ {
MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value, ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value,
Symbol = symbol, Symbol = symbol,
Category = category, Category = category,
PricePerUnit = pricePerUnit, PricePerUnit = pricePerUnit,
@@ -119,8 +121,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
[Fact] [Fact]
public async Task Get_List_ReturnsPagedResult() public async Task Get_List_ReturnsPagedResult()
{ {
// Seed 2 active rows with unique symbols to avoid conflicts // Seed an active row with unique symbol to avoid conflicts
var sym1 = $"L{Guid.NewGuid():N}"[..1];
await SeedConfigDirectAsync(null, "§", "Currency", 1.50m, await SeedConfigDirectAsync(null, "§", "Currency", 1.50m,
new DateOnly(2026, 1, 1), null, true); 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", using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new body: new
{ {
medioId = (long?)null, productTypeId = (long?)null,
symbol = "¥", symbol = "¥",
category = "Currency", category = "Currency",
pricePerUnit = 1.75m, pricePerUnit = 1.75m,
@@ -221,7 +222,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
public async Task Post_Unauthenticated_Returns401() public async Task Post_Unauthenticated_Returns401()
{ {
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
} }
@@ -232,7 +233,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{ {
var token = GetCajeroToken(); var token = GetCajeroToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
@@ -244,7 +245,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{ {
var token = GetAdminToken(); var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -256,7 +257,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{ {
var token = GetAdminToken(); var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -268,7 +269,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{ {
var token = GetAdminToken(); var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); 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). // "😀" 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. // 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", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
// Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test. // 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); resp.StatusCode.Should().Be(HttpStatusCode.OK);
} }
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
/// <summary>PRC-001 — PATCH reactivate on last closed row returns 200.</summary>
[Fact]
public async Task Patch_Reactivate_LastClosed_Returns200()
{
// Seed a closed row (isActive=false) — no other active row for this symbol
// Use a unique symbol to avoid conflicts with other tests
var uniqueSymbol = $"R{Guid.NewGuid():N}"[..1];
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Patch,
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("id").GetInt64().Should().Be(id);
body.GetProperty("isActive").GetBoolean().Should().BeTrue();
}
/// <summary>PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE.</summary>
[Fact]
public async Task Patch_Reactivate_AlreadyActive_Returns409_AlreadyActive()
{
var uniqueSymbol = $"A{Guid.NewGuid():N}"[..1];
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), null, true); // already active
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Patch,
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE");
}
/// <summary>PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS.</summary>
[Fact]
public async Task Patch_Reactivate_VigenteExists_Returns409_VigenteExists()
{
// Seed: one active (vigente) row + one closed row for the same symbol
var uniqueSymbol = $"V{Guid.NewGuid():N}"[..1];
// First: closed row (older)
var closedId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
// Second: active vigente row (newer — this blocks reactivation of closedId)
await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
new DateOnly(2026, 4, 1), null, true);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Patch,
$"/api/v1/admin/chargeable-chars/{closedId}/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS");
}
/// <summary>PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST.</summary>
[Fact]
public async Task Patch_Reactivate_PosteriorRowsExist_Returns409_PosteriorRowsExist()
{
// Seed: target closed row + a posterior (newer ValidFrom) closed row for the same symbol
var uniqueSymbol = $"P{Guid.NewGuid():N}"[..1];
// First: target closed row
var targetId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
// Second: posterior closed row (newer ValidFrom, also closed — blocks reactivation)
await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
new DateOnly(2026, 4, 1), new DateOnly(2026, 6, 30), false);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Patch,
$"/api/v1/admin/chargeable-chars/{targetId}/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST");
}
/// <summary>PRC-001 — PATCH reactivate without auth returns 401.</summary>
[Fact]
public async Task Patch_Reactivate_Unauthorized_Returns401()
{
using var req = BuildRequest(HttpMethod.Patch,
"/api/v1/admin/chargeable-chars/1/reactivate");
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
/// <summary>PRC-001 — PATCH reactivate without permission returns 403.</summary>
[Fact]
public async Task Patch_Reactivate_WithoutPermission_Returns403()
{
var token = GetCajeroToken();
using var req = BuildRequest(HttpMethod.Patch,
"/api/v1/admin/chargeable-chars/1/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
/// <summary>PRC-001 — PATCH reactivate emits audit event.</summary>
[Fact]
public async Task Patch_Reactivate_AuditEventEmitted()
{
var uniqueSymbol = $"Q{Guid.NewGuid():N}"[..1];
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Patch,
$"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK,
because: "PATCH reactivate must succeed before checking audit");
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var auditCount = await conn.QuerySingleAsync<int>("""
SELECT COUNT(1) FROM dbo.AuditEvent
WHERE Action = 'tasacion.chargeable_char.reactivate'
AND TargetType = 'ChargeableCharConfig'
AND TargetId = @TargetId
""", new { TargetId = id.ToString() });
auditCount.Should().Be(1,
because: "IAuditLogger must record tasacion.chargeable_char.reactivate after successful PATCH");
}
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
/// <summary>PRC-001 — DELETE existing row returns 200.</summary>
[Fact]
public async Task Delete_Existing_Returns200()
{
var uniqueSymbol = $"D{Guid.NewGuid():N}"[..1];
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), null, true);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Delete,
$"/api/v1/admin/chargeable-chars/{id}", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("id").GetInt64().Should().Be(id);
}
/// <summary>PRC-001 — DELETE non-existent row returns 404.</summary>
[Fact]
public async Task Delete_NotFound_Returns404()
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Delete,
"/api/v1/admin/chargeable-chars/999999998", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
/// <summary>PRC-001 — DELETE emits audit event.</summary>
[Fact]
public async Task Delete_AuditEventEmitted()
{
var uniqueSymbol = $"E{Guid.NewGuid():N}"[..1];
var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
new DateOnly(2026, 1, 1), null, true);
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Delete,
$"/api/v1/admin/chargeable-chars/{id}", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.OK,
because: "DELETE must succeed before checking audit");
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var auditCount = await conn.QuerySingleAsync<int>("""
SELECT COUNT(1) FROM dbo.AuditEvent
WHERE Action = 'tasacion.chargeable_char.delete'
AND TargetType = 'ChargeableCharConfig'
AND TargetId = @TargetId
""", new { TargetId = id.ToString() });
auditCount.Should().Be(1,
because: "IAuditLogger must record tasacion.chargeable_char.delete after successful DELETE");
}
/// <summary>PRC-001 — DELETE without auth returns 401.</summary>
[Fact]
public async Task Delete_Unauthorized_Returns401()
{
using var req = BuildRequest(HttpMethod.Delete,
"/api/v1/admin/chargeable-chars/1");
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
/// <summary>PRC-001 — DELETE without permission returns 403.</summary>
[Fact]
public async Task Delete_WithoutPermission_Returns403()
{
var token = GetCajeroToken();
using var req = BuildRequest(HttpMethod.Delete,
"/api/v1/admin/chargeable-chars/1", token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
// ── Audit ───────────────────────────────────────────────────────────────── // ── Audit ─────────────────────────────────────────────────────────────────
/// <summary>PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.</summary> /// <summary>PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.</summary>
@@ -386,7 +610,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
var validFrom = TomorrowStr(); var validFrom = TomorrowStr();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars", 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); token: token);
var resp = await _client.SendAsync(req); var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created, resp.StatusCode.Should().Be(HttpStatusCode.Created,
@@ -502,7 +726,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = JsonContent.Create(new req.Content = JsonContent.Create(new
{ {
medioId = (long?)null, productTypeId = (long?)null,
symbol = "←", symbol = "←",
category = "Currency", category = "Currency",
pricePerUnit = 1.50m, pricePerUnit = 1.50m,

View File

@@ -0,0 +1,32 @@
using FluentAssertions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Unit tests for ChargeableCharConfigReactivationNotAllowedException.
/// </summary>
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<SIGCM2.Domain.Exceptions.DomainException>();
}
}

View File

@@ -95,15 +95,15 @@ public sealed class ChargeableCharConfigTests
entity.Category.Should().Be("Currency"); entity.Category.Should().Be("Currency");
entity.PricePerUnit.Should().Be(1.5m); entity.PricePerUnit.Should().Be(1.5m);
entity.ValidFrom.Should().Be(Today); entity.ValidFrom.Should().Be(Today);
entity.MedioId.Should().BeNull(); entity.ProductTypeId.Should().BeNull();
} }
[Fact] [Fact]
public void Create_WithMedioId_SetsCorrectly() public void Create_WithProductTypeId_SetsCorrectly()
{ {
var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today); var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today);
entity.MedioId.Should().Be(5); entity.ProductTypeId.Should().Be(5);
} }
[Fact] [Fact]
@@ -218,11 +218,11 @@ public sealed class ChargeableCharConfigTests
{ {
// Rehydrate can create entities that would fail Create (e.g., IsActive=false) // Rehydrate can create entities that would fail Create (e.g., IsActive=false)
var entity = ChargeableCharConfig.Rehydrate( 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); price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false);
entity.Id.Should().Be(42); entity.Id.Should().Be(42);
entity.MedioId.Should().Be(5); entity.ProductTypeId.Should().Be(5);
entity.Symbol.Should().Be("$"); entity.Symbol.Should().Be("$");
entity.Category.Should().Be("Currency"); entity.Category.Should().Be("Currency");
entity.PricePerUnit.Should().Be(1.5m); entity.PricePerUnit.Should().Be(1.5m);

View File

@@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Seed two dedicated ProductTypes for override/fallback resolution tests. // Seed two dedicated ProductTypes for override/fallback resolution tests.
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
_productType1Id = await conn.ExecuteScalarAsync<int>(""" _productType1Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive) INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id OUTPUT INSERTED.Id
VALUES ('Hardening PT1 (override)', 1) VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
"""); """);
_productType2Id = await conn.ExecuteScalarAsync<int>(""" _productType2Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive) INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id 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 // 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 SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit.
// The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205). // 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] [Fact]
@@ -94,7 +94,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await conn.OpenAsync(); await conn.OpenAsync();
var p = new DynamicParameters(); 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("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String); p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4); p.Add("@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(PT1, today) → '$' = 5.00 (per-PT override wins)
// GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback) // 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] [Fact]
@@ -297,13 +293,13 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Build the repository + service (C# method will be renamed in Agent 2) // Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository(); 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 // The per-PT '$' must be returned
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it"); 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"); "the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m, 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 // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync
// provides '$' at global price (0.0000 after V024). // provides '$' at global price (0.0000 after V024).
var repo = BuildRepository(); 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 // Must have at least the global '$' from seed
rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); 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 == "$"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)"); 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)"); "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)); await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
// Build the service (wraps repo with priority resolution) // Build the service (wraps repo with priority resolution)
// TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync
var service = BuildService(); var service = BuildService();
var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2 var pt1Config = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf);
var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2 var pt2Config = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf);
// ProductType1: '%' must come from per-PT override at 3.00 // ProductType1: '%' must come from per-PT override at 3.00
pt1Config.Should().ContainKey("%", pt1Config.Should().ContainKey("%",

View File

@@ -16,9 +16,8 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync().
/// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating. /// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating.
/// ///
/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync /// V023 scope delta: MedioId → ProductTypeId. Uses dbo.ProductType for per-PT override tests.
/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor). /// Uses unique name "RepoIntegration PT1" to avoid uniqueness conflicts with HardeningTests.
/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected.
/// ///
/// Spec coverage: /// Spec coverage:
/// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id /// 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.10 GetByIdAsync — exists → returns entity
/// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today /// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today
/// T4.12 DeactivateAsync — already inactive → idempotent (no-op) /// 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
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{ {
private readonly SqlTestFixture _db; private readonly SqlTestFixture _db;
private int _medioId; private int _productTypeId;
public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db) public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db)
{ {
@@ -49,14 +52,15 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{ {
await _db.ResetAndSeedAsync(); 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 using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync(); await conn.OpenAsync();
_medioId = await conn.ExecuteScalarAsync<int>(""" _productTypeId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id OUTPUT INSERTED.Id
VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1) VALUES ('RepoIntegration PT1', 'RI_PT1', 1)
"""); """);
} }
@@ -69,12 +73,12 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
[Fact] [Fact]
public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId() 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 = "@"; const string symbol = "@";
var repo = BuildRepository(); var repo = BuildRepository();
var newId = await repo.InsertWithCloseAsync( var newId = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Other", category: "Other",
price: 2.5000m, price: 2.5000m,
@@ -108,7 +112,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// First insert — becomes the vigente // First insert — becomes the vigente
var firstId = await repo.InsertWithCloseAsync( var firstId = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Other", category: "Other",
price: 1.0000m, price: 1.0000m,
@@ -117,7 +121,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Second insert (forward) — must close the first // Second insert (forward) — must close the first
var secondValidFrom = new DateOnly(2026, 6, 1); var secondValidFrom = new DateOnly(2026, 6, 1);
var secondId = await repo.InsertWithCloseAsync( var secondId = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Other", category: "Other",
price: 2.0000m, price: 2.0000m,
@@ -157,7 +161,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Establish a vigente at 2026-04-01 // Establish a vigente at 2026-04-01
await repo.InsertWithCloseAsync( await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Currency", category: "Currency",
price: 1.5000m, price: 1.5000m,
@@ -165,7 +169,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Try to insert retroactively — SP will THROW 50409 // Try to insert retroactively — SP will THROW 50409
var act = async () => await repo.InsertWithCloseAsync( var act = async () => await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Currency", category: "Currency",
price: 1.2000m, price: 1.2000m,
@@ -188,7 +192,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Insert the first row // Insert the first row
var firstId = await repo.InsertWithCloseAsync( var firstId = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Currency", category: "Currency",
price: 3.0000m, price: 3.0000m,
@@ -196,7 +200,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Insert a second row — this triggers an UPDATE on the first row → history // Insert a second row — this triggers an UPDATE on the first row → history
await repo.InsertWithCloseAsync( await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: symbol, symbol: symbol,
category: "Currency", category: "Currency",
price: 4.0000m, price: 4.0000m,
@@ -214,64 +218,61 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows // T4.5 — GetActiveForProductTypeAsync: PT has override → returns both PT and global rows
// Note: SP returns ALL rows (global + per-medio); service does priority resolution. // Note: SP returns ALL rows (global + per-PT); service does priority resolution.
// This test verifies the REPOSITORY returns both, not just one. // This test verifies the REPOSITORY returns both, not just one.
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows() public async Task GetActiveForProductTypeAsync_PTHasOverride_ReturnsBothPTAndGlobalRows()
{ {
var repo = BuildRepository(); var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1); 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 // Canonical seed already has global '$' from ResetAndSeedAsync
await repo.InsertWithCloseAsync( await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: "$", symbol: "$",
category: "Currency", category: "Currency",
price: 5.0000m, price: 5.0000m,
validFrom: new DateOnly(2026, 1, 1)); 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 // The SP returns both the per-PT '$' AND global rows for other symbols
// (at minimum: global '$' was replaced by per-medio; other globals still present)
// SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio.
// So we should get exactly one row per symbol that is active as of asOf.
rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed"); rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed");
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("the SP must return a row for '$'"); dollarRow.Should().NotBeNull("the SP must return a row for '$'");
dollarRow!.MedioId.Should().Be(_medioId, dollarRow!.ProductTypeId.Should().Be(_productTypeId,
"per-medio row takes priority over global in the SP's ROW_NUMBER ordering"); "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] [Fact]
public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows() public async Task GetActiveForProductTypeAsync_NoPTOverride_ReturnsOnlyGlobalRows()
{ {
var repo = BuildRepository(); var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1); 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 using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync(); await conn.OpenAsync();
var otherMedioId = await conn.ExecuteScalarAsync<int>(""" var otherPTId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id 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().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01");
rows.Should().AllSatisfy(r => 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(); var repo = BuildRepository();
// Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2). // 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 page1 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 0, take: 2);
var page2 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 2, 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"); page1.Should().HaveCount(2, "take=2 with at least 4 rows");
page2.Should().HaveCount(2, "second page of 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() public async Task ListAsync_PageBeyondTotal_ReturnsEmpty()
{ {
var repo = BuildRepository(); 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"); result.Should().BeEmpty("skip far beyond available data must return empty");
} }
@@ -313,8 +314,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository(); var repo = BuildRepository();
// Canonical seed: 4 active global rows // Canonical seed: 4 active global rows
var countAll = await repo.CountAsync(medioId: null, activeOnly: false); var countAll = await repo.CountAsync(productTypeId: null, activeOnly: false);
var countActive = await repo.CountAsync(medioId: null, activeOnly: true); var countActive = await repo.CountAsync(productTypeId: null, activeOnly: true);
countAll.Should().BeGreaterThanOrEqualTo(4, countAll.Should().BeGreaterThanOrEqualTo(4,
"canonical seed provides at least 4 rows (may have more if other tests ran)"); "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 // Insert a row, then deactivate it — active count should decrease by 1
var id = await repo.InsertWithCloseAsync( var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: "~", symbol: "~",
category: "Other", category: "Other",
price: 1.0000m, price: 1.0000m,
validFrom: new DateOnly(2026, 1, 1)); validFrom: new DateOnly(2026, 1, 1));
var today = new DateOnly(2026, 4, 20); 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); 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, afterDeactivate.Should().Be(beforeDeactivate - 1,
"deactivating one row must decrease the active count by 1"); "deactivating one row must decrease the active count by 1");
@@ -371,7 +372,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var expectedValidFrom = new DateOnly(2026, 2, 1); var expectedValidFrom = new DateOnly(2026, 2, 1);
var id = await repo.InsertWithCloseAsync( var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: "^", symbol: "^",
category: "Other", category: "Other",
price: 7.5000m, price: 7.5000m,
@@ -381,7 +382,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
entity.Should().NotBeNull(); entity.Should().NotBeNull();
entity!.Id.Should().Be(id); entity!.Id.Should().Be(id);
entity.MedioId.Should().Be(_medioId); entity.ProductTypeId.Should().Be(_productTypeId);
entity.Symbol.Should().Be("^"); entity.Symbol.Should().Be("^");
entity.Category.Should().Be("Other"); entity.Category.Should().Be("Other");
entity.PricePerUnit.Should().Be(7.5000m); entity.PricePerUnit.Should().Be(7.5000m);
@@ -400,7 +401,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository(); var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync( var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: "&", symbol: "&",
category: "Other", category: "Other",
price: 1.0000m, price: 1.0000m,
@@ -426,7 +427,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository(); var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync( var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId, productTypeId: (long?)_productTypeId,
symbol: "*", symbol: "*",
category: "Other", category: "Other",
price: 1.0000m, price: 1.0000m,
@@ -447,6 +448,101 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
entity.ValidTo.Should().Be(today); 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<ChargeableCharConfigReactivationNotAllowedException>()
.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<KeyNotFoundException>(
"non-existent Id must throw KeyNotFoundException");
}
// ── Helper ─────────────────────────────────────────────────────────────── // ── Helper ───────────────────────────────────────────────────────────────
private static ChargeableCharConfigRepository BuildRepository() private static ChargeableCharConfigRepository BuildRepository()

View File

@@ -26,57 +26,57 @@ public class ChargeableCharConfigServiceTests
private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) => private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) =>
ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) => private static ChargeableCharConfig ProductTypeConfig(long id, int productTypeId, string symbol, decimal price) =>
ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); ChargeableCharConfig.Rehydrate(id, productTypeId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
// ── Global fallback ───────────────────────────────────────────────────────── // ── Global fallback ─────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs() public async Task GetActiveConfig_NoPerProductType_ReturnsGlobalConfigs()
{ {
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>()) _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig> .Returns(new List<ChargeableCharConfig>
{ {
GlobalConfig("$", 1.0m), GlobalConfig("$", 1.0m),
GlobalConfig("%", 0.5m), 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.Should().ContainKey("$");
result["$"].PricePerUnit.Should().Be(1.0m); result["$"].PricePerUnit.Should().Be(1.0m);
result.Should().ContainKey("%"); result.Should().ContainKey("%");
} }
// ── Per-medio wins over global ─────────────────────────────────────────────── // ── Per-ProductType wins over global ─────────────────────────────────────────
[Fact] [Fact]
public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol() public async Task GetActiveConfig_PerProductTypeExists_OverridesGlobalForSameSymbol()
{ {
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<CancellationToken>()) _repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig> .Returns(new List<ChargeableCharConfig>
{ {
GlobalConfig("$", 1.0m), // global price = 1.0 GlobalConfig("$", 1.0m), // global price = 1.0
MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins ProductTypeConfig(20L, 5, "$", 3.0m), // per-PT price = 3.0 → wins
GlobalConfig("%", 0.5m), // global only 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 result["%"].PricePerUnit.Should().Be(0.5m); // global only
} }
[Fact] [Fact]
public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory() public async Task GetActiveConfig_PerProductTypeExists_IncludesCorrectCategory()
{ {
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<CancellationToken>()) _repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig> .Returns(new List<ChargeableCharConfig>
{ {
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); result["$"].Category.Should().Be(ChargeableCharCategories.Currency);
} }
@@ -86,10 +86,10 @@ public class ChargeableCharConfigServiceTests
[Fact] [Fact]
public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary() public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary()
{ {
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>()) _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig>()); .Returns(new List<ChargeableCharConfig>());
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
result.Should().BeEmpty(); result.Should().BeEmpty();
} }
@@ -99,13 +99,13 @@ public class ChargeableCharConfigServiceTests
[Fact] [Fact]
public async Task GetActiveConfig_KeyIsSymbol() public async Task GetActiveConfig_KeyIsSymbol()
{ {
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>()) _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig> .Returns(new List<ChargeableCharConfig>
{ {
GlobalConfig("!", 2.0m), 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.Should().ContainKey("!");
result["!"].PricePerUnit.Should().Be(2.0m); result["!"].PricePerUnit.Should().Be(2.0m);

View File

@@ -24,7 +24,7 @@ public class CreateChargeableCharConfigCommandValidatorTests
} }
private static CreateChargeableCharConfigCommand ValidCmd() => new( private static CreateChargeableCharConfigCommand ValidCmd() => new(
MedioId: null, ProductTypeId: null,
Symbol: "$", Symbol: "$",
Category: ChargeableCharCategories.Currency, Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.0m, PricePerUnit: 1.0m,

View File

@@ -37,7 +37,7 @@ public class CreateChargeableCharConfigHandlerTests
} }
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new( private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
MedioId: null, ProductTypeId: null,
Symbol: "$", Symbol: "$",
Category: ChargeableCharCategories.Currency, Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.5m, PricePerUnit: 1.5m,
@@ -80,9 +80,9 @@ public class CreateChargeableCharConfigHandlerTests
} }
[Fact] [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); await _handler.Handle(cmd);

View File

@@ -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;
/// <summary>
/// PRC-001 — DeleteChargeableCharConfigCommandHandler tests.
/// Strict TDD — RED written before implementation.
/// Covers: happy path, not-found, audit emission, audit fail-closed.
/// </summary>
public class DeleteChargeableCharConfigHandlerTests
{
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>())
.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<CancellationToken>());
}
[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<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Not found ───────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException()
{
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
.Returns((ChargeableCharConfig?)null);
var act = async () => await _handler.Handle(new DeleteChargeableCharConfigCommand(Id: 99L));
await act.Should().ThrowAsync<KeyNotFoundException>();
}
// ── Audit fail → rollback (fail-closed) ─────────────────────────────────────
[Fact]
public async Task Handle_AuditThrows_ExceptionPropagates()
{
_audit.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit down"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Audit down");
}
[Fact]
public async Task Handle_AuditThrows_DeleteWasCalled_TransactionNotCompleted()
{
_audit.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit down"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>();
await _repo.Received(1).DeleteAsync(1L, Arg.Any<CancellationToken>());
}
}

View File

@@ -43,7 +43,7 @@ public class ListChargeableCharConfigHandlerTests
_repo.CountAsync(null, true, Arg.Any<CancellationToken>()) _repo.CountAsync(null, true, Arg.Any<CancellationToken>())
.Returns(2); .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); var result = await _handler.Handle(query);
result.Items.Should().HaveCount(2); result.Items.Should().HaveCount(2);
@@ -104,7 +104,7 @@ public class ListChargeableCharConfigHandlerTests
} }
[Fact] [Fact]
public async Task Handle_FiltersByMedioId_WhenProvided() public async Task Handle_FiltersByProductTypeId_WhenProvided()
{ {
_repo.ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>()) _repo.ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>())
.Returns(new List<ChargeableCharConfig>()); .Returns(new List<ChargeableCharConfig>());

View File

@@ -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;
/// <summary>
/// PRC-001 — ReactivateChargeableCharConfigCommandHandler tests.
/// Strict TDD — RED written before implementation.
/// Covers: happy path, audit emission, audit fail-closed, repo exception propagation.
/// </summary>
public class ReactivateChargeableCharConfigHandlerTests
{
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>())
.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<CancellationToken>());
}
[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<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Guard failures propagate ────────────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyActive_ThrowsReactivationNotAllowed()
{
_repo.ReactivateAsync(2L, Arg.Any<CancellationToken>())
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(2L, "ALREADY_ACTIVE"));
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 2L));
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
.Where(e => e.Reason == "ALREADY_ACTIVE");
}
[Fact]
public async Task Handle_VigenteExists_ThrowsReactivationNotAllowed()
{
_repo.ReactivateAsync(3L, Arg.Any<CancellationToken>())
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(3L, "VIGENTE_EXISTS"));
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 3L));
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
.Where(e => e.Reason == "VIGENTE_EXISTS");
}
[Fact]
public async Task Handle_PosteriorRowsExist_ThrowsReactivationNotAllowed()
{
_repo.ReactivateAsync(4L, Arg.Any<CancellationToken>())
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(4L, "POSTERIOR_ROWS_EXIST"));
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 4L));
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
.Where(e => e.Reason == "POSTERIOR_ROWS_EXIST");
}
// ── Audit fail → rollback (fail-closed) ─────────────────────────────────────
[Fact]
public async Task Handle_AuditThrows_ExceptionPropagates()
{
_audit.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit down"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Audit down");
}
[Fact]
public async Task Handle_AuditThrows_ReactivateWasCalled_TransactionNotCompleted()
{
_audit.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit down"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>();
await _repo.Received(1).ReactivateAsync(1L, Arg.Any<CancellationToken>());
}
}