/** * 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: '', lastName: 'bold' }, ], }), }); await page.goto('/bookings/b-1'); const row = page.locator('table.data tbody tr').first(); await expect(row).toContainText('bold'); 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(); 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'); }); });