//go:build integration package integration_test import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "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" ) // TestTokenIssuePublishesInvitation walks the new wire-up: issuing a // token for a guest with an email-on-file should publish an // invitation.send event over NATS, and the API response should reflect // that the invitation was queued. func TestTokenIssuePublishesInvitation(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) // Subscribe before issuing — JetStream replay is on by default but // this guarantees we don't race the consumer setup. var seen atomic.Int32 captured := make(chan natspub.InvitationSend, 1) sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-invitation-send", func(ctx context.Context, evt natspub.InvitationSend) error { seen.Add(1) select { case captured <- evt: 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 server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) hostID := insertHost(t, ctx, db.Pool) token := issueHostToken(t, hostID) eventID := createEvent(t, srv.URL, token, "Invitation Test", "invitation-test") guestID, guestEmail := createGuestWithEmail(t, srv.URL, token, eventID, "Mira") var issued struct { Token string `json:"token"` InvitationQueued bool `json:"invitation_queued"` InvitationLink string `json:"invitation_link"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens", srv.URL, eventID, guestID), token, nil, http.StatusCreated, &issued) if !issued.InvitationQueued { t.Fatalf("expected invitation_queued=true (response: %+v)", issued) } if !strings.HasPrefix(issued.InvitationLink, "https://gg.example.test/rsvp/tk_") { t.Fatalf("invitation_link should use publicBaseURL: got %q", issued.InvitationLink) } select { case evt := <-captured: if evt.GuestID.String() != guestID.String() { t.Errorf("guest id: got %s want %s", evt.GuestID, guestID) } if evt.GuestEmail != guestEmail { t.Errorf("guest email: got %s want %s", evt.GuestEmail, guestEmail) } if evt.EventName != "Invitation Test" { t.Errorf("event name: got %s", evt.EventName) } if !strings.HasPrefix(evt.Link, "https://gg.example.test/rsvp/tk_") { t.Errorf("link: got %s", evt.Link) } case <-time.After(10 * time.Second): t.Fatalf("did not see invitation.send within 10s (seen=%d)", seen.Load()) } } // TestTokenIssueWithoutGuestEmailSkipsInvitation confirms that a guest // with no email on file does NOT trigger a publish — the host still gets // a copy-pasteable link. func TestTokenIssueWithoutGuestEmailSkipsInvitation(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 invitations atomic.Int32 sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-no-email-invitation", func(ctx context.Context, evt natspub.InvitationSend) error { invitations.Add(1) 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: "http://localhost", }) must(t, err, "build api server") 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, "No Email Event", "no-email-event") // createGuest helper produces a guest with no email field set. guestID := createGuest(t, srv.URL, hostToken, eventID, "Phone-only Guest") var issued struct { InvitationQueued bool `json:"invitation_queued"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens", srv.URL, eventID, guestID), hostToken, nil, http.StatusCreated, &issued) if issued.InvitationQueued { t.Fatalf("expected invitation_queued=false for emailless guest") } // Give NATS a moment to surface any (unwanted) message. time.Sleep(500 * time.Millisecond) if invitations.Load() != 0 { t.Fatalf("expected 0 invitation.send messages, got %d", invitations.Load()) } } // createGuestWithEmail is a thin wrapper that adds an email field, since // the existing helper omits it. func createGuestWithEmail(t *testing.T, base, accessToken string, eventID uuid.UUID, name string) (uuid.UUID, string) { t.Helper() email := fmt.Sprintf("guest-%d@example.test", time.Now().UnixNano()) body := map[string]any{"name": name, "email": email} var out struct{ ID uuid.UUID `json:"id"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", base, eventID), accessToken, body, http.StatusCreated, &out) return out.ID, email } // Avoid the import being marked unused if a future refactor drops it. var _ = json.Marshal