using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using NSubstitute; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; using ProposalSystem.Infrastructure.Services; using ProposalSystem.Tests.Helpers; using Xunit; namespace ProposalSystem.Tests.Services; /// /// QA-C2: Proposal state machine transition tests. /// Verifies that only valid state transitions succeed and invalid ones throw. /// State machine: InReview -> Approved -> Sent -> Revised (creates new proposal). /// public class ProposalStateMachineTests : IDisposable { private readonly Infrastructure.Data.ProposalDbContext _db; private readonly ICurrentUserService _currentUser; private readonly IAuditService _audit; private readonly IJobPublisher _jobPublisher; private readonly IProposalNumberGenerator _numberGenerator; private readonly IEmailService _emailService; private readonly IS3Service _s3Service; private readonly ProposalService _sut; public ProposalStateMachineTests() { _db = DbContextFactory.Create(); _currentUser = Substitute.For(); _audit = Substitute.For(); _jobPublisher = Substitute.For(); _numberGenerator = Substitute.For(); _emailService = Substitute.For(); _s3Service = Substitute.For(); var userId = Guid.NewGuid(); _currentUser.UserId.Returns(userId); _currentUser.Role.Returns(UserRole.Admin); // InMemory provider enforces FK constraints; create the required User entity _db.Users.Add(new User { Id = userId, CognitoSub = $"sub-{userId}", Email = "admin@test.com", DisplayName = "Test Admin", Role = UserRole.Admin, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); _db.SaveChanges(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "GENERATED_BUCKET", "test-bucket" }, }) .Build(); _sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher, _emailService, _s3Service, config, Substitute.For>()); } public void Dispose() { _db.Dispose(); } #region Valid Transitions [Fact(DisplayName = "QA-C2: InReview -> Approved succeeds when line items have prices")] public async Task ApproveAsync_InReview_TransitionsToApproved() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "HVAC duct replacement", Quantity = 1, Unit = "each", TotalPrice = 5000m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.AI, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.ApproveAsync(proposal.Id); // Assert result.Status.Should().Be(ProposalStatus.Approved); result.ApprovedById.Should().Be(_currentUser.UserId); result.TotalBidAmount.Should().Be(5000m); } [Fact(DisplayName = "QA-C2: Approved -> Sent succeeds")] public async Task MarkSentAsync_Approved_TransitionsToSent() { // Arrange var proposal = CreateProposal(ProposalStatus.Approved); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.MarkSentAsync(proposal.Id); // Assert result.Status.Should().Be(ProposalStatus.Sent); result.SentAt.Should().NotBeNull(); } [Fact(DisplayName = "QA-C2: Sent -> Revised creates new revision proposal")] public async Task ReviseAsync_Sent_CreatesNewProposalInReview() { // Arrange var proposal = CreateProposal(ProposalStatus.Sent); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Pipe repair", Quantity = 2, Unit = "hours", UnitPrice = 150m, TotalPrice = 300m, PricingMode = PricingMode.UnitPrice, Source = LineItemSource.Manual, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.ReviseAsync(proposal.Id); // Assert result.Status.Should().Be(ProposalStatus.InReview); result.ParentProposalId.Should().Be(proposal.Id); result.CurrentRevision.Should().Be(proposal.CurrentRevision + 1); result.ProposalNumber.Should().Contain("-R"); // Original proposal should now be Revised var original = await _db.Proposals.FindAsync(proposal.Id); original!.Status.Should().Be(ProposalStatus.Revised); } [Fact(DisplayName = "QA-C2: Approve is idempotent on already-approved proposal")] public async Task ApproveAsync_AlreadyApproved_ReturnsWithoutError() { // Arrange var proposal = CreateProposal(ProposalStatus.Approved); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.ApproveAsync(proposal.Id); // Assert result.Status.Should().Be(ProposalStatus.Approved); } [Fact(DisplayName = "QA-C2: MarkSent is idempotent on already-sent proposal")] public async Task MarkSentAsync_AlreadySent_ReturnsWithoutError() { // Arrange var proposal = CreateProposal(ProposalStatus.Sent); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.MarkSentAsync(proposal.Id); // Assert result.Status.Should().Be(ProposalStatus.Sent); } #endregion #region Invalid Transitions [Fact(DisplayName = "QA-C2: InReview -> Sent is invalid, must approve first")] public async Task MarkSentAsync_InReview_ThrowsInvalidOperation() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.MarkSentAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*approved*"); } [Fact(DisplayName = "QA-C2: Approved -> Revised is invalid, must send first")] public async Task ReviseAsync_Approved_ThrowsInvalidOperation() { // Arrange var proposal = CreateProposal(ProposalStatus.Approved); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.ReviseAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*sent*"); } [Fact(DisplayName = "QA-C2: Draft -> Approved is invalid")] public async Task ApproveAsync_Draft_ThrowsInvalidOperation() { // Arrange var proposal = CreateProposal(ProposalStatus.Draft); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Some work", Quantity = 1, Unit = "each", TotalPrice = 100m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.ApproveAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*in review*"); } [Fact(DisplayName = "QA-C2: InReview -> Revised is invalid")] public async Task ReviseAsync_InReview_ThrowsInvalidOperation() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.ReviseAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*sent*"); } [Fact(DisplayName = "QA-C2: Sent -> Approved is invalid")] public async Task MarkSentAsync_Sent_StaysIdempotent_But_ApproveThrows() { // Sent cannot go back to Approved var proposal = CreateProposal(ProposalStatus.Sent); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var act = () => _sut.ApproveAsync(proposal.Id); await act.Should().ThrowAsync() .WithMessage("*in review*"); } [Fact(DisplayName = "QA-C2: Cannot approve proposal without priced line items")] public async Task ApproveAsync_NoLineItems_ThrowsInvalidOperation() { // Arrange - proposal InReview but no line items var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.ApproveAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*priced line items*"); } [Fact(DisplayName = "QA-C2: Cannot approve proposal with zero-price line items only")] public async Task ApproveAsync_AllZeroPriceLineItems_ThrowsInvalidOperation() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Unprice item", Quantity = 1, Unit = "each", TotalPrice = 0m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.AI, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.ApproveAsync(proposal.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*priced line items*"); } [Fact(DisplayName = "QA-C2: Approve/Send/Revise on nonexistent proposal throws KeyNotFoundException")] public async Task StateTransitions_NonexistentProposal_ThrowsKeyNotFound() { var missingId = Guid.NewGuid(); var approveAct = () => _sut.ApproveAsync(missingId); var sendAct = () => _sut.MarkSentAsync(missingId); var reviseAct = () => _sut.ReviseAsync(missingId); await approveAct.Should().ThrowAsync(); await sendAct.Should().ThrowAsync(); await reviseAct.Should().ThrowAsync(); } #endregion #region Revision Edge Cases [Fact(DisplayName = "QA-C2: Revision copies line items from parent")] public async Task ReviseAsync_CopiesLineItems() { // Arrange var proposal = CreateProposal(ProposalStatus.Sent); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Item 1", Quantity = 5, Unit = "sq ft", UnitPrice = 10m, TotalPrice = 50m, PricingMode = PricingMode.UnitPrice, SortOrder = 1, Source = LineItemSource.Manual, }); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Item 2", Quantity = 1, Unit = "each", TotalPrice = 200m, PricingMode = PricingMode.TotalPrice, SortOrder = 2, Source = LineItemSource.AI, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var result = await _sut.ReviseAsync(proposal.Id); // Assert var revision = await _db.Proposals.FindAsync(result.Id); var revisionItems = _db.LineItems.Where(li => li.ProposalId == result.Id).ToList(); revisionItems.Should().HaveCount(2); revisionItems.Should().AllSatisfy(li => li.ProposalId.Should().Be(result.Id)); revisionItems.Select(li => li.Description).Should().BeEquivalentTo("Item 1", "Item 2"); } [Fact(DisplayName = "QA-C2: Audit is logged for approve transition")] public async Task ApproveAsync_LogsAuditEvent() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Work item", Quantity = 1, Unit = "each", TotalPrice = 1000m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act await _sut.ApproveAsync(proposal.Id); // Assert await _audit.Received(1).LogAsync(AuditAction.Approve, proposal.Id, Arg.Any(), Arg.Any()); } [Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")] public async Task MarkSentAsync_PublishesLibraryIngestJob() { // Arrange var proposal = CreateProposal(ProposalStatus.Approved); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act await _sut.MarkSentAsync(proposal.Id); // Assert await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any(), Arg.Any()); } #endregion private Proposal CreateProposal(ProposalStatus status) { return new Proposal { Id = Guid.NewGuid(), ProposalNumber = $"P-{Guid.NewGuid():N}".Substring(0, 12), WorkOrderNumber = "WO-001", CustomerName = "Test Customer", CustomerAddress = "123 Test St", ScopeOfWork = "Test scope of work", ServiceCategory = ServiceCategory.HVAC, Priority = Priority.Standard, Status = status, Notes = "", SubmittedById = _currentUser.UserId, SubmittedAt = DateTime.UtcNow.AddDays(-1), CurrentRevision = 1, CreatedAt = DateTime.UtcNow.AddDays(-1), UpdatedAt = DateTime.UtcNow.AddDays(-1), }; } }