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,265 @@
|
||||
/**
|
||||
* 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user