feat(application): commands/queries + IChargeableCharConfigService (PRC-001)
This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user