test: add unit, API, component and end-to-end test suites

The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
yusufipk
2026-07-26 11:17:26 +07:00
parent 52b2c8d2a9
commit 1d099c68f2
101 changed files with 27625 additions and 122 deletions
+190
View File
@@ -0,0 +1,190 @@
// Per-file setup for the `api` Vitest project.
//
// Runs before each test file is imported, which is what makes the vi.mock()
// registrations below reliable: they are in place before any test file pulls
// `@/lib/auth` or `nodemailer` into its module graph. Putting them in a helper
// that a test file imports would make the registration order depend on the
// order of that file's import statements.
//
// See TESTING.md section 5.
// MUST stay the first import. tests/helpers/env.ts loads .env.test on
// evaluation, and `@/lib/db` (reached below through tests/helpers/db.ts) reads
// process.env.DATABASE_URL once at module load and memoizes the pg pool on
// globalThis. ESM evaluates dependencies in import order, so anything that moves
// above this line points the whole suite at the wrong database.
import '../helpers/env';
import { afterEach, beforeAll, beforeEach, vi } from 'vitest';
import { resetDb } from '../helpers/db';
import { resetSentMail } from '../helpers/mail';
// lib/db.ts registers a SIGINT and a SIGTERM listener at module scope. With one
// module registry per test file that is two listeners per file, which trips
// Node's default limit of 10 and floods the output with
// MaxListenersExceededWarning. Lifting the cap is enough; lib/db.ts is
// production code and is left alone.
process.setMaxListeners(0);
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
// Partial mock: only `auth` is replaced. checkProjectAccess(),
// checkWorkspaceAccess() and computeProjectAccess() keep their real
// implementations and keep querying the real test database, because they are the
// subject of these tests rather than a dependency of them.
vi.mock('@/lib/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth')>();
return { ...actual, auth: vi.fn() };
});
// ---------------------------------------------------------------------------
// Next.js cache primitives
// ---------------------------------------------------------------------------
// revalidatePath() has no request scope to work with outside a server render,
// and unstable_cache() would wrap the Bunny/R2 stat helpers in a cache that has
// no incremental-cache handler behind it. Identity is the right stand-in for
// both.
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: <T extends (...args: never[]) => unknown>(fn: T) => fn,
unstable_noStore: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Object storage
// ---------------------------------------------------------------------------
// Presigners return deterministic fake URLs so a test can assert on the object
// key that a route chose, which is the part that actually matters. Nothing here
// speaks S3.
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
createPresignedVideoPutUrl: vi.fn(async (key: string) => `https://r2.test/put/${key}`),
createPresignedImagePutUrl: vi.fn(async (key: string) => `https://r2.test/put/${key}`),
createPresignedUploadPartUrl: vi.fn(
async (key: string, uploadId: string, partNumber: number) =>
`https://r2.test/part/${key}?uploadId=${uploadId}&partNumber=${partNumber}`
),
createMultipartVideoUpload: vi.fn(async () => 'test-multipart-upload-id'),
completeMultipartVideoUpload: vi.fn(async () => undefined),
abortMultipartVideoUpload: vi.fn(async () => undefined),
uploadAudio: vi.fn(async (key: string) => `https://r2.test/object/${key}`),
deleteVideoObject: vi.fn(async () => undefined),
deleteR2Object: vi.fn(async () => undefined),
headVideoObject: vi.fn(async () => ({
contentLength: 1024,
contentType: 'video/mp4',
etag: 'test-etag',
})),
readVideoObjectBytes: vi.fn(async () => ({
body: new Uint8Array(0),
contentLength: 0,
contentType: 'video/mp4',
})),
ensureR2BucketExists: vi.fn(async () => undefined),
ensureR2UploadCors: vi.fn(async () => []),
};
});
// ---------------------------------------------------------------------------
// Stripe
// ---------------------------------------------------------------------------
// getStripe() is the single seam every Stripe call goes through. Suites that
// need a specific response (the webhook suite, mainly) override these per test.
vi.mock('@/lib/stripe', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/stripe')>();
return {
...actual,
getStripe: vi.fn(() => ({
customers: { create: vi.fn(async () => ({ id: 'cus_test_default' })) },
subscriptions: { list: vi.fn(async () => ({ data: [] })) },
checkout: {
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/checkout' })) },
},
billingPortal: {
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/portal' })) },
},
webhooks: {
constructEvent: vi.fn(() => {
throw new Error('stripe.webhooks.constructEvent was not stubbed for this test');
}),
},
})),
};
});
// ---------------------------------------------------------------------------
// Bunny storage stats
// ---------------------------------------------------------------------------
// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in
// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota
// suite overrides it to prove Bunny usage counts against the limit.
vi.mock('@/lib/admin-stats', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/admin-stats')>();
return {
...actual,
getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>),
};
});
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
// notifyUsers()/notifyProjectOwner() fan out to Telegram over fetch() and to
// SMTP. Tests assert on the rows a route writes, not on delivery.
vi.mock('@/lib/notifications', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/notifications')>();
return {
...actual,
notifyUsers: vi.fn(async () => undefined),
notifyProjectOwner: vi.fn(async () => undefined),
};
});
// ---------------------------------------------------------------------------
// Mail
// ---------------------------------------------------------------------------
// SMTP_* is configured in .env.test on purpose, so isEmailVerificationEnabled()
// is true and the routes take their production branch. Messages are captured
// instead of sent; assert on them with tests/helpers/mail.ts.
vi.mock('nodemailer', async () => {
const { recordSentMail } = await import('../helpers/mail');
const createTransport = vi.fn(() => ({
sendMail: vi.fn(async (message: unknown) => {
recordSentMail(message);
return { messageId: 'test-message-id', accepted: [], rejected: [] };
}),
verify: vi.fn(async () => true),
}));
return { default: { createTransport }, createTransport };
});
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
// A stale database from a crashed previous run would otherwise leak into the
// first test of the file.
beforeAll(async () => {
await resetDb();
});
beforeEach(() => {
resetSentMail();
});
// Every test creates the data it needs and nothing survives it, so no test can
// depend on execution order or on another test's rows.
afterEach(async () => {
// Vitest's `unstubEnvs` option defaults to false, so a vi.stubEnv() leaks into
// every following test in the file. That is not a theoretical worry here: one
// test turning OPENFRAME_ENABLE_STRIPE off silently disarms hasBillingAccess()
// and every quota check for the rest of the file, and the tests that follow
// pass for the wrong reason. Undo it centrally rather than trusting 13 files
// to remember.
vi.unstubAllEnvs();
await resetDb();
});
+95
View File
@@ -0,0 +1,95 @@
// Per-file setup for the `component` Vitest project (jsdom environment).
//
// Two jobs:
// 1. Register the jest-dom matchers (`toBeVisible`, `toHaveAccessibleName`, ...).
// 2. Polyfill the browser APIs jsdom does not implement. Radix and the video
// player reach for these on mount, so without them a render throws before
// any assertion runs.
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
/**
* Testing Library normally registers this itself, but only when it can see a
* global `afterEach`. This repo runs Vitest without globals (every test file
* imports what it needs), so the auto-cleanup never installs and each rendered
* component stays mounted for the rest of the file: its effects keep running,
* its window/document listeners keep firing and the next test sees them. Do it
* explicitly here instead of in every test file.
*/
afterEach(() => {
cleanup();
});
/**
* jsdom has no CSS media query engine, so `window.matchMedia` is missing
* entirely. Every query reports as not matching, which keeps components on
* their desktop / no-preference code path.
*/
function createMediaQueryList(query: string): MediaQueryList {
return {
matches: false,
media: query,
onchange: null,
// Deprecated aliases, still used by some libraries.
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
};
}
window.matchMedia = (query: string): MediaQueryList => createMediaQueryList(query);
/**
* jsdom implements no layout, so it ships no `scrollIntoView`. Radix calls it
* when it moves focus inside a scrollable list.
*/
Element.prototype.scrollIntoView = () => {};
/**
* jsdom does not implement ResizeObserver. A stub that never fires is enough:
* components only need the constructor not to throw, and any size-dependent
* behaviour belongs in E2E anyway.
*/
class ResizeObserverStub implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver = ResizeObserverStub;
/**
* Pointer capture is unimplemented in jsdom. Radix uses it for its
* drag-to-select behaviour (Select, Slider, Menu) and throws without it.
*/
Element.prototype.hasPointerCapture = () => false;
Element.prototype.setPointerCapture = () => {};
Element.prototype.releasePointerCapture = () => {};
/**
* jsdom raises "Not implemented" for media playback. Resolve instead, so the
* player hooks can await `play()`.
*/
HTMLMediaElement.prototype.play = () => Promise.resolve();
HTMLMediaElement.prototype.pause = () => {};
/**
* Object URLs are used by the upload previews. jsdom leaves both of these
* undefined.
*/
URL.createObjectURL = () => 'blob:openframe-test';
URL.revokeObjectURL = () => {};
/**
* jsdom does not implement `navigator.sendBeacon`. The watch-progress hook
* feature-detects it and silently skips its unload flush when it is missing, so
* without this stub that whole branch would be untestable. Tests spy on it.
*/
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
writable: true,
value: () => true,
});
+246
View File
@@ -0,0 +1,246 @@
// Global setup for the `api` Vitest project. Runs once per run, in the main
// Vitest process, before any test file is loaded.
//
// Never import from 'vitest' here: globalSetup runs outside the test context.
//
// ---------------------------------------------------------------------------
// Why this uses `prisma db push` and not `prisma migrate deploy`
// ---------------------------------------------------------------------------
// TESTING.md section 5 specifies `prisma migrate deploy`. That does not work in
// this repo, and the reason is worth stating so nobody "fixes" it back:
//
// prisma/migrations holds fifteen incremental patches on top of a baseline
// that was never captured as a migration. The schema was originally created
// with `db push`. So the second migration in the sequence,
// 20260227000000_add_audio_asset_kind_and_provider, opens with
// `ALTER TYPE "VideoAssetKind" ADD VALUE 'AUDIO'` against a type that nothing
// in the migration history ever created. Against an empty database
// `migrate deploy` dies on it with P3018 / 42704
// (`type "VideoAssetKind" does not exist`).
//
// So the schema comes from `prisma db push`, and the parts of the hand-written
// SQL that schema.prisma cannot express are replayed afterwards in
// POST_PUSH_SQL. Those parts are load-bearing: `cleanup_rate_limits()` is
// called by lib/rate-limit.ts, and the three partial unique indexes are what
// stop an R2 object key being claimed by two video versions.
//
// REVIEWED_MIGRATIONS below is a drift guard. Add a migration and this setup
// fails until someone has looked at whether it contains SQL that `db push`
// cannot derive from schema.prisma, exactly like the route list in
// tests/api/auth-matrix.test.ts.
// MUST stay the first import: it loads .env.test, and everything below reads
// process.env.DATABASE_URL.
import { REPO_ROOT } from '../helpers/env';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { promisify } from 'node:util';
import { Pool } from 'pg';
const execFileAsync = promisify(execFile);
const MIGRATIONS_DIR = path.join(REPO_ROOT, 'prisma', 'migrations');
/**
* Every migration directory that has been checked against POST_PUSH_SQL.
*
* Entries marked "replayed" contain SQL that `prisma db push` cannot produce
* from schema.prisma, so POST_PUSH_SQL carries an idempotent copy. Everything
* else is a plain table/column/enum addition that db push derives on its own.
*/
const REVIEWED_MIGRATIONS = [
'20260226110000_rate_limit_extras', // replayed: cleanup_rate_limits(), UNLOGGED
'20260227000000_add_audio_asset_kind_and_provider',
'20260227120000_add_onboarding',
'20260320110000_add_stripe_billing',
'20260320123000_add_billing_trials_and_cleanup_dates',
'20260321003000_add_subscription_cancel_state',
'20260321094500_add_billing_trial_consumed_at',
'20260414120000_add_size_bytes_to_video_assets',
'20260415120000_add_upload_reservations',
'20260527120000_add_size_bytes_to_video_versions',
'20260527154000_add_video_upload_sessions',
'20260527155000_add_r2_video_uniqueness_indexes', // replayed: 3 partial unique indexes
'20260613120000_add_r2_video_asset_provider',
'20260614160000_add_project_allow_downloads',
'20260627140000_add_video_upload_multipart_id',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
const REQUIRED_FUNCTIONS = ['cleanup_rate_limits'];
const REQUIRED_INDEXES = [
'video_versions_r2_videoid_unique',
'video_versions_r2_originalurl_unique',
'video_versions_r2_thumbnail_unique',
];
const POST_PUSH_SQL = `
-- Replayed from 20260226110000_rate_limit_extras. lib/rate-limit.ts calls
-- cleanup_rate_limits() on an interval and tests/api/rate-limit.test.ts asserts
-- on what it deletes.
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $fn$
BEGIN
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
END;
$fn$ LANGUAGE plpgsql;
DO $do$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_class
WHERE relname = 'rate_limits'
AND relkind = 'r'
AND relpersistence <> 'u'
) THEN
ALTER TABLE rate_limits SET UNLOGGED;
END IF;
END $do$;
-- Replayed from 20260527155000_add_r2_video_uniqueness_indexes. Partial indexes
-- have no representation in schema.prisma, so db push never creates them, and
-- app/api/projects/[projectId]/videos/r2-complete/route.ts relies on them to
-- reject a second version claiming the same object key.
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_videoid_unique"
ON "video_versions" ("videoId")
WHERE "providerId" = 'r2';
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_originalurl_unique"
ON "video_versions" ("originalUrl")
WHERE "providerId" = 'r2' AND "originalUrl" LIKE '/api/upload/video/%';
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_thumbnail_unique"
ON "video_versions" ("thumbnailUrl")
WHERE "providerId" = 'r2' AND "thumbnailUrl" LIKE '/api/upload/image/%';
`;
function assertMigrationsReviewed(): void {
const found = fs
.readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const unreviewed = found.filter((name) => !REVIEWED_MIGRATIONS.includes(name));
const vanished = REVIEWED_MIGRATIONS.filter((name) => !found.includes(name));
if (unreviewed.length === 0 && vanished.length === 0) return;
throw new Error(
[
'prisma/migrations no longer matches REVIEWED_MIGRATIONS in tests/setup/db-global.ts.',
unreviewed.length > 0 ? ` new, unreviewed: ${unreviewed.join(', ')}` : null,
vanished.length > 0 ? ` listed but missing: ${vanished.join(', ')}` : null,
'',
'The api suite builds its schema with `prisma db push`, not `migrate deploy`',
'(see the comment at the top of this file). Open the new migration.sql and',
'decide: if it is a plain table/column/enum change that schema.prisma also',
'describes, just add its directory name to REVIEWED_MIGRATIONS. If it holds',
'SQL that db push cannot derive (a function, a trigger, a partial or',
'expression index, an UNLOGGED table), add an idempotent copy to',
'POST_PUSH_SQL as well, or the routes that depend on it will be tested',
'against a database that does not have it.',
]
.filter((line) => line !== null)
.join('\n')
);
}
async function waitForPostgres(pool: Pool): Promise<void> {
const deadline = Date.now() + 60_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
await pool.query('SELECT 1');
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
throw new Error(
`Test Postgres was not reachable within 60s at the DATABASE_URL from .env.test. ` +
`Start it with \`podman compose -f docker-compose.test.yml up -d\` and make sure the ` +
`test runner is attached to the openframe-test network. Last error: ${String(lastError)}`
);
}
async function pushSchema(): Promise<void> {
const prismaBin = path.join(REPO_ROOT, 'node_modules', '.bin', 'prisma');
if (!fs.existsSync(prismaBin)) {
throw new Error(`Prisma CLI not found at ${prismaBin}. Run \`bun install\` first.`);
}
try {
// Run through the current runtime (bun locally, node on CI) rather than the
// shebang, so this does not depend on `node` being on PATH.
await execFileAsync(process.execPath, [prismaBin, 'db', 'push', '--accept-data-loss'], {
cwd: REPO_ROOT,
env: process.env,
timeout: 180_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (error) {
const detail = error as { stdout?: string; stderr?: string; message?: string };
throw new Error(
`\`prisma db push\` failed against the test database.\n` +
`${detail.stdout ?? ''}\n${detail.stderr ?? detail.message ?? ''}`
);
}
}
async function assertPostPushObjects(pool: Pool): Promise<void> {
const functions = await pool.query<{ proname: string }>(
`SELECT proname FROM pg_proc WHERE proname = ANY($1::text[])`,
[REQUIRED_FUNCTIONS]
);
const missingFunctions = REQUIRED_FUNCTIONS.filter(
(name) => !functions.rows.some((row) => row.proname === name)
);
const indexes = await pool.query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname = ANY($1::text[])`,
[REQUIRED_INDEXES]
);
const missingIndexes = REQUIRED_INDEXES.filter(
(name) => !indexes.rows.some((row) => row.indexname === name)
);
if (missingFunctions.length > 0 || missingIndexes.length > 0) {
throw new Error(
'POST_PUSH_SQL did not produce everything the routes depend on. Missing ' +
`functions: [${missingFunctions.join(', ')}], indexes: [${missingIndexes.join(', ')}].`
);
}
}
export async function setup(): Promise<void> {
assertMigrationsReviewed();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 3_000,
});
// An idle client dropped by a restarting Postgres emits on the pool, not on a
// query promise, and an unhandled 'error' would take the whole run down.
pool.on('error', () => {});
try {
await waitForPostgres(pool);
await pushSchema();
await pool.query(POST_PUSH_SQL);
await assertPostPushObjects(pool);
} finally {
await pool.end();
}
}
export async function teardown(): Promise<void> {
// Nothing to do. The database is left up on purpose so the next run skips the
// push, and its data directory is a tmpfs that dies with the container.
}