Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions src/parks/phantasialand/__tests__/signageLocation.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, number[]> = {
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);
}
});
});
55 changes: 40 additions & 15 deletions src/parks/phantasialand/phantasialand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<HTTPObj> {
// 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}`,
Expand Down
24 changes: 24 additions & 0 deletions src/tools/__tests__/signageProbeReport.test.ts
Original file line number Diff line number Diff line change
@@ -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});
});
});
84 changes: 84 additions & 0 deletions src/tools/signageProbe.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeResult[]> {
// 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<string, ProbeResult>();
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<number> {
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); });
24 changes: 24 additions & 0 deletions src/tools/signageProbeReport.ts
Original file line number Diff line number Diff line change
@@ -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};
}
Loading