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:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* The shape of a run report, and the vocabulary it is written in.
*
* Kept separate from both the collector and the renderers so neither depends on
* the other: `evidence.ts` gathers, `render-html.ts` presents, and both agree
* on what a scenario is by importing from here.
*/
import type { TestResult } from '@playwright/test/reporter';
/** One captured moment inside a scenario. */
export interface Step {
seq: string;
name: string;
shot?: string; // path relative to the report root
facts?: Record<string, unknown>;
}
/** One test, as the report talks about it. */
export interface Scenario {
project: string;
file: string;
suite: string;
title: string;
status: TestResult['status'];
expected: boolean;
durationMs: number;
steps: Step[];
error?: string;
}
/** How the run was configured, for the cover page. */
export interface RunMeta {
baseURL: string;
projects: string[];
workers: number;
}
export interface Summary {
generatedAt: string;
outcome: string;
durationMs: number;
total: number;
passed: number;
failed: number;
skipped: number;
narrated: number;
screenshots: number;
}
// --- areas -----------------------------------------------------------------
/**
* Spec filenames are an implementation detail. The report groups by what the
* tests actually cover, so somebody who has never opened the codebase can read
* it without a glossary.
*/
export interface Area {
title: string;
blurb: string;
}
export const AREAS: Record<string, Area> = {
'auth.spec.ts': {
title: 'Signing in and sessions',
blurb:
'Agents sign in against their own agency. Credentials issued by one agency must not work at another, and a session should survive a page reload but not outlive a sign-out.',
},
'search.spec.ts': {
title: 'Searching for flights',
blurb:
'Filtering the agency inventory by route, date, cabin and party size; ordering results by fare, journey time or departure; and paging through them.',
},
'booking.spec.ts': {
title: 'Booking and cancelling',
blurb:
'Selling a ticket end to end: the fare carried into checkout, traveller details, confirmation with a booking reference, and cancellation returning the seats to inventory.',
},
'tenant-isolation.spec.ts': {
title: 'Keeping agencies separate',
blurb:
'The central promise of the platform. Two agencies share one deployment and one database, and neither may see the other’s inventory, customers or revenue — not even by guessing a web address.',
},
'dashboard.spec.ts': {
title: 'Agency dashboard',
blurb:
'Trading figures for the signed-in agency — revenue, seats sold, bookings and best-selling routes — and that they move correctly when a sale is made and when it is cancelled.',
},
'simulation.spec.ts': {
title: 'Behaviour when things go wrong',
blurb:
'Here the backend is simulated so it can misbehave on demand: server errors, slow responses, a dropped network, an expired session, a seat sold mid-purchase, and awkward data such as very long names.',
},
'responsive.spec.ts': {
title: 'Phones and small screens',
blurb:
'The console is used at a desk and on a phone. No screen may scroll sideways at phone width, and wide tables must scroll inside their own frame rather than stretching the page.',
},
};
/** Reading order, rather than alphabetical: a reader follows the user journey. */
export const AREA_ORDER = [
'auth.spec.ts',
'search.spec.ts',
'booking.spec.ts',
'tenant-isolation.spec.ts',
'dashboard.spec.ts',
'simulation.spec.ts',
'responsive.spec.ts',
];
export function areaFor(file: string): Area {
const name = file.split('/').pop() ?? file;
return AREAS[name] ?? { title: name, blurb: '' };
}
export function group(scenarios: Scenario[]): Array<{ name: string; items: Scenario[] }> {
const map = new Map<string, Scenario[]>();
for (const scenario of scenarios) {
const list = map.get(scenario.file) ?? [];
list.push(scenario);
map.set(scenario.file, list);
}
const rank = (file: string) => {
const index = AREA_ORDER.indexOf(file.split('/').pop() ?? file);
return index === -1 ? AREA_ORDER.length : index;
};
return [...map.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([name, items]) => ({ name, items }));
}
// --- formatting ------------------------------------------------------------
export function escapeHtml(value: unknown): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
/** "1 min 30 s" reads better in a summary than "89.8 s". */
export function friendly(ms: number): string {
if (ms < 1000) {
return `${ms} ms`;
}
const total = Math.round(ms / 1000);
const minutes = Math.floor(total / 60);
const secs = total % 60;
return minutes === 0 ? `${secs} s` : `${minutes} min ${String(secs).padStart(2, '0')} s`;
}
export function sentenceCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
/** seatsAvailable -> "Seats available", so data tables read as English. */
export function label(key: string): string {
const spaced = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.toLowerCase()
.trim();
return sentenceCase(spaced);
}
export function statusWord(status: Scenario['status']): string {
switch (status) {
case 'passed':
return 'Passed';
case 'skipped':
return 'Not run';
case 'timedOut':
return 'Timed out';
default:
return 'Failed';
}
}
export function statusClass(status: Scenario['status']): string {
if (status === 'passed') {
return 'pass';
}
return status === 'skipped' ? 'skip' : 'fail';
}