Files
edr-platform/e2e-ui/specs/guest/search-date-availability.spec.ts
2026-08-02 23:08:15 +03:00

137 lines
5.9 KiB
TypeScript

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<string> {
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}/);
});
});