mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user