//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) } } }