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
+341
View File
@@ -0,0 +1,341 @@
/**
* Simulated-condition UI tests.
*
* Everything here answers the question "what does the screen do when the world
* misbehaves?" -- a failing API, a slow one, an absent one, an empty agency, a
* seat sold out from under the form, a session that expired mid-session.
*
* The backend is intercepted rather than used, which makes these tests fast,
* deterministic, and free of database side effects. They are the complement to
* the real-stack specs, not a replacement: those prove the system works, these
* prove the UI degrades well.
*/
import { expect, test } from '../fixtures/test';
import {
SESSION,
fail,
makeBooking,
makeDashboard,
makeFlight,
page$,
primeCredentials,
simulateApi,
slow,
} from '../fixtures/api-mock';
test.beforeEach(async ({ page }) => {
await primeCredentials(page);
});
// --- failing backend -------------------------------------------------------
test.describe('When the API fails', () => {
test('search shows an error instead of an empty page', async ({ page, search }) => {
await simulateApi(page, { flights: fail(500, 'boom') });
await page.goto('/search');
await expect(page.locator('.alert-error')).toContainText('Could not load flights');
// The filter panel stays usable so the agent can retry.
await expect(search.searchButton).toBeEnabled();
});
test('the dashboard explains itself rather than showing zeroes', async ({ page }) => {
await simulateApi(page, { dashboard: fail(503, 'unavailable') });
await page.goto('/dashboard');
await expect(page.locator('.alert-error')).toContainText('Could not load dashboard');
// Crucially, it does not render "$0 revenue" as though that were the truth.
await expect(page.locator('.tile-value')).toHaveCount(0);
});
test('the bookings list reports a failure', async ({ page }) => {
await simulateApi(page, { bookings: fail(500) });
await page.goto('/bookings');
await expect(page.locator('.alert-error')).toContainText('Could not load bookings');
});
test('a missing backend is named on the sign-in screen', async ({ page, login }) => {
// Status 0 is what the browser reports when nothing answers at all.
await simulateApi(page, { offline: true });
await page.goto('/login');
await expect(login.error).toContainText('Go backend');
});
test('going offline mid-session surfaces a failure, not a hang', async ({ page, search }) => {
await simulateApi(page);
await page.goto('/search');
await expect(search.results.first()).toBeVisible();
// The network drops, then the agent searches again.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await simulateApi(page, { offline: true });
await search.searchButton.click();
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 15_000 });
});
});
// --- slow backend ----------------------------------------------------------
test.describe('When the API is slow', () => {
test('search shows skeletons while it waits', async ({ page }) => {
await simulateApi(page, { flights: slow(page$([makeFlight()]), 2000) });
await page.goto('/search');
// The loading state must appear rather than a blank panel.
await expect(page.locator('.skeleton-card').first()).toBeVisible();
await expect(page.locator('.results-bar')).toContainText('Searching');
await expect(page.locator('.flight').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.skeleton-card')).toHaveCount(0);
});
test('the sign-in button reports that it is working', async ({ page, login }) => {
await simulateApi(page, { session: slow(SESSION, 1500) });
await page.context().clearCookies();
await page.addInitScript(() => localStorage.clear());
await page.goto('/login');
await login.useDemoChip({
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'agent@skyline.test',
password: 'demo1234',
fullName: 'Marcus Reid',
role: 'agent',
});
await login.submit.click();
await expect(login.submit).toContainText('Signing in');
await expect(login.submit).toBeDisabled();
});
test('the dashboard shows skeleton tiles before its figures', async ({ page }) => {
await simulateApi(page, { dashboard: slow(makeDashboard(), 1500) });
await page.goto('/dashboard');
await expect(page.locator('.skeleton').first()).toBeVisible();
await expect(page.locator('.tile-value').first()).toBeVisible({ timeout: 15_000 });
});
});
// --- expired session -------------------------------------------------------
test.describe('When the session expires', () => {
test('a 401 mid-session returns the agent to sign-in', async ({ page }) => {
await simulateApi(page);
await page.goto('/dashboard');
await expect(page.locator('.tile-value').first()).toBeVisible();
// The account is disabled elsewhere; the next call is rejected.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await simulateApi(page, {
bookings: fail(401, 'invalid credentials for this tenant'),
session: fail(401, 'invalid credentials for this tenant'),
});
await page.locator('.nav-link[href="/bookings"]').click();
await expect(page).toHaveURL(/\/login/);
// Stale credentials must not be left lying around.
expect(await page.evaluate(() => localStorage.getItem('travelhub.credentials'))).toBeNull();
});
});
// --- inventory and sales edge cases ----------------------------------------
test.describe('Edge cases in the data', () => {
test('an agency with no inventory sees an empty state, not an error', async ({ page }) => {
await simulateApi(page, { flights: page$([], 0) });
await page.goto('/search');
await expect(page.locator('.empty')).toContainText('Nothing on this route yet');
await expect(page.locator('.alert-error')).toHaveCount(0);
});
test('a brand new agency sees zeroes without a broken dashboard', async ({ page }) => {
await simulateApi(page, {
dashboard: makeDashboard({
totalBookings: 0,
confirmedBookings: 0,
cancelledBookings: 0,
totalRevenue: 0,
seatsSold: 0,
recentBookings: [],
// Go marshals an empty slice as null, which is the shape that breaks
// a naive .length in a template.
topRoutes: null,
}),
});
await page.goto('/dashboard');
await expect(page.locator('.tile-value').first()).toBeVisible();
await expect(page.getByText('No bookings yet')).toBeVisible();
await expect(page.getByText('Nothing sold yet')).toBeVisible();
});
test('the last seat is flagged as urgent', async ({ page, search }) => {
await simulateApi(page, {
flights: page$([
makeFlight({ id: 'f-scarce', flightNumber: 'AA1', seatsAvailable: 1 }),
makeFlight({ id: 'f-roomy', flightNumber: 'AA2', seatsAvailable: 120 }),
]),
});
await page.goto('/search');
await expect(search.results.nth(0).locator('.badge-warn')).toContainText('1 left');
await expect(search.results.nth(1).locator('.badge-warn')).toHaveCount(0);
});
test('a seat sold during checkout is explained clearly', async ({ page, checkout, search }) => {
await simulateApi(page, {
flights: page$([makeFlight({ seatsAvailable: 2 })]),
flightById: makeFlight({ seatsAvailable: 2 }),
// The seat goes while the form is open.
createBooking: fail(409, 'not enough seats available on this flight'),
});
await page.goto('/search');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.fillTraveller(0, { firstName: 'Dana', lastName: 'Whitfield' });
await checkout.confirm.click();
await expect(checkout.error).toContainText('just sold');
// The agent keeps their typing, so they can pick another flight and retry.
await expect(page.locator('#first-0')).toHaveValue('Dana');
});
test('a party larger than the cabin is blocked before submitting', async ({
page,
checkout,
search,
}) => {
await simulateApi(page, {
flights: page$([makeFlight({ seatsAvailable: 1 })]),
flightById: makeFlight({ seatsAvailable: 1 }),
});
await page.goto('/search');
await search.card(0).selectButton.click();
await page.waitForURL(/\/book\//);
await checkout.waitUntilReady();
await checkout.addTraveller.click();
await expect(page.locator('.alert-error')).toContainText('Only 1 seat');
await expect(checkout.confirm).toBeDisabled();
});
test('a huge result set still paginates sensibly', async ({ page, search }) => {
const many = Array.from({ length: 10 }, (_, i) =>
makeFlight({ id: `f-${i}`, flightNumber: `AA${100 + i}`, basePrice: 100 + i }),
);
await simulateApi(page, { flights: page$(many, 98_432) });
await page.goto('/search');
await expect(search.resultSummary).toContainText('98432');
await expect(search.results).toHaveCount(10);
await expect(search.nextPage).toBeEnabled();
await expect(search.prevPage).toBeDisabled();
});
});
// --- hostile content -------------------------------------------------------
test.describe('Awkward content', () => {
test('very long names do not break the layout or escape the page', async ({ page }) => {
const longName = 'Wolfeschlegelsteinhausenbergerdorff'.repeat(4);
await simulateApi(page, {
bookingById: makeBooking({
passengers: [{ id: 'p-1', firstName: longName, lastName: longName }],
contactEmail: `${'a'.repeat(120)}@example.com`,
}),
});
await page.goto('/bookings/b-1');
await expect(page.locator('table.data tbody tr').first()).toBeVisible();
const overflows = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth + 1,
);
expect(overflows, 'a long passenger name must not widen the page').toBe(false);
});
test('markup in data is rendered as text, never as HTML', async ({ page }) => {
// Angular escapes interpolated values; this asserts nobody has reached for
// innerHTML since.
await simulateApi(page, {
bookingById: makeBooking({
passengers: [
{ id: 'p-1', firstName: '<img src=x onerror=alert(1)>', lastName: '<b>bold</b>' },
],
}),
});
await page.goto('/bookings/b-1');
const row = page.locator('table.data tbody tr').first();
await expect(row).toContainText('<b>bold</b>');
await expect(row.locator('b')).toHaveCount(0);
await expect(row.locator('img')).toHaveCount(0);
});
});
// --- keyboard only ---------------------------------------------------------
test.describe('Keyboard operation', () => {
test('the sign-in form can be completed without a mouse', async ({ page, login }) => {
await simulateApi(page);
await page.addInitScript(() => localStorage.clear());
await page.goto('/login');
await login.tenantSelect.focus();
await page.keyboard.press('Tab');
await expect(login.email).toBeFocused();
await page.keyboard.type('agent@skyline.test');
await page.keyboard.press('Tab');
await expect(login.password).toBeFocused();
await page.keyboard.type('demo1234');
await page.keyboard.press('Tab');
await expect(login.submit).toBeFocused();
});
test('every interactive control on search is reachable by tab', async ({ page }) => {
await simulateApi(page);
await page.goto('/search');
await expect(page.locator('.flight').first()).toBeVisible();
const reached = new Set<string>();
for (let i = 0; i < 25; i++) {
await page.keyboard.press('Tab');
const id = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
return el ? (el.id || el.className || el.tagName) : '';
});
reached.add(id);
}
// The filter panel and the first result's action are all tab-reachable.
expect([...reached].join(' ')).toContain('origin');
expect([...reached].join(' ')).toContain('destination');
expect([...reached].join(' ')).toContain('btn');
});
});