//go:build integration package integration_test import ( "context" "fmt" "net/http" "testing" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" ) // Tier 2 Block H — day-of check-in. type checkInResp struct { CheckIn struct { ID uuid.UUID `json:"id"` GuestID uuid.UUID `json:"guest_id"` ArrivalCount int `json:"arrival_count"` WalkIn bool `json:"walk_in"` } `json:"check_in"` Summary struct { ArrivedHeadcount int `json:"arrived_headcount"` ExpectedHeadcount int `json:"expected_headcount"` GuestsCheckedIn int `json:"guests_checked_in"` } `json:"summary"` } // TestCheckInHappyPath walks the door-flow: host scans a guest's QR, // arrival is recorded, the live summary reflects the new total. A // duplicate scan returns 409 with a friendly message so the scanner UI // can flash orange instead of green. func TestCheckInHappyPath(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, "Check-In Day", "checkin-day") // Seed a guest with an attending RSVP so the QR JWT we mint is // realistic. Plus-ones=1 so expected headcount = 2. seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Sam", 1, "attending", 1, true) // Look the guest up directly so we know their id. var guestID uuid.UUID must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1 LIMIT 1`, eventID).Scan(&guestID), "load guest id") // Mint a QR JWT with the test's known JWT secret + issuer (same the // API was constructed with via setupAuthedAPI). signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, 6*time.Hour) must(t, err, "qr signer") qrJWT, _, err := signer.Issue(eventID, guestID, time.Now().Add(24*time.Hour), time.Now()) must(t, err, "mint qr") // First scan: 200, arrival count=2 (guest + 1 plus-one walked in). var ok checkInResp postJSONAuthed(t, fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID), token, map[string]any{"qr_payload": qrJWT, "arrival_count": 2}, http.StatusOK, &ok) if ok.CheckIn.ArrivalCount != 2 { t.Errorf("arrival_count: got %d want 2", ok.CheckIn.ArrivalCount) } if ok.Summary.ArrivedHeadcount != 2 { t.Errorf("arrived_headcount: got %d want 2", ok.Summary.ArrivedHeadcount) } if ok.Summary.ExpectedHeadcount != 2 { t.Errorf("expected_headcount: got %d want 2", ok.Summary.ExpectedHeadcount) } // Second scan with same QR: 409 conflict, no second row written. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventID), token, map[string]any{"qr_payload": qrJWT, "arrival_count": 1}, http.StatusConflict) } // TestCheckInRejectsForeignQR confirms a JWT minted for one event can't // be used to check someone in on another. The path 404s rather than // 403 to avoid leaking the existence of the foreign event. func TestCheckInRejectsForeignQR(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) eventA := createEvent(t, srv.URL, token, "A", "evt-a") eventB := createEvent(t, srv.URL, token, "B", "evt-b") seedAnalyticsGuest(t, ctx, db.Pool, eventA, "Sam A", 0, "attending", 0, true) var guestA uuid.UUID must(t, db.Pool.QueryRow(ctx, `SELECT id FROM guests WHERE event_id=$1`, eventA).Scan(&guestA), "load guest") signer, err := auth.NewCheckInQRSigner(testJWTSecret, testJWTIssuer, time.Hour) must(t, err, "qr signer") // JWT bound to event A. qrA, _, err := signer.Issue(eventA, guestA, time.Now().Add(24*time.Hour), time.Now()) must(t, err, "mint qr") // Posting that QR to event B should be rejected. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/check-in", srv.URL, eventB), token, map[string]any{"qr_payload": qrA, "arrival_count": 1}, http.StatusBadRequest) } // TestWalkInCreatesGuestAndCheckIn confirms a door-add registers a new // guest plus their check-in in one logical operation. func TestWalkInCreatesGuestAndCheckIn(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, "Walk-Ins", "walk-ins") var out checkInResp postJSONAuthed(t, fmt.Sprintf("%s/events/%s/walk-ins", srv.URL, eventID), token, map[string]any{ "name": "Door Crash", "arrival_count": 3, "notes": "Friend of bride", }, http.StatusCreated, &out) if !out.CheckIn.WalkIn { t.Errorf("walk_in flag should be true") } if out.CheckIn.ArrivalCount != 3 { t.Errorf("arrival_count: got %d want 3", out.CheckIn.ArrivalCount) } // Sanity: a new guest row exists with plus_ones = 2 (3 in the // party, one of whom is the guest, plus two more). var plusOnes int must(t, db.Pool.QueryRow(ctx, `SELECT plus_ones FROM guests WHERE event_id = $1`, eventID, ).Scan(&plusOnes), "load guest") if plusOnes != 2 { t.Errorf("guest plus_ones: got %d want 2", plusOnes) } }