From 3dcb3863ed67fea259fbd69528f7632a69ae92e4 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Mon, 4 May 2026 18:59:32 -0700 Subject: [PATCH 1/5] test(integration): real-Supabase API route tests via bunx supabase start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the "honest follow-up" flagged in #115's PR description: replace mock-Supabase tests with real-stack integration tests that run against the actual local Supabase boot, exercising the same migrations, PostgREST, and CLI JWT signing path that production runs. What's new: - apps/web/__tests__/integration/ — new directory with one exemplar test for POST /api/usage/submit. Calls the real route handler with a real Request, mints a real CLI JWT via createCliToken, and asserts on rows Postgres actually persisted. No vi.mock of the Supabase client, the auth helper, or query chains. - apps/web/vitest.integration.config.ts — separate vitest config wired to a globalSetup that asserts the local stack is reachable and reads ephemeral keys via `bunx supabase status -o env`. - New `bun run --cwd apps/web test:integration` script. - CI: supabase/setup-cli@v1 + bunx supabase start before integration tests. - CONTRIBUTING.md updated with the run-it-yourself instructions. The exemplar covers four cases the existing mocked usage-submit.test.ts cannot: 1. Real route → real DB roundtrip (rows actually present in daily_usage, device_usage, posts). 2. Real CHECK constraint rejecting negative cost (defense-in-depth past the route's TS validation). 3. Real backfill-window 400 + zero rows leaked. 4. Real X-Straude-Refreshed-Token header emitted under real JWT signing when the token is older than the refresh threshold. Existing mocked tests stay as-is; future PRs can migrate cases incrementally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 12 + CONTRIBUTING.md | 9 +- apps/web/__tests__/integration/db.ts | 52 ++++ .../web/__tests__/integration/global-setup.ts | 93 ++++++ .../integration/usage-submit.test.ts | 268 ++++++++++++++++++ apps/web/package.json | 3 + apps/web/vitest.config.ts | 5 +- apps/web/vitest.integration.config.ts | 36 +++ bun.lock | 32 +++ docs/CHANGELOG.md | 2 + 10 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 apps/web/__tests__/integration/db.ts create mode 100644 apps/web/__tests__/integration/global-setup.ts create mode 100644 apps/web/__tests__/integration/usage-submit.test.ts create mode 100644 apps/web/vitest.integration.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f928f72b..2006c49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,18 @@ jobs: run: bun run test working-directory: packages/cli + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Start local Supabase stack + run: bunx supabase start --workdir . + + - name: Test (web — integration, real Supabase) + run: bun run test:integration + working-directory: apps/web + - name: Install Playwright Chromium run: bunx playwright install --with-deps chromium working-directory: apps/web diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db476942..b2b30c2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,11 +97,14 @@ The bar is "would this have caught the bug if it had existed before?" — not 10 Run tests: ```bash -bun --cwd apps/web test # unit (vitest + jsdom) -bun --cwd apps/web test:e2e # e2e (playwright, chromium) -bun --cwd packages/cli test # CLI unit (vitest) +bun --cwd apps/web test # unit (vitest + jsdom, mock-based) +bun --cwd apps/web test:integration # integration (vitest + real Supabase) +bun --cwd apps/web test:e2e # e2e (playwright, chromium) +bun --cwd packages/cli test # CLI unit (vitest) ``` +Integration tests in `apps/web/__tests__/integration/` exercise the real route handlers against a real local Supabase stack — full migration history, real Postgres, real PostgREST, real CLI JWT signing. Avoid `vi.mock` in this directory; that's the whole point. Start the stack with `bun run local:up` (or `bunx supabase start`) before running them. CI starts it automatically. See `apps/web/__tests__/integration/usage-submit.test.ts` for the pattern. + ## Code Style - TypeScript strict; let `tsc` and `eslint` enforce the rest. diff --git a/apps/web/__tests__/integration/db.ts b/apps/web/__tests__/integration/db.ts new file mode 100644 index 00000000..8e2af3a2 --- /dev/null +++ b/apps/web/__tests__/integration/db.ts @@ -0,0 +1,52 @@ +import { Client } from "pg"; + +export async function openTestDb(): Promise { + const dsn = process.env.TEST_DB_URL; + if (!dsn) { + throw new Error( + "TEST_DB_URL not set — integration tests must run via vitest.integration.config.ts", + ); + } + const client = new Client({ connectionString: dsn }); + await client.connect(); + return client; +} + +/** + * Reset the project's data tables to a clean slate. We TRUNCATE just the + * tables tests write to and leave Supabase-managed schemas (auth, storage, + * realtime, etc.) alone — those carry the running stack's machinery and + * shouldn't be wiped by a unit-style cleanup. + * + * `RESTART IDENTITY CASCADE` resets serial sequences and drops dependent + * rows in tables we don't list explicitly, so adding a new test table + * usually doesn't require updating this list. + */ +const TRUNCATE_TABLES = [ + "device_usage", + "daily_usage", + "posts", + "users", +]; + +export async function cleanDb(client: Client): Promise { + await client.query( + `TRUNCATE TABLE ${TRUNCATE_TABLES.map((t) => `public.${t}`).join(", ")} RESTART IDENTITY CASCADE`, + ); +} + +/** Insert a real user row directly via SQL. Returns the generated UUID. */ +export async function insertUser( + client: Client, + overrides: Partial<{ id: string; username: string; email: string; is_public: boolean; onboarding_completed: boolean }> = {}, +): Promise { + const id = overrides.id ?? crypto.randomUUID(); + const username = overrides.username ?? `user_${id.slice(0, 8)}`; + const email = overrides.email ?? `${username}@example.test`; + await client.query( + `INSERT INTO public.users (id, username, email, is_public, onboarding_completed) + VALUES ($1, $2, $3, $4, $5)`, + [id, username, email, overrides.is_public ?? true, overrides.onboarding_completed ?? true], + ); + return id; +} diff --git a/apps/web/__tests__/integration/global-setup.ts b/apps/web/__tests__/integration/global-setup.ts new file mode 100644 index 00000000..6e93b86c --- /dev/null +++ b/apps/web/__tests__/integration/global-setup.ts @@ -0,0 +1,93 @@ +import { execSync } from "node:child_process"; +import { Client } from "pg"; + +/** + * Vitest globalSetup. Verifies the local Supabase stack is reachable and + * exports the env vars integration tests rely on. The stack is expected to + * be running already — `bun run local:up` or `bunx supabase start` from the + * dev workflow. CI runs `supabase start` in a workflow step before this + * config is invoked. + * + * We deliberately do not start/stop the stack from the test runner — that + * would couple test lifetimes to a 60s boot and force every contributor's + * machine to tear down/restart Supabase between runs. Run-it-yourself, point + * tests at it. + */ + +const SUPABASE_API_URL = process.env.SUPABASE_TEST_API_URL ?? "http://127.0.0.1:54321"; +const SUPABASE_DB_URL = + process.env.SUPABASE_TEST_DB_URL ?? "postgres://postgres:postgres@127.0.0.1:54322/postgres"; + +interface SupabaseStatus { + ANON_KEY?: string; + SERVICE_ROLE_KEY?: string; + JWT_SECRET?: string; +} + +function readSupabaseStatus(): SupabaseStatus { + // `supabase status -o env` prints KEY=value lines for the running stack. + // Faster and more reliable than parsing the human-readable default. + try { + const raw = execSync("bunx supabase status -o env", { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + const out: SupabaseStatus = {}; + for (const line of raw.split("\n")) { + const m = line.match(/^([A-Z_]+)="?([^"]*)"?$/); + if (!m) continue; + const [, k, v] = m; + if (k === "ANON_KEY") out.ANON_KEY = v; + if (k === "SERVICE_ROLE_KEY") out.SERVICE_ROLE_KEY = v; + if (k === "JWT_SECRET") out.JWT_SECRET = v; + } + return out; + } catch { + return {}; + } +} + +export async function setup(): Promise { + // 1. Can we reach the DB? + const probe = new Client({ connectionString: SUPABASE_DB_URL }); + try { + await probe.connect(); + await probe.query("SELECT 1"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Integration tests need a running Supabase stack at ${SUPABASE_DB_URL}.\n` + + `Start it with \`bunx supabase start\` (or \`bun run local:up\`) and re-run.\n` + + `Underlying error: ${message}`, + ); + } finally { + await probe.end().catch(() => undefined); + } + + // 2. Read the running stack's keys + JWT secret so the route handlers + // we exercise can authenticate. These are the ephemeral local values + // `supabase start` prints; never production secrets. + const status = readSupabaseStatus(); + if (!status.SERVICE_ROLE_KEY || !status.ANON_KEY) { + throw new Error( + "supabase status did not return SERVICE_ROLE_KEY/ANON_KEY. Is the stack actually running?", + ); + } + + // 3. Hand env to test workers. The route file in production reads these + // same vars; we let it run as-is against the local stack. + process.env.TEST_DB_URL = SUPABASE_DB_URL; + process.env.NEXT_PUBLIC_SUPABASE_URL = SUPABASE_API_URL; + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = status.ANON_KEY; + process.env.SUPABASE_SECRET_KEY = status.SERVICE_ROLE_KEY; + // CLI JWT secret: tests mint real tokens via createCliToken() so the route + // exercises real verifyCliTokenWithRefresh(). Pin a deterministic test + // value (does not need to match the supabase JWT secret — it's a separate + // CLI signing secret). + process.env.CLI_JWT_SECRET = "integration-test-cli-secret"; + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; +} + +export async function teardown(): Promise { + // Nothing to do — the stack outlives the test run. +} diff --git a/apps/web/__tests__/integration/usage-submit.test.ts b/apps/web/__tests__/integration/usage-submit.test.ts new file mode 100644 index 00000000..5ab6868a --- /dev/null +++ b/apps/web/__tests__/integration/usage-submit.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { Client } from "pg"; +import { openTestDb, cleanDb, insertUser } from "./db"; + +/** + * Real-stack integration test for POST /api/usage/submit. + * + * Compared to __tests__/api/usage-submit.test.ts (which mocks the Supabase + * client, the auth helper, and every chained query), this test: + * + * - Runs against a real Postgres with the full migration history applied + * (whatever `bunx supabase start` boots). + * - Exercises the real `verifyCliTokenWithRefresh` against a real signed + * JWT we mint with `createCliToken`. + * - Calls the route's exported `POST` handler with a real Request. + * - Asserts on rows the handler actually wrote to Postgres, not on the + * shape of our mock calls. + * + * What this catches that the mock test cannot: + * - Missing columns / column-type mismatches (the bug class behind the + * "collector_meta column not in schema cache" incident). + * - Real CHECK constraints rejecting bad data (negative cost, etc.). + * - FK + cascade behavior when one route writes to several tables. + * - Real numeric precision (Postgres NUMERIC → JS number roundtrip). + * - JWT signing/verification end-to-end (a stale signing secret would + * break this; the mock test would happily pass). + */ + +let db: Client; + +beforeAll(async () => { + db = await openTestDb(); +}); + +afterAll(async () => { + await db.end(); +}); + +beforeEach(async () => { + await cleanDb(db); +}); + +async function mintCliToken(userId: string, username: string): Promise { + // Import after globalSetup has populated CLI_JWT_SECRET on the env. + const { createCliToken } = await import("@/lib/api/cli-auth"); + return createCliToken(userId, username); +} + +async function callSubmit( + body: unknown, + token: string, +): Promise { + // Same dynamic-import-after-env trick: the route module captures + // SUPABASE_SECRET_KEY at first import, so we have to wait until + // global-setup.ts has set it. + const { POST } = await import("@/app/api/usage/submit/route"); + const req = new Request("http://localhost/api/usage/submit", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + return POST(req); +} + +const DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const today = new Date().toISOString().slice(0, 10); + +describe("POST /api/usage/submit (real Supabase)", () => { + it("rejects unauthenticated requests without writing anything", async () => { + const before = await db.query("SELECT count(*)::int AS n FROM public.daily_usage"); + const res = await callSubmit( + { + entries: [ + { + date: today, + data: { + date: today, + models: ["claude-sonnet-4-5"], + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 0.01, + }, + }, + ], + source: "cli", + device_id: DEVICE_ID, + }, + "not-a-real-token", + ); + expect(res.status).toBe(401); + const after = await db.query("SELECT count(*)::int AS n FROM public.daily_usage"); + expect(after.rows[0].n).toBe(before.rows[0].n); + }); + + it("writes a real daily_usage + device_usage + post row when the CLI submits valid data", async () => { + const userId = await insertUser(db, { username: "integration_user" }); + const token = await mintCliToken(userId, "integration_user"); + + const res = await callSubmit( + { + entries: [ + { + date: today, + data: { + date: today, + models: ["claude-sonnet-4-5-20250929"], + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 200, + totalTokens: 1800, + costUSD: 0.05, + modelBreakdown: [{ model: "claude-sonnet-4-5-20250929", cost_usd: 0.05 }], + }, + }, + ], + source: "cli", + device_id: DEVICE_ID, + device_name: "test-device", + }, + token, + ); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results).toHaveLength(1); + expect(json.results[0].action).toBe("created"); + + // Real row in real Postgres — the only assertion that proves the route + // actually persisted what it said it did. + const { rows } = await db.query<{ + cost_usd: string; + input_tokens: string; + output_tokens: string; + total_tokens: string; + models: string[]; + session_count: number; + }>( + `SELECT cost_usd, input_tokens, output_tokens, total_tokens, models, session_count + FROM public.daily_usage + WHERE user_id = $1 AND date = $2`, + [userId, today], + ); + expect(rows).toHaveLength(1); + // Postgres NUMERIC comes back as string from pg.Client; that's intentional + // (no precision loss). Coerce in the test, not the assertion target. + expect(Number(rows[0].cost_usd)).toBeCloseTo(0.05, 6); + expect(Number(rows[0].input_tokens)).toBe(1000); + expect(Number(rows[0].output_tokens)).toBe(500); + expect(Number(rows[0].total_tokens)).toBe(1800); + expect(rows[0].models).toContain("claude-sonnet-4-5-20250929"); + + // The route also writes a device_usage row keyed by device_id. + const dev = await db.query( + `SELECT count(*)::int AS n FROM public.device_usage + WHERE daily_usage_id = (SELECT id FROM public.daily_usage WHERE user_id = $1 AND date = $2)`, + [userId, today], + ); + expect(dev.rows[0].n).toBe(1); + + // And a post row. + const posts = await db.query( + `SELECT count(*)::int AS n FROM public.posts WHERE user_id = $1`, + [userId], + ); + expect(posts.rows[0].n).toBe(1); + }); + + it("real CHECK constraint rejects negative cost even if the route's TS validation were bypassed", async () => { + // Defense-in-depth: this verifies the schema itself is the last line of + // defense against bad data, not just the route's TypeScript guards. + const userId = await insertUser(db); + await expect( + db.query( + `INSERT INTO public.daily_usage + (user_id, date, cost_usd, input_tokens, output_tokens, total_tokens, models, session_count) + VALUES ($1, $2, -1.00, 100, 50, 150, ARRAY['claude-sonnet'], 1)`, + [userId, today], + ), + ).rejects.toThrow(); + }); + + it("rejects dates outside the 30-day backfill window with a real 400", async () => { + const userId = await insertUser(db); + const token = await mintCliToken(userId, "user"); + const oldDate = new Date(Date.now() - 60 * 86_400_000).toISOString().slice(0, 10); + + const res = await callSubmit( + { + entries: [ + { + date: oldDate, + data: { + date: oldDate, + models: ["claude-sonnet-4-5"], + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 0.01, + }, + }, + ], + source: "cli", + device_id: DEVICE_ID, + }, + token, + ); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/outside the 30-day backfill window/); + + // No row leaked through. + const after = await db.query("SELECT count(*)::int AS n FROM public.daily_usage"); + expect(after.rows[0].n).toBe(0); + }); + + it("emits the X-Straude-Refreshed-Token header when the CLI token is older than the refresh threshold", async () => { + const userId = await insertUser(db, { username: "old_token_user" }); + // Mint a token whose iat is older than the refresh threshold (7 days). + // We mint normally, then forge a stale-iat token by calling createCliToken + // under a moved system clock. + const realDateNow = Date.now; + try { + Date.now = () => realDateNow() - 8 * 24 * 60 * 60 * 1000; + const staleToken = await mintCliToken(userId, "old_token_user"); + Date.now = realDateNow; + + const res = await callSubmit( + { + entries: [ + { + date: today, + data: { + date: today, + models: ["claude-sonnet-4-5"], + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 15, + costUSD: 0.001, + }, + }, + ], + source: "cli", + device_id: DEVICE_ID, + }, + staleToken, + ); + + expect(res.status).toBe(200); + const refreshed = res.headers.get("x-straude-refreshed-token"); + expect(refreshed).toBeTruthy(); + expect(refreshed).not.toBe(staleToken); + } finally { + Date.now = realDateNow; + } + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index ac29cb47..2b634286 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "generate:og:athletic": "bun run ./scripts/generate-og-athletic-surge.ts", "typecheck": "tsc --noEmit -p tsconfig.check.json", "test": "vitest run", + "test:integration": "vitest run -c vitest.integration.config.ts", "test:e2e": "playwright test" }, "dependencies": { @@ -49,12 +50,14 @@ "@testing-library/react": "^16.3.2", "@types/heic-convert": "^2.1.0", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", "jsdom": "^28.1.0", + "pg": "^8.20.0", "tailwindcss": "^4", "typescript": "5.9.3", "vitest": "^4.0.18" diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 0f7f90c8..5c7f859e 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -9,7 +9,10 @@ export default defineConfig({ globals: true, setupFiles: ["./__tests__/setup.ts"], include: ["__tests__/**/*.test.{ts,tsx}", "**/*.test.{ts,tsx}"], - exclude: ["node_modules", ".next"], + // Integration tests live under __tests__/integration and run via + // vitest.integration.config.ts (real Supabase stack required) — keep + // them out of the fast default suite. + exclude: ["node_modules", ".next", "__tests__/integration/**"], }, resolve: { alias: { diff --git a/apps/web/vitest.integration.config.ts b/apps/web/vitest.integration.config.ts new file mode 100644 index 00000000..5a6e1ae6 --- /dev/null +++ b/apps/web/vitest.integration.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +/** + * Separate config for integration tests that exercise the real Supabase + * stack (Postgres + PostgREST + Storage + GoTrue) booted via + * `bunx supabase start`. The default `vitest.config.ts` runs the fast + * mock-based suite; this one opts in via `bun run test:integration`. + * + * CI installs the Supabase CLI and runs `bunx supabase start` before + * invoking this config. Locally, run `bun run local:up` first. + */ + +export default defineConfig({ + test: { + environment: "node", + globals: false, + include: ["__tests__/integration/**/*.test.ts"], + exclude: ["node_modules", ".next"], + globalSetup: ["./__tests__/integration/global-setup.ts"], + // First request through the route handler can be slow under cold + // PostgREST + initial query plan — give individual tests room. + testTimeout: 30_000, + hookTimeout: 60_000, + // Run integration suites serially. They share one Supabase stack and + // each suite's `beforeEach` truncates data tables — parallel execution + // would let one suite TRUNCATE rows another is mid-test on. Adding more + // suites later is fine; this keeps them isolated by serialization. + fileParallelism: false, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); diff --git a/bun.lock b/bun.lock index 9b127f96..1cd31177 100644 --- a/bun.lock +++ b/bun.lock @@ -52,12 +52,14 @@ "@testing-library/react": "^16.3.2", "@types/heic-convert": "^2.1.0", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", "jsdom": "^28.1.0", + "pg": "^8.20.0", "tailwindcss": "^4", "typescript": "5.9.3", "vitest": "^4.0.18", @@ -658,6 +660,8 @@ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -1478,6 +1482,22 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1494,6 +1514,14 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "posthog-js": ["posthog-js@1.359.1", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.2", "@posthog/types": "1.359.1", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-Gy/eX02im6ON0zMxfTR61GNk1sjgLT9rVGfBQ5C757/WS4mN3vTUJveQYoX9jr3y0pqPZ57DqCcf6zcw++bpzQ=="], "posthog-node": ["posthog-node@5.29.1", "", { "dependencies": { "@posthog/core": "1.25.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-XA1OGrE8SAmO3JkfWjeVg7XxEN/6M6ZXzYmBJKl3uXv/xxk7ru0oeoqi/rcWJ6z5+nFP9wUFVoS37//qaPxwFg=="], @@ -1620,6 +1648,8 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -1808,6 +1838,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 907c1c93..af82aed1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Real-Supabase integration tests for API routes (`bun run --cwd apps/web test:integration`).** New `apps/web/__tests__/integration/` directory + `vitest.integration.config.ts` with a globalSetup that asserts a local Supabase stack is reachable, reads its ephemeral keys via `bunx supabase status -o env`, and exposes them to test workers. The exemplar `usage-submit.test.ts` calls the real `POST /api/usage/submit` handler with a real Request, mints a real CLI JWT via `createCliToken`, and asserts on rows actually written to Postgres — no mocks of the Supabase client, the auth helper, or the chained queries. Catches bug classes the existing mocked `__tests__/api/usage-submit.test.ts` cannot: missing columns (the `collector_meta` cache-mismatch class), real CHECK constraints, FK behavior, NUMERIC→JS roundtrip precision, and end-to-end JWT signing with real `CLI_JWT_SECRET`. CI step added: `supabase/setup-cli@v1` plus `bunx supabase start` before `test:integration`. Existing mocked tests stay for now; future PRs can migrate cases incrementally to the integration directory. + - **Team affiliation badge.** Users can enter an organization URL on `/settings` ("Team" field, between Website and GitHub Username). On save, the server validates the URL, fetches the favicon from Google's `s2/favicons` endpoint, uploads the PNG bytes to a new public-read `team-favicons` Supabase Storage bucket keyed by `.png`, and writes the resulting public URL to two new columns on `users` (`team_url`, `team_favicon_url`). Subsequent users that enter the same domain reuse the cached favicon — no extra Google fetch, no extra upload. A new `` component (`apps/web/components/app/shared/TeamBadge.tsx`) renders the favicon as a clickable badge inline next to the user's @handle on every surface it appears: profile header (next to display name + LevelBadge), feed cards (next to @handle in `ActivityCard`), leaderboard rows (desktop table + mobile list), and the sidebar current-user chip. Click target opens the team URL in a new tab with `rel="noopener noreferrer"`; alt text is derived from the hostname (e.g. `anthropic.com logo`); a Building2 lucide icon is rendered as a fallback when the favicon URL fails to load. The favicon resolver is server-only with a 5s fetch timeout and gracefully degrades to `team_favicon_url: null` (renders the fallback icon) on Google or Storage failure rather than blocking the save. Two migrations: `20260501120000_add_team_affiliation.sql` adds the columns + Storage bucket, and `20260501130000_team_affiliation_full_redaction.sql` re-states the sanitized `public.users` SELECT list with `team_url`/`team_favicon_url` added and refreshes `get_feed` to surface them via the existing `jsonb_build_object` allow-list (the previous list was bound to the pre-team-affiliation column set). 13 vitest cases cover the resolver (validation, normalization, cache hit/miss, graceful degradation); a Playwright spec at `apps/web/e2e/team-badge.spec.ts` asserts the badge renders correctly on `/u/{user}` and `/leaderboard`. Modeled on X Premium Organizations. - **Recap "Midnight" dark theme.** New background option (id `11`) for `/recap` and the public `/recap/[username]` page — a dark gradient (`#0B0D12 → #1B1F2A → #2A1A12`) with a black overlay and inverted text palette so the card stays legible. Plumbed `dark` through `RecapCardImage` so the downloaded landscape PNG (`/api/recap/image?bg=11`) matches the in-app card. The Open Graph share card still renders the default light background — Next.js's `opengraph-image.tsx` route convention only sees `params`, not `searchParams`. Existing light backgrounds are unchanged. Suggested by @larpa via community prompts. - **`CONTRIBUTING.md` at repo root.** Lightweight contribution guide inspired by Warp's, scaled down for a smaller project: TL;DR up top, mermaid flow diagram (bug → PR; feature → issue → PR), pointers to `docs/SETUP.md` / `docs/LOCAL_DEV.md` for environment setup, branch/commit conventions (`handle/short-description` prefix), and a testing bar that asks for regression tests on bug fixes and unit/e2e coverage on new behavior — without the heavier readiness-label and spec-PR gating Warp uses. Also covers code style (defers to `CLAUDE.md`), agent-assisted contributions, security disclosure via GitHub's private reporting, and the Contributor Covenant. Issue/repo links use the real `github.com/ohong/straude` remote. From 3147b5e3392e31858ae64cad2ff3f3026921d110 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 5 May 2026 13:59:32 -0700 Subject: [PATCH 2/5] test: seed real auth users in Supabase integration tests --- apps/web/__tests__/integration/db.ts | 49 +++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/integration/db.ts b/apps/web/__tests__/integration/db.ts index 8e2af3a2..f7bd43ef 100644 --- a/apps/web/__tests__/integration/db.ts +++ b/apps/web/__tests__/integration/db.ts @@ -38,15 +38,56 @@ export async function cleanDb(client: Client): Promise { /** Insert a real user row directly via SQL. Returns the generated UUID. */ export async function insertUser( client: Client, - overrides: Partial<{ id: string; username: string; email: string; is_public: boolean; onboarding_completed: boolean }> = {}, + overrides: Partial<{ + id: string; + username: string; + email: string; + is_public: boolean; + onboarding_completed: boolean; + }> = {}, ): Promise { const id = overrides.id ?? crypto.randomUUID(); const username = overrides.username ?? `user_${id.slice(0, 8)}`; const email = overrides.email ?? `${username}@example.test`; + + await client.query( + `INSERT INTO auth.users ( + id, + instance_id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at + ) + VALUES ( + $1, + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + $2, + '', + now(), + '{"provider":"email","providers":["email"]}'::jsonb, + jsonb_build_object('user_name', $3), + now(), + now() + )`, + [id, email, username], + ); + await client.query( - `INSERT INTO public.users (id, username, email, is_public, onboarding_completed) - VALUES ($1, $2, $3, $4, $5)`, - [id, username, email, overrides.is_public ?? true, overrides.onboarding_completed ?? true], + `INSERT INTO public.users (id, username, is_public, onboarding_completed) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE + SET username = EXCLUDED.username, + is_public = EXCLUDED.is_public, + onboarding_completed = EXCLUDED.onboarding_completed`, + [id, username, overrides.is_public ?? true, overrides.onboarding_completed ?? true], ); return id; } From 1470ac796c8b901cc937c5b3029eb78ff36eb965 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 5 May 2026 14:06:48 -0700 Subject: [PATCH 3/5] test: cast auth metadata seed value for Postgres --- apps/web/__tests__/integration/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/__tests__/integration/db.ts b/apps/web/__tests__/integration/db.ts index f7bd43ef..83d3c15c 100644 --- a/apps/web/__tests__/integration/db.ts +++ b/apps/web/__tests__/integration/db.ts @@ -73,7 +73,7 @@ export async function insertUser( '', now(), '{"provider":"email","providers":["email"]}'::jsonb, - jsonb_build_object('user_name', $3), + jsonb_build_object('user_name', $3::text), now(), now() )`, From 5c225db14dde89799398d7cf5ac2f0e8ff3f9631 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 5 May 2026 14:12:42 -0700 Subject: [PATCH 4/5] test: seed required public user timezone --- apps/web/__tests__/integration/db.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/integration/db.ts b/apps/web/__tests__/integration/db.ts index 83d3c15c..24efb662 100644 --- a/apps/web/__tests__/integration/db.ts +++ b/apps/web/__tests__/integration/db.ts @@ -81,12 +81,13 @@ export async function insertUser( ); await client.query( - `INSERT INTO public.users (id, username, is_public, onboarding_completed) - VALUES ($1, $2, $3, $4) + `INSERT INTO public.users (id, username, is_public, onboarding_completed, timezone) + VALUES ($1, $2, $3, $4, 'UTC') ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, is_public = EXCLUDED.is_public, - onboarding_completed = EXCLUDED.onboarding_completed`, + onboarding_completed = EXCLUDED.onboarding_completed, + timezone = EXCLUDED.timezone`, [id, username, overrides.is_public ?? true, overrides.onboarding_completed ?? true], ); return id; From 831a55c6b73b69dcd02118ef4325bdae8de8dd08 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 5 May 2026 14:19:57 -0700 Subject: [PATCH 5/5] test: assert device usage by real schema keys --- apps/web/__tests__/integration/usage-submit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/integration/usage-submit.test.ts b/apps/web/__tests__/integration/usage-submit.test.ts index 5ab6868a..9396e474 100644 --- a/apps/web/__tests__/integration/usage-submit.test.ts +++ b/apps/web/__tests__/integration/usage-submit.test.ts @@ -159,8 +159,8 @@ describe("POST /api/usage/submit (real Supabase)", () => { // The route also writes a device_usage row keyed by device_id. const dev = await db.query( `SELECT count(*)::int AS n FROM public.device_usage - WHERE daily_usage_id = (SELECT id FROM public.daily_usage WHERE user_id = $1 AND date = $2)`, - [userId, today], + WHERE user_id = $1 AND date = $2 AND device_id = $3`, + [userId, today, DEVICE_ID], ); expect(dev.rows[0].n).toBe(1);