Files
TS_Proto/scripts/db.mjs
T
nilesh.pandClaude Opus 4.8 45b5b34680 Add AI-assisted QA test workflow: scenario cards, runner, hooks, API capture
A complete loop for defining, generating, running and gating tests:

- qa/: scenario-card template + examples (API/UI/binding), the Claude
  generation prompt, and a QA runbook tying it together
- e2e/CLAUDE.md: conventions that keep AI-generated tests consistent
- run-tests.ps1: one runner for every layer (backend/api/web/ui/binding),
  logs per run under .testlogs/, JUnit output, evidence report, and a
  re-seed guard against stale demo data
- .githooks/ + scripts/install-hooks.ps1: pre-commit secret scan and
  pre-push unit tests (shift-left before CI)
- evidence report now captures each API call's request, payload and
  response (Authorization redacted); JUnit reporter added for CI
- scripts/db.mjs: db:check freshness guard

Azure DevOps pipeline deferred; the runner already emits JUnit + the
evidence report for any CI to publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 13:04:46 +05:30

217 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Database chores for TravelHub, driven through psql.
*
* Credentials come from backend/.env (or the real environment, which wins), so
* this works the same on a developer machine and in CI.
*
* node scripts/db.mjs create create the application database
* node scripts/db.mjs migrate apply every migration in order
* node scripts/db.mjs reset drop, recreate and migrate (destructive)
* node scripts/db.mjs test-db create the empty database the Go tests use
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const MIGRATIONS = join(ROOT, 'backend', 'migrations');
// --- configuration ---------------------------------------------------------
/** Reads backend/.env without overriding anything already in the environment. */
function loadDotEnv() {
const path = join(ROOT, 'backend', '.env');
if (!existsSync(path)) {
return;
}
for (const line of readFileSync(path, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const eq = trimmed.indexOf('=');
if (eq === -1) {
continue;
}
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (value.length >= 2 && /^["'].*["']$/.test(value)) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
loadDotEnv();
const CONFIG = {
host: process.env.DB_HOST ?? 'localhost',
port: process.env.DB_PORT ?? '5432',
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'travelhub',
testDatabase: process.env.TEST_DB_NAME ?? 'travelhub_test',
};
/**
* Finds psql. It is routinely absent from PATH on Windows, so fall back to the
* standard install location before giving up.
*/
function findPsql() {
if (process.env.PSQL) {
return process.env.PSQL;
}
try {
execFileSync('psql', ['--version'], { stdio: 'ignore' });
return 'psql';
} catch {
// Not on PATH; try the usual Windows install root, newest version first.
const base = 'C:\\Program Files\\PostgreSQL';
if (existsSync(base)) {
const versions = readdirSync(base).sort((a, b) => Number(b) - Number(a));
for (const version of versions) {
const candidate = join(base, version, 'bin', 'psql.exe');
if (existsSync(candidate)) {
return candidate;
}
}
}
}
throw new Error(
'psql was not found. Add it to PATH, or set PSQL to its full path.\n' +
'On Windows it usually lives in C:\\Program Files\\PostgreSQL\\17\\bin',
);
}
const PSQL = findPsql();
// --- psql helpers ----------------------------------------------------------
function run(args, { database = 'postgres', capture = false } = {}) {
return execFileSync(
PSQL,
['-U', CONFIG.user, '-h', CONFIG.host, '-p', CONFIG.port, '-d', database, ...args],
{
env: { ...process.env, PGPASSWORD: CONFIG.password },
encoding: 'utf8',
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
},
);
}
function databaseExists(name) {
const out = run(['-tAc', `SELECT 1 FROM pg_database WHERE datname='${name}'`], { capture: true });
return out.trim() === '1';
}
function createDatabase(name) {
if (databaseExists(name)) {
console.log(`· ${name} already exists`);
return false;
}
run(['-c', `CREATE DATABASE "${name}"`], { capture: true });
console.log(`✓ created ${name}`);
return true;
}
function dropDatabase(name) {
if (!databaseExists(name)) {
return;
}
// Existing sessions would block the drop, so close them first.
run(
[
'-c',
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname='${name}' AND pid <> pg_backend_pid()`,
],
{ capture: true },
);
run(['-c', `DROP DATABASE "${name}"`], { capture: true });
console.log(`✓ dropped ${name}`);
}
function migrate(name) {
const files = readdirSync(MIGRATIONS)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
process.stdout.write(` applying ${file} ... `);
run(['-v', 'ON_ERROR_STOP=1', '-q', '-f', join(MIGRATIONS, file)], { database: name });
console.log('ok');
}
console.log(`✓ ${name} is up to date (${files.length} migrations)`);
}
// --- commands --------------------------------------------------------------
const commands = {
create() {
createDatabase(CONFIG.database);
},
migrate() {
createDatabase(CONFIG.database);
migrate(CONFIG.database);
},
reset() {
console.log(`Resetting ${CONFIG.database}. All data in it will be lost.`);
dropDatabase(CONFIG.database);
createDatabase(CONFIG.database);
migrate(CONFIG.database);
},
'test-db'() {
// The Go tests apply the schema themselves; they only need the database.
createDatabase(CONFIG.testDatabase);
console.log(`✓ ${CONFIG.testDatabase} ready for go test ./...`);
},
// Freshness guard for the e2e suite. The seed generates flights relative to
// now(), so after ~3 weeks all seeded departures are in the past and the
// browser tests fail on empty search results. Exit non-zero when stale so a
// runner can re-seed before running.
check() {
if (!databaseExists(CONFIG.database)) {
console.error(`✗ ${CONFIG.database} does not exist`);
process.exit(2);
}
const out = run(
['-tAc', 'SELECT count(*) FROM flights WHERE departure_time > now()'],
{ database: CONFIG.database, capture: true },
);
const future = Number((out || '').trim());
if (Number.isFinite(future) && future > 0) {
console.log(`✓ inventory fresh: ${future} future flights`);
process.exit(0);
}
console.error('✗ inventory stale: no future flights — re-seed needed (npm run db:reset)');
process.exit(1);
},
};
const command = process.argv[2];
if (!command || !(command in commands)) {
console.error(`Usage: node scripts/db.mjs <${Object.keys(commands).join('|')}>`);
process.exit(1);
}
if (!CONFIG.password) {
console.error('No database password. Set DB_PASSWORD, or fill in backend/.env.');
process.exit(1);
}
try {
commands[command]();
} catch (err) {
console.error(`\n✗ ${command} failed: ${err.message}`);
process.exit(1);
}