proposal-system/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs
Adam Moussa 9c04ba4756 fix: resolve test compile errors from merge, update AUDIT-REPORT.md
Fix CreateProposalRequest constructor calls (missing PoNumber param)
and ProposalService constructor (missing ILogger param) that diverged
when test-bootstrap and api-hardening worktrees merged.

Mark all Critical and High findings as fixed in AUDIT-REPORT.md with
remediation status for each phase.
2026-05-27 18:18:44 -04:00

432 lines
14 KiB
C#

using FluentAssertions;
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;
/// <summary>
/// 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).
/// </summary>
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 ProposalService _sut;
public ProposalStateMachineTests()
{
_db = DbContextFactory.Create();
_currentUser = Substitute.For<ICurrentUserService>();
_audit = Substitute.For<IAuditService>();
_jobPublisher = Substitute.For<IJobPublisher>();
_numberGenerator = Substitute.For<IProposalNumberGenerator>();
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();
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher,
Substitute.For<ILogger<ProposalService>>());
}
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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<KeyNotFoundException>();
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
await reviseAct.Should().ThrowAsync<KeyNotFoundException>();
}
#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<string?>(), Arg.Any<CancellationToken>());
}
[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<object>(), Arg.Any<CancellationToken>());
}
#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),
};
}
}