proposal-system/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs
Adam Moussa 1fca0fa978
feat: proposal delivery — email customers the PDF on Mark as Sent (#126)
* feat: proposal delivery — email customers the PDF on "Mark as Sent"

Makes the system's namesake feature real: marking a proposal Sent now emails the
customer an expiring link to the branded PDF, and customers are managed (with
contact emails) instead of hardcoded. v1 PR4.

API:
- Customer.ContactEmail + migration; Customer list/update endpoints. Search stays
  additive at GET /api/customers?query= (frozen-mobile + web compat); new paginated
  list at GET /api/customers/list (admin).
- IEmailService (SesEmailService v2 / DevEmailService, dev-gated). MarkSentAsync
  resolves the customer's email, presigns the latest PDF (7d), and sends via SES.
  Email/presign failures are caught + audited and NEVER roll back the Sent transition.
- Startup EF migration guarded by a Postgres advisory lock (concurrency-safe).

Infra:
- SES email identity (proposals@seahavenind.com); least-privilege ses:SendEmail/
  SendRawEmail scoped to the identity ARN + ses:FromAddress condition; SES_FROM_ADDRESS
  env. SES starts in sandbox — production access needed for unverified recipients.

Web:
- Customer management page (/admin/customers): list / create / edit incl. contact email.
- New-proposal form searches real customers (free-solo) instead of a hardcoded value.
- Mark-as-Sent dialog notes the PDF will be emailed to the customer.

GPT-4.1 cross-review (SES IAM): no BLOCK (ses:FromAddress condition applied).
Verified: api build + 121 tests; web tsc + 26 tests; infra tsc; ruff clean.

* Potential fix for pull request finding 'CodeQL / Exposure of private information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Exposure of private information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Exposure of private information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-18 12:40:55 -04:00

445 lines
15 KiB
C#

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;
/// <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 IEmailService _emailService;
private readonly IS3Service _s3Service;
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>();
_emailService = Substitute.For<IEmailService>();
_s3Service = Substitute.For<IS3Service>();
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<string, string?>
{
{ "GENERATED_BUCKET", "test-bucket" },
})
.Build();
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher,
_emailService, _s3Service, config,
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),
};
}
}