feat(application): commands/queries + IChargeableCharConfigService (PRC-001)
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
||||||
|
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
||||||
|
///
|
||||||
|
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
||||||
|
/// closes any active row for (MedioId, Symbol) and inserts the new row.
|
||||||
|
///
|
||||||
|
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns
|
||||||
|
/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate.
|
||||||
|
/// The Application service applies the per-medio > global priority rule.
|
||||||
|
/// </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.
|
||||||
|
/// 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,
|
||||||
|
string symbol,
|
||||||
|
string category,
|
||||||
|
decimal price,
|
||||||
|
DateOnly validFrom,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
|
||||||
|
long medioId,
|
||||||
|
DateOnly asOfDate,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns paginated rows filtered by MedioId and IsActive.
|
||||||
|
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||||
|
long? medioId,
|
||||||
|
bool activeOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns total row count for the given filters (used for pagination metadata).
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountAsync(
|
||||||
|
long? medioId,
|
||||||
|
bool activeOnly,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the row with the given Id, or null if not found.
|
||||||
|
/// </summary>
|
||||||
|
Task<ChargeableCharConfig?> GetByIdAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
||||||
|
/// Idempotent: no-op if already inactive.
|
||||||
|
/// Called inside the ambient TransactionScope of the handler.
|
||||||
|
/// </summary>
|
||||||
|
Task DeactivateAsync(
|
||||||
|
long id,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -83,6 +83,12 @@ using SIGCM2.Application.ProductTypes.Update;
|
|||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
using SIGCM2.Application.ProductTypes.List;
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
using SIGCM2.Application.ProductTypes.GetById;
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -200,6 +206,14 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||||
|
|
||||||
|
// ChargeableCharConfig (PRC-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
||||||
|
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChargeableCharConfigDto(
|
||||||
|
long Id,
|
||||||
|
long? MedioId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom,
|
||||||
|
DateOnly? ValidTo,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Implements IChargeableCharConfigService.
|
||||||
|
/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies
|
||||||
|
/// the per-medio > global priority rule in memory.
|
||||||
|
///
|
||||||
|
/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and
|
||||||
|
/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
|
||||||
|
long medioId,
|
||||||
|
DateOnly asOf,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct);
|
||||||
|
|
||||||
|
// Build a dictionary keyed by Symbol.
|
||||||
|
// Per-medio rows (MedioId != null) take priority over global rows (MedioId == null).
|
||||||
|
var result = new Dictionary<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))
|
||||||
|
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||||
|
|
||||||
|
foreach (var row in allRows.Where(r => r.MedioId is not null))
|
||||||
|
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
|
||||||
|
/// at the time of word counting. Used by IChargeableCharConfigService.
|
||||||
|
/// Keyed by Symbol in the returned dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChargeableCharSnapshot(
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to create a new ChargeableCharConfig.
|
||||||
|
/// MedioId = null → global config. MedioId set → per-medio config.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateChargeableCharConfigCommand(
|
||||||
|
long? MedioId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
|
||||||
|
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public CreateChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
long newId;
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
newId = await _repo.InsertWithCloseAsync(
|
||||||
|
command.MedioId,
|
||||||
|
command.Symbol,
|
||||||
|
command.Category,
|
||||||
|
command.PricePerUnit,
|
||||||
|
command.ValidFrom);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.create",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
command.MedioId,
|
||||||
|
command.Symbol,
|
||||||
|
command.Category,
|
||||||
|
command.PricePerUnit,
|
||||||
|
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateChargeableCharConfigResponse(
|
||||||
|
newId,
|
||||||
|
command.Symbol,
|
||||||
|
command.PricePerUnit,
|
||||||
|
command.ValidFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
|
||||||
|
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateChargeableCharConfigCommandValidator
|
||||||
|
: AbstractValidator<CreateChargeableCharConfigCommand>
|
||||||
|
{
|
||||||
|
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var today = timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
RuleFor(x => x.Symbol)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Symbol no puede estar vacío.")
|
||||||
|
.MaximumLength(4)
|
||||||
|
.WithMessage("Symbol no puede exceder 4 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Category)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Category no puede estar vacío.")
|
||||||
|
.Must(ChargeableCharCategories.IsValid)
|
||||||
|
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
||||||
|
|
||||||
|
RuleFor(x => x.PricePerUnit)
|
||||||
|
.GreaterThan(0m)
|
||||||
|
.WithMessage("PricePerUnit debe ser > 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidFrom)
|
||||||
|
.GreaterThanOrEqualTo(today)
|
||||||
|
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateChargeableCharConfigResponse(
|
||||||
|
long Id,
|
||||||
|
string Symbol,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeactivateChargeableCharConfigCommand(long Id);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
|
||||||
|
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeactivateChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeactivateChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeactivateChargeableCharConfigResponse> Handle(
|
||||||
|
DeactivateChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
var today = _timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
// 1. Load existing — ensures the row exists.
|
||||||
|
var existing = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||||
|
|
||||||
|
// 2. TX + deactivate + audit (fail-closed).
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
await _repo.DeactivateAsync(command.Id, today);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.deactivate",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new
|
||||||
|
{
|
||||||
|
id = existing.Id,
|
||||||
|
symbol = existing.Symbol,
|
||||||
|
medioId = existing.MedioId,
|
||||||
|
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
},
|
||||||
|
deactivatedOn = today.ToString("yyyy-MM-dd"),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeactivateChargeableCharConfigResponse(
|
||||||
|
Id: command.Id,
|
||||||
|
ValidTo: today);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
|
||||||
|
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeactivateChargeableCharConfigResponse(
|
||||||
|
long Id,
|
||||||
|
DateOnly ValidTo);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
|
||||||
|
/// Returns null if not found (caller decides whether to 404).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetChargeableCharConfigByIdQuery(long Id);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
|
||||||
|
/// Returns null DTO when not found (API layer maps to 404).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetChargeableCharConfigByIdQueryHandler
|
||||||
|
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(query.Id);
|
||||||
|
return entity is null ? null : ToDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||||
|
c.Id,
|
||||||
|
c.MedioId,
|
||||||
|
c.Symbol,
|
||||||
|
c.Category,
|
||||||
|
c.PricePerUnit,
|
||||||
|
c.ValidFrom,
|
||||||
|
c.ValidTo,
|
||||||
|
c.IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Application service for resolving active chargeable-char config for a Medio.
|
||||||
|
///
|
||||||
|
/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol.
|
||||||
|
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
|
||||||
|
/// </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 an empty dictionary if no config exists at all.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
|
||||||
|
long medioId,
|
||||||
|
DateOnly asOf,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
|
||||||
|
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ListChargeableCharConfigQuery(
|
||||||
|
long? MedioId,
|
||||||
|
bool ActiveOnly,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
|
||||||
|
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListChargeableCharConfigQueryHandler
|
||||||
|
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
var skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize);
|
||||||
|
var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly);
|
||||||
|
|
||||||
|
var dtos = items.Select(ToDto).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||||
|
c.Id,
|
||||||
|
c.MedioId,
|
||||||
|
c.Symbol,
|
||||||
|
c.Category,
|
||||||
|
c.PricePerUnit,
|
||||||
|
c.ValidFrom,
|
||||||
|
c.ValidTo,
|
||||||
|
c.IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
|
||||||
|
/// Id: the existing row whose price should be superseded.
|
||||||
|
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SchedulePriceChangeCommand(
|
||||||
|
long Id,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for SchedulePriceChangeCommand.
|
||||||
|
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulePriceChangeCommandHandler
|
||||||
|
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public SchedulePriceChangeCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
|
||||||
|
var existing = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||||
|
|
||||||
|
// 2. Domain entity validates forward-only rule and builds the new entity value.
|
||||||
|
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
|
||||||
|
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
|
||||||
|
|
||||||
|
// 3. TX + SP + audit (fail-closed).
|
||||||
|
long newId;
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
newId = await _repo.InsertWithCloseAsync(
|
||||||
|
newEntity.MedioId,
|
||||||
|
newEntity.Symbol,
|
||||||
|
newEntity.Category,
|
||||||
|
newEntity.PricePerUnit,
|
||||||
|
newEntity.ValidFrom);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.price_change",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new
|
||||||
|
{
|
||||||
|
id = existing.Id,
|
||||||
|
pricePerUnit = existing.PricePerUnit,
|
||||||
|
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
},
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
pricePerUnit = newEntity.PricePerUnit,
|
||||||
|
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchedulePriceChangeResponse(
|
||||||
|
NewId: newId,
|
||||||
|
PreviousValidFrom: existing.ValidFrom,
|
||||||
|
NewValidFrom: newEntity.ValidFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
|
||||||
|
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
|
||||||
|
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
|
||||||
|
/// where the existing entity is loaded.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
|
||||||
|
{
|
||||||
|
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var today = timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0L)
|
||||||
|
.WithMessage("Id debe ser un entero positivo.");
|
||||||
|
|
||||||
|
RuleFor(x => x.PricePerUnit)
|
||||||
|
.GreaterThan(0m)
|
||||||
|
.WithMessage("PricePerUnit debe ser > 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidFrom)
|
||||||
|
.GreaterThanOrEqualTo(today)
|
||||||
|
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for SchedulePriceChangeCommand.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SchedulePriceChangeResponse(
|
||||||
|
long NewId,
|
||||||
|
DateOnly PreviousValidFrom,
|
||||||
|
DateOnly NewValidFrom);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — ChargeableCharConfigService tests.
|
||||||
|
/// Covers: per-medio wins over global, global fallback when no per-medio,
|
||||||
|
/// empty result when no config at all.
|
||||||
|
/// </summary>
|
||||||
|
public class ChargeableCharConfigServiceTests
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||||
|
private readonly ChargeableCharConfigService _service;
|
||||||
|
|
||||||
|
private static readonly DateOnly AsOf = new(2026, 4, 20);
|
||||||
|
|
||||||
|
public ChargeableCharConfigServiceTests()
|
||||||
|
{
|
||||||
|
_service = new ChargeableCharConfigService(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) =>
|
||||||
|
ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
|
||||||
|
|
||||||
|
private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) =>
|
||||||
|
ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
|
||||||
|
|
||||||
|
// ── Global fallback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs()
|
||||||
|
{
|
||||||
|
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>
|
||||||
|
{
|
||||||
|
GlobalConfig("$", 1.0m),
|
||||||
|
GlobalConfig("%", 0.5m),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().ContainKey("$");
|
||||||
|
result["$"].PricePerUnit.Should().Be(1.0m);
|
||||||
|
result.Should().ContainKey("%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-medio wins over global ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol()
|
||||||
|
{
|
||||||
|
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<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
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
|
||||||
|
|
||||||
|
result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins
|
||||||
|
result["%"].PricePerUnit.Should().Be(0.5m); // global only
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory()
|
||||||
|
{
|
||||||
|
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>
|
||||||
|
{
|
||||||
|
MedioConfig(20L, 5, "$", 3.0m),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
|
||||||
|
|
||||||
|
result["$"].Category.Should().Be(ChargeableCharCategories.Currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty result ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary()
|
||||||
|
{
|
||||||
|
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>());
|
||||||
|
|
||||||
|
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key: Symbol, Value: snapshot ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveConfig_KeyIsSymbol()
|
||||||
|
{
|
||||||
|
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>
|
||||||
|
{
|
||||||
|
GlobalConfig("!", 2.0m),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().ContainKey("!");
|
||||||
|
result["!"].PricePerUnit.Should().Be(2.0m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using FluentValidation.TestHelper;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Validator tests for CreateChargeableCharConfigCommand.
|
||||||
|
/// Covers: Symbol length, Category enum, PricePerUnit > 0, ValidFrom >= today_AR.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateChargeableCharConfigCommandValidatorTests
|
||||||
|
{
|
||||||
|
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly CreateChargeableCharConfigCommandValidator _validator;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
private static readonly DateOnly Yesterday = new(2026, 4, 19);
|
||||||
|
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||||
|
|
||||||
|
public CreateChargeableCharConfigCommandValidatorTests()
|
||||||
|
{
|
||||||
|
_validator = new CreateChargeableCharConfigCommandValidator(_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateChargeableCharConfigCommand ValidCmd() => new(
|
||||||
|
MedioId: null,
|
||||||
|
Symbol: "$",
|
||||||
|
Category: ChargeableCharCategories.Currency,
|
||||||
|
PricePerUnit: 1.0m,
|
||||||
|
ValidFrom: Today);
|
||||||
|
|
||||||
|
// ── Symbol ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Symbol_Empty_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Symbol = "" };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Symbol_TooLong_FailsValidation()
|
||||||
|
{
|
||||||
|
// max 4 chars
|
||||||
|
var cmd = ValidCmd() with { Symbol = "ABCDE" };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Symbol_SingleChar_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Symbol = "$" };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Symbol_FourChars_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Symbol = "ABCD" };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Category_Invalid_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Category = "Unknown" };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Category_Empty_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Category = "" };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Currency")]
|
||||||
|
[InlineData("Percentage")]
|
||||||
|
[InlineData("Exclamation")]
|
||||||
|
[InlineData("Question")]
|
||||||
|
[InlineData("Other")]
|
||||||
|
public void Category_ValidValues_Pass(string category)
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Category = category };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Zero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Negative_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = -1m };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Positive_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = 0.01m };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_InPast_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Yesterday };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_Today_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_Future_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Tomorrow };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidCommand_PassesAllRules()
|
||||||
|
{
|
||||||
|
_validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — CreateChargeableCharConfigCommandHandler tests.
|
||||||
|
/// Covers: happy path, audit emit, audit fail → rollback, validator chain.
|
||||||
|
/// NSubstitute + FakeTimeProvider.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateChargeableCharConfigHandlerTests
|
||||||
|
{
|
||||||
|
// Hoy en ART: 2026-04-20T12:00:00 UTC → 2026-04-20T09:00:00 ART
|
||||||
|
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly CreateChargeableCharConfigCommandHandler _handler;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||||
|
|
||||||
|
public CreateChargeableCharConfigHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.InsertWithCloseAsync(
|
||||||
|
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(42L);
|
||||||
|
|
||||||
|
_handler = new CreateChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
|
||||||
|
MedioId: null,
|
||||||
|
Symbol: "$",
|
||||||
|
Category: ChargeableCharCategories.Currency,
|
||||||
|
PricePerUnit: 1.5m,
|
||||||
|
ValidFrom: validFrom ?? Today);
|
||||||
|
|
||||||
|
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_ReturnsCreateResponse()
|
||||||
|
{
|
||||||
|
var result = await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Id.Should().Be(42L);
|
||||||
|
result.Symbol.Should().Be("$");
|
||||||
|
result.PricePerUnit.Should().Be(1.5m);
|
||||||
|
result.ValidFrom.Should().Be(Today);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_CallsInsertWithCloseAsync()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _repo.Received(1).InsertWithCloseAsync(
|
||||||
|
null, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_EmitsAuditEvent()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.create",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: "42",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithMedioId_PassesMedioIdToRepo()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { MedioId = 7 };
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).InsertWithCloseAsync(
|
||||||
|
7L, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_FutureDateValidFrom_Succeeds()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd(validFrom: Tomorrow);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
result.ValidFrom.Should().Be(Tomorrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||||
|
{
|
||||||
|
_audit.LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("Audit DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_RepoWasCalled_ButTransactionNotCompleted()
|
||||||
|
{
|
||||||
|
// Audit fail is fail-closed: the TransactionScope was NOT completed
|
||||||
|
// (we can observe the exception propagating; if TX were committed, no exception would reach caller)
|
||||||
|
_audit.LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
// Repo was called (within the TX) but TX never completed
|
||||||
|
await _repo.Received(1).InsertWithCloseAsync(
|
||||||
|
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — DeactivateChargeableCharConfigCommandHandler tests.
|
||||||
|
/// Covers: happy path, not-found, audit emit, audit fail → rollback.
|
||||||
|
/// </summary>
|
||||||
|
public class DeactivateChargeableCharConfigHandlerTests
|
||||||
|
{
|
||||||
|
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 DeactivateChargeableCharConfigCommandHandler _handler;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
|
||||||
|
private static ChargeableCharConfig ActiveConfig() =>
|
||||||
|
ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true);
|
||||||
|
|
||||||
|
public DeactivateChargeableCharConfigHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ActiveConfig());
|
||||||
|
|
||||||
|
_handler = new DeactivateChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeactivateChargeableCharConfigCommand ValidCmd() => new(Id: 1L);
|
||||||
|
|
||||||
|
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_ReturnsDeactivateResponse()
|
||||||
|
{
|
||||||
|
var result = await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Id.Should().Be(1L);
|
||||||
|
result.ValidTo.Should().Be(Today);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_CallsDeactivateAsync()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_EmitsAuditDeactivate()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.deactivate",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<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(ValidCmd() with { Id = 99L });
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[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 error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("Audit error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_DeactivateWasCalled_TransactionNotCompleted()
|
||||||
|
{
|
||||||
|
_audit.LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCmd());
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
// Repo.DeactivateAsync was called but TX not completed (exception propagated)
|
||||||
|
await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — GetChargeableCharConfigByIdQueryHandler tests.
|
||||||
|
/// Covers: found → returns DTO, not-found → returns null.
|
||||||
|
/// </summary>
|
||||||
|
public class GetChargeableCharConfigByIdHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||||
|
private readonly GetChargeableCharConfigByIdQueryHandler _handler;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
|
||||||
|
public GetChargeableCharConfigByIdHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetChargeableCharConfigByIdQueryHandler(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfig MakeConfig(long id) =>
|
||||||
|
ChargeableCharConfig.Rehydrate(id, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true);
|
||||||
|
|
||||||
|
// ── Found ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Found_ReturnsDto()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(MakeConfig(1L));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(1L));
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Id.Should().Be(1L);
|
||||||
|
result.Symbol.Should().Be("$");
|
||||||
|
result.PricePerUnit.Should().Be(1.0m);
|
||||||
|
result.ValidFrom.Should().Be(Today);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not found ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ReturnsNull()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((ChargeableCharConfig?)null);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(99L));
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — ListChargeableCharConfigQueryHandler tests.
|
||||||
|
/// Covers: happy path with items, empty page, projection to DTO, pagination metadata.
|
||||||
|
/// </summary>
|
||||||
|
public class ListChargeableCharConfigHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||||
|
private readonly ListChargeableCharConfigQueryHandler _handler;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
|
||||||
|
private static ChargeableCharConfig MakeConfig(long id, string symbol, decimal price) =>
|
||||||
|
ChargeableCharConfig.Rehydrate(id, null, symbol, ChargeableCharCategories.Currency, price, Today, null, true);
|
||||||
|
|
||||||
|
public ListChargeableCharConfigHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ListChargeableCharConfigQueryHandler(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithItems_ReturnsPagedDtos()
|
||||||
|
{
|
||||||
|
var items = new List<ChargeableCharConfig>
|
||||||
|
{
|
||||||
|
MakeConfig(1, "$", 1.0m),
|
||||||
|
MakeConfig(2, "%", 0.5m),
|
||||||
|
};
|
||||||
|
|
||||||
|
_repo.ListAsync(null, true, 0, 20, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(items);
|
||||||
|
_repo.CountAsync(null, true, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(2);
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(MedioId: null, ActiveOnly: true, Page: 1, PageSize: 20);
|
||||||
|
var result = await _handler.Handle(query);
|
||||||
|
|
||||||
|
result.Items.Should().HaveCount(2);
|
||||||
|
result.Total.Should().Be(2);
|
||||||
|
result.Page.Should().Be(1);
|
||||||
|
result.PageSize.Should().Be(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithItems_ProjectsToDto()
|
||||||
|
{
|
||||||
|
var items = new List<ChargeableCharConfig> { MakeConfig(5, "$", 1.5m) };
|
||||||
|
|
||||||
|
_repo.ListAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(items);
|
||||||
|
_repo.CountAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(1);
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(null, true, 1, 20);
|
||||||
|
var result = await _handler.Handle(query);
|
||||||
|
|
||||||
|
var dto = result.Items[0];
|
||||||
|
dto.Id.Should().Be(5);
|
||||||
|
dto.Symbol.Should().Be("$");
|
||||||
|
dto.PricePerUnit.Should().Be(1.5m);
|
||||||
|
dto.ValidFrom.Should().Be(Today);
|
||||||
|
dto.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_EmptyPage_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
_repo.ListAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>());
|
||||||
|
_repo.CountAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(null, true, 1, 20);
|
||||||
|
var result = await _handler.Handle(query);
|
||||||
|
|
||||||
|
result.Items.Should().BeEmpty();
|
||||||
|
result.Total.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SkipIsComputed_FromPageAndPageSize()
|
||||||
|
{
|
||||||
|
// Page 3, PageSize 10 → skip = 20
|
||||||
|
_repo.ListAsync(null, false, 20, 10, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>());
|
||||||
|
_repo.CountAsync(null, false, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(null, false, 3, 10);
|
||||||
|
await _handler.Handle(query);
|
||||||
|
|
||||||
|
await _repo.Received(1).ListAsync(null, false, 20, 10, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_FiltersByMedioId_WhenProvided()
|
||||||
|
{
|
||||||
|
_repo.ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ChargeableCharConfig>());
|
||||||
|
_repo.CountAsync(7L, true, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(7L, true, 1, 20);
|
||||||
|
await _handler.Handle(query);
|
||||||
|
|
||||||
|
await _repo.Received(1).ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using FluentValidation.TestHelper;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Validator tests for SchedulePriceChangeCommand.
|
||||||
|
/// Covers: PricePerUnit > 0, ValidFrom >= today_AR.
|
||||||
|
/// Forward-only check (ValidFrom > existing.ValidFrom) is done in handler, not validator.
|
||||||
|
/// </summary>
|
||||||
|
public class SchedulePriceChangeCommandValidatorTests
|
||||||
|
{
|
||||||
|
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly SchedulePriceChangeCommandValidator _validator;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
private static readonly DateOnly Yesterday = new(2026, 4, 19);
|
||||||
|
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||||
|
|
||||||
|
public SchedulePriceChangeCommandValidatorTests()
|
||||||
|
{
|
||||||
|
_validator = new SchedulePriceChangeCommandValidator(_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchedulePriceChangeCommand ValidCmd() => new(
|
||||||
|
Id: 1L,
|
||||||
|
PricePerUnit: 2.0m,
|
||||||
|
ValidFrom: Tomorrow);
|
||||||
|
|
||||||
|
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Zero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Negative_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = -1m };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PricePerUnit_Positive_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { PricePerUnit = 0.01m };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_InPast_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Yesterday };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_Today_Passes()
|
||||||
|
{
|
||||||
|
// today is valid — forward-only check vs existing row is done in handler
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidFrom_Future_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Tomorrow };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Id ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Id_Zero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Id = 0L };
|
||||||
|
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Id_Positive_Passes()
|
||||||
|
{
|
||||||
|
var cmd = ValidCmd() with { Id = 1L };
|
||||||
|
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidCommand_PassesAllRules()
|
||||||
|
{
|
||||||
|
_validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — SchedulePriceChangeCommandHandler tests.
|
||||||
|
/// Covers: happy path, forward-only validation, audit emit, audit fail → rollback.
|
||||||
|
/// </summary>
|
||||||
|
public class SchedulePriceChangeHandlerTests
|
||||||
|
{
|
||||||
|
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 SchedulePriceChangeCommandHandler _handler;
|
||||||
|
|
||||||
|
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||||
|
private static readonly DateOnly NextMonth = new(2026, 5, 1);
|
||||||
|
|
||||||
|
private static ChargeableCharConfig ExistingConfig(DateOnly validFrom) =>
|
||||||
|
ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, validFrom, null, true);
|
||||||
|
|
||||||
|
public SchedulePriceChangeHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ExistingConfig(Today));
|
||||||
|
|
||||||
|
_repo.InsertWithCloseAsync(
|
||||||
|
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(99L);
|
||||||
|
|
||||||
|
_handler = new SchedulePriceChangeCommandHandler(_repo, _audit, _time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchedulePriceChangeCommand ValidCmd() => new(
|
||||||
|
Id: 1L,
|
||||||
|
PricePerUnit: 2.5m,
|
||||||
|
ValidFrom: NextMonth);
|
||||||
|
|
||||||
|
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_ReturnsScheduleResponse()
|
||||||
|
{
|
||||||
|
var result = await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.NewId.Should().Be(99L);
|
||||||
|
result.PreviousValidFrom.Should().Be(Today);
|
||||||
|
result.NewValidFrom.Should().Be(NextMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_CallsInsertWithCloseAsync()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _repo.Received(1).InsertWithCloseAsync(
|
||||||
|
null, "$", ChargeableCharCategories.Currency, 2.5m, NextMonth, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_HappyPath_EmitsAuditPriceChange()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.price_change",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: "99",
|
||||||
|
metadata: Arg.Any<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(ValidCmd() with { Id = 99L });
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forward-only enforcement ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidFromNotGreaterThanCurrent_ThrowsForwardOnlyException()
|
||||||
|
{
|
||||||
|
// The existing config has ValidFrom = Today; scheduling for Today is not > Today
|
||||||
|
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ChargeableCharConfigForwardOnlyException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[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 error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCmd());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("Audit error");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user