mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 12:23:14 +00:00
254 lines
9.1 KiB
C#
254 lines
9.1 KiB
C#
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, 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<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, Ver(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, Ver(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, Ver(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),
|
|
};
|
|
}
|
|
private string Ver(Guid id) =>
|
|
ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1);
|
|
|
|
}
|