mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 15:53:15 +00:00
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.
432 lines
14 KiB
C#
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),
|
|
};
|
|
}
|
|
}
|