Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates TEST-CATALOG.md by reading the test files themselves.
|
||||
*
|
||||
* Static parsing rather than running the suites: the catalogue can then be
|
||||
* regenerated in a second, with no database and no browser, which is the only
|
||||
* way a document like this stays current.
|
||||
*
|
||||
* node scripts/test-catalog.mjs write TEST-CATALOG.md
|
||||
* node scripts/test-catalog.mjs --check fail if it is out of date (CI)
|
||||
* node scripts/test-catalog.mjs --stdout print instead of writing
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT = join(ROOT, 'TEST-CATALOG.md');
|
||||
|
||||
// --- file discovery --------------------------------------------------------
|
||||
|
||||
function walk(dir, match, found = []) {
|
||||
if (!existsSync(dir)) {
|
||||
return found;
|
||||
}
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full, match, found);
|
||||
} else if (match(entry)) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const posix = (p) => relative(ROOT, p).split(sep).join('/');
|
||||
|
||||
// --- Go ---------------------------------------------------------------------
|
||||
|
||||
/** Turns TestCreateBookingSellsSeats into "Create booking sells seats". */
|
||||
function humanizeGo(name) {
|
||||
const body = name.replace(/^Test/, '');
|
||||
const words = body
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
function parseGo(file) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
|
||||
const tests = [];
|
||||
let current = null;
|
||||
let buffer = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.subtests = subtestsFrom(buffer.join('\n'));
|
||||
tests.push(current);
|
||||
current = null;
|
||||
buffer = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const start = /^func (Test\w+)\(t \*testing\.T\)/.exec(line);
|
||||
if (start) {
|
||||
flush();
|
||||
current = { name: start[1], label: humanizeGo(start[1]), subtests: [] };
|
||||
continue;
|
||||
}
|
||||
if (/^func /.test(line)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls subtest names out of a function body. Covers the three shapes used
|
||||
* here: a literal t.Run, a table with a `name:` field, and a positional table
|
||||
* whose first column is the subtest name.
|
||||
*/
|
||||
function subtestsFrom(body) {
|
||||
const names = new Set();
|
||||
|
||||
for (const m of body.matchAll(/t\.Run\(\s*"([^"]+)"/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
for (const m of body.matchAll(/\bname:\s*"([^"]+)"/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
// Positional table rows: {"price", "CHEAPSLOW"},
|
||||
const usesPositional = /t\.Run\(\s*tc\.(\w+)/.test(body) && !/\bname:/.test(body);
|
||||
if (usesPositional) {
|
||||
for (const m of body.matchAll(/^\s*\{"([^"]+)",/gm)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
}
|
||||
// A table of awkward values run by their own value, e.g. passwords.
|
||||
for (const m of body.matchAll(/t\.Run\(\s*(\w+),\s*func/g)) {
|
||||
void m; // the values are not statically knowable; counted as one case
|
||||
}
|
||||
|
||||
return [...names];
|
||||
}
|
||||
|
||||
// --- TypeScript (Vitest and Playwright) -------------------------------------
|
||||
|
||||
/**
|
||||
* Walks a spec file tracking describe nesting by brace depth, so each test is
|
||||
* reported under the describes that contain it.
|
||||
*/
|
||||
function parseSpec(file) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
|
||||
const groups = [];
|
||||
const stack = [];
|
||||
let depth = 0;
|
||||
|
||||
const DESCRIBE = /^\s*(?:test\.)?describe(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/;
|
||||
const CASE = /^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/;
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\/\/.*$/, '');
|
||||
|
||||
const describe = DESCRIBE.exec(line);
|
||||
if (describe) {
|
||||
stack.push({ title: describe[2], depth });
|
||||
} else {
|
||||
const testCase = CASE.exec(line);
|
||||
if (testCase) {
|
||||
groups.push({ suite: stack.map((s) => s.title).join(' › '), title: testCase[2] });
|
||||
}
|
||||
}
|
||||
|
||||
depth += (line.match(/\{/g) ?? []).length;
|
||||
depth -= (line.match(/\}/g) ?? []).length;
|
||||
while (stack.length && depth <= stack[stack.length - 1].depth) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// --- rendering --------------------------------------------------------------
|
||||
|
||||
function goSection(files) {
|
||||
const out = [];
|
||||
let functions = 0;
|
||||
let cases = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const tests = parseGo(file);
|
||||
if (tests.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const fileCases = tests.reduce((n, t) => n + Math.max(1, t.subtests.length), 0);
|
||||
functions += tests.length;
|
||||
cases += fileCases;
|
||||
|
||||
out.push(`### \`${posix(file)}\``, '');
|
||||
out.push(`${tests.length} tests, ${fileCases} cases.`, '');
|
||||
for (const test of tests) {
|
||||
out.push(`- **${test.label}** — \`${test.name}\``);
|
||||
for (const sub of test.subtests) {
|
||||
out.push(` - ${sub}`);
|
||||
}
|
||||
}
|
||||
out.push('');
|
||||
}
|
||||
|
||||
return { lines: out, functions, cases };
|
||||
}
|
||||
|
||||
function specSection(files, label) {
|
||||
const out = [];
|
||||
let cases = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const found = parseSpec(file);
|
||||
if (found.length === 0) {
|
||||
continue;
|
||||
}
|
||||
cases += found.length;
|
||||
|
||||
out.push(`### \`${posix(file)}\``, '');
|
||||
out.push(`${found.length} ${found.length === 1 ? 'test' : 'tests'}.`, '');
|
||||
|
||||
let lastSuite = null;
|
||||
for (const item of found) {
|
||||
if (item.suite !== lastSuite) {
|
||||
if (item.suite) {
|
||||
out.push(`- **${item.suite}**`);
|
||||
}
|
||||
lastSuite = item.suite;
|
||||
}
|
||||
out.push(item.suite ? ` - ${item.title}` : `- ${item.title}`);
|
||||
}
|
||||
out.push('');
|
||||
}
|
||||
|
||||
return { lines: out, cases, label };
|
||||
}
|
||||
|
||||
// --- assemble ---------------------------------------------------------------
|
||||
|
||||
const goFiles = walk(join(ROOT, 'backend'), (f) => f.endsWith('_test.go')).sort();
|
||||
const webFiles = walk(join(ROOT, 'frontend', 'src'), (f) => f.endsWith('.spec.ts')).sort();
|
||||
const e2eFiles = walk(join(ROOT, 'e2e', 'tests'), (f) => f.endsWith('.spec.ts')).sort();
|
||||
|
||||
const go = goSection(goFiles);
|
||||
const web = specSection(webFiles, 'Angular');
|
||||
const e2e = specSection(e2eFiles, 'Playwright');
|
||||
|
||||
const total = go.cases + web.cases + e2e.cases;
|
||||
const files = goFiles.length + webFiles.length + e2eFiles.length;
|
||||
|
||||
// Specs that a second Playwright project re-runs under a mobile viewport.
|
||||
const REPEATED_ON_MOBILE = /(auth|responsive)\.spec\.ts$/;
|
||||
const mobileRepeats = e2eFiles
|
||||
.filter((f) => REPEATED_ON_MOBILE.test(f))
|
||||
.reduce((n, f) => n + parseSpec(f).length, 0);
|
||||
|
||||
const doc = [
|
||||
'# TravelHub test catalogue',
|
||||
'',
|
||||
'> Generated by `npm run docs:tests` from the test sources.',
|
||||
'> Do not edit by hand — regenerate instead.',
|
||||
'',
|
||||
`**${total} test cases** across ${files} files.`,
|
||||
'',
|
||||
'| Suite | Files | Cases | Command |',
|
||||
'| ----- | ----: | ----: | ------- |',
|
||||
`| [Go](#go-backend) | ${goFiles.length} | ${go.cases} | \`npm run test:go\` |`,
|
||||
`| [Angular](#angular-frontend) | ${webFiles.length} | ${web.cases} | \`npm run test:web\` |`,
|
||||
`| [Playwright](#playwright-end-to-end) | ${e2eFiles.length} | ${e2e.cases} | \`npm run test:e2e\` |`,
|
||||
`| **Total** | **${files}** | **${total}** | \`npm test\` |`,
|
||||
'',
|
||||
'### Why the runners report different numbers',
|
||||
'',
|
||||
'A *case* below is one thing being tested. The runners count differently, so',
|
||||
'both figures are given rather than one being quietly preferred:',
|
||||
'',
|
||||
`- **Go** reports ${go.functions} test functions plus each table-driven subtest`,
|
||||
' separately, so `go test -v` prints more `PASS` lines than there are cases',
|
||||
' here. A few tables build their subtest names at runtime and cannot be',
|
||||
' listed statically; those appear as a single case.',
|
||||
`- **Playwright** re-runs \`auth\` and \`responsive\` (${mobileRepeats} tests) under a second,`,
|
||||
` mobile viewport, so a full run executes ${e2e.cases + mobileRepeats} tests from ${e2e.cases} declared.`,
|
||||
'- **Angular** counts exactly as listed.',
|
||||
'',
|
||||
'Running a single file:',
|
||||
'',
|
||||
'```bash',
|
||||
'cd backend && go test ./internal/repository/ -run TestCreateBookingSellsSeats -v',
|
||||
'cd frontend && npx ng test --no-watch # all specs',
|
||||
'cd e2e && npx playwright test tests/booking.spec.ts',
|
||||
'```',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Go (backend)',
|
||||
'',
|
||||
`${go.functions} test functions covering ${go.cases} cases. These run against a real`,
|
||||
'PostgreSQL, with a fresh tenant created and dropped per test.',
|
||||
'',
|
||||
...go.lines,
|
||||
'---',
|
||||
'',
|
||||
'## Angular (frontend)',
|
||||
'',
|
||||
`${web.cases} tests. Vitest with jsdom, via \`ng test\`.`,
|
||||
'',
|
||||
...web.lines,
|
||||
'---',
|
||||
'',
|
||||
'## Playwright (end to end)',
|
||||
'',
|
||||
`${e2e.cases} tests, each run against a real browser and the real stack.`,
|
||||
'',
|
||||
...e2e.lines,
|
||||
].join('\n');
|
||||
|
||||
// --- output -----------------------------------------------------------------
|
||||
|
||||
if (process.argv.includes('--stdout')) {
|
||||
process.stdout.write(doc);
|
||||
} else if (process.argv.includes('--check')) {
|
||||
const existing = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '';
|
||||
if (existing.trim() !== doc.trim()) {
|
||||
console.error('✗ TEST-CATALOG.md is out of date. Run: npm run docs:tests');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ TEST-CATALOG.md is up to date');
|
||||
} else {
|
||||
writeFileSync(OUT, doc + '\n', 'utf8');
|
||||
console.log(`✓ wrote ${posix(OUT)} — ${total} cases`);
|
||||
console.log(` Go ${go.cases} · Angular ${web.cases} · Playwright ${e2e.cases}`);
|
||||
}
|
||||
Reference in New Issue
Block a user