diff --git a/services/mam-api/package.json b/services/mam-api/package.json index f65b552..e55dedd 100644 --- a/services/mam-api/package.json +++ b/services/mam-api/package.json @@ -6,7 +6,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test test/**/*.test.js" }, "dependencies": { "express": "^4.18.2", diff --git a/services/mam-api/test/helpers/setup-db.js b/services/mam-api/test/helpers/setup-db.js new file mode 100644 index 0000000..e608741 --- /dev/null +++ b/services/mam-api/test/helpers/setup-db.js @@ -0,0 +1,44 @@ +// Pure helper for integration tests. Requires TEST_DATABASE_URL pointing at +// a throwaway Postgres. Returns a pg Pool with the full schema applied. +// Tests that need this should `skip` (not fail) when TEST_DATABASE_URL is unset. +import { Pool } from 'pg'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const migrationsDir = join(__dirname, '..', '..', 'src', 'db', 'migrations'); +const schemaFile = join(__dirname, '..', '..', 'src', 'db', 'schema.sql'); +const patches = [ + 'schema_patch_ampp.sql', + 'schema_patch_editor.sql', + 'schema_patch_groups_tokens.sql', +]; + +export function isTestDbConfigured() { + return !!process.env.TEST_DATABASE_URL; +} + +export async function setupTestDb() { + if (!process.env.TEST_DATABASE_URL) { + throw new Error('TEST_DATABASE_URL not set'); + } + const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }); + + // Wipe and recreate the public schema so each test run starts clean. + await pool.query('DROP SCHEMA IF EXISTS public CASCADE'); + await pool.query('CREATE SCHEMA public'); + await pool.query('GRANT ALL ON SCHEMA public TO PUBLIC'); + + // Base schema, then patches, then migrations in order. + await pool.query(readFileSync(schemaFile, 'utf8')); + for (const p of patches) { + await pool.query(readFileSync(join(__dirname, '..', '..', 'src', 'db', p), 'utf8')); + } + const migrations = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of migrations) { + await pool.query(readFileSync(join(migrationsDir, f), 'utf8')); + } + + return pool; +} diff --git a/services/mam-api/test/helpers/test-app.js b/services/mam-api/test/helpers/test-app.js new file mode 100644 index 0000000..b4314be --- /dev/null +++ b/services/mam-api/test/helpers/test-app.js @@ -0,0 +1,45 @@ +// Builds an Express app with the production middleware stack pointed at a +// test pool, listens on an ephemeral port, returns { baseUrl, server, pool, cleanup }. +// Tests use fetch() against baseUrl — no supertest dep needed. +import express from 'express'; +import cors from 'cors'; +import session from 'express-session'; +import connectPgSimple from 'connect-pg-simple'; +import { setupTestDb } from './setup-db.js'; + +const PgStore = connectPgSimple(session); + +export async function createTestApp({ authEnabled = true } = {}) { + const pool = await setupTestDb(); + process.env.AUTH_ENABLED = authEnabled ? 'true' : 'false'; + process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret-' + Math.random(); + + const app = express(); + app.use(cors({ origin: true, credentials: true })); + app.use(express.json({ limit: '5mb' })); + app.use(session({ + store: new PgStore({ pool, tableName: 'sessions' }), + secret: process.env.SESSION_SECRET, + name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, path: '/', maxAge: 8 * 3600 * 1000 }, + rolling: false, + resave: false, + saveUninitialized: false, + })); + + app.get('/health', (_req, res) => res.json({ status: 'ok' })); + + // Tests that need additional routes mount them on the returned `app` + // before calling listen. + return new Promise(resolve => { + const server = app.listen(0, '127.0.0.1', () => { + const port = server.address().port; + const baseUrl = `http://127.0.0.1:${port}`; + const cleanup = async () => { + await new Promise(r => server.close(r)); + await pool.end(); + }; + resolve({ app, server, pool, baseUrl, cleanup }); + }); + }); +} diff --git a/services/mam-api/test/smoke.test.js b/services/mam-api/test/smoke.test.js new file mode 100644 index 0000000..cd89ced --- /dev/null +++ b/services/mam-api/test/smoke.test.js @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isTestDbConfigured } from './helpers/setup-db.js'; +import { createTestApp } from './helpers/test-app.js'; + +test('test infra: /health responds on an ephemeral port', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const { baseUrl, cleanup } = await createTestApp(); + try { + const res = await fetch(baseUrl + '/health'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, 'ok'); + } finally { + await cleanup(); + } +});