using FluentAssertions; using Microsoft.Extensions.Time.Testing; using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Audit; using SIGCM2.Application.Pricing.ChargeableChars.Delete; using SIGCM2.Domain.Pricing.ChargeableChars; namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; /// /// PRC-001 — DeleteChargeableCharConfigCommandHandler tests. /// Strict TDD — RED written before implementation. /// Covers: happy path, not-found, audit emission, audit fail-closed. /// public class DeleteChargeableCharConfigHandlerTests { 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 DeleteChargeableCharConfigCommandHandler _handler; private static ChargeableCharConfig SomeConfig() => ChargeableCharConfig.Rehydrate( id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency, price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true); public DeleteChargeableCharConfigHandlerTests() { _repo.GetByIdAsync(1L, Arg.Any()) .Returns(SomeConfig()); _handler = new DeleteChargeableCharConfigCommandHandler(_repo, _audit, _time); } private static DeleteChargeableCharConfigCommand ValidCmd() => new(Id: 1L); // ── Happy path ────────────────────────────────────────────────────────────── [Fact] public async Task Handle_HappyPath_ReturnsResponse() { var result = await _handler.Handle(ValidCmd()); result.Should().NotBeNull(); result.Id.Should().Be(1L); } [Fact] public async Task Handle_HappyPath_CallsDeleteAsync() { await _handler.Handle(ValidCmd()); await _repo.Received(1).DeleteAsync(1L, Arg.Any()); } [Fact] public async Task Handle_HappyPath_EmitsAuditDelete() { await _handler.Handle(ValidCmd()); await _audit.Received(1).LogAsync( action: "tasacion.chargeable_char.delete", targetType: "ChargeableCharConfig", targetId: "1", metadata: Arg.Any(), ct: Arg.Any()); } // ── Not found ─────────────────────────────────────────────────────────────── [Fact] public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException() { _repo.GetByIdAsync(99L, Arg.Any()) .Returns((ChargeableCharConfig?)null); var act = async () => await _handler.Handle(new DeleteChargeableCharConfigCommand(Id: 99L)); await act.Should().ThrowAsync(); } // ── Audit fail → rollback (fail-closed) ───────────────────────────────────── [Fact] public async Task Handle_AuditThrows_ExceptionPropagates() { _audit.LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Audit down")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync() .WithMessage("Audit down"); } [Fact] public async Task Handle_AuditThrows_DeleteWasCalled_TransactionNotCompleted() { _audit.LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Audit down")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); await _repo.Received(1).DeleteAsync(1L, Arg.Any()); } }