proposal-system/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs

252 lines
8.8 KiB
C#
Raw Normal View History

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
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// PR4: Proposal delivery tests. Verifies that:
/// 1. MarkSent succeeds (transition completes) even when the email service throws.
/// 2. Email is sent when customer and PDF exist.
/// 3. Email is skipped gracefully when customer or PDF is missing.
/// </summary>
public class ProposalDeliveryTests : 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;
private readonly Guid _userId;
public ProposalDeliveryTests()
{
_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>();
_userId = Guid.NewGuid();
_currentUser.UserId.Returns(_userId);
_currentUser.Role.Returns(UserRole.Admin);
_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();
}
[Fact(DisplayName = "PR4: MarkSent completes transition even when email service throws")]
public async Task MarkSentAsync_EmailServiceThrows_TransitionStillCompletes()
{
// Arrange — customer with email and a generated PDF
var proposal = CreateProposal(ProposalStatus.Approved, "Failing Customer");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "Failing Customer",
ContactEmail = "test@example.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.GeneratedPdfs.Add(new GeneratedPdf
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Revision = 1,
S3Key = "pdfs/test.pdf",
GeneratedAt = DateTime.UtcNow,
GeneratedById = _userId,
});
await _db.SaveChangesAsync();
_s3Service.GeneratePresignedDownloadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns("https://s3.example.com/test.pdf");
// Make the email service throw
_emailService.SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("SES is down"));
// Act — should NOT throw
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert — transition completed despite email failure
result.Status.Should().Be(ProposalStatus.Sent);
result.SentAt.Should().NotBeNull();
}
[Fact(DisplayName = "PR4: MarkSent sends email when customer and PDF exist")]
public async Task MarkSentAsync_CustomerAndPdfExist_SendsEmail()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved, "Acme Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "Acme Corp",
ContactEmail = "billing@acme.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.GeneratedPdfs.Add(new GeneratedPdf
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Revision = 1,
S3Key = "pdfs/acme.pdf",
GeneratedAt = DateTime.UtcNow,
GeneratedById = _userId,
});
await _db.SaveChangesAsync();
_s3Service.GeneratePresignedDownloadUrlAsync("test-bucket", "pdfs/acme.pdf", 7 * 24 * 60)
.Returns("https://s3.example.com/acme.pdf");
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.Received(1).SendProposalDeliveryAsync(
"billing@acme.com",
proposal.ProposalNumber,
"Acme Corp",
"https://s3.example.com/acme.pdf",
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when customer has no ContactEmail")]
public async Task MarkSentAsync_NoContactEmail_SkipsEmail()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved, "No Email Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "No Email Corp",
ContactEmail = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when no customer record matches")]
public async Task MarkSentAsync_NoCustomerRecord_SkipsEmail()
{
// Arrange — no customer in DB
var proposal = CreateProposal(ProposalStatus.Approved, "Unknown Customer");
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when no PDF has been generated")]
public async Task MarkSentAsync_NoPdfGenerated_SkipsEmail()
{
// Arrange — customer exists but no PDF
var proposal = CreateProposal(ProposalStatus.Approved, "PDF-less Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "PDF-less Corp",
ContactEmail = "contact@pdfless.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private Proposal CreateProposal(ProposalStatus status, string customerName)
{
return new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16],
WorkOrderNumber = "WO-001",
CustomerName = customerName,
CustomerAddress = "123 Test St",
ScopeOfWork = "Test scope of work",
ServiceCategory = ServiceCategory.HVAC,
Priority = Priority.Standard,
Status = status,
Notes = "",
SubmittedById = _userId,
SubmittedAt = DateTime.UtcNow.AddDays(-1),
CurrentRevision = 1,
CreatedAt = DateTime.UtcNow.AddDays(-1),
UpdatedAt = DateTime.UtcNow.AddDays(-1),
};
}
}