import { test, expect, type Page } from "@playwright/test"; import { API_URL, STATIONS, staffToken } from "../../fixtures/data"; /** * Search form — date selectability is driven by whether a route actually exists. * * Two behaviours, both backed by GET /search/available-dates: * * 1. No route between the selected From/To → the Date control is DISABLED outright and an * explanation is shown. Letting someone pick a date and only failing at submit wastes the * interaction, and graying out individual days would imply the date is the problem when * the station pair is. * 2. A route exists → the control is enabled, and only the days with no bookable schedule * are individually disabled inside the calendar. * * The form prefills From/To from the URL query (see booking/search/page.tsx), so these specs set * the pair deterministically instead of driving the station modal across three layout variants. * * Runs in the `guest` project — the search form needs no authentication. */ /** A station on no route at all — created per-run so the shared seed stays untouched. */ async function createOrphanStation(request: Page["request"]): Promise { const res = await request.post(`${API_URL}/stations`, { headers: { Authorization: `Bearer ${staffToken()}` }, data: { code: `ORP${Date.now().toString().slice(-4)}`, name: `Orphan ${Date.now()}`, city: "Nowhere", }, }); expect(res.ok(), `station create failed: ${res.status()} ${await res.text()}`).toBeTruthy(); const body = await res.json(); return body?.data?.id ?? body?.id; } function searchUrl(origin: string, destination: string): string { return `/booking/search?origin=${origin}&destination=${destination}`; } /** The departure-date trigger. Several layout variants exist; only one is visible at a time. */ function dateTrigger(page: Page) { return page .locator("button") .filter({ hasText: /^(Departure date|Departure|Select date|Date)$/i }) .first(); } test.describe("search date availability", () => { test("no route between the stations disables the date picker", async ({ page, request }) => { const orphanId = await createOrphanStation(request); await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" }); // The notice only renders once /search/available-dates has answered routeExists:false. await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 }); await expect(dateTrigger(page)).toBeDisabled(); }); test("a disabled date picker cannot be opened", async ({ page, request }) => { const orphanId = await createOrphanStation(request); await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 }); // force:true bypasses Playwright's own actionability guard, so this asserts the app // really refuses to open — not merely that the button looks unclickable. await dateTrigger(page).click({ force: true }).catch(() => {}); await page.waitForTimeout(500); await expect(page.getByRole("button", { name: /^\d{1,2}$/ })).toHaveCount(0); }); test("a route that exists leaves the date picker enabled and openable", async ({ page }) => { await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" }); // Give the availability query the same chance to resolve as the no-route case gets. await page.waitForTimeout(3_000); await expect(page.getByTestId("no-route-notice")).toHaveCount(0); const trigger = dateTrigger(page); await expect(trigger).toBeEnabled(); await trigger.click(); // Calendar day cells confirm it actually opened. await expect(page.getByRole("button", { name: /^\d{1,2}$/ }).first()).toBeVisible({ timeout: 10_000, }); }); test("dates with no bookable schedule are individually disabled", async ({ page }) => { await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" }); await page.waitForTimeout(3_000); await dateTrigger(page).click(); const dayCells = page.getByRole("button", { name: /^\d{1,2}$/ }); await expect(dayCells.first()).toBeVisible({ timeout: 10_000 }); // The seed creates exactly one bookable departure in the window, so the calendar must // contain both enabled and disabled days — never all-enabled (which would mean the // availability data was ignored) and never all-disabled (which would mean it was // misapplied to a route that does have a trip). const total = await dayCells.count(); let disabled = 0; for (let i = 0; i < total; i++) { if (await dayCells.nth(i).isDisabled()) disabled++; } expect(total).toBeGreaterThan(0); expect(disabled).toBeGreaterThan(0); expect(disabled).toBeLessThan(total); }); test("switching to a routeless destination clears an already-chosen date", async ({ page, request, }) => { const orphanId = await createOrphanStation(request); // Start on a valid pair with a date already in the URL, so a value is definitely set. const withDate = new Date(); withDate.setDate(withDate.getDate() + 2); const dateStr = withDate.toISOString().slice(0, 10); await page.goto(`${searchUrl(STATIONS.A, STATIONS.C)}&date=${dateStr}`, { waitUntil: "domcontentloaded", }); await page.waitForTimeout(2_500); // Now navigate to the routeless pair — the stale date must not survive behind the // disabled control, or a doomed search could still be submitted. await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 }); const trigger = dateTrigger(page); await expect(trigger).toBeDisabled(); // Placeholder text (not a formatted date) proves the value was cleared. await expect(trigger).not.toContainText(/\d{4}/); }); });