());
}
[Fact]
- public async Task SendWaveAsync_SubstitutesNameInSubject()
+ public async Task SendWaveAsync_PassesTemplateBodyAndSubjectToEmailService()
{
var campaign = await SeedActiveCampaignWithCodesAsync(
new[] { "CODE-1" },
- emailSubject: "Hi {{Name}}, your code");
+ emailSubject: "Hi {{Name}}, your code",
+ emailBodyTemplate: "Hi {{Name}}, your code is {{Code}}
");
var user = SeedUser(displayName: "Charlie");
var team = SeedTeam("Gamma");
@@ -326,8 +314,15 @@ public async Task SendWaveAsync_SubstitutesNameInSubject()
await _service.SendWaveAsync(campaign.Id, team.Id);
- var outbox = await _dbContext.EmailOutboxMessages.SingleAsync();
- outbox.Subject.Should().Be("Hi Charlie, your code");
+ // CampaignService delegates rendering to IEmailService: it must pass through
+ // the raw template subject/body + code so OutboxEmailService can render it.
+ await _emailService.Received(1).SendCampaignCodeAsync(
+ Arg.Is(r =>
+ r.Subject == "Hi {{Name}}, your code"
+ && r.MarkdownBody == "Hi {{Name}}, your code is {{Code}}
"
+ && r.Code == "CODE-1"
+ && r.RecipientName == "Charlie"),
+ Arg.Any());
}
[Fact]
@@ -370,8 +365,11 @@ await act.Should().ThrowAsync()
}
[Fact]
- public async Task SendWaveAsync_HtmlEncodesValuesInBody()
+ public async Task SendWaveAsync_PassesRawCodeAndRecipientToEmailService()
{
+ // HTML-encoding of values happens inside OutboxEmailService (owner of the
+ // outbox table) — CampaignService must forward raw values to it so that
+ // encoding is applied consistently across all email templates.
var campaign = await SeedActiveCampaignWithCodesAsync(
new[] { "AC" },
emailBodyTemplate: "Code: {{Code}}, Name: {{Name}}
");
@@ -383,10 +381,11 @@ public async Task SendWaveAsync_HtmlEncodesValuesInBody()
await _service.SendWaveAsync(campaign.Id, team.Id);
- var outbox = await _dbContext.EmailOutboxMessages.SingleAsync();
- // Body should contain HTML-encoded values
- outbox.HtmlBody.Should().Contain("A<B>C");
- outbox.HtmlBody.Should().Contain("O'Brien & Co");
+ await _emailService.Received(1).SendCampaignCodeAsync(
+ Arg.Is(r =>
+ r.Code == "AC"
+ && r.RecipientName == "O'Brien & Co"),
+ Arg.Any());
}
// ==========================================================================
@@ -394,7 +393,7 @@ public async Task SendWaveAsync_HtmlEncodesValuesInBody()
// ==========================================================================
[Fact]
- public async Task ResendToGrantAsync_CreatesNewOutboxMessage()
+ public async Task ResendToGrantAsync_EnqueuesNewEmailAndResetsGrantStatus()
{
var campaign = await SeedActiveCampaignWithCodesAsync(new[] { "RESEND-CODE" });
@@ -404,6 +403,7 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage()
await _dbContext.SaveChangesAsync();
await _service.SendWaveAsync(campaign.Id, team.Id);
+ _emailService.ClearReceivedCalls();
var grant = await _dbContext.CampaignGrants.SingleAsync();
grant.LatestEmailStatus = EmailOutboxStatus.Failed;
@@ -411,10 +411,9 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage()
await _service.ResendToGrantAsync(grant.Id);
- var outboxMessages = await _dbContext.EmailOutboxMessages
- .Where(m => m.CampaignGrantId == grant.Id)
- .ToListAsync();
- outboxMessages.Should().HaveCount(2);
+ await _emailService.Received(1).SendCampaignCodeAsync(
+ Arg.Is(r => r.CampaignGrantId == grant.Id),
+ Arg.Any());
var updatedGrant = await _dbContext.CampaignGrants.FindAsync(grant.Id);
updatedGrant!.LatestEmailStatus.Should().Be(EmailOutboxStatus.Queued);
@@ -425,7 +424,7 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage()
// ==========================================================================
[Fact]
- public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants()
+ public async Task RetryAllFailedAsync_EnqueuesEmailsForFailedGrantsOnly()
{
var campaign = await SeedActiveCampaignWithCodesAsync(new[] { "FAIL-1", "FAIL-2" });
@@ -437,6 +436,7 @@ public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants()
await _dbContext.SaveChangesAsync();
await _service.SendWaveAsync(campaign.Id, team.Id);
+ _emailService.ClearReceivedCalls();
// Mark one as failed
var grants = await _dbContext.CampaignGrants.ToListAsync();
@@ -445,9 +445,10 @@ public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants()
await _service.RetryAllFailedAsync(campaign.Id);
- // Should have 3 outbox messages total (2 original + 1 retry)
- var outboxCount = await _dbContext.EmailOutboxMessages.CountAsync();
- outboxCount.Should().Be(3);
+ // Only the failed grant should be re-enqueued.
+ await _emailService.Received(1).SendCampaignCodeAsync(
+ Arg.Is(r => r.CampaignGrantId == grants[0].Id),
+ Arg.Any());
var retriedGrant = await _dbContext.CampaignGrants.FindAsync(grants[0].Id);
retriedGrant!.LatestEmailStatus.Should().Be(EmailOutboxStatus.Queued);
diff --git a/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs b/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs
index 45e51e28..9acdff88 100644
--- a/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs
+++ b/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs
@@ -27,6 +27,7 @@ public class ProfileServiceTests : IDisposable
private readonly IAuditLogService _auditLogService = Substitute.For();
private readonly IMembershipCalculator _membershipCalculator = Substitute.For();
private readonly IConsentService _consentService = Substitute.For();
+ private readonly ITicketQueryService _ticketQueryService = Substitute.For();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
public ProfileServiceTests()
@@ -39,9 +40,12 @@ public ProfileServiceTests()
_clock = new FakeClock(Instant.FromUtc(2026, 3, 1, 12, 0));
_service = new ProfileService(
_dbContext, _onboardingService, _emailService, _auditLogService,
- _membershipCalculator, _consentService, _clock, _cache,
+ _membershipCalculator, _consentService, _ticketQueryService, _clock, _cache,
NullLogger.Instance);
+ _ticketQueryService.GetUserTicketExportDataAsync(Arg.Any(), Arg.Any())
+ .Returns(new UserTicketExportData([], []));
+
// Default: return all input IDs as Active (sufficient for most tests that don't filter by status)
_membershipCalculator
.PartitionUsersAsync(Arg.Any>(), Arg.Any())
diff --git a/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs b/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs
index e0c2f20b..79a74a18 100644
--- a/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs
+++ b/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs
@@ -23,6 +23,7 @@ public class TicketSyncServiceTests : IDisposable
private readonly FakeClock _clock;
private readonly ITicketVendorService _vendorService;
private readonly IStripeService _stripeService;
+ private readonly ICampaignService _campaignService;
private readonly TicketSyncService _service;
public TicketSyncServiceTests()
@@ -44,6 +45,7 @@ public TicketSyncServiceTests()
_stripeService = Substitute.For();
var userService = Substitute.For();
+ _campaignService = Substitute.For();
_service = new TicketSyncService(
_dbContext,
_vendorService,
@@ -52,7 +54,8 @@ public TicketSyncServiceTests()
settings,
NullLogger.Instance,
new MemoryCache(new MemoryCacheOptions()),
- userService);
+ userService,
+ _campaignService);
// Seed the singleton TicketSyncState row
_dbContext.TicketSyncStates.Add(new TicketSyncState
@@ -171,51 +174,11 @@ public async Task SyncOrdersAndAttendeesAsync_UpsertDoesNotCreateDuplicates()
// ==========================================================================
[Fact]
- public async Task SyncOrdersAndAttendeesAsync_MatchesDiscountCodeToCampaignGrant()
+ public async Task SyncOrdersAndAttendeesAsync_ForwardsDiscountCodesToCampaignService()
{
- // Seed campaign infrastructure
- var creatorId = Guid.NewGuid();
- SeedUser(creatorId, "Creator");
-
- var campaignId = Guid.NewGuid();
- var codeId = Guid.NewGuid();
- var grantUserId = Guid.NewGuid();
- SeedUser(grantUserId, "GrantUser");
-
- var campaign = new Campaign
- {
- Id = campaignId,
- Title = "Test Campaign",
- EmailSubject = "Your code",
- EmailBodyTemplate = "{{Code}}
",
- Status = CampaignStatus.Active,
- CreatedAt = _clock.GetCurrentInstant(),
- CreatedByUserId = creatorId
- };
- _dbContext.Campaigns.Add(campaign);
-
- var code = new CampaignCode
- {
- Id = codeId,
- CampaignId = campaignId,
- Code = "DISCOUNT10",
- ImportedAt = _clock.GetCurrentInstant()
- };
- _dbContext.CampaignCodes.Add(code);
-
- var grant = new CampaignGrant
- {
- Id = Guid.NewGuid(),
- CampaignId = campaignId,
- CampaignCodeId = codeId,
- UserId = grantUserId,
- AssignedAt = _clock.GetCurrentInstant(),
- RedeemedAt = null
- };
- _dbContext.CampaignGrants.Add(grant);
- await _dbContext.SaveChangesAsync();
-
- // Order uses the discount code
+ // Order uses a discount code — TicketSyncService is expected to aggregate
+ // these into DiscountCodeRedemption instances and delegate to ICampaignService
+ // (which owns CampaignGrants) for the actual RedeemedAt updates.
var orders = new List
{
MakeOrderDto("ord_disc", "Buyer", "buyer@example.com", discountCode: "discount10")
@@ -226,12 +189,20 @@ public async Task SyncOrdersAndAttendeesAsync_MatchesDiscountCodeToCampaignGrant
_vendorService.GetIssuedTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new List());
+ _campaignService.MarkGrantsRedeemedAsync(
+ Arg.Any>(),
+ Arg.Any())
+ .Returns(1);
+
var result = await _service.SyncOrdersAndAttendeesAsync();
result.CodesRedeemed.Should().Be(1);
- var updatedGrant = await _dbContext.CampaignGrants.FindAsync(grant.Id);
- updatedGrant!.RedeemedAt.Should().NotBeNull();
+ // Verify the redemption was forwarded to the campaign service
+ await _campaignService.Received(1).MarkGrantsRedeemedAsync(
+ Arg.Is>(r =>
+ r.Count == 1 && string.Equals(r.First().Code, "discount10", StringComparison.Ordinal)),
+ Arg.Any());
}
// ==========================================================================
@@ -290,7 +261,8 @@ public async Task SyncOrdersAndAttendeesAsync_SkipsWhenEventIdNotConfigured()
settings,
NullLogger.Instance,
new MemoryCache(new MemoryCacheOptions()),
- Substitute.For());
+ Substitute.For(),
+ Substitute.For());
var result = await service.SyncOrdersAndAttendeesAsync();