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>
266 lines
7.9 KiB
TypeScript
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],
|
|
);
|
|
}
|