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; /// /// 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. /// 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(); _audit = Substitute.For(); _jobPublisher = Substitute.For(); _numberGenerator = Substitute.For(); _emailService = Substitute.For(); _s3Service = Substitute.For(); _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 { { "GENERATED_BUCKET", "test-bucket" }, }) .Build(); _sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher, _emailService, _s3Service, config, Substitute.For>()); } 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(), Arg.Any(), Arg.Any()) .Returns("https://s3.example.com/test.pdf"); // Make the email service throw _emailService.SendProposalDeliveryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("SES is down")); // Act — should NOT throw var result = await _sut.MarkSentAsync(proposal.Id, Ver(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, Ver(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()); } [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, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); await _emailService.DidNotReceive().SendProposalDeliveryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [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, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); await _emailService.DidNotReceive().SendProposalDeliveryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [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, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); await _emailService.DidNotReceive().SendProposalDeliveryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } 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), }; } private string Ver(Guid id) => ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1); }