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

View File

@@ -695,7 +695,7 @@ public sealed class ExceptionFilter : IExceptionFilter
{
error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
medioId = forwardOnlyCharEx.MedioId,
productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
@@ -707,6 +707,33 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_reactivation_not_allowed",
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
id = reactivationEx.Id,
reason = reactivationEx.Reason,
message = reactivationEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case KeyNotFoundException keyNotFoundEx:
context.Result = new ObjectResult(new
{
error = "not_found",
message = keyNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)

View File

@@ -7,24 +7,24 @@ namespace SIGCM2.Application.Abstractions.Persistence;
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
/// closes any active row for (MedioId, Symbol) and inserts the new row.
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
///
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns
/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate.
/// The Application service applies the per-medio > global priority rule.
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
/// The Application service applies the per-ProductType > global priority rule.
/// </summary>
public interface IChargeableCharConfigRepository
{
/// <summary>
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
/// Closes any active row matching (MedioId, Symbol) and inserts a new one.
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
/// Returns the Id of the newly inserted row.
/// Throws:
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
/// </summary>
Task<long> InsertWithCloseAsync(
long? medioId,
long? productTypeId,
string symbol,
string category,
decimal price,
@@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository
/// <summary>
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
/// for the specified medio, including global rows (MedioId IS NULL).
/// The SP returns both per-medio AND global rows — callers apply priority.
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
/// The SP returns both per-ProductType AND global rows — callers apply priority.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
long medioId,
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default);
/// <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.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? medioId,
long? productTypeId,
bool activeOnly,
int skip,
int take,
@@ -56,7 +56,7 @@ public interface IChargeableCharConfigRepository
/// Returns total row count for the given filters (used for pagination metadata).
/// </summary>
Task<int> CountAsync(
long? medioId,
long? productTypeId,
bool activeOnly,
CancellationToken ct = default);
@@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository
long id,
DateOnly today,
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.SchedulePrice;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
@@ -210,6 +212,8 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
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<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();

View File

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

View File

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

View File

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

View File

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

View File

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

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(
c.Id,
c.MedioId,
c.ProductTypeId,
c.Symbol,
c.Category,
c.PricePerUnit,

View File

@@ -1,21 +1,21 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <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.
/// </summary>
public interface IChargeableCharConfigService
{
/// <summary>
/// Returns the resolved active config for the given medio as of the given date.
/// Per-medio rows take priority over global rows for the same Symbol.
/// Global rows are used as fallback when no per-medio row exists for that Symbol.
/// Returns the resolved active config for the given ProductType as of the given date.
/// Per-ProductType rows take priority over global rows for the same Symbol.
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
/// Returns an empty dictionary if no config exists at all.
/// </summary>
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
long medioId,
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long productTypeId,
DateOnly asOf,
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]).
/// </summary>
public sealed record ListChargeableCharConfigQuery(
long? MedioId,
long? ProductTypeId,
bool ActiveOnly,
int Page = 1,
int PageSize = 20);

View File

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

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)
{
// 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
@@ -44,7 +44,7 @@ public sealed class SchedulePriceChangeCommandHandler
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
newEntity.MedioId,
newEntity.ProductTypeId,
newEntity.Symbol,
newEntity.Category,
newEntity.PricePerUnit,

View File

@@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Rich domain entity for chargeable character configuration.
/// Represents a price-per-occurrence for a special character in classified ad text,
/// scoped to a Medio (MedioId) or global (MedioId = null).
/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null).
///
/// Forward-only price history: each new price schedules a NEW row; the current row
/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
/// this instance — it returns a new one. The actual close+insert happens in the repository.
///
/// MedioId = null → global default (lowest priority, overridden by per-medio row).
/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row).
/// </summary>
public sealed class ChargeableCharConfig
{
public long Id { get; }
public int? MedioId { get; }
public int? ProductTypeId { get; }
public string Symbol { get; }
public string Category { get; }
public decimal PricePerUnit { get; private set; }
@@ -25,11 +25,11 @@ public sealed class ChargeableCharConfig
public bool IsActive { get; private set; }
private ChargeableCharConfig(
long id, int? medioId, string symbol, string category,
long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
{
Id = id;
MedioId = medioId;
ProductTypeId = productTypeId;
Symbol = symbol;
Category = category;
PricePerUnit = price;
@@ -43,7 +43,7 @@ public sealed class ChargeableCharConfig
/// Id is set to 0 until the entity is persisted.
/// </summary>
public static ChargeableCharConfig Create(
int? medioId, string symbol, string category, decimal price, DateOnly validFrom)
int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom)
{
if (string.IsNullOrWhiteSpace(symbol))
throw new ChargeableCharConfigInvalidException(
@@ -61,7 +61,7 @@ public sealed class ChargeableCharConfig
throw new ChargeableCharConfigInvalidException(
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true);
return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true);
}
/// <summary>
@@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
/// </summary>
public static ChargeableCharConfig Rehydrate(
long id, int? medioId, string symbol, string category,
long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
=> new(id, medioId, symbol, category, price, validFrom, validTo, isActive);
=> new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive);
/// <summary>
/// Schedules a new price (forward-only semantics).
@@ -93,10 +93,10 @@ public sealed class ChargeableCharConfig
$"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
if (newValidFrom <= ValidFrom)
throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom);
throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom);
// Create validates price > 0 and category — reuse factory
return Create(MedioId, Symbol, Category, newPrice, newValidFrom);
return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
}
/// <summary>

View File

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

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.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found)
/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
///
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio.
/// Returns all rows (global + per-medio) — the Application service applies priority.
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
/// Returns all rows (global + per-ProductType) — the Application service applies priority.
///
/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps:
/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found)
///
/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException.
/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current
/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the
/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved.
///
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
///
/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
/// </summary>
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{
@@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/>
public async Task<long> InsertWithCloseAsync(
long? medioId,
long? productTypeId,
string symbol,
string category,
decimal price,
@@ -41,14 +52,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021)
p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32);
p.Add("@Symbol", symbol, DbType.String, size: 4);
p.Add("@Category", category, DbType.String, size: 32);
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
p.Add("@Symbol", symbol, DbType.String, size: 4);
p.Add("@Category", category, DbType.String, size: 32);
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
@@ -64,16 +75,16 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
}
catch (SqlException ex) when (ex.Number == 50404)
{
// Medio not found (SP validates MedioId when not null)
// ProductType not found (SP validates ProductTypeId when not null)
throw new ChargeableCharConfigInvalidException(
nameof(medioId),
$"Medio with Id={medioId} not found.");
nameof(productTypeId),
$"ProductType with Id={productTypeId} not found.");
}
catch (SqlException ex) when (ex.Number == 50409)
{
// Forward-only violation: new ValidFrom <= active.ValidFrom
throw new ChargeableCharConfigForwardOnlyException(
medioId.HasValue ? (int?)checked((int)medioId.Value) : null,
productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
symbol,
validFrom,
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
@@ -83,22 +94,22 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
long medioId,
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP @MedioId is INT
p.Add("@MedioId", checked((int)medioId), DbType.Int32);
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
// SP @ProductTypeId is INT
p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_GetActiveForMedio",
"dbo.usp_ChargeableCharConfig_GetActiveForProductType",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
@@ -108,20 +119,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? medioId,
long? productTypeId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default)
{
// NULL-aware MedioId filter:
// - medioId provided → filter to that medio only
// - medioId null → return all rows regardless of medio
// NULL-aware ProductTypeId filter:
// - productTypeId provided → filter to that ProductType only
// - productTypeId null → return all rows regardless of ProductType
// activeOnly filters by IsActive = 1.
const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
ORDER BY ValidFrom DESC, Id DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
@@ -135,10 +146,10 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql,
new
{
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0,
Skip = skip,
Take = take
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0,
Skip = skip,
Take = take
},
cancellationToken: ct));
@@ -147,14 +158,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/>
public async Task<int> CountAsync(
long? medioId,
long? productTypeId,
bool activeOnly,
CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
""";
@@ -166,8 +177,8 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql,
new
{
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0
},
cancellationToken: ct));
}
@@ -178,7 +189,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default)
{
const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE Id = @Id
""";
@@ -221,24 +232,91 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
cancellationToken: ct));
}
/// <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 ────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
// Same pattern as ProductPriceRepository.
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
=> ChargeableCharConfig.Rehydrate(
id: r.Id,
medioId: r.MedioId,
symbol: r.Symbol,
category: r.Category,
price: r.PricePerUnit,
validFrom: DateOnly.FromDateTime(r.ValidFrom),
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
isActive: r.IsActive);
id: r.Id,
productTypeId: r.ProductTypeId,
symbol: r.Symbol,
category: r.Category,
price: r.PricePerUnit,
validFrom: DateOnly.FromDateTime(r.ValidFrom),
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
isActive: r.IsActive);
private sealed record ChargeableCharConfigRow(
long Id,
int? MedioId,
int? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,

View File

@@ -26,6 +26,8 @@ namespace SIGCM2.Api.Tests.Pricing.ChargeableChars;
/// POST /api/v1/admin/chargeable-chars
/// PUT /api/v1/admin/chargeable-chars/{id}/price
/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate
/// PATCH /api/v1/admin/chargeable-chars/{id}/reactivate
/// DELETE /api/v1/admin/chargeable-chars/{id}
///
/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission.
@@ -84,20 +86,20 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
/// <summary>Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.</summary>
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)
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
return await conn.QuerySingleAsync<long>("""
INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""",
new
{
MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value,
ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value,
Symbol = symbol,
Category = category,
PricePerUnit = pricePerUnit,
@@ -119,8 +121,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
[Fact]
public async Task Get_List_ReturnsPagedResult()
{
// Seed 2 active rows with unique symbols to avoid conflicts
var sym1 = $"L{Guid.NewGuid():N}"[..1];
// Seed an active row with unique symbol to avoid conflicts
await SeedConfigDirectAsync(null, "§", "Currency", 1.50m,
new DateOnly(2026, 1, 1), null, true);
@@ -198,7 +199,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new
{
medioId = (long?)null,
productTypeId = (long?)null,
symbol = "¥",
category = "Currency",
pricePerUnit = 1.75m,
@@ -221,7 +222,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
public async Task Post_Unauthenticated_Returns401()
{
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
@@ -232,7 +233,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
var token = GetCajeroToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
@@ -244,7 +245,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -256,7 +257,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
body: new { productTypeId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -268,7 +269,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" },
body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -288,7 +289,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
// "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4).
// Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7.
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
// Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test.
@@ -376,6 +377,229 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
resp.StatusCode.Should().Be(HttpStatusCode.OK);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
/// <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 ─────────────────────────────────────────────────────────────────
/// <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();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { medioId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
body: new { productTypeId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created,
@@ -502,7 +726,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = JsonContent.Create(new
{
medioId = (long?)null,
productTypeId = (long?)null,
symbol = "←",
category = "Currency",
pricePerUnit = 1.50m,

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

View File

@@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Seed two dedicated ProductTypes for override/fallback resolution tests.
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
_productType1Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('Hardening PT1 (override)', 1)
VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
""");
_productType2Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('Hardening PT2 (fallback)', 1)
VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1)
""");
}
@@ -68,11 +68,11 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// ─────────────────────────────────────────────────────────────────────────
// T7.1 — Concurrency: only one winner survives the race
//
// Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol).
// Three parallel connections try to InsertWithClose for the same (ProductTypeId=null, Symbol).
// The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit.
// The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205).
//
// After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol).
// After resolution: exactly 1 vigente row exists for (ProductTypeId=NULL, Symbol).
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -94,7 +94,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await conn.OpenAsync();
var p = new DynamicParameters();
p.Add("@MedioId", null, System.Data.DbType.Int32);
p.Add("@ProductTypeId", null, System.Data.DbType.Int32);
p.Add("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -278,10 +278,6 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
// GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
//
// NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be
// renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2.
// SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already
// updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -297,13 +293,13 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
var rows = await repo.GetActiveForProductTypeAsync((long)_productType1Id, asOf);
// The per-PT '$' must be returned
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it");
dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId
dollarRow!.ProductTypeId.Should().Be(_productType1Id,
"the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m,
@@ -318,7 +314,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync
// provides '$' at global price (0.0000 after V024).
var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
var rows = await repo.GetActiveForProductTypeAsync((long)_productType2Id, asOf);
// Must have at least the global '$' from seed
rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");
@@ -326,7 +322,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId
dollarRow!.ProductTypeId.Should().BeNull(
"ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
}
@@ -343,11 +339,10 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
// Build the service (wraps repo with priority resolution)
// TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync
var service = BuildService();
var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2
var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2
var pt1Config = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf);
var pt2Config = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf);
// ProductType1: '%' must come from per-PT override at 3.00
pt1Config.Should().ContainKey("%",

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ public class CreateChargeableCharConfigHandlerTests
}
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
MedioId: null,
ProductTypeId: null,
Symbol: "$",
Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.5m,
@@ -80,9 +80,9 @@ public class CreateChargeableCharConfigHandlerTests
}
[Fact]
public async Task Handle_WithMedioId_PassesMedioIdToRepo()
public async Task Handle_WithProductTypeId_PassesProductTypeIdToRepo()
{
var cmd = ValidCmd() with { MedioId = 7 };
var cmd = ValidCmd() with { ProductTypeId = 7 };
await _handler.Handle(cmd);

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