//go:build integration package integration_test import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/storage" ) // TestFreeTierEventLimit confirms free-tier hosts are capped at one // event per calendar month with a 402 response carrying the upgrade // payload the frontend uses to render the modal. func TestFreeTierEventLimit(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) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, TokenTTL: 24 * time.Hour, JWTSecret: testJWTSecret, JWTIssuer: testJWTIssuer, AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 24 * time.Hour, EmailVerificationTTL: 1 * time.Hour, PasswordResetTTL: 1 * time.Hour, PublicBaseURL: "http://localhost:3000", }) must(t, err, "api server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) // Bypass insertHost (which auto-grants Business) so this host stays free. hostID := insertFreeTierHost(t, ctx, db.Pool) token := issueHostToken(t, hostID) // First event under the limit — should succeed. _ = createEvent(t, srv.URL, token, "First", "free-first") // Second event must be 402 with the upgrade payload. body, _ := json.Marshal(map[string]any{ "name": "Second", "slug": "free-second", "event_date": time.Now().Add(30 * 24 * time.Hour).UTC().Format(time.RFC3339), "venue": "Hall", }) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/events", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) must(t, err, "POST /events second") defer resp.Body.Close() if resp.StatusCode != http.StatusPaymentRequired { b, _ := io.ReadAll(resp.Body) t.Fatalf("expected 402 on 2nd event, got %d body=%s", resp.StatusCode, b) } var rb struct { Error string `json:"error"` Tier string `json:"tier"` Used int `json:"used"` Limit int `json:"limit"` UpgradeURL string `json:"upgrade_url"` } must(t, json.NewDecoder(resp.Body).Decode(&rb), "decode 402 body") if rb.Tier != "free" || rb.Used != 1 || rb.Limit != 1 { t.Errorf("402 payload: %+v", rb) } if !strings.Contains(rb.UpgradeURL, "/dashboard/billing") { t.Errorf("expected upgrade_url to point at /dashboard/billing, got %q", rb.UpgradeURL) } } // TestFreeTierGuestLimit confirms the per-event guest cap kicks in at // the right number, again with a 402 payload. func TestFreeTierGuestLimit(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) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) db, err := storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, TokenTTL: 24 * time.Hour, JWTSecret: testJWTSecret, JWTIssuer: testJWTIssuer, AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 24 * time.Hour, EmailVerificationTTL: 1 * time.Hour, PasswordResetTTL: 1 * time.Hour, PublicBaseURL: "http://localhost:3000", }) must(t, err, "api server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) hostID := insertFreeTierHost(t, ctx, db.Pool) token := issueHostToken(t, hostID) eventID := createEvent(t, srv.URL, token, "Free Event", "free-guests-event") // Free tier allows 50 guests per event. Seed 50 directly so we don't // pay 50 HTTP round-trips, then attempt one more via the API. for i := 0; i < 50; i++ { _, err := db.Pool.Exec(ctx, `INSERT INTO guests (event_id, name) VALUES ($1, $2)`, eventID, fmt.Sprintf("Seeded %d", i), ) must(t, err, "seed guest") } // 51st guest must be 402. body, _ := json.Marshal(map[string]any{"name": "Overflow"}) req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID), strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) must(t, err, "POST /guests overflow") defer resp.Body.Close() if resp.StatusCode != http.StatusPaymentRequired { b, _ := io.ReadAll(resp.Body) t.Fatalf("expected 402 on 51st guest, got %d body=%s", resp.StatusCode, b) } } // TestBusinessTierBypassesLimits sanity-checks that a host with an // active Business subscription can create more than the free-tier // allowance — the enforcer code path that returns "unlimited". func TestBusinessTierBypassesLimits(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, _, _, token := setupAuthedAPI(t, ctx) // setupAuthedAPI already grants Business via insertHost. for i := 0; i < 3; i++ { _ = createEvent(t, srv.URL, token, fmt.Sprintf("Biz Event %d", i), fmt.Sprintf("biz-event-%d", i)) } } // insertFreeTierHost mints a verified user WITHOUT granting any // subscription — opposite of the default test helper. Used to exercise // the free-tier enforcement path. func insertFreeTierHost(t *testing.T, ctx context.Context, pool *pgxpool.Pool) uuid.UUID { t.Helper() var id uuid.UUID err := pool.QueryRow(ctx, `INSERT INTO users (email, name, email_verified, email_verified_at) VALUES ($1, 'Free Tier', TRUE, now()) RETURNING id`, fmt.Sprintf("free-%d@guestguard.test", time.Now().UnixNano()), ).Scan(&id) must(t, err, "insert free-tier host") return id }