//go:build integration package integration_test import ( "context" "encoding/json" "fmt" "io" "net/http" "testing" "time" "github.com/google/uuid" ) // TestDataExport confirms GET /me/data-export returns a JSON payload // containing the user + their events + nested records, and rejects // unauthenticated callers. func TestDataExport(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, _, hostID, token := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, token, "Export Event", "export-event") guestID, _ := createGuestWithEmail(t, srv.URL, token, eventID, "Export Guest") _ = issueToken(t, srv.URL, token, eventID, guestID) // Unauthenticated → 401. resp, err := http.Get(srv.URL + "/me/data-export") must(t, err, "GET unauthed") resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthed export should 401, got %d", resp.StatusCode) } // Authed → JSON dump with the expected shape. req, _ := http.NewRequest(http.MethodGet, srv.URL+"/me/data-export", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err = http.DefaultClient.Do(req) must(t, err, "GET authed") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("authed export status=%d body=%s", resp.StatusCode, body) } if cd := resp.Header.Get("Content-Disposition"); cd == "" { t.Errorf("missing Content-Disposition header — browser won't offer download") } body, _ := io.ReadAll(resp.Body) var out struct { Format string `json:"format"` User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` } `json:"user"` Events []struct { ID uuid.UUID `json:"id"` } `json:"events"` Guests []struct { ID uuid.UUID `json:"id"` } `json:"guests"` Tokens []struct { ID uuid.UUID `json:"id"` } `json:"tokens"` } must(t, json.Unmarshal(body, &out), "decode export") if out.Format != "guestguard.v1" { t.Errorf("format: got %q want guestguard.v1", out.Format) } if out.User.ID.String() != uuid.UUID(hostID).String() { t.Errorf("user id mismatch: got %s want %s", out.User.ID, uuid.UUID(hostID)) } if len(out.Events) != 1 || out.Events[0].ID != eventID { t.Errorf("events: got %d entries, want 1 for the seeded event", len(out.Events)) } if len(out.Guests) != 1 || out.Guests[0].ID != guestID { t.Errorf("guests: got %d entries, want 1", len(out.Guests)) } if len(out.Tokens) != 1 { t.Errorf("tokens: got %d entries, want 1 (issued one above)", len(out.Tokens)) } } // TestDeleteMe walks the soft-delete flow: account row gets tombstoned, // subsequent /me requests fail because the user is no longer findable, // and re-signup with the same email succeeds (proves the unique index // only constrains live users). func TestDeleteMe(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, hostID, token := setupAuthedAPI(t, ctx) // Capture the original email so we can prove the tombstone scrubbed it. var originalEmail string must(t, db.Pool.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, uuid.UUID(hostID)).Scan(&originalEmail), "fetch original email") // Hit DELETE /me. req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/me", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) must(t, err, "DELETE /me") resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("DELETE /me status=%d want 204", resp.StatusCode) } // DB: row is soft-deleted with PII scrubbed. var deletedAt *time.Time var emailAfter, nameAfter string must(t, db.Pool.QueryRow(ctx, `SELECT deleted_at, email, name FROM users WHERE id = $1`, uuid.UUID(hostID), ).Scan(&deletedAt, &emailAfter, &nameAfter), "fetch after delete") if deletedAt == nil { t.Fatal("expected deleted_at set") } if emailAfter == originalEmail { t.Errorf("expected email scrubbed, got %q", emailAfter) } if nameAfter != "Deleted user" { t.Errorf("expected name='Deleted user', got %q", nameAfter) } // API: subsequent /me with the same JWT returns 401 (user not found // by GetByID since it filters on deleted_at). req, _ = http.NewRequest(http.MethodGet, srv.URL+"/me", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err = http.DefaultClient.Do(req) must(t, err, "GET /me after delete") resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("post-delete /me should 401, got %d", resp.StatusCode) } // Re-signup with the ORIGINAL email succeeds because the unique // index is partial — soft-deleted rows don't block new accounts. body := fmt.Sprintf(`{"email":%q,"name":"New owner","password":"correct-horse","accept_terms":true}`, originalEmail) resp, err = http.Post(srv.URL+"/auth/signup", "application/json", stringReader(body)) must(t, err, "POST /auth/signup after delete") resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("re-signup status=%d (expected 201 — soft-deleted shouldn't block new signups)", resp.StatusCode) } } // TestAcceptTerms confirms a user created without terms acceptance can // record it post-hoc via POST /me/accept-terms. func TestAcceptTerms(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, hostID, token := setupAuthedAPI(t, ctx) // insertHost (in e2e_test.go) doesn't set the terms columns, so a // fresh host starts with NULLs. Confirm. var acceptedAt *time.Time must(t, db.Pool.QueryRow(ctx, `SELECT terms_accepted_at FROM users WHERE id = $1`, uuid.UUID(hostID), ).Scan(&acceptedAt), "fetch pre") if acceptedAt != nil { t.Fatal("setup: expected fresh host to have no terms_accepted_at") } // Hit the endpoint. req, _ := http.NewRequest(http.MethodPost, srv.URL+"/me/accept-terms", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) must(t, err, "POST /me/accept-terms") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("accept-terms status=%d", resp.StatusCode) } // DB: timestamps now set on both columns. var terms, privacy *time.Time must(t, db.Pool.QueryRow(ctx, `SELECT terms_accepted_at, privacy_policy_accepted_at FROM users WHERE id = $1`, uuid.UUID(hostID), ).Scan(&terms, &privacy), "fetch post") if terms == nil || privacy == nil { t.Errorf("expected both timestamps set: terms=%v privacy=%v", terms, privacy) } } func stringReader(s string) *stringReadCloser { return &stringReadCloser{s: s} } type stringReadCloser struct { s string pos int } func (r *stringReadCloser) Read(p []byte) (int, error) { if r.pos >= len(r.s) { return 0, io.EOF } n := copy(p, r.s[r.pos:]) r.pos += n return n, nil } func (r *stringReadCloser) Close() error { return nil }