/** * Test database utilities for e2e tests */ import type { Database } from "@reviq/db-schema"; import { join } from "node:path"; import { Kysely, PostgresDialect, sql } from "kysely"; import pg from "pg"; const { Pool, Client } = pg; /** Tables to truncate between tests (in order that respects foreign keys) */ const TABLES_TO_TRUNCATE = [ "sessions", "api_tokens", "login_requests", "passkeys", "user_devices", "webauthn_challenges", "email_verifications", "password_resets", "org_invites", "org_sites", "org_members", "orgs", "users", ] as const; /** * Creates a test database connection. * Uses TEST_DATABASE_URL env var, falls back to DATABASE_URL with _test suffix. */ export function createTestDb(): Kysely { const connectionString = getTestDatabaseUrl(); if (!connectionString) { throw new Error( "Test database URL not configured. Set TEST_DATABASE_URL environment variable.", ); } const dialect = new PostgresDialect({ pool: new Pool({ connectionString }), }); return new Kysely({ dialect }); } /** * Gets the test database URL from environment. * Adds sslmode=disable for local development. */ export function getTestDatabaseUrl(): string { let url: string; // Prefer explicit TEST_DATABASE_URL if (Bun.env.TEST_DATABASE_URL) { url = Bun.env.TEST_DATABASE_URL; } else if (Bun.env.DATABASE_URL) { // Fall back to DATABASE_URL with _test suffix const parsed = new URL(Bun.env.DATABASE_URL); parsed.pathname = `${parsed.pathname}_test`; url = parsed.toString(); } else { return ""; } // Add sslmode=disable for local postgres const parsed = new URL(url); if (!parsed.searchParams.has("sslmode")) { parsed.searchParams.set("sslmode", "disable"); } return parsed.toString(); } /** * Parses a postgres URL to extract components */ function parsePostgresUrl(url: string): { host: string; port: number; user: string; password: string; database: string; } { const parsed = new URL(url); return { host: parsed.hostname, port: Number.parseInt(parsed.port || "5432", 10), user: parsed.username, password: parsed.password, database: parsed.pathname.slice(1), // Remove leading / }; } /** * Creates the test database if it doesn't exist. */ async function ensureTestDatabaseExists(): Promise { const testDbUrl = getTestDatabaseUrl(); if (!testDbUrl) { throw new Error("Test database URL not configured"); } const { host, port, user, password, database } = parsePostgresUrl(testDbUrl); // Connect to 'postgres' database to create the test database const client = new Client({ host, port, user, password, database: "postgres", }); try { await client.connect(); // Check if database exists const result = await client.query( "SELECT 1 FROM pg_database WHERE datname = $1", [database], ); if (result.rows.length === 0) { // Create the database // Note: database names can't be parameterized, but we control this value await client.query(`CREATE DATABASE "${database}"`); console.log(`Created test database: ${database}`); } } finally { await client.end(); } } /** * Runs database migrations using dbmate CLI. * Creates the database if it doesn't exist. * Should be called once before running test suite. */ export async function runMigrations(): Promise { const testDbUrl = getTestDatabaseUrl(); if (!testDbUrl) { throw new Error("Test database URL not configured"); } // Ensure the database exists first await ensureTestDatabaseExists(); // Find the repo root (where db/migrations lives) // From apps/api-server/src/__tests__/helpers/ -> repo root is 5 levels up const repoRoot = join(import.meta.dir, "../../../../.."); const proc = Bun.spawn(["dbmate", "up"], { env: { ...process.env, DATABASE_URL: testDbUrl }, cwd: repoRoot, stdout: "pipe", stderr: "pipe", }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw new Error( `Migration failed with code ${String(exitCode)}: ${stderr}`, ); } } /** * Truncates all tables to reset database state. * Uses TRUNCATE ... CASCADE to handle foreign keys. */ export async function truncateAllTables(db: Kysely): Promise { // Use a single TRUNCATE statement with CASCADE for efficiency const tableList = TABLES_TO_TRUNCATE.join(", "); await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute( db, ); } /** * Creates a test user for e2e tests. * Returns the created user with ID. */ export async function createTestUser( db: Kysely, overrides: Partial<{ email: string; displayName: string; fullName: string; passwordHash: string; emailVerifiedAt: Date; isSuperuser: boolean; }> = {}, ): Promise<{ id: number; email: string }> { const email = overrides.email ?? `test-${String(Date.now())}@example.com`; const result = await db .insertInto("users") .values({ email, display_name: overrides.displayName ?? "Test User", full_name: overrides.fullName ?? null, password_hash: overrides.passwordHash ?? null, email_verified_at: overrides.emailVerifiedAt ?? null, is_superuser: overrides.isSuperuser ?? false, }) .returning(["id", "email"]) .executeTakeFirstOrThrow(); return result; } /** * Destroys the database connection pool. * Call this in afterAll() to clean up. */ export async function destroyTestDb(db: Kysely): Promise { await db.destroy(); }