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; /// /// PRC-001 — CreateChargeableCharConfigCommandHandler tests. /// Covers: happy path, audit emit, audit fail → rollback, validator chain. /// NSubstitute + FakeTimeProvider. /// 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(); private readonly IAuditLogger _audit = Substitute.For(); 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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(42L); _handler = new CreateChargeableCharConfigCommandHandler(_repo, _audit, _time); } private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new( ProductTypeId: 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()); } [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(), ct: Arg.Any()); } [Fact] public async Task Handle_WithProductTypeId_PassesProductTypeIdToRepo() { var cmd = ValidCmd() with { ProductTypeId = 7 }; await _handler.Handle(cmd); await _repo.Received(1).InsertWithCloseAsync( 7L, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any()); } [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Audit DB error")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync() .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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Audit DB error")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); // Repo was called (within the TX) but TX never completed await _repo.Received(1).InsertWithCloseAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }