//go:build integration package integration_test import ( "context" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/storage" ) // TestBulkIssueInvitations confirms the bulk endpoint: // - mints tokens for guests without one // - skips guests that already have a token // - publishes invitation.send only for guests with an email // - returns an accurate per-bucket summary func TestBulkIssueInvitations(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) natsURL := startNATS(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") natsClient, err := natspub.Connect(ctx, natsURL, logger) must(t, err, "connect nats") t.Cleanup(natsClient.Close) var pubCount atomic.Int32 emails := make(chan string, 32) sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-bulk-invitation", func(_ context.Context, evt natspub.InvitationSend) error { pubCount.Add(1) select { case emails <- evt.GuestEmail: default: } return nil }, logger) must(t, err, "build subscriber") cc, err := sub.Start(ctx) must(t, err, "start subscriber") t.Cleanup(cc.Stop) apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, AccessPublisher: natsClient, RSVPPublisher: natsClient, InvitationPublisher: natsClient, 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: "https://gg.example.test", }) must(t, err, "build api") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) hostID := insertHost(t, ctx, db.Pool) hostToken := issueHostToken(t, hostID) eventID := createEvent(t, srv.URL, hostToken, "Bulk Event", "bulk-event") // Three guests with emails, two without. var withEmail []uuid.UUID for i := 0; i < 3; i++ { id, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, fmt.Sprintf("Email-%d", i)) withEmail = append(withEmail, id) } var noEmail []uuid.UUID for i := 0; i < 2; i++ { noEmail = append(noEmail, createGuest(t, srv.URL, hostToken, eventID, fmt.Sprintf("Phone-%d", i))) } // Pre-issue one token for the first email guest to test the // skipped_existing path. issueToken(t, srv.URL, hostToken, eventID, withEmail[0]) // Call bulk endpoint (empty body = "all eligible"). var result struct { Issued int `json:"issued"` Queued int `json:"queued"` SkippedExisting int `json:"skipped_existing"` SkippedNoEmail int `json:"skipped_no_email"` Errors []struct { GuestID string `json:"guest_id"` Reason string `json:"reason"` } `json:"errors"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID), hostToken, map[string]any{}, http.StatusOK, &result) if result.Issued != 4 { t.Errorf("issued: got %d want 4 (2 emails + 2 no-email; the third email guest was pre-issued)", result.Issued) } if result.Queued != 2 { t.Errorf("queued: got %d want 2", result.Queued) } if result.SkippedExisting != 1 { t.Errorf("skipped_existing: got %d want 1", result.SkippedExisting) } if result.SkippedNoEmail != 2 { t.Errorf("skipped_no_email: got %d want 2", result.SkippedNoEmail) } if len(result.Errors) != 0 { t.Errorf("unexpected errors: %+v", result.Errors) } // Wait for the two NATS messages. deadline := time.After(10 * time.Second) received := 0 loop: for received < 2 { select { case <-emails: received++ case <-deadline: break loop } } if received != 2 { t.Fatalf("expected 2 invitation.send messages, got %d", received) } // Re-running bulk should be a no-op (everyone now has a token). var second struct { Issued int `json:"issued"` Queued int `json:"queued"` SkippedExisting int `json:"skipped_existing"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID), hostToken, map[string]any{}, http.StatusOK, &second) if second.Issued != 0 || second.Queued != 0 || second.SkippedExisting != 5 { t.Errorf("re-run: got issued=%d queued=%d skipped_existing=%d (want 0/0/5)", second.Issued, second.Queued, second.SkippedExisting) } } // TestBulkIssueExplicitSubset confirms guest_ids is honoured. func TestBulkIssueExplicitSubset(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) natsURL := startNATS(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") natsClient, err := natspub.Connect(ctx, natsURL, logger) must(t, err, "connect nats") t.Cleanup(natsClient.Close) apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, AccessPublisher: natsClient, RSVPPublisher: natsClient, InvitationPublisher: natsClient, 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: "https://gg.example.test", }) must(t, err, "build api") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) hostID := insertHost(t, ctx, db.Pool) hostToken := issueHostToken(t, hostID) eventID := createEvent(t, srv.URL, hostToken, "Subset Event", "subset-event") var ids []uuid.UUID for i := 0; i < 3; i++ { id, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, fmt.Sprintf("G-%d", i)) ids = append(ids, id) } // Send to only the middle guest. var result struct { Issued int `json:"issued"` Queued int `json:"queued"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID), hostToken, map[string]any{"guest_ids": []string{ids[1].String()}}, http.StatusOK, &result) if result.Issued != 1 || result.Queued != 1 { t.Fatalf("subset send: got issued=%d queued=%d, want 1/1", result.Issued, result.Queued) } // The other two should still be tokenless. var hasToken int must(t, db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM tokens WHERE guest_id = ANY($1)", []uuid.UUID{ids[0], ids[2]}, ).Scan(&hasToken), "count tokens for non-targets") if hasToken != 0 { t.Fatalf("expected 0 tokens for non-targets, got %d", hasToken) } }