Files
nilesh.pandClaude Opus 4.8 4d3eb18a6c 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>
2026-09-13 11:38:01 +05:30

266 lines
7.9 KiB
TypeScript

/**
* Simulated backend for UI tests.
*
* The end-to-end specs drive the real stack, which is the right way to prove
* the system works. But some conditions are impractical to stage for real:
* a 500 from the database, a fifteen-second response, a seat sold by someone
* else in the half second between opening checkout and confirming, an agency
* with a hundred thousand flights, a passenger whose name is 200 characters
* long.
*
* These helpers intercept `/api/**` in the browser and answer it directly, so
* a test can put the UI in any of those states deterministically, in
* milliseconds, without touching the database.
*/
import { Page } from '@playwright/test';
// --- canned data -----------------------------------------------------------
export const TENANTS = [
{ slug: 'skyline', name: 'Skyline Travel' },
{ slug: 'globetrotter', name: 'Globetrotter Tours' },
];
export const SESSION = {
user: {
id: 'u-1',
tenantId: 't-1',
email: 'agent@skyline.test',
fullName: 'Marcus Reid',
role: 'agent',
createdAt: '2026-01-01T00:00:00Z',
},
tenant: {
id: 't-1',
slug: 'skyline',
name: 'Skyline Travel',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
},
};
export const AIRPORTS = [
{ id: 'a-jfk', iataCode: 'JFK', name: 'John F. Kennedy International', city: 'New York', country: 'United States', timezone: 'America/New_York' },
{ id: 'a-lhr', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'United Kingdom', timezone: 'Europe/London' },
];
export const AIRLINES = [{ id: 'al-aa', iataCode: 'AA', name: 'American Airlines' }];
export function makeFlight(overrides: Record<string, unknown> = {}) {
return {
id: 'f-1',
flightNumber: 'AA100',
airline: AIRLINES[0],
origin: AIRPORTS[0],
destination: AIRPORTS[1],
departureTime: '2026-12-19T18:00:00Z',
arrivalTime: '2026-12-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 483.3,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 165,
status: 'scheduled',
...overrides,
};
}
export function makeBooking(overrides: Record<string, unknown> = {}) {
return {
id: 'b-1',
bookingReference: 'SIM001',
flight: makeFlight(),
passengers: [{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield' }],
passengerCount: 1,
totalAmount: 483.3,
currency: 'USD',
status: 'confirmed',
contactEmail: 'ops@example.com',
contactPhone: '',
bookedBy: 'Marcus Reid',
createdAt: '2026-09-12T13:10:00Z',
...overrides,
};
}
export function makeDashboard(overrides: Record<string, unknown> = {}) {
return {
totalBookings: 6,
confirmedBookings: 5,
cancelledBookings: 1,
totalRevenue: 7263.16,
currency: 'USD',
flightsInStock: 294,
seatsSold: 12,
recentBookings: [makeBooking()],
topRoutes: [{ route: 'JFK to LHR', bookings: 3, revenue: 4000 }],
...overrides,
};
}
export function page$<T>(items: T[], total = items.length, limit = 10, offset = 0) {
return { items, total, limit, offset };
}
// --- scenario --------------------------------------------------------------
/**
* How one endpoint should answer. A bare value is a 200 with that value as the
* body; use `reply`, `fail` or `slow` for anything else.
*
* The envelope carries a symbol brand rather than being recognised by shape.
* Domain objects here have a `status` field of their own -- a flight is
* "scheduled", a booking is "confirmed" -- so duck-typing on `status` would
* silently read a booking as an HTTP response.
*/
const ENVELOPE = Symbol('travelhub.reply');
export interface ReplyEnvelope {
[ENVELOPE]: true;
status: number;
body: unknown;
delayMs?: number;
}
export type Reply = ReplyEnvelope | unknown;
/** An explicit response: status, body and optional latency. */
export function reply(opts: { status?: number; body?: unknown; delayMs?: number }): ReplyEnvelope {
return {
[ENVELOPE]: true,
status: opts.status ?? 200,
body: opts.body ?? {},
delayMs: opts.delayMs,
};
}
/** An error response, in the shape the Go API uses. */
export function fail(status: number, error = 'simulated failure'): ReplyEnvelope {
return reply({ status, body: { error } });
}
/** A successful but slow response, for asserting on loading states. */
export function slow(body: unknown, delayMs: number): ReplyEnvelope {
return reply({ status: 200, body, delayMs });
}
export interface Scenario {
/** Latency applied to every endpoint that does not set its own. */
delayMs?: number;
/** Abort every API call, as though the network were gone. */
offline?: boolean;
session?: Reply;
tenants?: Reply;
airports?: Reply;
airlines?: Reply;
flights?: Reply;
flightById?: Reply;
bookings?: Reply;
bookingById?: Reply;
createBooking?: Reply;
cancelBooking?: Reply;
dashboard?: Reply;
}
function normalise(value: Reply): { status: number; body: unknown; delayMs?: number } {
if (value !== null && typeof value === 'object' && ENVELOPE in value) {
const envelope = value as ReplyEnvelope;
return { status: envelope.status, body: envelope.body, delayMs: envelope.delayMs };
}
return { status: 200, body: value };
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Installs the simulated API. Anything the scenario does not mention falls
* back to a sensible, healthy default, so a test only spells out the one
* condition it is about.
*/
export async function simulateApi(page: Page, scenario: Scenario = {}): Promise<void> {
const defaults: Scenario = {
session: SESSION,
tenants: TENANTS,
airports: AIRPORTS,
airlines: AIRLINES,
flights: page$([makeFlight()]),
flightById: makeFlight(),
bookings: page$([makeBooking()]),
bookingById: makeBooking(),
createBooking: reply({ status: 201, body: makeBooking() }),
cancelBooking: makeBooking({ status: 'cancelled' }),
dashboard: makeDashboard(),
};
const merged = { ...defaults, ...scenario };
await page.route('**/api/**', async (route) => {
if (scenario.offline) {
await route.abort('internetdisconnected');
return;
}
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
let answer: Reply;
if (path === '/api/health') {
answer = { status: 'ok' };
} else if (path === '/api/tenants') {
answer = merged.tenants;
} else if (path === '/api/session') {
answer = merged.session;
} else if (path === '/api/airports') {
answer = merged.airports;
} else if (path === '/api/airlines') {
answer = merged.airlines;
} else if (path === '/api/dashboard') {
answer = merged.dashboard;
} else if (path === '/api/flights') {
answer = merged.flights;
} else if (path.startsWith('/api/flights/')) {
answer = merged.flightById;
} else if (path.endsWith('/cancel')) {
answer = merged.cancelBooking;
} else if (path === '/api/bookings') {
answer = method === 'POST' ? merged.createBooking : merged.bookings;
} else if (path.startsWith('/api/bookings/')) {
answer = merged.bookingById;
} else {
answer = fail(404, 'not found');
}
const { status, body, delayMs } = normalise(answer);
const wait = delayMs ?? merged.delayMs ?? 0;
if (wait > 0) {
await sleep(wait);
}
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/**
* Puts credentials in localStorage before the app boots, so a simulated test
* lands straight on the screen under test instead of signing in first.
*/
export async function primeCredentials(page: Page, tenant = 'skyline'): Promise<void> {
await page.addInitScript(
([slug]) => {
localStorage.setItem(
'travelhub.credentials',
JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }),
);
},
[tenant],
);
}