Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions tests/fixtures/date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test'
import { FIXTURE_DATE, israelServiceTime, siriWindow, utcDayBeforeClock } from './date'

// Anchor for the FIXTURE_DATE-derived stride dates: pins each helper to a value captured from a
// real vehicle-page run. A drifted derivation fails here pointing straight at the helper — a
// clearer signal than the "unmocked request" the same break causes in vehicle.spec. These
// literals are valid only for the FIXTURE_DATE below; changing the knob must re-capture them
// (a deliberate, reviewed event — see tests/fixtures/date.ts), which the first assertion flags.
test('fixture date derivation is anchored to captured reference values', () => {
expect(FIXTURE_DATE, 're-capture the reference dates below when FIXTURE_DATE changes').toBe(
'2024-02-12',
)
expect(siriWindow()).toEqual({
from: '2024-02-11T22:00:00.000Z',
to: '2024-02-13T02:00:00.000Z',
})
expect([utcDayBeforeClock(1), utcDayBeforeClock(7)]).toEqual(['2024-02-11', '2024-02-05'])
expect(israelServiceTime(4, 30).toISOString()).toBe('2024-02-12T02:30:00.000Z')
expect(israelServiceTime(8, 0).toISOString()).toBe('2024-02-12T06:00:00.000Z')
expect(israelServiceTime(0, 30, { nextDay: true }).toISOString()).toBe('2024-02-12T22:30:00.000Z')
})
75 changes: 75 additions & 0 deletions tests/fixtures/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import dayjs, { ISRAEL_TIMEZONE } from 'src/dayjs'

/**
* The single date the whole fixture/test world is pinned to — an **Israel service-day**.
* Everything derives from it: the emulated clock (`getPastDate` in tests/utils), every
* builder's default dates, and the request URLs the tests assert.
*
* Kept a plain `YYYY-MM-DD` string (the Israel service day), NOT a `Date`, so there is no
* UTC/local ambiguity at the source — anything needing an instant builds one explicitly
* (and in Israel time where the service-day matters). This is the one knob: change it and
* the clock, the builders, and every derived request URL move together automatically. The
* only thing that must then be re-captured is the anchor literals in date.spec.ts — a
* deliberate, reviewed event, which that spec's first assertion flags.
*/
export const FIXTURE_DATE = '2024-02-12'

/**
* The instant the emulated clock is set to (`getPastDate` in tests/utils). 15:00 UTC = 17:00
* Israel: mid-day in both zones, so the UTC and Israel calendar dates coincide on FIXTURE_DATE.
* Anything the app derives from `Date.now()` — e.g. the agency list's look-back dates — is
* derived from here, so it moves with the knob too.
*/
export const FIXTURE_CLOCK = `${FIXTURE_DATE}T15:00:00.000Z`

/**
* The UTC calendar date `days` before the emulated clock, in the form the generated client
* serializes a Date into a date-only query param (`toISOString()[0:10]`).
*/
export const utcDayBeforeClock = (days: number): string =>
new Date(new Date(FIXTURE_CLOCK).getTime() - days * 24 * 60 * 60 * 1000)
.toISOString()
.substring(0, 10)

/**
* The dates the vehicle page's stride requests carry, DERIVED from FIXTURE_DATE so it stays
* the one knob — change the date and the golden URLs move with it. Where the app's rule is
* real arithmetic (the siri service-day window) it is re-implemented here with dayjs primitives
* ON PURPOSE and must NOT import the app's own serviceDayBounds/serializer: the stub URL and
* the app's real request have to come from INDEPENDENT producers, or full-URL matching would
* compare a function against itself and be blind to exactly the date drift it exists to catch.
* Where the app's rule leaves the date alone there is no helper at all — the request URL uses
* FIXTURE_DATE directly, rather than an identity function dressed up as a derivation. Outputs
* are anchored to captured reference values in tests/fixtures/date.spec.ts, and — live, against
* the running app — by tests/vehicle.spec.ts, where any wrong date surfaces as an unmocked
* request. That live check, not the arithmetic here, is the real guard.
*/

// Service day = 00:00 Israel time through 04:00 the next morning, tz-aware so it is DST-safe
// (a summer date resolves to 21:00Z, not 22:00Z). Mirrors serviceDayBounds() in
// src/pages/components/utils/startTimeUtils.ts, re-derived here rather than imported.
const serviceDay = (date: string) => {
const start = dayjs.tz(date, ISRAEL_TIMEZONE).startOf('day')
const end = start.add(1, 'day').startOf('day').add(4, 'hours')
return { start, end }
}

/** siri `scheduled_start_time_from`/`_to` — the service-day window as the wire ISO instants
* the vehicle page sends (e.g. 2024-02-11T22:00:00.000Z … 2024-02-13T02:00:00.000Z). */
export const siriWindow = (date: string = FIXTURE_DATE) => {
const { start, end } = serviceDay(date)
return { from: start.toDate().toISOString(), to: end.toDate().toISOString() }
}

/** A wall-clock time on the fixture service day, as a wire instant — for siri ride BODY
* `scheduledStartTime` values. `nextDay` places it in the post-midnight tail (00:00–04:00)
* that still belongs to this service day. Deriving these (not literal Dates) keeps the ride
* bodies inside the window when FIXTURE_DATE changes, so the render assertions stay valid. */
export const israelServiceTime = (
hour: number,
minute = 0,
{ nextDay = false }: { nextDay?: boolean } = {},
): Date => {
const base = dayjs.tz(FIXTURE_DATE, ISRAEL_TIMEZONE).startOf('day')
return (nextDay ? base.add(1, 'day') : base).hour(hour).minute(minute).toDate()
}
49 changes: 49 additions & 0 deletions tests/fixtures/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { utcDayBeforeClock } from './date'
import { gtfsAgenciesWire, gtfsAgency } from './gtfs'
import { okStub, StrideStub } from './stride'

/**
* The DEFAULT stride world: the endpoints the app asks for on its own, on many pages, that no
* single test is about. Installed once per test (`setupTest(page, lng, strideDefaults())`) and
* layered under the scenario; any test that IS about one of these URLs re-stubs it and wins
* (stubs merge, last one per URL wins — see tests/fixtures/mockRouter.ts).
*
* A default is still an exact-URL link from one request to one built fixture — nothing here
* relaxes matching. That is what makes the agency list expressible despite its retry loop.
*
* Covering an endpoint a page MIGHT ask for is the point, so a default going unrequested is
* normal and costs nothing: only an unmatched REQUEST fails a test (tests/fixtures/mockRouter.ts),
* never an unused stub. Installing the whole catalogue on a page that uses none of it — /vehicle,
* which never calls getAgencyList — is therefore free.
*/

/**
* `getAgencyList()` (src/api/agencyList.ts) walks three dates until one returns a non-empty
* list: now−1d, now−7d, then a hard-coded 2025-05-18 fallback. All three are exact URLs under
* the emulated clock, so all three are stubbed with the same fixture — the retry loop needs no
* special support, it is just three links to one body. With a non-empty default the app stops
* at the first, and the other two sit unused; that is the intended shape of a default (unlike a
* scenario stub, a default is allowed to go unclaimed).
*/
const agencyListUrls = [
`/gtfs_agencies/list?date_from=${utcDayBeforeClock(1)}`,
`/gtfs_agencies/list?date_from=${utcDayBeforeClock(7)}`,
'/gtfs_agencies/list?date_from=2025-05-18',
]

/** Real operator refs and names, verified against the live stride API for FIXTURE_DATE. Covers
* MAJOR_OPERATORS (src/model/operator.ts), the train, and the operators the vehicle scenario
* uses, so an operator dropdown built from this renders real choices. */
export const DEFAULT_AGENCIES = [
gtfsAgency({ operatorRef: 2, agencyName: 'רכבת ישראל' }),
gtfsAgency({ operatorRef: 3, agencyName: 'אגד' }),
gtfsAgency({ operatorRef: 5, agencyName: 'דן' }),
gtfsAgency({ operatorRef: 15, agencyName: 'מטרופולין' }),
gtfsAgency({ operatorRef: 18, agencyName: 'קווים' }),
gtfsAgency({ operatorRef: 25, agencyName: 'אלקטרה אפיקים' }),
gtfsAgency({ operatorRef: 34, agencyName: 'תנופה' }),
gtfsAgency({ operatorRef: 97, agencyName: 'אודליה מוניות בעמ' }),
]

export const strideDefaults = (): StrideStub[] =>
agencyListUrls.map((url) => okStub(url, gtfsAgenciesWire(DEFAULT_AGENCIES)))
38 changes: 38 additions & 0 deletions tests/fixtures/gtfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
GtfsAgencyPydanticModel,
GtfsAgencyPydanticModelToJSON,
GtfsRoutePydanticModel,
GtfsRoutePydanticModelToJSON,
} from '@hasadna/open-bus-api-client'
import { FIXTURE_DATE } from './date'

/**
* GTFS (planned) domain fixtures. Same pattern as siri.ts: typed camelCase builder in,
* exact snake_case wire out via the client's `…ToJSON`. `GtfsRoutePydanticModel` has four
* required fields (`id`, `date`, `lineRef`, `operatorRef`), so the builder defaults them;
* callers override only what their scenario asserts on.
*/
export const gtfsRoute = (
overrides: Partial<GtfsRoutePydanticModel> = {},
): GtfsRoutePydanticModel => ({
id: 0,
date: new Date(`${FIXTURE_DATE}T00:00:00Z`),
lineRef: 0,
operatorRef: 0,
...overrides,
})

export const gtfsRoutesWire = (routes: GtfsRoutePydanticModel[]): unknown[] =>
routes.map(GtfsRoutePydanticModelToJSON)

export const gtfsAgency = (
overrides: Partial<GtfsAgencyPydanticModel> = {},
): GtfsAgencyPydanticModel => ({
date: new Date(`${FIXTURE_DATE}T00:00:00Z`),
operatorRef: 0,
agencyName: 'מפעיל',
...overrides,
})

export const gtfsAgenciesWire = (agencies: GtfsAgencyPydanticModel[]): unknown[] =>
agencies.map(GtfsAgencyPydanticModelToJSON)
149 changes: 149 additions & 0 deletions tests/fixtures/mockRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Page } from '@playwright/test'

/**
* Generic HTTP-mock core, shared by every backend the app talks to. It knows nothing about
* stride or any one service — a *service binding* (e.g. tests/fixtures/stride.ts) fixes the
* URL pattern and re-exports a named router; a *scenario* (e.g. tests/vehicleMocks.ts) pairs
* each response body with the exact request URL it answers and hands the list to that router.
*
* Full-URL matching is MANDATORY. A request is served only if its whole URL matches a stub
* exactly (pathname + every query param, order-independent, nothing ignored — deliberately
* unlike the old HAR `urlMatcher` that dropped t/limit/date_from/date_to and rounded floats,
* which let request bugs like a wrong date window or operator pass unnoticed). Anything else
* is recorded as a miss (and 599'd), and the shared `test` fixture fails the test at teardown
* listing the offending URL(s).
*
* Stubs LAYER. Each call merges into one per-page registry keyed by the canonical URL, so a
* later stub for the same URL replaces the earlier one and every other stub survives — which
* is what lets a default catalogue (tests/fixtures/defaults.ts) be installed once for the whole
* suite and a single test override just the URL it is about. Only the FIRST call per service
* installs a Playwright route; the handler reads the registry at request time, so what matters
* is that a stub is registered before the navigation that needs it, not the order the calls
* were made in. (Registering a second `page.route` per pattern would instead shadow the first
* entirely: Playwright evaluates handlers in reverse registration order and ours always
* fulfills, never falls through. The registry is what replaces that broken layering.)
*
* Only a MISS fails a test. Stubs nothing requested are tracked too, but purely as CONTEXT for a
* miss on the same endpoint — a wrong date or limit produces one of each, and printing them
* together shows the URL that was sent next to the one that expected it. An unused stub is never
* an error by itself: a default catalogue or a shared scenario is meant to over-provision, so in
* any one test most of its stubs go unclaimed, and demanding otherwise would tax every test that
* reuses one while catching nothing the miss list and the render assertions do not already catch.
*/
export type RouteStub = {
/** Exact expected request — pathname + full query (order-independent, nothing ignored). */
url: string
body?: unknown
status?: number
}

export const okStub = (url: string, body: unknown): RouteStub => ({ url, body })

/** An error response for an exact URL (exercises react-query retry / load-error paths). */
export const errorStub = (url: string, status = 500): RouteStub => ({ url, status })

/** Canonical form for comparison: pathname + params sorted, NOTHING dropped. */
const canon = (url: string): string => {
const u = new URL(url, 'http://mock')
const params = [...u.searchParams.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return `${u.pathname}?${params}`
}

type ServiceState = {
stubs: Map<string, RouteStub>
misses: string[]
/** Stubs that answered at least one request, tracked by identity so that re-stubbing a URL
* starts it over — the replacement has to earn its own claim. */
claimed: Set<RouteStub>
installed: boolean
}

/** Per page, per service pattern. WeakMap so a closed page's state cannot outlive it. */
const servicesByPage = new WeakMap<Page, Map<string, ServiceState>>()

const stateFor = (page: Page, pattern: RegExp): ServiceState => {
let services = servicesByPage.get(page)
if (!services) {
services = new Map<string, ServiceState>()
servicesByPage.set(page, services)
}
let state = services.get(pattern.source)
if (!state) {
state = { stubs: new Map(), misses: [], claimed: new Set(), installed: false }
services.set(pattern.source, state)
}
return state
}

/** Read and clear the requests that matched no stub for this page (across all services). */
export const takeServiceMisses = (page: Page): string[] => {
const services = servicesByPage.get(page)
if (!services) return []
const misses = [...services.values()].flatMap((state) => state.misses)
services.forEach((state) => (state.misses.length = 0))
return misses
}

/**
* The registered-but-never-requested stubs for this page, as canonical URLs. Diagnostic only —
* the shared `test` fixture reads this after a miss and prints the entries for the SAME endpoint,
* which is what turns "unmocked request" into a readable diff: the request that was sent above
* the stub that expected it, differing in just the param that drifted.
*/
export const unclaimedStubs = (page: Page): string[] => {
const services = servicesByPage.get(page)
if (!services) return []
return [...services.values()].flatMap(({ stubs, claimed }) =>
[...stubs.entries()].filter(([, stub]) => !claimed.has(stub)).map(([url]) => url),
)
}

/**
* Intercept every request whose URL matches `pattern` and answer it from `stubs` by exact
* URL; unmatched requests are 599'd and recorded (see takeServiceMisses). Bind one of these
* per service (stride.ts, and later backend.ts). Different patterns coexist on one page
* because Playwright only invokes a route handler for requests matching its own pattern, so
* a stride request never reaches the backend route and vice versa.
*
* Call it as often as you like for the same service: stubs merge, last one wins per URL.
*/
export async function routeService(page: Page, pattern: RegExp, stubs: RouteStub[]) {
const state = stateFor(page, pattern)
for (const stub of stubs) state.stubs.set(canon(stub.url), stub)
if (state.installed) return
state.installed = true

await page.route(pattern, (route) => {
const stub = state.stubs.get(canon(route.request().url()))
if (stub) state.claimed.add(stub)
if (!stub) {
state.misses.push(route.request().url())
return route.fulfill({
status: 599,
contentType: 'text/plain',
body: 'unmocked request',
})
}
if (stub.status && stub.status >= 400) {
return route.fulfill({ status: stub.status, contentType: 'application/json', body: '{}' })
}
return route.fulfill({
status: stub.status ?? 200,
contentType: 'application/json',
body: JSON.stringify(stub.body),
})
})
}

/**
* Drop stubs a lower layer registered, so those URLs become misses again. This is how a test
* asserts a request must NOT be issued: remove the default and the test fails at teardown if
* the app asks for it anyway.
*/
export const unrouteStubs = (page: Page, pattern: RegExp, urls: string[]) => {
const state = stateFor(page, pattern)
urls.forEach((url) => state.stubs.delete(canon(url)))
}
22 changes: 22 additions & 0 deletions tests/fixtures/siri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
SiriRideWithRelatedPydanticModel,
SiriRideWithRelatedPydanticModelToJSON,
} from '@hasadna/open-bus-api-client'
import { FIXTURE_DATE } from './date'

/**
* SIRI (real-time) domain fixtures. Builders take a typed *camelCase* partial of the
* generated client model and fill defaults; `…Wire` serializes through the client's own
* `…ToJSON` so the body is the exact snake_case shape the client re-parses
* (e.g. `siri_route__line_ref`, `scheduled_start_time`). See tests/fixtures/stride.ts for
* how a body is paired with its golden request URL.
*/
export const siriRide = (
overrides: Partial<SiriRideWithRelatedPydanticModel> = {},
): SiriRideWithRelatedPydanticModel => ({
scheduledStartTime: new Date(`${FIXTURE_DATE}T12:00:00Z`),
...overrides,
})

export const siriRidesWire = (rides: SiriRideWithRelatedPydanticModel[]): unknown[] =>
rides.map(SiriRideWithRelatedPydanticModelToJSON)
22 changes: 22 additions & 0 deletions tests/fixtures/stride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Page } from '@playwright/test'
import { routeService, RouteStub, unrouteStubs } from './mockRouter'

/**
* Stride-api binding of the generic mock router (tests/fixtures/mockRouter.ts). Stride is the
* app's public-transport data service (VITE_STRIDE_API); its requests are identified by the
* `/stride-api/` substring of its host. Scenario files (e.g. tests/vehicleMocks.ts) build the
* typed bodies (siri.ts, gtfs.ts), pair each with its exact request URL as a StrideStub, and
* hand the list to `routeStride`. Mandatory full-URL matching, miss enforcement and stub
* layering live in the generic core — see mockRouter.ts for the rules.
*/
export type StrideStub = RouteStub

export { okStub, errorStub, optionalStub } from './mockRouter'

Check failure on line 14 in tests/fixtures/stride.ts

View workflow job for this annotation

GitHub Actions / Local Tests

Module '"./mockRouter"' has no exported member 'optionalStub'.

const STRIDE = /stride-api/

/** Register stride stubs. Repeatable: stubs merge, and a repeated URL replaces the earlier one. */
export const routeStride = (page: Page, stubs: StrideStub[]) => routeService(page, STRIDE, stubs)

/** Remove stride stubs a lower layer registered, making those URLs misses again. */
export const unrouteStride = (page: Page, urls: string[]) => unrouteStubs(page, STRIDE, urls)
Loading
Loading