//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" ) // TestGuestUpdate confirms PATCH semantics: partial fields update, empty // strings clear nullable columns, missing fields are left untouched. func TestGuestUpdate(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, "Update Event", "update-event") guestID, originalEmail := createGuestWithEmail(t, srv.URL, token, eventID, "Original Name") // Patch name + email together. var updated struct { Name string `json:"name"` Email *string `json:"email"` } patchJSON(t, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token, map[string]any{"name": "Renamed", "email": "new-" + originalEmail}, http.StatusOK, &updated) if updated.Name != "Renamed" { t.Errorf("name: got %q want Renamed", updated.Name) } if updated.Email == nil || !strings.HasPrefix(*updated.Email, "new-") { t.Errorf("email: got %v", updated.Email) } // Clear the email by sending empty string. domain.Guest tags Email // as omitempty so a nil pointer doesn't serialize; check DB state // directly instead of relying on the response shape. patchJSON(t, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token, map[string]any{"email": ""}, http.StatusOK, nil) var dbEmail *string must(t, db.Pool.QueryRow(ctx, "SELECT email FROM guests WHERE id = $1", guestID).Scan(&dbEmail), "fetch email after clear") if dbEmail != nil { t.Errorf("expected DB email cleared (NULL), got %q", *dbEmail) } // Empty name is rejected. assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token, map[string]any{"name": ""}, http.StatusBadRequest) } // TestGuestDelete confirms the row goes away + cascade-deletes the token. func TestGuestDelete(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, "Delete Event", "delete-event") guestID, _ := createGuestWithEmail(t, srv.URL, token, eventID, "Bye") // Give the guest a token first so we can prove the cascade. issueToken(t, srv.URL, token, eventID, guestID) var hasToken int must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM tokens WHERE guest_id = $1", guestID).Scan(&hasToken), "count tokens before delete") if hasToken != 1 { t.Fatalf("setup: expected 1 token, got %d", hasToken) } assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token, nil, http.StatusNoContent) var remaining int must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM guests WHERE id = $1", guestID).Scan(&remaining), "count after delete") if remaining != 0 { t.Errorf("guest still exists after delete") } must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM tokens WHERE guest_id = $1", guestID).Scan(&hasToken), "count tokens after delete") if hasToken != 0 { t.Errorf("expected cascade to delete the token, %d still exist", hasToken) } // Re-deleting → 404. assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token, nil, http.StatusNotFound) } // TestTokenRotate confirms the old token stops working and the new one // is recognised. Optionally re-publishes invitation.send when send_email // is true. func TestTokenRotate(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 invitationCount atomic.Int32 sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-rotate", func(_ context.Context, _ natspub.InvitationSend) error { invitationCount.Add(1) return nil }, logger) must(t, err, "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, "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, "Rotate Event", "rotate-event") guestID, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, "Mira") // Initial issue — captures the original token + 1 invitation publish. originalToken := issueToken(t, srv.URL, hostToken, eventID, guestID) // The /access endpoint accepts the original token before rotation. resp, err := http.Get(srv.URL + "/access/" + originalToken) must(t, err, "GET original access") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("original access pre-rotate: got %d want 200", resp.StatusCode) } // Rotate WITHOUT email — just want a fresh link. var rotated struct { Token string `json:"token"` InvitationQueued bool `json:"invitation_queued"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens/rotate", srv.URL, eventID, guestID), hostToken, map[string]any{"send_email": false}, http.StatusOK, &rotated) if rotated.Token == "" || rotated.Token == originalToken { t.Fatalf("rotated token should be fresh and non-empty (was %q, original %q)", rotated.Token, originalToken) } if rotated.InvitationQueued { t.Error("expected invitation_queued=false when send_email=false") } // Old token no longer works. resp, err = http.Get(srv.URL + "/access/" + originalToken) must(t, err, "GET original after rotate") resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Errorf("old token should not authenticate after rotation, got 200") } // New token works. resp, err = http.Get(srv.URL + "/access/" + rotated.Token) must(t, err, "GET new access") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("new token should authenticate: got %d", resp.StatusCode) } // Rotate again WITH email — should bump the invitation count. preCount := invitationCount.Load() var rotated2 struct { Token string `json:"token"` InvitationQueued bool `json:"invitation_queued"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens/rotate", srv.URL, eventID, guestID), hostToken, map[string]any{"send_email": true}, http.StatusOK, &rotated2) if !rotated2.InvitationQueued { t.Error("expected invitation_queued=true when send_email=true") } // Wait briefly for the NATS round-trip. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if invitationCount.Load() > preCount { break } time.Sleep(100 * time.Millisecond) } if invitationCount.Load() <= preCount { t.Fatalf("expected invitation publish after rotate-with-send, count %d -> %d", preCount, invitationCount.Load()) } } func patchJSONRaw(t *testing.T, url, bearer string, body any, wantStatus int) []byte { t.Helper() b, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPatch, url, strings.NewReader(string(b))) must(t, err, "build patch") req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) must(t, err, "do patch") defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != wantStatus { t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, respBody) } return respBody } func patchJSON(t *testing.T, url, bearer string, body any, wantStatus int, out any) { t.Helper() b, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPatch, url, strings.NewReader(string(b))) must(t, err, "build patch") req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) must(t, err, "do patch") defer resp.Body.Close() if resp.StatusCode != wantStatus { body, _ := io.ReadAll(resp.Body) t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body) } if out != nil { must(t, json.NewDecoder(resp.Body).Decode(out), "decode patch response") } } // Silence unused import warning if the future drops something. var _ uuid.UUID