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.List;
|
||||
using SIGCM2.Application.ProductTypes.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -200,6 +206,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||
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)
|
||||
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