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 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,11 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
|
|||||||
-- name: GetRsvpByName :one
|
-- name: GetRsvpByName :one
|
||||||
SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1;
|
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
|
-- name: GetUserByPhone :one
|
||||||
SELECT * FROM users WHERE phone = ?;
|
SELECT * FROM users WHERE phone = ?;
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,17 @@ func (q *Queries) DeleteClaim(ctx context.Context, id int64) error {
|
|||||||
return err
|
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
|
const deleteEvent = `-- name: DeleteEvent :exec
|
||||||
DELETE FROM events WHERE id = ?
|
DELETE FROM events WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ func runMigrations(database *sql.DB) {
|
|||||||
// Indexes for auth tables (created here so they run after column migrations).
|
// 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_sessions_user ON sessions(user_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier)`,
|
`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 {
|
for _, m := range migrations {
|
||||||
_, err := database.Exec(m)
|
_, err := database.Exec(m)
|
||||||
|
|||||||
Reference in New Issue
Block a user