//go:build integration package integration_test import ( "context" "encoding/csv" "fmt" "io" "net/http" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // Tier 2 Block E — host analytics. type analyticsBody struct { Overview struct { Invited int `json:"invited"` Attending int `json:"attending"` Declined int `json:"declined"` Maybe int `json:"maybe"` Pending int `json:"pending"` PlusOnesTotal int `json:"plus_ones_total"` } `json:"overview"` ResponseRate []struct { Date time.Time `json:"date"` Count int `json:"count"` } `json:"response_rate"` Funnel struct { Invited int `json:"invited"` Opened int `json:"opened"` Responded int `json:"responded"` } `json:"funnel"` TimeToRespond []struct { Label string `json:"label"` Count int `json:"count"` } `json:"time_to_respond"` PlusOnes []struct { Label string `json:"label"` Count int `json:"count"` } `json:"plus_ones"` Channels []struct { Source string `json:"source"` Invited int `json:"invited"` Attending int `json:"attending"` } `json:"channels"` StaleGuests []struct { GuestID uuid.UUID `json:"guest_id"` Name string `json:"name"` HasOpened bool `json:"has_opened"` HasResponded bool `json:"has_responded"` } `json:"stale_guests"` } // TestAnalyticsAggregations seeds a synthetic event with a known mix of // guests + RSVPs + access logs, then asserts every aggregation matches the // expected counts. This is the regression net for the SQL — if a JOIN // pivots wrong the numbers will move. func TestAnalyticsAggregations(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, "Analytics Test", "analytics-test") type seedRow struct { name string plusOnes int response string confirmed int opened bool } seeds := []seedRow{ {name: "Alice (attending+2)", plusOnes: 3, response: "attending", confirmed: 2, opened: true}, {name: "Bob (attending+0)", plusOnes: 1, response: "attending", confirmed: 0, opened: true}, {name: "Carol (declined)", plusOnes: 0, response: "declined", confirmed: 0, opened: true}, {name: "Dave (maybe)", plusOnes: 1, response: "maybe", confirmed: 0, opened: true}, {name: "Eve (pending)", plusOnes: 0, response: "", confirmed: 0, opened: false}, } for _, s := range seeds { seedAnalyticsGuest(t, ctx, db.Pool, eventID, s.name, s.plusOnes, s.response, s.confirmed, s.opened) } var body analyticsBody getJSONAuthed(t, fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID), token, http.StatusOK, &body) if body.Overview.Invited != 5 { t.Errorf("invited: got %d want 5", body.Overview.Invited) } if body.Overview.Attending != 2 { t.Errorf("attending: got %d want 2", body.Overview.Attending) } if body.Overview.Declined != 1 { t.Errorf("declined: got %d want 1", body.Overview.Declined) } if body.Overview.Maybe != 1 { t.Errorf("maybe: got %d want 1", body.Overview.Maybe) } if body.Overview.Pending != 1 { t.Errorf("pending: got %d want 1", body.Overview.Pending) } if body.Overview.PlusOnesTotal != 2 { t.Errorf("plus_ones_total: got %d want 2", body.Overview.PlusOnesTotal) } if body.Funnel.Invited != 5 || body.Funnel.Opened != 4 || body.Funnel.Responded != 4 { t.Errorf("funnel: got %+v want {5,4,4}", body.Funnel) } if len(body.StaleGuests) != 1 || body.StaleGuests[0].Name != "Eve (pending)" { t.Errorf("stale guests: got %+v want [Eve]", body.StaleGuests) } if body.StaleGuests[0].HasOpened { t.Error("Eve should not be marked opened") } pones := map[string]int{} for _, p := range body.PlusOnes { pones[p.Label] = p.Count } if pones["0"] != 1 || pones["2"] != 1 { t.Errorf("plus_ones: got %+v want {0:1, 2:1, ...}", pones) } ttr := map[string]int{} for _, b := range body.TimeToRespond { ttr[b.Label] = b.Count } if ttr["0-1h"] != 4 { t.Errorf("time_to_respond[0-1h]: got %d want 4 (%+v)", ttr["0-1h"], ttr) } for _, want := range []string{"0-1h", "1-24h", "1-3d", "3-7d", "7d+"} { if _, ok := ttr[want]; !ok { t.Errorf("time_to_respond missing canonical bucket %q", want) } } if len(body.ResponseRate) != 30 { t.Errorf("response_rate length: got %d want 30", len(body.ResponseRate)) } var total int for _, p := range body.ResponseRate { total += p.Count } if total != 4 { t.Errorf("response_rate total: got %d want 4", total) } } // TestAnalyticsCSVExport asserts the endpoint serves a parseable CSV with // the right columns and one row per guest. func TestAnalyticsCSVExport(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, "CSV Export", "csv-export") seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Alice", 2, "attending", 1, true) seedAnalyticsGuest(t, ctx, db.Pool, eventID, "Bob", 0, "", 0, false) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID), nil) must(t, err, "build req") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) must(t, err, "do req") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status: %d body=%s", resp.StatusCode, body) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") { t.Errorf("content-type: %q", ct) } if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "filename=") { t.Errorf("content-disposition: %q", cd) } r := csv.NewReader(resp.Body) rows, err := r.ReadAll() must(t, err, "parse csv") if len(rows) != 3 { // header + 2 guests t.Fatalf("expected 3 rows (header + 2 guests), got %d", len(rows)) } header := rows[0] for _, want := range []string{"name", "email", "response", "plus_ones_confirmed", "submitted_at", "utm_source"} { if !containsString(header, want) { t.Errorf("header missing column %q in %v", want, header) } } } // TestAnalyticsAuthzMatrix confirms viewers can read analytics (it's a // read-only feature) and non-members get 404. func TestAnalyticsAuthzMatrix(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, ownerID, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Authz Analytics", "authz-analytics") viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool) directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID)) // Viewer can read analytics. assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID), viewerToken, nil, http.StatusOK) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID), viewerToken, nil, http.StatusOK) // Outsider: 404 on both. _, outsiderToken := makeAuthedUser(t, ctx, db.Pool) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/analytics", srv.URL, eventID), outsiderToken, nil, http.StatusNotFound) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/analytics/export.csv", srv.URL, eventID), outsiderToken, nil, http.StatusNotFound) } // --- helpers --- // seedAnalyticsGuest inserts a guest + token (with utm_source=test) and // optionally an access_log entry + an rsvp row. Bypasses the HTTP API // because the API would require token-based fraud scoring which is out of // scope for these aggregation tests. func seedAnalyticsGuest( t *testing.T, ctx context.Context, pool *pgxpool.Pool, eventID uuid.UUID, name string, plusOnesAllowed int, response string, plusOnesConfirmed int, opened bool, ) { t.Helper() var guestID uuid.UUID must(t, pool.QueryRow(ctx, ` INSERT INTO guests (event_id, name, plus_ones) VALUES ($1, $2, $3) RETURNING id `, eventID, name, plusOnesAllowed).Scan(&guestID), "insert guest") tokenHash := fmt.Sprintf("hash-%s-%d", guestID.String(), time.Now().UnixNano()) _, err := pool.Exec(ctx, ` INSERT INTO tokens (guest_id, token_hash, expires_at, utm_source) VALUES ($1, $2, now() + interval '30 days', 'test') `, guestID, tokenHash) must(t, err, "insert token") if opened { _, err = pool.Exec(ctx, ` INSERT INTO access_logs (guest_id) VALUES ($1) `, guestID) must(t, err, "insert access_log") } if response != "" { _, err = pool.Exec(ctx, ` INSERT INTO rsvps (guest_id, response, plus_ones) VALUES ($1, $2::rsvp_response, $3) `, guestID, response, plusOnesConfirmed) must(t, err, "insert rsvp") } } func containsString(slice []string, want string) bool { for _, s := range slice { if s == want { return true } } return false }