//go:build integration package integration_test import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/notification" "github.com/alchemistkay/guestguard/internal/storage" ) // mustInsertEventAndGuest seeds the bare minimum rows the notifications // webhook tests need to attach a notification to a real guest. func mustInsertEventAndGuest(t *testing.T, ctx context.Context, db *storage.DB, hostID uuid.UUID) (uuid.UUID, uuid.UUID) { t.Helper() var eventID uuid.UUID must(t, db.Pool.QueryRow(ctx, ` INSERT INTO events (host_id, name, slug, event_date) VALUES ($1, 'Notif Test', $2, now() + interval '30 day') RETURNING id `, hostID, fmt.Sprintf("notif-%d", time.Now().UnixNano())).Scan(&eventID), "insert event") var guestID uuid.UUID must(t, db.Pool.QueryRow(ctx, ` INSERT INTO guests (event_id, name, email) VALUES ($1, 'Notif Guest', $2) RETURNING id `, eventID, fmt.Sprintf("notif-%d@example.test", time.Now().UnixNano())).Scan(&guestID), "insert guest") return eventID, guestID } func setupNotificationsAPI(t *testing.T, ctx context.Context) (*httptest.Server, *storage.DB, *notification.UnsubscribeSigner) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") suppressions := notification.NewSuppressionRepo(db) notifRepo := notification.NewRepo(db) const secret = "test-unsubscribe-secret-at-least-32-bytes-long" signer := notification.NewUnsubscribeSigner(secret) apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, TokenTTL: 24 * time.Hour, JWTSecret: testJWTSecret, JWTIssuer: testJWTIssuer, AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 24 * time.Hour, EmailVerificationTTL: 1 * time.Hour, PasswordResetTTL: 1 * time.Hour, PublicBaseURL: "http://localhost", NotificationRepo: notifRepo, SuppressionRepo: suppressions, UnsubscribeSigner: signer, }) must(t, err, "build api server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) return srv, db, signer } // TestUnsubscribeFlow exercises the signed-link end-to-end: preview surfaces // the email, confirm writes the suppression row, and a tampered token is // rejected. func TestUnsubscribeFlow(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, signer := setupNotificationsAPI(t, ctx) email := "mira@example.test" token := signer.Sign(email) // Preview returns the bound email. var preview struct{ Email string } getJSONAuthed(t, srv.URL+"/unsubscribe/"+token, "", http.StatusOK, &preview) if preview.Email != email { t.Fatalf("preview email: got %q want %q", preview.Email, email) } // Confirm writes the row. assertStatus(t, http.MethodPost, srv.URL+"/unsubscribe/"+token, "", nil, http.StatusOK) yep, err := notification.NewSuppressionRepo(db).IsSuppressed(ctx, email) must(t, err, "check suppression") if !yep { t.Fatalf("expected email %s suppressed", email) } // Tampered token is rejected. tampered := token[:len(token)-2] + "xx" assertStatus(t, http.MethodGet, srv.URL+"/unsubscribe/"+tampered, "", nil, http.StatusBadRequest) } // TestSESBounceWebhook walks the inbound bounce → suppression chain. We // build a notification row first (so MarkBounce has something to update), // then post a Bounce envelope, then verify both the status flip and the // suppression entry. func TestSESBounceWebhook(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, _ := setupNotificationsAPI(t, ctx) // Insert a fake guest + notification with a known provider_message_id. hostID := insertHost(t, ctx, db.Pool) var eventID, guestID = mustInsertEventAndGuest(t, ctx, db, hostID) const msgID = "ses-fake-message-id-1234" notifRepo := notification.NewRepo(db) _, err := notifRepo.Record(ctx, notification.RecordParams{ GuestID: guestID, Channel: notification.ChannelEmail, Type: notification.TypeInvitation, Status: notification.StatusSent, ProviderMessageID: msgID, }) must(t, err, "seed notification") _ = eventID // SES → SNS envelope: outer "Notification" carries inner JSON as a string. innerJSON, _ := json.Marshal(map[string]any{ "notificationType": "Bounce", "mail": map[string]any{"messageId": msgID}, "bounce": map[string]any{ "bounceType": "Permanent", "bouncedRecipients": []map[string]any{ {"emailAddress": "bouncer@example.test"}, }, }, }) envelope, _ := json.Marshal(map[string]any{ "Type": "Notification", "Message": string(innerJSON), }) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/webhooks/ses/notifications", strings.NewReader(string(envelope))) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) must(t, err, "post ses webhook") resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) } // Notification row marked as bounced/permanent. var status, bounceType string must(t, db.Pool.QueryRow(ctx, "SELECT status, bounce_type FROM notifications WHERE provider_message_id = $1", msgID, ).Scan(&status, &bounceType), "fetch notification") if status != "bounced" || bounceType != "permanent" { t.Fatalf("bad row: status=%s bounce_type=%s", status, bounceType) } // Suppression row populated. yep, err := notification.NewSuppressionRepo(db).IsSuppressed(ctx, "bouncer@example.test") must(t, err, "check suppression") if !yep { t.Fatal("expected bouncer email suppressed") } } // TestTwilioStatusWebhook flips a row's status to delivered. func TestTwilioStatusWebhook(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, _ := setupNotificationsAPI(t, ctx) hostID := insertHost(t, ctx, db.Pool) _, guestID := mustInsertEventAndGuest(t, ctx, db, hostID) const sid = "SMfake0123456789" _, err := notification.NewRepo(db).Record(ctx, notification.RecordParams{ GuestID: guestID, Channel: notification.ChannelSMS, Type: notification.TypeInvitation, Status: notification.StatusSent, ProviderMessageID: sid, }) must(t, err, "seed notification") form := url.Values{} form.Set("MessageSid", sid) form.Set("MessageStatus", "delivered") req, _ := http.NewRequest(http.MethodPost, srv.URL+"/webhooks/twilio/status", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) must(t, err, "post twilio webhook") resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) } var status string must(t, db.Pool.QueryRow(ctx, "SELECT status FROM notifications WHERE provider_message_id = $1", sid, ).Scan(&status), "fetch status") if status != "delivered" { t.Fatalf("expected delivered, got %s", status) } }