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,155 @@
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { GLOBETROTTER_AGENT, SKYLINE_ADMIN, SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.describe('Sign in', () => {
|
||||
test('offers every active agency in the picker', async ({ login }) => {
|
||||
await login.goto();
|
||||
|
||||
// One placeholder option plus the seeded agencies.
|
||||
await expect(login.tenantSelect.locator('option')).toHaveCount(3);
|
||||
await expect(login.tenantSelect.locator('option')).toContainText([
|
||||
'Select an agency',
|
||||
'Globetrotter Tours',
|
||||
'Skyline Travel',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a demo shortcut fills the whole form', async ({ login }) => {
|
||||
await login.goto();
|
||||
await login.useDemoChip(SKYLINE_AGENT);
|
||||
|
||||
await expect(login.tenantSelect).toHaveValue('skyline');
|
||||
await expect(login.email).toHaveValue(SKYLINE_AGENT.email);
|
||||
await expect(login.password).toHaveValue(SKYLINE_AGENT.password);
|
||||
});
|
||||
|
||||
test('signs an agent into their own agency', async ({ login, shell, page, evidence }) => {
|
||||
await login.goto();
|
||||
await evidence.step('Sign-in screen with the agency picker', {
|
||||
agenciesOffered: (await login.tenantSelect.locator('option').count()) - 1,
|
||||
demoShortcuts: await login.demoChips.count(),
|
||||
});
|
||||
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
|
||||
await expect(shell.accountName).toHaveText(SKYLINE_AGENT.fullName);
|
||||
await expect(shell.accountMeta).toContainText(SKYLINE_AGENT.email);
|
||||
|
||||
await evidence.step('Signed in to the agency console', {
|
||||
agency: SKYLINE_AGENT.tenantName,
|
||||
user: SKYLINE_AGENT.fullName,
|
||||
email: SKYLINE_AGENT.email,
|
||||
role: SKYLINE_AGENT.role,
|
||||
landedOn: '/dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
test('signs an admin in and shows the admin role', async ({ login, shell }) => {
|
||||
await login.signIn(SKYLINE_ADMIN);
|
||||
|
||||
await expect(shell.accountName).toHaveText(SKYLINE_ADMIN.fullName);
|
||||
await expect(shell.accountMeta).toContainText('admin');
|
||||
});
|
||||
|
||||
test('rejects a wrong password', async ({ login }) => {
|
||||
await login.goto();
|
||||
|
||||
const message = await login.signInExpectingFailure(SKYLINE_AGENT, 'definitely-wrong');
|
||||
|
||||
expect(message).toContain('not valid for this agency');
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
// The central multi-tenant rule, exercised through the UI.
|
||||
test('rejects valid credentials aimed at the wrong agency', async ({ login }) => {
|
||||
await login.goto();
|
||||
await login.tenantSelect.selectOption(GLOBETROTTER_AGENT.tenant);
|
||||
await login.email.fill(SKYLINE_AGENT.email);
|
||||
await login.password.fill(SKYLINE_AGENT.password);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(login.error).toContainText('not valid for this agency');
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
test('will not submit without an agency', async ({ login, page }) => {
|
||||
await login.goto();
|
||||
await login.email.fill(SKYLINE_AGENT.email);
|
||||
await login.password.fill(SKYLINE_AGENT.password);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page.locator('.field .error')).toContainText('choose an agency');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('rejects a malformed email before contacting the server', async ({ login, page }) => {
|
||||
await login.goto();
|
||||
await login.tenantSelect.selectOption(SKYLINE_AGENT.tenant);
|
||||
await login.email.fill('not-an-email');
|
||||
await login.password.fill('whatever');
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page.locator('.field .error')).toContainText('valid email');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session', () => {
|
||||
test('survives a page reload', async ({ login, shell, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
|
||||
});
|
||||
|
||||
test('sends an anonymous visitor to sign-in and back again', async ({ login, page }) => {
|
||||
await page.goto('/bookings');
|
||||
|
||||
// The guard should bounce to the sign-in screen and remember the target.
|
||||
await expect(page).toHaveURL(/\/login\?returnTo=%2Fbookings/);
|
||||
|
||||
await login.useDemoChip(SKYLINE_AGENT);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/bookings$/);
|
||||
});
|
||||
|
||||
test('signing out clears the session for good', async ({ login, shell, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await shell.signOutAndWait();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// Going back must not resurrect the console.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('an already signed-in user is moved off the sign-in screen', async ({ login, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test('stale stored credentials are discarded', async ({ login, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
// Corrupt the stored password the way a changed account would.
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'travelhub.credentials',
|
||||
JSON.stringify({ tenant: 'skyline', basic: btoa('agent@skyline.test:stale') }),
|
||||
);
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
const stored = await page.evaluate(() => localStorage.getItem('travelhub.credentials'));
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs, shell, search }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
});
|
||||
|
||||
test.describe('Booking a flight', () => {
|
||||
test('carries the fare and traveller count into checkout', async ({ search, checkout, page }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.setTravellers(2);
|
||||
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
});
|
||||
|
||||
test('reprices as travellers are added and removed', async ({ search, checkout, page }) => {
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
|
||||
await checkout.addTraveller.click();
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await checkout.removeTraveller(1);
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(1);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
});
|
||||
|
||||
test('refuses to submit with missing traveller names', async ({ search, checkout, page }) => {
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.confirm.click();
|
||||
|
||||
await expect(checkout.error).toBeVisible();
|
||||
await expect(page.locator('.pax .error').first()).toContainText('Required');
|
||||
// Still on the form, nothing sold.
|
||||
await expect(page).toHaveURL(/\/book\//);
|
||||
});
|
||||
|
||||
test('books, confirms, lists and cancels, returning the seats', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname();
|
||||
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const card = search.card(0);
|
||||
const flightNumber = await card.flightNumber();
|
||||
const fare = await card.fare();
|
||||
const seatsBefore = await card.seatsLeft();
|
||||
|
||||
await evidence.step('Search results: JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
matchingFlights: await search.totalResults(),
|
||||
cheapestFlight: flightNumber,
|
||||
fare: `$${fare.toFixed(2)}`,
|
||||
seatsAvailable: seatsBefore,
|
||||
});
|
||||
|
||||
await card.selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.fillTravellers([
|
||||
{ firstName: 'Dana', lastName: surname, dateOfBirth: '1988-04-12', passportNumber: 'X1234567' },
|
||||
{ firstName: 'Elias', lastName: surname },
|
||||
]);
|
||||
await checkout.contactPhone.fill('+1-555-0101');
|
||||
|
||||
const expectedTotal = await checkout.totalAmount();
|
||||
|
||||
await evidence.step('Checkout completed for two travellers', {
|
||||
travellers: `Dana ${surname}, Elias ${surname}`,
|
||||
farePerTraveller: `$${fare.toFixed(2)}`,
|
||||
total: `$${expectedTotal.toFixed(2)}`,
|
||||
contactPhone: '+1-555-0101',
|
||||
});
|
||||
|
||||
await checkout.confirmBooking();
|
||||
|
||||
// --- confirmation -------------------------------------------------------
|
||||
await expect(bookingDetail.confirmationBanner).toBeVisible();
|
||||
await expect(bookingDetail.confirmationBanner).toContainText('Booking confirmed');
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
await expect(bookingDetail.travellerRows).toHaveCount(2);
|
||||
await expect(bookingDetail.travellerRows.first()).toContainText(surname);
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
expect(reference).toMatch(/^[A-Z0-9]{6}$/);
|
||||
expect(Number((await bookingDetail.totalPaid.innerText()).replace(/[^0-9.]/g, ''))).toBeCloseTo(
|
||||
expectedTotal,
|
||||
2,
|
||||
);
|
||||
expect(expectedTotal).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await evidence.step('Booking confirmed', {
|
||||
bookingReference: reference,
|
||||
status: 'confirmed',
|
||||
travellers: 2,
|
||||
totalPaid: `$${expectedTotal.toFixed(2)}`,
|
||||
soldBy: SKYLINE_AGENT.fullName,
|
||||
});
|
||||
|
||||
// --- appears in the list ------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
await expect(bookings.rowFor(reference)).toContainText(/confirmed/i);
|
||||
await expect(bookings.rowFor(reference)).toContainText(SKYLINE_AGENT.fullName);
|
||||
|
||||
await evidence.step('Booking appears in the agency list', {
|
||||
bookingReference: reference,
|
||||
visibleInList: true,
|
||||
});
|
||||
|
||||
// --- seats were taken out of inventory ----------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterSale = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterSale).toBe(seatsBefore - 2);
|
||||
|
||||
await evidence.step('Seats removed from inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
difference: seatsBefore - seatsAfterSale,
|
||||
});
|
||||
|
||||
// --- cancel -------------------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/cancelled/i);
|
||||
await expect(bookingDetail.cancelledNotice).toContainText('returned to inventory');
|
||||
await expect(bookingDetail.cancelButton).toHaveCount(0);
|
||||
|
||||
await evidence.step('Booking cancelled', {
|
||||
bookingReference: reference,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
// --- seats came back ----------------------------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterCancel = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterCancel).toBe(seatsBefore);
|
||||
|
||||
await evidence.step('Seats returned to inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
seatsAfterCancel,
|
||||
restored: seatsAfterCancel === seatsBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancel can be backed out of', async ({ search, checkout, bookingDetail, page }) => {
|
||||
const surname = uniqueSurname('KEEP');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Mina', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await bookingDetail.cancelButton.click();
|
||||
await expect(bookingDetail.confirmCancelButton).toBeVisible();
|
||||
|
||||
await bookingDetail.keepButton.click();
|
||||
|
||||
await expect(bookingDetail.confirmCancelButton).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
// Leave the data as we found it.
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('the bookings list filters by status', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('FILTER');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Otto', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
|
||||
// Cancel it, then confirm it moves between the two filters.
|
||||
await bookings.filterBy('all');
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a booking opened directly shows no stale confirmation banner', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookingDetail,
|
||||
bookings,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('DIRECT');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Rhea', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
// Re-entering from the list is not a fresh purchase.
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await expect(bookingDetail.confirmationBanner).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
});
|
||||
|
||||
test.describe('Dashboard', () => {
|
||||
test('greets the agent and names the agency', async ({ dashboard }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await expect(dashboard.greeting).toContainText('Marcus');
|
||||
await expect(dashboard.greeting.locator('..')).toContainText('Skyline Travel');
|
||||
});
|
||||
|
||||
test('shows the four headline figures', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await expect(dashboard.tiles).toHaveCount(4);
|
||||
await expect(page.locator('.tile-label')).toContainText([
|
||||
'Revenue',
|
||||
'Bookings',
|
||||
'Seats sold',
|
||||
'Flights in stock',
|
||||
]);
|
||||
expect(await dashboard.tileValue('Flights in stock')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('lists recent bookings and best routes from the seed data', async ({ dashboard }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
expect(await dashboard.recentRows.count()).toBeGreaterThan(0);
|
||||
expect(await dashboard.topRoutes.count()).toBeGreaterThan(0);
|
||||
await expect(dashboard.topRoutes.first()).toContainText(' to ');
|
||||
});
|
||||
|
||||
// Delta-based, so the test does not care what the database already held.
|
||||
test('a new sale moves the figures, and cancelling moves them back', async ({
|
||||
dashboard,
|
||||
shell,
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
await dashboard.settled();
|
||||
const revenueBefore = await dashboard.tileValue('Revenue');
|
||||
const seatsBefore = await dashboard.tileValue('Seats sold');
|
||||
const bookingsBefore = await dashboard.tileValue('Bookings');
|
||||
|
||||
await evidence.step('Dashboard before the sale', {
|
||||
agency: 'Skyline Travel',
|
||||
revenue: `$${revenueBefore.toLocaleString()}`,
|
||||
seatsSold: seatsBefore,
|
||||
bookings: bookingsBefore,
|
||||
});
|
||||
|
||||
await shell.goToSearch();
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
const surname = uniqueSurname('DASH');
|
||||
await checkout.fillTravellers([
|
||||
{ firstName: 'Ana', lastName: surname },
|
||||
{ firstName: 'Bo', lastName: surname },
|
||||
]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await shell.goToDashboard();
|
||||
await dashboard.settled();
|
||||
|
||||
// Revenue is rounded to whole units in the tile, so allow a unit of slack.
|
||||
expect(await dashboard.tileValue('Revenue')).toBeGreaterThanOrEqual(
|
||||
Math.floor(revenueBefore + fare * 2) - 1,
|
||||
);
|
||||
const revenueAfter = await dashboard.tileValue('Revenue');
|
||||
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore + 2);
|
||||
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
|
||||
await expect(dashboard.recentRows.first()).toContainText(reference);
|
||||
|
||||
await evidence.step('Dashboard after the sale', {
|
||||
bookingReference: reference,
|
||||
revenueBefore: `$${revenueBefore.toLocaleString()}`,
|
||||
revenueAfter: `$${revenueAfter.toLocaleString()}`,
|
||||
farePerTraveller: `$${fare.toFixed(2)}`,
|
||||
seatsSold: `${seatsBefore} to ${seatsBefore + 2}`,
|
||||
bookings: `${bookingsBefore} to ${bookingsBefore + 1}`,
|
||||
});
|
||||
|
||||
// --- cancel and re-check ------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await shell.goToDashboard();
|
||||
await dashboard.settled();
|
||||
|
||||
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore);
|
||||
// The booking still exists; it is just no longer confirmed.
|
||||
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
|
||||
|
||||
await evidence.step('Dashboard after cancelling', {
|
||||
bookingReference: reference,
|
||||
seatsSold: `back to ${seatsBefore}`,
|
||||
bookings: `${bookingsBefore + 1} (the cancelled booking is still counted)`,
|
||||
revenue: `$${(await dashboard.tileValue('Revenue')).toLocaleString()}`,
|
||||
});
|
||||
});
|
||||
|
||||
test('a recent booking row opens the booking', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await dashboard.recentRows.first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/bookings\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('the new booking shortcut goes to search', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await page.getByRole('link', { name: '+ New booking' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/search$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The console is built to work at phone width. These checks guard the layout
|
||||
* rules that make that true -- chiefly that nothing forces a horizontal scroll.
|
||||
*/
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
/** True when the document is wider than the viewport. */
|
||||
async function scrollsSideways(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
return page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
|
||||
}
|
||||
|
||||
test.describe('Layout', () => {
|
||||
test('the sign-in screen fits without sideways scrolling', async ({ page, login }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login.goto();
|
||||
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
// The decorative panel is hidden rather than squeezed.
|
||||
await expect(page.locator('.hero')).toBeHidden();
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
test('the console screens fit at phone width', async ({ page, signInAs, shell, search }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
await expect(search.results.first()).toBeVisible();
|
||||
|
||||
await shell.goToBookings();
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('wide tables scroll inside their own container', async ({ page, signInAs, shell }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToBookings();
|
||||
|
||||
const wrapper = page.locator('.table-wrap').first();
|
||||
await expect(wrapper).toBeVisible();
|
||||
|
||||
// The table may overflow; the page must not.
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('the sidebar collapses into a top bar on narrow screens', async ({ page, signInAs }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
|
||||
const sidebar = page.locator('.sidebar');
|
||||
const content = page.locator('.content');
|
||||
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
const contentBox = await content.boundingBox();
|
||||
|
||||
expect(sidebarBox).not.toBeNull();
|
||||
expect(contentBox).not.toBeNull();
|
||||
// Stacked, not side by side.
|
||||
expect(contentBox!.y).toBeGreaterThan(sidebarBox!.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs, shell, search }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
});
|
||||
|
||||
test.describe('Flight search', () => {
|
||||
test('opens with the agency inventory already listed', async ({ search }) => {
|
||||
await expect(search.results.first()).toBeVisible();
|
||||
await expect(search.resultSummary).toContainText('flights');
|
||||
expect(await search.totalResults()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('offers only airports this agency flies', async ({ search }) => {
|
||||
const codes = await search.origin.locator('option').allInnerTexts();
|
||||
|
||||
expect(codes.join(' ')).toContain('JFK');
|
||||
expect(codes.join(' ')).toContain('LHR');
|
||||
expect(codes[0]).toContain('Any airport');
|
||||
});
|
||||
|
||||
test('narrows results to a route', async ({ search, evidence }) => {
|
||||
const before = await search.totalResults();
|
||||
await evidence.step('Full inventory before filtering', { flightsVisible: before });
|
||||
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const after = await search.totalResults();
|
||||
|
||||
await evidence.step('Filtered to JFK to LHR', {
|
||||
route: 'JFK to LHR',
|
||||
flightsBefore: before,
|
||||
flightsAfter: after,
|
||||
cheapestFare: `$${(await search.card(0).fare()).toFixed(2)}`,
|
||||
firstFlight: await search.card(0).flightNumber(),
|
||||
});
|
||||
|
||||
expect(after).toBeGreaterThan(0);
|
||||
expect(after).toBeLessThan(before);
|
||||
|
||||
// Every visible card should be on the requested route.
|
||||
const cards = await search.results.all();
|
||||
for (const card of cards) {
|
||||
await expect(card.locator('.iata').first()).toHaveText('JFK');
|
||||
await expect(card.locator('.iata').last()).toHaveText('LHR');
|
||||
}
|
||||
});
|
||||
|
||||
test('a route with no inventory shows an empty state', async ({ search }) => {
|
||||
// Nothing in the seed data flies Heathrow to Heathrow.
|
||||
await search.searchRoute('LHR', 'LHR');
|
||||
|
||||
await expect(search.emptyState).toBeVisible();
|
||||
await expect(search.emptyState).toContainText('Nothing on this route yet');
|
||||
await expect(search.results).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('resets back to the full list', async ({ search }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const narrowed = await search.totalResults();
|
||||
|
||||
await search.resetButton.click();
|
||||
await search.settled();
|
||||
|
||||
expect(await search.totalResults()).toBeGreaterThan(narrowed);
|
||||
await expect(search.origin).toHaveValue('');
|
||||
});
|
||||
|
||||
test('sorts by lowest fare', async ({ search, evidence }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
|
||||
const fares: number[] = [];
|
||||
for (let i = 0; i < (await search.results.count()); i++) {
|
||||
fares.push(await search.card(i).fare());
|
||||
}
|
||||
|
||||
expect(fares.length).toBeGreaterThan(1);
|
||||
expect([...fares]).toEqual([...fares].sort((a, b) => a - b));
|
||||
|
||||
await evidence.step('Results ordered by lowest fare', {
|
||||
sortedBy: 'price',
|
||||
faresOnPage: fares.map((f) => `$${f.toFixed(2)}`).join(', '),
|
||||
lowest: `$${fares[0].toFixed(2)}`,
|
||||
highest: `$${fares[fares.length - 1].toFixed(2)}`,
|
||||
ascending: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('sorts by shortest trip', async ({ search }) => {
|
||||
await search.sortBy('duration');
|
||||
|
||||
const durations: number[] = [];
|
||||
for (let i = 0; i < Math.min(5, await search.results.count()); i++) {
|
||||
durations.push(await search.card(i).durationMinutes());
|
||||
}
|
||||
|
||||
expect([...durations]).toEqual([...durations].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('filters by cabin', async ({ search }) => {
|
||||
await search.cabin.selectOption('business');
|
||||
await search.searchButton.click();
|
||||
await search.settled();
|
||||
|
||||
const count = await search.results.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(search.results.nth(i).locator('.fare-tags .badge').first()).toHaveText(
|
||||
/business/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('hides flights without room for the whole party', async ({ search }) => {
|
||||
await search.setTravellers(9);
|
||||
|
||||
const count = await search.results.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(await search.card(i).seatsLeft()).toBeGreaterThanOrEqual(9);
|
||||
}
|
||||
});
|
||||
|
||||
test('pages through results without repeating them', async ({ search }) => {
|
||||
await expect(search.prevPage).toBeDisabled();
|
||||
|
||||
const firstPage = await search.results.first().innerText();
|
||||
|
||||
await search.nextPage.click();
|
||||
await search.settled();
|
||||
|
||||
await expect(search.prevPage).toBeEnabled();
|
||||
await expect(search.resultSummary).toContainText('Showing 11');
|
||||
expect(await search.results.first().innerText()).not.toBe(firstPage);
|
||||
|
||||
await search.prevPage.click();
|
||||
await search.settled();
|
||||
await expect(search.resultSummary).toContainText('Showing 1–10');
|
||||
});
|
||||
|
||||
test('a result card carries everything needed to choose', async ({ search }) => {
|
||||
const card = search.card(0);
|
||||
|
||||
await expect(card.root.locator('.carrier-name')).not.toBeEmpty();
|
||||
await expect(card.root.locator('.leg-duration')).toContainText(/\d+h|\d+m/);
|
||||
await expect(card.price).toContainText('$');
|
||||
await expect(card.seatsBadge).toContainText('left');
|
||||
await expect(card.root.locator('.departure-date')).not.toBeEmpty();
|
||||
await expect(card.selectButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* The point of the product: two agencies share one deployment and one database,
|
||||
* and neither can see the other's inventory, customers or revenue.
|
||||
*
|
||||
* These assertions are made through the UI, the way a customer would notice a
|
||||
* leak, rather than against the API.
|
||||
*/
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { GLOBETROTTER_AGENT, SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.describe('Tenant isolation', () => {
|
||||
test('the same route is priced per agency', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
search,
|
||||
login,
|
||||
evidence,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const skylineFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Skyline Travel: cheapest JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${skylineFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const globetrotterFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Globetrotter Tours: same route, its own fare', {
|
||||
agency: 'Globetrotter Tours',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${globetrotterFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await evidence.note('Per-tenant pricing confirmed', {
|
||||
skyline: `$${skylineFare.toFixed(2)}`,
|
||||
globetrotter: `$${globetrotterFare.toFixed(2)}`,
|
||||
difference: `$${(globetrotterFare - skylineFare).toFixed(2)}`,
|
||||
markup: `${(((globetrotterFare - skylineFare) / skylineFare) * 100).toFixed(1)}%`,
|
||||
});
|
||||
|
||||
expect(skylineFare).toBeGreaterThan(0);
|
||||
expect(globetrotterFare).toBeGreaterThan(0);
|
||||
// Globetrotter sells the same seats at a negotiated markup.
|
||||
expect(globetrotterFare).not.toBeCloseTo(skylineFare, 2);
|
||||
expect(globetrotterFare).toBeGreaterThan(skylineFare);
|
||||
});
|
||||
|
||||
test('the sidebar always names the agency you are working in', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
login,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await expect(shell.agencyName).toHaveText('Skyline Travel');
|
||||
await expect(shell.agencySlug).toHaveText('skyline');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
|
||||
await expect(shell.agencyName).toHaveText('Globetrotter Tours');
|
||||
await expect(shell.agencySlug).toHaveText('globetrotter');
|
||||
});
|
||||
|
||||
test('one agency never sees another agency booking', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname('ISO');
|
||||
|
||||
// --- Skyline sells a ticket --------------------------------------------
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Iris', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
const bookingUrl = page.url();
|
||||
|
||||
await evidence.step('Skyline sells a ticket', {
|
||||
agency: 'Skyline Travel',
|
||||
bookingReference: reference,
|
||||
passenger: `Iris ${surname}`,
|
||||
bookingUrl,
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// --- Globetrotter must not find it -------------------------------------
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToBookings();
|
||||
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Globetrotter cannot see it in their bookings', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
searchedFor: reference,
|
||||
rowsFound: 0,
|
||||
passengerNameOnPage: false,
|
||||
});
|
||||
|
||||
// Not even by deep-linking straight at the booking's own URL.
|
||||
await page.goto(bookingUrl);
|
||||
await expect(page.getByText('Booking not found')).toBeVisible();
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Deep link to the booking URL is refused', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
requestedUrl: bookingUrl,
|
||||
result: 'Booking not found',
|
||||
dataLeaked: false,
|
||||
});
|
||||
|
||||
// --- clean up as the owning agency -------------------------------------
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('revenue figures are per agency', async ({ signInAs, login, shell, dashboard }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await dashboard.settled();
|
||||
const skylineRevenue = await dashboard.tileValue('Revenue');
|
||||
const skylineBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await dashboard.settled();
|
||||
const globetrotterRevenue = await dashboard.tileValue('Revenue');
|
||||
const globetrotterBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
expect(skylineRevenue).toBeGreaterThan(0);
|
||||
expect(globetrotterRevenue).toBeGreaterThan(0);
|
||||
// Separate books: the seeded agencies sold different volumes.
|
||||
expect(skylineRevenue).not.toBe(globetrotterRevenue);
|
||||
expect(skylineBookings).not.toBe(globetrotterBookings);
|
||||
});
|
||||
|
||||
test('a deep link to another agency flight is refused', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
page,
|
||||
}) => {
|
||||
// Capture a flight id that belongs to Globetrotter.
|
||||
await signInAs(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
const globetrotterBookUrl = page.url();
|
||||
|
||||
await page.goto('/dashboard');
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// Skyline holds a valid id, and still cannot reach it.
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await page.goto(globetrotterBookUrl);
|
||||
|
||||
await expect(page.getByText('Flight not available')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user