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,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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user