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; /// /// PRC-001 — SchedulePriceChangeCommandHandler tests. /// Covers: happy path, forward-only validation, audit emit, audit fail → rollback. /// public class SchedulePriceChangeHandlerTests { 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 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()) .Returns(ExistingConfig(Today)); _repo.InsertWithCloseAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .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()); } [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(), 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(ValidCmd() with { Id = 99L }); await act.Should().ThrowAsync(); } // ── 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(); } // ── 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 error")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync() .WithMessage("Audit error"); } }