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,96 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The bookings list. */
|
||||
export class BookingsPage {
|
||||
readonly rows: Locator;
|
||||
readonly emptyState: Locator;
|
||||
readonly filterAll: Locator;
|
||||
readonly filterConfirmed: Locator;
|
||||
readonly filterCancelled: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.rows = page.locator('table.data tbody tr');
|
||||
this.emptyState = page.locator('.empty');
|
||||
this.filterAll = page.locator('.filter', { hasText: 'All' });
|
||||
this.filterConfirmed = page.locator('.filter', { hasText: 'Confirmed' });
|
||||
this.filterCancelled = page.locator('.filter', { hasText: 'Cancelled' });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/bookings');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
/** Waits for either a populated table or the empty state. */
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.filters')).toBeVisible();
|
||||
await expect(this.page.locator('.skeleton')).toHaveCount(0);
|
||||
}
|
||||
|
||||
rowFor(reference: string): Locator {
|
||||
return this.rows.filter({ hasText: reference });
|
||||
}
|
||||
|
||||
async open(reference: string): Promise<void> {
|
||||
await this.rowFor(reference).getByRole('link', { name: 'View' }).click();
|
||||
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
|
||||
}
|
||||
|
||||
async filterBy(status: 'all' | 'confirmed' | 'cancelled'): Promise<void> {
|
||||
const button = {
|
||||
all: this.filterAll,
|
||||
confirmed: this.filterConfirmed,
|
||||
cancelled: this.filterCancelled,
|
||||
}[status];
|
||||
await button.click();
|
||||
await this.settled();
|
||||
}
|
||||
}
|
||||
|
||||
/** A single booking, with its cancel flow. */
|
||||
export class BookingDetailPage {
|
||||
readonly confirmationBanner: Locator;
|
||||
readonly reference: Locator;
|
||||
readonly statusBadge: Locator;
|
||||
readonly travellerRows: Locator;
|
||||
readonly totalPaid: Locator;
|
||||
readonly cancelButton: Locator;
|
||||
readonly confirmCancelButton: Locator;
|
||||
readonly keepButton: Locator;
|
||||
readonly cancelledNotice: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.confirmationBanner = page.locator('.confirmed-banner');
|
||||
this.reference = page.locator('.sum-list div', { hasText: 'Reference' }).locator('dd');
|
||||
this.statusBadge = page.locator('.head-actions .badge');
|
||||
this.travellerRows = page.locator('table.data tbody tr');
|
||||
this.totalPaid = page.locator('.total-row strong');
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel booking' });
|
||||
this.confirmCancelButton = page.getByRole('button', { name: /Yes, cancel booking|Cancelling/ });
|
||||
this.keepButton = page.getByRole('button', { name: 'Keep it' });
|
||||
this.cancelledNotice = page.locator('.alert-info');
|
||||
}
|
||||
|
||||
async bookingReference(): Promise<string> {
|
||||
return (await this.reference.innerText()).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks through the two-step cancel confirmation.
|
||||
*
|
||||
* The caller must name the booking it means to void. Cancelling is
|
||||
* destructive and these tests share a database with seeded demo data, so the
|
||||
* page object refuses to act on whatever booking happens to be on screen
|
||||
* after a mis-step earlier in the test.
|
||||
*/
|
||||
async cancel(expectedReference: string): Promise<void> {
|
||||
await expect(
|
||||
this.reference,
|
||||
`refusing to cancel: expected booking ${expectedReference} on screen`,
|
||||
).toHaveText(expectedReference);
|
||||
|
||||
await this.cancelButton.click();
|
||||
await this.confirmCancelButton.click();
|
||||
await expect(this.cancelledNotice).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
export interface Traveller {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth?: string;
|
||||
passportNumber?: string;
|
||||
}
|
||||
|
||||
/** The booking form. */
|
||||
export class CheckoutPage {
|
||||
readonly travellerFieldsets: Locator;
|
||||
readonly addTraveller: Locator;
|
||||
readonly contactEmail: Locator;
|
||||
readonly contactPhone: Locator;
|
||||
readonly confirm: Locator;
|
||||
readonly total: Locator;
|
||||
readonly error: Locator;
|
||||
readonly seatsLeft: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.travellerFieldsets = page.locator('.pax');
|
||||
this.addTraveller = page.getByRole('button', { name: '+ Add traveller' });
|
||||
this.contactEmail = page.locator('#contactEmail');
|
||||
this.contactPhone = page.locator('#contactPhone');
|
||||
this.confirm = page.getByRole('button', { name: /Confirm booking|Confirming/ });
|
||||
this.total = page.locator('.fare-row.total strong');
|
||||
this.error = page.locator('.alert-error');
|
||||
this.seatsLeft = page.locator('.sum-list div', { hasText: 'Seats left' }).locator('dd');
|
||||
}
|
||||
|
||||
async waitUntilReady(): Promise<void> {
|
||||
await expect(this.travellerFieldsets.first()).toBeVisible();
|
||||
}
|
||||
|
||||
async totalAmount(): Promise<number> {
|
||||
const text = await this.total.innerText();
|
||||
return Number(text.replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
|
||||
/** Fills one traveller block, adding a row first if necessary. */
|
||||
async fillTraveller(index: number, traveller: Traveller): Promise<void> {
|
||||
// Wait for each added row to render before deciding whether another is
|
||||
// needed -- re-counting immediately races Angular and adds one too many.
|
||||
let count = await this.travellerFieldsets.count();
|
||||
while (count <= index) {
|
||||
await this.addTraveller.click();
|
||||
count += 1;
|
||||
await expect(this.travellerFieldsets).toHaveCount(count);
|
||||
}
|
||||
await this.page.locator(`#first-${index}`).fill(traveller.firstName);
|
||||
await this.page.locator(`#last-${index}`).fill(traveller.lastName);
|
||||
if (traveller.dateOfBirth) {
|
||||
await this.page.locator(`#dob-${index}`).fill(traveller.dateOfBirth);
|
||||
}
|
||||
if (traveller.passportNumber) {
|
||||
await this.page.locator(`#passport-${index}`).fill(traveller.passportNumber);
|
||||
}
|
||||
}
|
||||
|
||||
async fillTravellers(travellers: Traveller[]): Promise<void> {
|
||||
for (let i = 0; i < travellers.length; i++) {
|
||||
await this.fillTraveller(i, travellers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
async removeTraveller(index: number): Promise<void> {
|
||||
await this.travellerFieldsets.nth(index).getByRole('button', { name: 'Remove' }).click();
|
||||
}
|
||||
|
||||
/** Confirms and waits for the confirmation screen. */
|
||||
async confirmBooking(): Promise<void> {
|
||||
await this.confirm.click();
|
||||
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The agency overview. */
|
||||
export class DashboardPage {
|
||||
readonly greeting: Locator;
|
||||
readonly tiles: Locator;
|
||||
readonly recentRows: Locator;
|
||||
readonly topRoutes: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.greeting = page.locator('h1');
|
||||
this.tiles = page.locator('.tile');
|
||||
this.recentRows = page.locator('table.data tbody tr');
|
||||
this.topRoutes = page.locator('.route-row');
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/dashboard');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.skeleton')).toHaveCount(0);
|
||||
await expect(this.tiles.first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Reads one headline figure by its label, e.g. "Revenue" or "Seats sold". */
|
||||
async tileValue(label: string): Promise<number> {
|
||||
const tile = this.tiles.filter({ has: this.page.locator('.tile-label', { hasText: label }) });
|
||||
const text = await tile.locator('.tile-value').innerText();
|
||||
return Number(text.replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
import { Account } from '../fixtures/accounts';
|
||||
|
||||
/** The sign-in screen: agency picker, credentials, demo shortcuts. */
|
||||
export class LoginPage {
|
||||
readonly tenantSelect: Locator;
|
||||
readonly email: Locator;
|
||||
readonly password: Locator;
|
||||
readonly submit: Locator;
|
||||
readonly error: Locator;
|
||||
readonly demoChips: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.tenantSelect = page.locator('#tenant');
|
||||
this.email = page.locator('#email');
|
||||
this.password = page.locator('#password');
|
||||
// Matched by role rather than label: the button relabels itself to
|
||||
// "Signing in…" while a request is in flight, and tests assert on that.
|
||||
this.submit = page.locator('form button[type="submit"]');
|
||||
this.error = page.locator('.alert-error');
|
||||
this.demoChips = page.locator('.demo-chip');
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/login');
|
||||
await expect(this.submit).toBeVisible();
|
||||
}
|
||||
|
||||
/** Fills the form from the demo shortcut for the given account. */
|
||||
async useDemoChip(account: Account): Promise<void> {
|
||||
await this.demoChips.filter({ hasText: account.email }).first().click();
|
||||
}
|
||||
|
||||
async fill(account: Account, password = account.password): Promise<void> {
|
||||
await this.tenantSelect.selectOption(account.tenant);
|
||||
await this.email.fill(account.email);
|
||||
await this.password.fill(password);
|
||||
}
|
||||
|
||||
/** Signs in and waits for the console to appear. */
|
||||
async signIn(account: Account): Promise<void> {
|
||||
await this.goto();
|
||||
await this.useDemoChip(account);
|
||||
await this.submit.click();
|
||||
await this.page.waitForURL('**/dashboard');
|
||||
}
|
||||
|
||||
/** Attempts a sign-in that is expected to fail, and returns the message. */
|
||||
async signInExpectingFailure(account: Account, password: string): Promise<string> {
|
||||
await this.fill(account, password);
|
||||
await this.submit.click();
|
||||
await expect(this.error).toBeVisible();
|
||||
return (await this.error.innerText()).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** One flight result card. */
|
||||
export class FlightCard {
|
||||
constructor(readonly root: Locator) {}
|
||||
|
||||
get price(): Locator {
|
||||
return this.root.locator('.price');
|
||||
}
|
||||
|
||||
get seatsBadge(): Locator {
|
||||
return this.root.locator('.fare-tags .badge').last();
|
||||
}
|
||||
|
||||
get selectButton(): Locator {
|
||||
return this.root.getByRole('button', { name: 'Select' });
|
||||
}
|
||||
|
||||
async fare(): Promise<number> {
|
||||
const text = (await this.price.innerText()).replace(/[^0-9.]/g, '');
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
async seatsLeft(): Promise<number> {
|
||||
const text = await this.seatsBadge.innerText();
|
||||
return Number(text.replace(/[^0-9]/g, ''));
|
||||
}
|
||||
|
||||
async flightNumber(): Promise<string> {
|
||||
return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim();
|
||||
}
|
||||
|
||||
async durationMinutes(): Promise<number> {
|
||||
const text = await this.root.locator('.leg-duration').innerText();
|
||||
const match = /(?:(\d+)h\s*)?(\d+)m/.exec(text);
|
||||
if (!match) {
|
||||
throw new Error(`unparseable duration: ${text}`);
|
||||
}
|
||||
return Number(match[1] ?? 0) * 60 + Number(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The flight search screen. */
|
||||
export class SearchPage {
|
||||
readonly origin: Locator;
|
||||
readonly destination: Locator;
|
||||
readonly date: Locator;
|
||||
readonly cabin: Locator;
|
||||
readonly travellers: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly resetButton: Locator;
|
||||
readonly sort: Locator;
|
||||
readonly resultSummary: Locator;
|
||||
readonly results: Locator;
|
||||
readonly emptyState: Locator;
|
||||
readonly nextPage: Locator;
|
||||
readonly prevPage: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.origin = page.locator('#origin');
|
||||
this.destination = page.locator('#destination');
|
||||
this.date = page.locator('#date');
|
||||
this.cabin = page.locator('#cabin');
|
||||
this.travellers = page.locator('#passengers');
|
||||
this.searchButton = page.getByRole('button', { name: 'Search', exact: true });
|
||||
this.resetButton = page.getByRole('button', { name: 'Reset' });
|
||||
this.sort = page.locator('.sort select');
|
||||
this.resultSummary = page.locator('.results-bar span').first();
|
||||
this.results = page.locator('.flight');
|
||||
this.emptyState = page.locator('.empty');
|
||||
this.nextPage = page.getByRole('button', { name: 'Next →' });
|
||||
this.prevPage = page.getByRole('button', { name: '← Previous' });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/search');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
card(index = 0): FlightCard {
|
||||
return new FlightCard(this.results.nth(index));
|
||||
}
|
||||
|
||||
/** Waits for the result list to stop showing skeletons. */
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.skeleton-card')).toHaveCount(0);
|
||||
await expect(this.resultSummary).not.toHaveText(/Searching/);
|
||||
}
|
||||
|
||||
async searchRoute(from: string, to: string): Promise<void> {
|
||||
await this.origin.selectOption(from);
|
||||
await this.destination.selectOption(to);
|
||||
await this.searchButton.click();
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async setTravellers(n: number): Promise<void> {
|
||||
await this.travellers.selectOption(String(n));
|
||||
await this.searchButton.click();
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async sortBy(value: 'price' | 'duration' | 'departure'): Promise<void> {
|
||||
await this.sort.selectOption(value);
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
/** Reads the "Showing 1-10 of 42 flights" line. */
|
||||
async totalResults(): Promise<number> {
|
||||
const text = await this.resultSummary.innerText();
|
||||
const match = /of (\d+) flights/.exec(text);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
/** Finds the card for a given flight number on the current page. */
|
||||
cardForFlight(flightNumber: string): FlightCard {
|
||||
return new FlightCard(this.results.filter({ hasText: flightNumber }).first());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The signed-in chrome: sidebar navigation and the account bar. */
|
||||
export class ShellPage {
|
||||
readonly agencyName: Locator;
|
||||
readonly agencySlug: Locator;
|
||||
readonly accountName: Locator;
|
||||
readonly accountMeta: Locator;
|
||||
readonly signOut: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.agencyName = page.locator('.tenant-name');
|
||||
this.agencySlug = page.locator('.tenant-slug');
|
||||
this.accountName = page.locator('.account-name');
|
||||
this.accountMeta = page.locator('.account-meta');
|
||||
this.signOut = page.getByRole('button', { name: 'Sign out' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates by sidebar link. The href selector is deliberate: several screens
|
||||
* also carry a "Search flights" call-to-action button, and matching by role
|
||||
* and name alone would be ambiguous.
|
||||
*/
|
||||
async goToDashboard(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/dashboard"]').click();
|
||||
await this.page.waitForURL('**/dashboard');
|
||||
}
|
||||
|
||||
async goToSearch(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/search"]').click();
|
||||
await this.page.waitForURL('**/search');
|
||||
}
|
||||
|
||||
async goToBookings(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/bookings"]').click();
|
||||
await this.page.waitForURL('**/bookings');
|
||||
}
|
||||
|
||||
async signOutAndWait(): Promise<void> {
|
||||
await this.signOut.click();
|
||||
await this.page.waitForURL('**/login');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user