Files
guestguard/test/integration/messages_test.go
T
Kwaku Danso dc840bfc14 feat(tier2): reminders + broadcasts pipeline — Block F
The Communications surface. Hosts can schedule custom broadcasts to a
chosen audience (everyone / attending / pending / declined / maybe),
edit or cancel anything that hasn't fired, and review delivery
outcomes. Four auto-reminders are pre-seeded on every new event:
7-day, 3-day last call, 1-day, and day-of.

Schema (migration 0012)
- scheduled_messages — one row per message envelope, with status
  walking draft -> scheduled -> sending -> sent (or cancelled/failed).
  Partial index on (send_at) WHERE status='scheduled' for the
  scheduler poll; per-event index for the Communications tab list.
- message_deliveries — per-recipient outcomes so a partial-failure
  batch doesn't lose the rows that did succeed.

Domain
- MessageAudience / MessageChannel / MessageStatus enums
- SeedAutoReminders helper that returns four canonical reminder rows
  for a given event_date, skipping any whose send_at would land in
  the past (events created close to the date)

Storage
- MessageRepo: Create / CreateBatch / Get / ListByEvent / Update
  (locks the row and refuses unless status is draft|scheduled) /
  Cancel / PromoteToScheduled (the send-now path) / ListDue /
  ClaimForSending (atomic guard against two replicas double-sending) /
  MarkSent / MarkFailed / RecordDelivery / DeliveryStats /
  LoadRecipients (audience-filtered guest list) / CountRecipients
- EventRepo.Create now seeds auto-reminders in the same transaction
  that inserts the event and its owner collaborator row

API (all editor+, except recipient-count which is viewer+)
- GET    /events/{id}/messages
- GET    /events/{id}/messages/recipient-count?audience=...
- POST   /events/{id}/messages   (draft / schedule / send-now)
- PATCH  /events/{id}/messages/{message_id}
- POST   /events/{id}/messages/{message_id}/send-now
- DELETE /events/{id}/messages/{message_id}

Scheduler worker (cmd/notifier)
- New file scheduler.go: polls ListDue every 30s, claims each row
  atomically (ClaimForSending uses a status=scheduled guard so two
  notifier replicas don't double-send), renders subject and body
  per recipient with the {{guest_name}} / {{event_name}} /
  {{event_date}} / {{venue}} / {{rsvp_link}} placeholders, sends via
  the existing GuestEmailDispatcher (Resend > SMTP > SES > log
  stub, same picker as the API), records each delivery row.

Frontend
- New CommunicationsCard.vue with compose form (audience + channel +
  subject + body + send-mode radios), live "X guests will receive
  this" recipient-count preview, and three sub-tabs for Scheduled /
  Sent / Cancelled. Per-message Send-now and Cancel actions for
  draft/scheduled rows. Friendly labels for auto-seeded reminders
  ("1-day reminder", "Day-of reminder") so the slugs never leak.
- New top-level tab "Communications" on the event-detail page,
  between Collaborators and Branding.

Tests
- TestAutoReminderSeeding confirms a future-dated event lands the
  four canonical reminders in scheduled state.
- TestComposeAndEditMessage walks draft -> patch -> send-now ->
  cancel and asserts the conflict on PATCH-after-cancel.
- TestRecipientCountAudienceFilter seeds a known guest mix and
  checks every audience preset returns the right count.
- Full integration suite passes (~177s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:56:37 +01:00

169 lines
5.2 KiB
Go

//go:build integration
package integration_test
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
)
// Tier 2 Block F — scheduled messages (reminders + broadcasts).
type messagePersisted struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
Audience string `json:"audience"`
Channel string `json:"channel"`
Status string `json:"status"`
TemplateKey *string `json:"template_key"`
Subject *string `json:"subject"`
Body string `json:"body"`
}
type listMessagesBody struct {
Messages []struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
TemplateKey *string `json:"template_key"`
Body string `json:"body"`
} `json:"messages"`
}
// TestAutoReminderSeeding confirms that creating an event in the future
// also seeds the reminder schedule. The seed is conservative: only
// reminders whose send_at would fall after "now" land in the DB.
func TestAutoReminderSeeding(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Far Future", "far-future")
// createEvent defaults event_date to +30d, so all four templates
// (7d / last_call / 1d / dayof) should seed.
var listed listMessagesBody
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
token, http.StatusOK, &listed)
if len(listed.Messages) < 3 {
t.Fatalf("expected at least 3 auto-reminders, got %d", len(listed.Messages))
}
seen := map[string]bool{}
for _, m := range listed.Messages {
if m.TemplateKey != nil {
seen[*m.TemplateKey] = true
}
if m.Status != "scheduled" {
t.Errorf("auto-reminder status: got %q want scheduled", m.Status)
}
}
for _, want := range []string{"reminder_7d", "last_call", "reminder_1d", "reminder_dayof"} {
if !seen[want] {
t.Errorf("missing auto-reminder template %q", want)
}
}
}
// TestComposeAndEditMessage walks the create -> list -> patch -> cancel
// path. Confirms the status machine: edits OK while scheduled, send-now
// flips to scheduled with send_at=now, cancel turns it to cancelled and
// blocks further edits.
func TestComposeAndEditMessage(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Compose", "compose-test")
// Compose a draft.
var created messagePersisted
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
token,
map[string]any{
"audience": "pending",
"channel": "email",
"subject": "Pls reply",
"body": "Hi {{guest_name}}, we'd love to know if you can make it!",
"draft": true,
},
http.StatusCreated, &created)
if created.Status != "draft" {
t.Fatalf("expected draft status, got %q", created.Status)
}
// Patch the body. Status: still draft.
patchJSON(t, fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token,
map[string]any{
"body": "Updated copy",
},
http.StatusOK, nil)
// Send-now: promotes the draft to scheduled with send_at=now.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/messages/%s/send-now", srv.URL, eventID, created.ID),
token, nil, http.StatusAccepted)
// Cancel: works while scheduled.
assertStatus(t, http.MethodDelete,
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token, nil, http.StatusNoContent)
// PATCH on a cancelled row is refused.
assertStatus(t, http.MethodPatch,
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token,
map[string]any{"body": "too late"},
http.StatusConflict)
}
// TestRecipientCountAudienceFilter confirms the live preview returns
// counts matching the audience filter.
func TestRecipientCountAudienceFilter(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Audience", "audience-test")
// Seed five guests: 2 attending, 1 declined, 1 maybe, 1 pending.
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A1", 2, "attending", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A2", 0, "attending", 1, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "D1", 0, "declined", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "M1", 0, "maybe", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "P1", 0, "", 0, false)
cases := map[string]int{
"all": 5,
"attending": 2,
"declined": 1,
"maybe": 1,
"pending": 1,
}
for audience, want := range cases {
var got struct {
Count int `json:"count"`
}
getJSONAuthed(t,
fmt.Sprintf("%s/events/%s/messages/recipient-count?audience=%s",
srv.URL, eventID, audience),
token, http.StatusOK, &got)
if got.Count != want {
t.Errorf("audience=%s: got %d want %d", audience, got.Count, want)
}
}
}