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