Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions apps/web/__tests__/integration/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Client } from "pg";

export async function openTestDb(): Promise<Client> {
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<void> {
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<string> {
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::text),
now(),
now()
)`,
[id, email, username],
);

await client.query(
`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,
timezone = EXCLUDED.timezone`,
[id, username, overrides.is_public ?? true, overrides.onboarding_completed ?? true],
);
return id;
}
93 changes: 93 additions & 0 deletions apps/web/__tests__/integration/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
// Nothing to do — the stack outlives the test run.
}
Loading
Loading