From c2857e9156a11fe78f346ad9830230bdd870b715 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Fri, 16 Oct 2026 07:42:37 +0100 Subject: [PATCH] fix(phantasialand): keep the signage position inside the park outline /signage-snapshots takes the caller's position as loc=lat,lng and answers 403 FORBIDDEN for a position outside the park outline (a bad or missing token gets 401). The box the position was drawn from reached outside the outline at its south-west corner, and a retry re-sends the same position, so a poll that drew one failed after the full backoff window. Draw from a box that stays inside the outline instead. Adds `npm run probe:phantasialand`, a live check that draws through the park's own fetch and exits 1 on any refusal. The comments that blamed a rate limit now describe what the endpoint does. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 3 +- .../__tests__/signageLocation.test.ts | 157 ++++++++++++++++++ src/parks/phantasialand/phantasialand.ts | 55 ++++-- .../__tests__/signageProbeReport.test.ts | 24 +++ src/tools/signageProbe.ts | 84 ++++++++++ src/tools/signageProbeReport.ts | 24 +++ 6 files changed, 331 insertions(+), 16 deletions(-) create mode 100644 src/parks/phantasialand/__tests__/signageLocation.test.ts create mode 100644 src/tools/__tests__/signageProbeReport.test.ts create mode 100644 src/tools/signageProbe.ts create mode 100644 src/tools/signageProbeReport.ts diff --git a/package.json b/package.json index 742dbe432..9de498da5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "docs": "typedoc", "prepare": "tsc && ([ \"$(git rev-parse --show-toplevel 2>/dev/null)\" = \"$(pwd -P)\" ] && git config core.hooksPath .githooks || true)", "readme": "tsx src/tools/readme/destinations.ts", - "stale": "tsx --env-file=.env src/tools/staleReport.ts" + "stale": "tsx --env-file=.env src/tools/staleReport.ts", + "probe:phantasialand": "tsx --env-file=.env src/tools/signageProbe.ts" }, "repository": { "type": "git", diff --git a/src/parks/phantasialand/__tests__/signageLocation.test.ts b/src/parks/phantasialand/__tests__/signageLocation.test.ts new file mode 100644 index 000000000..59623c4f7 --- /dev/null +++ b/src/parks/phantasialand/__tests__/signageLocation.test.ts @@ -0,0 +1,157 @@ +import {describe, it, expect, vi, beforeEach, afterEach, afterAll} from 'vitest'; +import {Phantasialand, SIGNAGE_BOX, signageLocation} from '../phantasialand.js'; +import {setHttpTransport} from '../../../httpProxy.js'; +import {stopHttpQueue} from '../../../http.js'; + +/** + * `/signage-snapshots?loc=lat,lng` answers 403 FORBIDDEN for a location outside + * the park's outline, which the box the park used to draw from reached at its + * south-west corner. A bad or missing token gets 401, so the two differ by + * status. The refusal depends on the position alone, and a retry re-sends the + * same `loc`, so retrying cannot help. + * + * REFUSED_CELLS (a coarse 8 x 10 grid over the old box) and SURVEY_REFUSED (a + * finer one) are positions seen refused, kept so the tests can show the box + * stays clear of them. + */ +const OLD_BOX = {latMin: 50.799683077, latMax: 50.800659529, lngMin: 6.877570152, lngMax: 6.878342628}; +const GRID = {rows: 10, cols: 8}; + +/** Centre of grid cell (row, col); row 0 is the southernmost. */ +function cell(row: number, col: number) { + return { + lat: OLD_BOX.latMin + ((row + 0.5) / GRID.rows) * (OLD_BOX.latMax - OLD_BOX.latMin), + lng: OLD_BOX.lngMin + ((col + 0.5) / GRID.cols) * (OLD_BOX.lngMax - OLD_BOX.lngMin), + }; +} + +const REFUSED_CELLS: Array<[number, number]> = [ + [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], + [1, 0], [1, 1], [1, 2], + [2, 0], [2, 1], +]; + +const within = (p: {lat: number; lng: number}, b: typeof SIGNAGE_BOX) => + p.lat >= b.latMin && p.lat <= b.latMax && p.lng >= b.lngMin && p.lng <= b.lngMax; + +/** + * A finer survey (latitude step 0.00004 from 50.79990, longitude step 0.00005 + * from 6.87755) refused these 13 points and no others. Keyed by latitude step, + * valued by longitude steps. + */ +const SURVEY_REFUSED: Record = { + 0: [0, 1, 2, 3, 4], + 1: [0, 1, 2, 3], + 2: [0, 1, 2], + 3: [0], +}; + +const SURVEY_REFUSED_POINTS = Object.entries(SURVEY_REFUSED).flatMap(([a, bs]) => + bs.map((b) => ({lat: 50.79990 + Number(a) * 0.00004, lng: 6.87755 + b * 0.00005})), +); + +/** Metres from a point to the nearest edge of the box, or 0 inside it. */ +function metresFromBox(p: {lat: number; lng: number}, b: typeof SIGNAGE_BOX): number { + const dLat = Math.max(b.latMin - p.lat, 0, p.lat - b.latMax); + const dLng = Math.max(b.lngMin - p.lng, 0, p.lng - b.lngMax); + const metresPerDegree = 111_320; + return Math.hypot(dLat * metresPerDegree, dLng * metresPerDegree * Math.cos((p.lat * Math.PI) / 180)); +} + +describe('signageLocation', () => { + it('stays inside the box at both extremes of the generator', () => { + for (const r of [0, 0.25, 0.5, 0.75, 1 - Number.EPSILON]) { + const p = signageLocation(() => r); + expect(within(p, SIGNAGE_BOX)).toBe(true); + } + }); + + it('maps the first draw to latitude and the second to longitude', () => { + const draws = [0, 1]; + const p = signageLocation(() => draws.shift()!); + expect(p.lat).toBeCloseTo(SIGNAGE_BOX.latMin, 10); + expect(p.lng).toBeCloseTo(SIGNAGE_BOX.lngMax, 10); + }); + + it('never lands on a cell the endpoint was seen to refuse', () => { + for (const [row, col] of REFUSED_CELLS) { + expect(within(cell(row, col), SIGNAGE_BOX), `cell ${row},${col}`).toBe(false); + } + }); + + it('keeps clear of the refused region, not just of its sampled cells', () => { + // The refused region narrows northward. The nearest accepted cell row to + // it is row 3, so the box must not reach below row 3's centre latitude. + expect(SIGNAGE_BOX.latMin).toBeGreaterThan(cell(3, 0).lat); + }); + + it('keeps at least 10 m clear of every point the fine survey saw refused', () => { + expect(SURVEY_REFUSED_POINTS).toHaveLength(13); + for (const p of SURVEY_REFUSED_POINTS) { + expect(metresFromBox(p, SIGNAGE_BOX), `${p.lat},${p.lng}`).toBeGreaterThanOrEqual(10); + } + }); + + it('is a sub-box of the one the park used to draw from', () => { + expect(SIGNAGE_BOX.latMin).toBeGreaterThanOrEqual(OLD_BOX.latMin); + expect(SIGNAGE_BOX.latMax).toBeLessThanOrEqual(OLD_BOX.latMax); + expect(SIGNAGE_BOX.lngMin).toBeGreaterThanOrEqual(OLD_BOX.lngMin); + expect(SIGNAGE_BOX.lngMax).toBeLessThanOrEqual(OLD_BOX.lngMax); + }); + + it('draws 1000 random points that all stay inside the box', () => { + for (let k = 0; k < 1000; k++) { + expect(within(signageLocation(), SIGNAGE_BOX)).toBe(true); + } + }); +}); + +describe('Phantasialand.fetchSignage', () => { + let sent: string[]; + + beforeEach(() => { + sent = []; + setHttpTransport(async (request) => { + sent.push(request.url); + return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}}); + }); + }); + + afterEach(() => { + setHttpTransport(null); + vi.restoreAllMocks(); + }); + + afterAll(() => stopHttpQueue()); + + function park() { + const p = new Phantasialand({config: {apiBase: 'https://phantasialand.invalid'}}); + vi.spyOn(p, 'getAccessToken').mockResolvedValue('test-token'); + return p; + } + + it('sends the location the generator produced, latitude first', async () => { + const draws = [0, 1]; + vi.spyOn(Math, 'random').mockImplementation(() => draws.shift() ?? 0.5); + + await park().fetchSignage(); + + expect(sent).toHaveLength(1); + const [lat, lng] = new URL(sent[0]).searchParams.get('loc')!.split(',').map(Number); + expect(lat).toBeCloseTo(SIGNAGE_BOX.latMin, 10); + expect(lng).toBeCloseTo(SIGNAGE_BOX.lngMax, 10); + }); + + it('never sends a location outside the box', async () => { + const p = park(); + for (const r of [0, 0.5, 0.999999]) { + vi.spyOn(Math, 'random').mockReturnValue(r); + await p.fetchSignage(); + } + expect(sent.length).toBeGreaterThan(0); + for (const u of sent) { + const [lat, lng] = new URL(u).searchParams.get('loc')!.split(',').map(Number); + expect(within({lat, lng}, SIGNAGE_BOX), u).toBe(true); + } + }); +}); diff --git a/src/parks/phantasialand/phantasialand.ts b/src/parks/phantasialand/phantasialand.ts index e371629fd..6e1c47804 100644 --- a/src/parks/phantasialand/phantasialand.ts +++ b/src/parks/phantasialand/phantasialand.ts @@ -84,6 +84,32 @@ function signageRowAge(entry: any, nowMs: number): number | null { return nowMs - Math.max(...stamps); } +/** + * Positions the signage endpoint will answer for, as a lat/lng box. + * + * `/signage-snapshots?loc=lat,lng` refuses a position outside the park's + * outline with 403 FORBIDDEN (a bad or missing token gets 401). The previous + * box reached outside the outline at its south-west corner. A retry re-sends + * the request as built, so a refused position stays refused; the box has to + * keep clear of the edge. Check any change with `npm run probe:phantasialand`. + */ +export const SIGNAGE_BOX = { + latMin: 50.80020, + latMax: 50.80060, + lngMin: 6.87760, + lngMax: 6.87830, +} as const; + +/** + * A random position inside SIGNAGE_BOX. Latitude is drawn first, then + * longitude. `rng` is injectable so a test can pin the draw. + */ +export function signageLocation(rng: () => number = Math.random): {lat: number; lng: number} { + const lat = SIGNAGE_BOX.latMin + rng() * (SIGNAGE_BOX.latMax - SIGNAGE_BOX.latMin); + const lng = SIGNAGE_BOX.lngMin + rng() * (SIGNAGE_BOX.lngMax - SIGNAGE_BOX.lngMin); + return {lat, lng}; +} + @destinationController({category: 'Phantasialand'}) export class Phantasialand extends Destination { @config @@ -178,14 +204,14 @@ export class Phantasialand extends Destination { * * 401 — unambiguous: token is invalid. Invalidate immediately. * - * 403 — ambiguous: phlsys returns the same body for brief per-IP rate - * limits AND for revoked/expired tokens. Eager re-login on every 403 - * doubles our request rate in rate-limit windows and makes the limit - * stick longer. So we only invalidate on the *final* retry — by which - * point any transient rate limit has had its full ~31s exponential- - * backoff window to clear. If the request is still 403 after all - * retries, the cause is much more likely a stale token, and the next - * polling cycle will pick up a fresh one. + * 403 — not a token verdict by itself. A bad or missing token gets 401; + * 403 FORBIDDEN is what the signage endpoint answers for a position + * outside the park (see SIGNAGE_BOX). Eager re-login on every 403 would + * double our request rate for no gain, so we only invalidate on the + * *final* retry, after the full ~31s exponential-backoff window. That + * stays as a hedge for a token that stops working in a way that shows up + * as 403; none has been reproduced, so a 403 that survives every retry + * with the location inside SIGNAGE_BOX is worth a look. */ @inject({ eventName: 'httpError', @@ -289,18 +315,17 @@ export class Phantasialand extends Destination { /** * Fetch signage/wait time data. - * Requires random coordinates within park bounds. + * The endpoint wants the caller's position, drawn here from SIGNAGE_BOX. * * `retries: 5` gives an exponential-backoff window of ~31s - * (1+2+4+8+16). phlsys rate-limits this endpoint per IP and the - * window typically clears within a few seconds, but a shorter retry - * budget (e.g. 2 → 3s) gives up too early during sustained bursts. + * (1+2+4+8+16). That can ride out a transient failure, but it cannot + * rescue a refused position: every retry re-sends the request as built, + * so the same `loc` goes out again. Keeping the position inside + * SIGNAGE_BOX is what keeps the refusals away. */ @http({cacheSeconds: 60, retries: 5}) // 1 minute async fetchSignage(): Promise { - // Generate random coordinates within park bounds - const lat = 50.799683077 + (Math.random() * (50.800659529 - 50.799683077)); - const lng = 6.877570152 + (Math.random() * (6.878342628 - 6.877570152)); + const {lat, lng} = signageLocation(); return { method: 'GET', url: `${this.apiBase}/signage-snapshots?loc=${lat},${lng}`, diff --git a/src/tools/__tests__/signageProbeReport.test.ts b/src/tools/__tests__/signageProbeReport.test.ts new file mode 100644 index 000000000..12c582882 --- /dev/null +++ b/src/tools/__tests__/signageProbeReport.test.ts @@ -0,0 +1,24 @@ +import {describe, it, expect} from 'vitest'; +import {summarise, type ProbeResult} from '../signageProbeReport.js'; + +const at = (lat: number, lng: number, status: number): ProbeResult => ({lat, lng, status}); + +describe('summarise', () => { + it('passes only when something was probed and every answer was 200', () => { + expect(summarise([at(1, 1, 200), at(2, 2, 200)])).toEqual({total: 2, accepted: 2, refused: 0, other: 0, ok: true}); + }); + + it('fails on a single refusal', () => { + const s = summarise([at(1, 1, 200), at(2, 2, 403), at(3, 3, 200)]); + expect(s).toMatchObject({total: 3, accepted: 2, refused: 1, other: 0, ok: false}); + }); + + it('counts an error or any other status separately, and fails on it', () => { + const s = summarise([at(1, 1, 200), at(2, 2, -1), at(3, 3, 500)]); + expect(s).toMatchObject({accepted: 1, refused: 0, other: 2, ok: false}); + }); + + it('does not pass an empty probe', () => { + expect(summarise([])).toMatchObject({total: 0, ok: false}); + }); +}); diff --git a/src/tools/signageProbe.ts b/src/tools/signageProbe.ts new file mode 100644 index 000000000..8755b2e8b --- /dev/null +++ b/src/tools/signageProbe.ts @@ -0,0 +1,84 @@ +/** + * @module + * Live check that Phantasialand's signage endpoint accepts the positions the + * park sends. It draws from SIGNAGE_BOX through the park's own `fetchSignage()` + * and exits 1 if any position is refused, so a refusal that unit tests cannot + * see shows up before it costs a poll. + * + * npm run probe:phantasialand # 60 draws, 2s apart + * npm run probe:phantasialand -- --draws 200 --gap 3000 + * + * Needs the park's API base in the environment, as `npm run dev` does. + */ +import {Phantasialand, SIGNAGE_BOX} from '../parks/phantasialand/phantasialand.js'; +import {setHttpTransport} from '../httpProxy.js'; +import {stopHttpQueue} from '../http.js'; +import {summarise, type ProbeResult} from './signageProbeReport.js'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function parseArgs(argv: string[]): {draws: number; gap: number} { + const o = {draws: 60, gap: 2000}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a !== '--draws' && a !== '--gap') throw new Error(`unknown argument ${a}`); + const n = Number(argv[++i]); + if (!Number.isFinite(n) || n <= 0) throw new Error(`${a} needs a positive number`); + if (a === '--draws') o.draws = n; + else o.gap = n; + } + return o; +} + +async function probeDraws(park: Phantasialand, n: number, gap: number): Promise { + // First answer per position: a refused draw is retried at the same loc, and + // those retries are one draw, not six. + const first = new Map(); + const record = (loc: string | null, status: number) => { + if (!loc || first.has(loc)) return; + const [lat, lng] = loc.split(',').map(Number); + first.set(loc, {lat, lng, status}); + }; + + setHttpTransport(async (request, _caller, send) => { + const loc = new URL(request.url).searchParams.get('loc'); + try { + const resp = await send(request); + record(loc, resp.status); + return resp; + } catch (err) { + record(loc, -1); + throw err; + } + }); + + for (let i = 1; i <= n; i++) { + try { + await park.fetchSignage(); + } catch { + // The transport recorded the status already. + } + if (i % 10 === 0) console.log(` ${i}/${n}`); + if (i < n) await sleep(gap); + } + setHttpTransport(null); + return [...first.values()]; +} + +async function main(): Promise { + const {draws, gap} = parseArgs(process.argv.slice(2)); + console.log(`${draws} draws from ${JSON.stringify(SIGNAGE_BOX)}, ${gap}ms apart`); + + const s = summarise(await probeDraws(new Phantasialand(), draws, gap)); + console.log(`\n${s.accepted} accepted, ${s.refused} refused, ${s.other} other of ${s.total} positions`); + if (!s.ok) { + console.log('FAIL: the park sent a position the endpoint did not accept.'); + return 1; + } + console.log('PASS'); + return 0; +} + +main() + .then((code) => { stopHttpQueue(); process.exit(code); }) + .catch((err) => { console.error(err); stopHttpQueue(); process.exit(2); }); diff --git a/src/tools/signageProbeReport.ts b/src/tools/signageProbeReport.ts new file mode 100644 index 000000000..4ccac8cf9 --- /dev/null +++ b/src/tools/signageProbeReport.ts @@ -0,0 +1,24 @@ +/** + * @module + * The verdict of the Phantasialand signage probe (signageProbe.ts), kept apart + * from the CLI so it can be tested without a network or a destination. + */ + +export type ProbeResult = {lat: number; lng: number; status: number}; + +export type ProbeSummary = { + total: number; + accepted: number; + refused: number; + /** Neither 200 nor 403: a network error is recorded as status -1. */ + other: number; + /** True when something was probed and every answer was a 200. */ + ok: boolean; +}; + +export function summarise(results: ProbeResult[]): ProbeSummary { + const accepted = results.filter((r) => r.status === 200).length; + const refused = results.filter((r) => r.status === 403).length; + const other = results.length - accepted - refused; + return {total: results.length, accepted, refused, other, ok: results.length > 0 && refused === 0 && other === 0}; +}