Files
Flight-Ops/e2e/expiry.spec.ts

93 lines
5.0 KiB
TypeScript

import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's
// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas.
import { haversineKm } from '../lib/geo';
import { travelDurationMs } from '../lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog';
import { EXPIRY_BUFFER_MS } from '../lib/game/seed';
// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to
// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even
// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1)
// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on
// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or
// map queries are involved (the assertion is self-contained in the hook response), so none of those
// conventions apply here.
const FROM = 'EDDB'; // Berlin Brandenburg
const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM
const COUNT = 10; // priority packages created and checked
interface Pkg {
id: string;
originIcao: string;
destIcao: string;
createdSimTime: number;
deadlineSimTime: number;
}
async function createPriorityPackage(page: Page, from: string, to: string): Promise<Pkg> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } });
expect(res.status()).toBe(201);
return (await res.json()) as Pkg;
}
test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => {
// The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses.
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const from = fc.features.find((f) => f.properties.icao === FROM)?.properties;
const to = fc.features.find((f) => f.properties.icao === TO)?.properties;
expect(from, `${FROM} missing from the airport dataset`).toBeTruthy();
expect(to, `${TO} missing from the airport dataset`).toBeTruthy();
if (!from || !to) return; // guarded above — keeps the math below total
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous
const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
const created: Pkg[] = [];
try {
for (let i = 0; i < COUNT; i++) {
const pkg = await createPriorityPackage(page, FROM, TO);
expect(pkg.originIcao).toBe(FROM);
expect(pkg.destIcao).toBe(TO);
// The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation.
// deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly.
const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime;
expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs));
// And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane.
expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000);
created.push(pkg);
}
} finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
}
}
});
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state. The age hook (FLIGHT_TEST_HOOKS=1) stamps a job past its
// lifetime and runs a tick, so expiry is observable in one round-trip; self-contained like the test
// above (the assertion lives in the hook response, no /api/state polling).
test('an open job aged past its wall-clock lifetime expires on the next tick', async ({ page }) => {
const pkg = await createPriorityPackage(page, FROM, TO);
const res = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(res.status()).toBe(200);
const aged = (await res.json()) as Pkg & { status: string };
expect(aged.id).toBe(pkg.id);
expect(aged.status, 'the hook runs a tick after aging the job, so it is expired by response time').toBe('expired');
// Unknown ids and non-open jobs still 404.
const missing = await page.request.post('/api/test/expire-package', { data: { packageId: 'no-such-id' } });
expect(missing.status()).toBe(404);
const alreadyExpired = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(alreadyExpired.status()).toBe(404); // expired is not open — the hook only ages live jobs
});