import Database from "better-sqlite3"; import { Kysely, PostgresDialect, SqliteDialect } from "kysely"; import { Pool } from "pg"; import { describe } from "vitest"; import { runMigrations } from "../../src/database/migrations/runner.js"; import type { Database as DatabaseSchema } from "../../src/database/types.js"; import { SchemaRegistry } from "../../src/schema/registry.js"; // --------------------------------------------------------------------------- // Environment // --------------------------------------------------------------------------- /** * PostgreSQL connection string for tests. * When set, Postgres tests run; when absent, they're skipped. */ export const PG_CONNECTION_STRING = process.env.EMDASH_TEST_PG ?? ""; /** * Whether a Postgres test database is available. */ export const hasPgTestDatabase = PG_CONNECTION_STRING.length > 0; // --------------------------------------------------------------------------- // SQLite helpers (unchanged) // --------------------------------------------------------------------------- /** * Create an in-memory SQLite database for testing */ export function createTestDatabase(): Kysely { const sqlite = new Database(":memory:"); return new Kysely({ dialect: new SqliteDialect({ database: sqlite, }), }); } /** * Setup a test database with migrations run */ export async function setupTestDatabase(): Promise> { const db = createTestDatabase(); await runMigrations(db); return db; } /** * Setup a test database with standard test collections (post, page) * This creates the ec_post and ec_page tables with title and content fields */ export async function setupTestDatabaseWithCollections(): Promise> { const db = await setupTestDatabase(); const registry = new SchemaRegistry(db); // Create post collection await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post", }); await registry.createField("post", { slug: "title", label: "Title", type: "string", }); await registry.createField("post", { slug: "content", label: "Content", type: "portableText", }); // Create page collection await registry.createCollection({ slug: "page", label: "Pages", labelSingular: "Page", }); await registry.createField("page", { slug: "title", label: "Title", type: "string", }); await registry.createField("page", { slug: "content", label: "Content", type: "portableText", }); return db; } /** * Cleanup and destroy a test database */ export async function teardownTestDatabase(db: Kysely): Promise { await db.destroy(); } // --------------------------------------------------------------------------- // PostgreSQL helpers // --------------------------------------------------------------------------- /** * Shared pool for Postgres tests. One pool per test process, many schemas. * Created lazily on first call to createTestPostgresDatabase(). */ let sharedPool: Pool | null = null; function getSharedPool(): Pool { if (!sharedPool) { sharedPool = new Pool({ connectionString: PG_CONNECTION_STRING, max: 10, }); } return sharedPool; } /** * Generate a unique schema name for test isolation. * Format: test__ — short, valid SQL identifier. */ function uniqueSchemaName(): string { const ts = Date.now().toString(36); const rand = Math.random().toString(36).slice(2, 8); return `test_${ts}_${rand}`; } export interface PgTestContext { db: Kysely; schemaName: string; } /** * Create an isolated Postgres database for a single test. * * Each call creates a unique schema and returns a Kysely instance * whose search_path is set to that schema. Tables are fully isolated. * * Call `teardownTestPostgresDatabase()` in afterEach to drop the schema. */ export async function createTestPostgresDatabase(): Promise { const pool = getSharedPool(); const schemaName = uniqueSchemaName(); // Create the isolated schema using a raw connection const client = await pool.connect(); try { await client.query(`CREATE SCHEMA ${schemaName}`); } finally { client.release(); } // Create a Kysely instance that targets this schema. // Test schema comes first so CREATE TABLE goes there. // public is included for Postgres system functions and extensions. const testPool = new Pool({ connectionString: PG_CONNECTION_STRING, max: 5, options: `-c search_path=${schemaName},public`, }); const db = new Kysely({ dialect: new PostgresDialect({ pool: testPool }), }); return { db, schemaName }; } /** * Setup a Postgres test database with migrations run. */ export async function setupTestPostgresDatabase(): Promise { const ctx = await createTestPostgresDatabase(); await runMigrations(ctx.db); return ctx; } /** * Setup a Postgres test database with standard test collections (post, page). */ export async function setupTestPostgresDatabaseWithCollections(): Promise { const ctx = await setupTestPostgresDatabase(); const registry = new SchemaRegistry(ctx.db); await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post", }); await registry.createField("post", { slug: "title", label: "Title", type: "string", }); await registry.createField("post", { slug: "content", label: "Content", type: "portableText", }); await registry.createCollection({ slug: "page", label: "Pages", labelSingular: "Page", }); await registry.createField("page", { slug: "title", label: "Title", type: "string", }); await registry.createField("page", { slug: "content", label: "Content", type: "portableText", }); return ctx; } /** * Tear down a Postgres test database — drops the schema and closes the pool. */ export async function teardownTestPostgresDatabase(ctx: PgTestContext): Promise { // Destroy the test pool first await ctx.db.destroy(); // Drop the schema using the shared pool const pool = getSharedPool(); const client = await pool.connect(); try { await client.query(`DROP SCHEMA IF EXISTS ${ctx.schemaName} CASCADE`); } finally { client.release(); } } /** * Shut down the shared Postgres pool. Call once at the end of the test run. */ export async function destroySharedPool(): Promise { if (sharedPool) { await sharedPool.end(); sharedPool = null; } } // --------------------------------------------------------------------------- // Dialect-parametric test helpers // --------------------------------------------------------------------------- export type DialectName = "sqlite" | "postgres"; export interface DialectTestContext { db: Kysely; dialect: DialectName; /** Only present for Postgres — needed for teardown */ pgCtx?: PgTestContext; } /** * Create a bare test database for a given dialect (no migrations). */ export async function createForDialect(dialect: DialectName): Promise { if (dialect === "postgres") { const pgCtx = await createTestPostgresDatabase(); return { db: pgCtx.db, dialect, pgCtx }; } const db = createTestDatabase(); return { db, dialect }; } /** * Create a test database for a given dialect (with migrations). */ export async function setupForDialect(dialect: DialectName): Promise { if (dialect === "postgres") { const pgCtx = await setupTestDatabase_pg(); return { db: pgCtx.db, dialect, pgCtx }; } const db = await setupTestDatabase(); return { db, dialect }; } /** * Create a test database with collections for a given dialect. */ export async function setupForDialectWithCollections( dialect: DialectName, ): Promise { if (dialect === "postgres") { const pgCtx = await setupTestPostgresDatabaseWithCollections(); return { db: pgCtx.db, dialect, pgCtx }; } const db = await setupTestDatabaseWithCollections(); return { db, dialect }; } /** * Tear down a test database for any dialect. */ export async function teardownForDialect(ctx: DialectTestContext): Promise { if (ctx.pgCtx) { await teardownTestPostgresDatabase(ctx.pgCtx); } else { await teardownTestDatabase(ctx.db); } } // Private alias to avoid name collision const setupTestDatabase_pg = setupTestPostgresDatabase; /** * Run a describe block once per available dialect. * * When EMDASH_TEST_PG is not set, only SQLite runs. * When set, the suite runs for both SQLite and Postgres. * * @example * ```ts * describeEachDialect("Migrations", (dialectName) => { * let ctx: DialectTestContext; * beforeEach(async () => { ctx = await setupForDialect(dialectName); }); * afterEach(async () => { await teardownForDialect(ctx); }); * * it("creates tables", async () => { * // ctx.db works with either dialect * }); * }); * ``` */ export function describeEachDialect(name: string, fn: (dialect: DialectName) => void): void { const dialects: DialectName[] = ["sqlite"]; if (hasPgTestDatabase) { dialects.push("postgres"); } for (const dialect of dialects) { describe(`${name} [${dialect}]`, () => { fn(dialect); }); } }