proposal-system/api/tests/ProposalSystem.Tests/Validators/CustomerEmailValidatorTests.cs

100 lines
3.6 KiB
C#
Raw Permalink 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 ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR4: Tests for customer email format validation on both Create and Update DTOs.
/// Ensures the ContactEmail field, when provided, must be a valid email address.
/// </summary>
public class CustomerEmailValidatorTests
{
private readonly CreateCustomerValidator _createValidator = new();
private readonly UpdateCustomerValidator _updateValidator = new();
#region CreateCustomerValidator — ContactEmail
[Fact(DisplayName = "Create: null ContactEmail passes validation")]
public void Create_NullEmail_Passes()
{
var request = new CreateCustomerRequest("Acme Corp", null, null);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Create: valid ContactEmail passes validation")]
public void Create_ValidEmail_Passes()
{
var request = new CreateCustomerRequest("Acme Corp", null, "john@example.com");
var result = _createValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Theory(DisplayName = "Create: invalid ContactEmail fails validation")]
[InlineData("not-an-email")]
[InlineData("missing@")]
[InlineData("@no-local.com")]
public void Create_InvalidEmail_Fails(string email)
{
var request = new CreateCustomerRequest("Acme Corp", null, email);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
[Fact(DisplayName = "Create: ContactEmail exceeding 320 chars fails")]
public void Create_EmailTooLong_Fails()
{
var longEmail = new string('a', 310) + "@example.com"; // 322 chars
var request = new CreateCustomerRequest("Acme Corp", null, longEmail);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
#endregion
#region UpdateCustomerValidator — ContactEmail
[Fact(DisplayName = "Update: null ContactEmail passes validation")]
public void Update_NullEmail_Passes()
{
var request = new UpdateCustomerRequest(null, null, null);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Update: valid ContactEmail passes validation")]
public void Update_ValidEmail_Passes()
{
var request = new UpdateCustomerRequest(null, null, "john@example.com");
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Theory(DisplayName = "Update: invalid ContactEmail fails validation")]
[InlineData("not-an-email")]
[InlineData("missing@")]
[InlineData("@no-local.com")]
public void Update_InvalidEmail_Fails(string email)
{
var request = new UpdateCustomerRequest(null, null, email);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
[Fact(DisplayName = "Update: name exceeding 200 chars fails")]
public void Update_NameTooLong_Fails()
{
var request = new UpdateCustomerRequest(new string('X', 201), null, null);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Name");
}
#endregion
}