From fe5cdd92a1fb6dee82495c8619aec588c625699b Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 18 May 2026 22:55:21 -0400 Subject: [PATCH] Dedupe RSVPs and add tests for auto-RSVP on claim Add migration to clean up duplicate RSVPs (keep earliest per event+name). Add GetRsvpByName and DeleteDuplicateRsvps queries. Tests cover auto-RSVP creation, duplicate prevention, and case-insensitive matching. Co-Authored-By: Claude Opus 4.6 --- db/queries.sql | 5 ++ db/queries.sql.go | 11 +++ handlers_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++ migrate.go | 2 + 4 files changed, 186 insertions(+) create mode 100644 handlers_test.go diff --git a/db/queries.sql b/db/queries.sql index ac3340b..5f99723 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -72,6 +72,11 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?; -- name: GetRsvpByName :one SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1; +-- name: DeleteDuplicateRsvps :exec +DELETE FROM rsvps WHERE id NOT IN ( + SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE +); + -- name: GetUserByPhone :one SELECT * FROM users WHERE phone = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go index d8d77f1..08c7790 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -232,6 +232,17 @@ func (q *Queries) DeleteClaim(ctx context.Context, id int64) error { return err } +const deleteDuplicateRsvps = `-- name: DeleteDuplicateRsvps :exec +DELETE FROM rsvps WHERE id NOT IN ( + SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE +) +` + +func (q *Queries) DeleteDuplicateRsvps(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteDuplicateRsvps) + return err +} + const deleteEvent = `-- name: DeleteEvent :exec DELETE FROM events WHERE id = ? ` diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..620b413 --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "database/sql" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/ryanchen/bbq/db" +) + +func setupTestDB(t *testing.T) (*sql.DB, *db.Queries) { + t.Helper() + database, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on") + if err != nil { + t.Fatal(err) + } + if _, err := database.Exec(schemaSQL); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + return database, db.New(database) +} + +func createTestEvent(t *testing.T, q *db.Queries) db.Event { + t.Helper() + event, err := q.CreateEvent(context.Background(), db.CreateEventParams{ + Slug: "test", Title: "Test Event", Date: "June 1", Time: "2pm", + Location: "Park", AdminToken: "tok123", + }) + if err != nil { + t.Fatal(err) + } + return event +} + +func TestAutoRsvpOnClaim(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + slot, err := q.CreateSlot(ctx, db.CreateSlotParams{ + EventID: event.ID, Name: "Drinks", MaxClaims: 5, + }) + if err != nil { + t.Fatal(err) + } + + // Claim a slot — should also create an RSVP + _, err = q.CreateClaim(ctx, db.CreateClaimParams{ + SlotID: slot.ID, Name: "Alice", + }) + if err != nil { + t.Fatal(err) + } + + // Simulate the auto-RSVP logic from handleClaim + _, err = q.GetRsvpByName(ctx, db.GetRsvpByNameParams{ + EventID: event.ID, Name: "Alice", + }) + if err == sql.ErrNoRows { + _, err = q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "Alice", + }) + if err != nil { + t.Fatal(err) + } + } + + rsvps, _ := q.ListRsvps(ctx, event.ID) + if len(rsvps) != 1 { + t.Fatalf("expected 1 RSVP, got %d", len(rsvps)) + } + if rsvps[0].Name != "Alice" { + t.Fatalf("expected RSVP name Alice, got %s", rsvps[0].Name) + } +} + +func TestAutoRsvpNoDuplicate(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + slot, _ := q.CreateSlot(ctx, db.CreateSlotParams{ + EventID: event.ID, Name: "Drinks", MaxClaims: 5, + }) + + // RSVP first + q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "Bob", + }) + + // Then claim — auto-RSVP should skip + q.CreateClaim(ctx, db.CreateClaimParams{ + SlotID: slot.ID, Name: "Bob", + }) + _, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{ + EventID: event.ID, Name: "Bob", + }) + if err == sql.ErrNoRows { + q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "Bob", + }) + } + + rsvps, _ := q.ListRsvps(ctx, event.ID) + if len(rsvps) != 1 { + t.Fatalf("expected 1 RSVP (no duplicate), got %d", len(rsvps)) + } +} + +func TestAutoRsvpCaseInsensitive(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + slot, _ := q.CreateSlot(ctx, db.CreateSlotParams{ + EventID: event.ID, Name: "Drinks", MaxClaims: 5, + }) + + // RSVP as "alice" + q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "alice", + }) + + // Claim as "Alice" — should not create duplicate + q.CreateClaim(ctx, db.CreateClaimParams{ + SlotID: slot.ID, Name: "Alice", + }) + _, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{ + EventID: event.ID, Name: "Alice", + }) + if err == sql.ErrNoRows { + q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "Alice", + }) + } + + rsvps, _ := q.ListRsvps(ctx, event.ID) + if len(rsvps) != 1 { + t.Fatalf("expected 1 RSVP (case-insensitive dedup), got %d", len(rsvps)) + } +} + +func TestDeduplicateExistingRsvps(t *testing.T) { + database, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + // Insert duplicate RSVPs directly + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"}) + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"}) + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie"}) + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana"}) + + rsvps, _ := q.ListRsvps(ctx, event.ID) + if len(rsvps) != 4 { + t.Fatalf("expected 4 RSVPs before dedup, got %d", len(rsvps)) + } + + // Run the dedup query (same as migration) + database.Exec(`DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`) + + rsvps, _ = q.ListRsvps(ctx, event.ID) + if len(rsvps) != 2 { + t.Fatalf("expected 2 RSVPs after dedup (Charlie + Dana), got %d", len(rsvps)) + } +} diff --git a/migrate.go b/migrate.go index e14af61..6310c86 100644 --- a/migrate.go +++ b/migrate.go @@ -18,6 +18,8 @@ func runMigrations(database *sql.DB) { // Indexes for auth tables (created here so they run after column migrations). `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, `CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier)`, + // Dedupe RSVPs: keep earliest per (event_id, name). + `DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`, } for _, m := range migrations { _, err := database.Exec(m)