284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||
import { expect, test } from '@playwright/test';
|
||
|
||
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||
|
||
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||
// player; player B is a second account minted through the same hook with a username override (the
|
||
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||
//
|
||
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||
|
||
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||
|
||
interface PlaneState {
|
||
id: string;
|
||
status: string;
|
||
locationIcao: string;
|
||
maxFuel: number;
|
||
queue: string[];
|
||
ownerId: string;
|
||
ownerName: string;
|
||
}
|
||
|
||
interface PlayerSummary {
|
||
id: string;
|
||
username: string;
|
||
cash: number;
|
||
reputation: number;
|
||
planeCount: number;
|
||
}
|
||
|
||
interface StateBody {
|
||
account: { cash: number; reputation: number };
|
||
simState: { clockMs: number };
|
||
planes: PlaneState[];
|
||
players: PlayerSummary[];
|
||
}
|
||
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function getState(page: Page): Promise<StateBody> {
|
||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||
for (let attempt = 1; ; attempt++) {
|
||
const res = await page.request.get('/api/state');
|
||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||
async function setScale(page: Page, scale: number): Promise<void> {
|
||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||
expect(res.status()).toBe(200);
|
||
const body = (await res.json()) as { timeScale: number };
|
||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||
}
|
||
|
||
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||
for (;;) {
|
||
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||
if (q.length === 0) return;
|
||
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||
}
|
||
}
|
||
|
||
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||
async function nearestToHub(page: Page): Promise<string> {
|
||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||
};
|
||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||
return fc.features
|
||
.filter((f) => f.properties.icao !== HUB)
|
||
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||
.sort((x, y) => x.d - y.d)[0].icao;
|
||
}
|
||
|
||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||
const dLat = toRad(lat2 - lat1);
|
||
const dLon = toRad(lon2 - lon1);
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||
}
|
||
|
||
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||
const ctx = await browser.newContext();
|
||
const page = await ctx.newPage();
|
||
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||
await applySession(page, sessionId);
|
||
return { ctx, page };
|
||
}
|
||
|
||
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||
async function healStarterHome(page: Page): Promise<void> {
|
||
await clearQueueViaApi(page, STARTER);
|
||
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||
if (p.status === 'flying') {
|
||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||
timeout: 240_000,
|
||
message: 'waiting for an in-flight starter to land during cleanup',
|
||
})
|
||
.toBe('available');
|
||
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||
}
|
||
if (p.locationIcao !== HUB) {
|
||
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyHome.status()).toBe(200);
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||
timeout: 240_000,
|
||
message: 'waiting for the starter to land back at the hub during cleanup',
|
||
})
|
||
.toBe(HUB);
|
||
}
|
||
}
|
||
|
||
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||
timeout: 120_000,
|
||
message: 'waiting for the fleet to be fully grounded',
|
||
})
|
||
.toBe('idle');
|
||
}
|
||
|
||
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||
|
||
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||
});
|
||
|
||
test.afterEach(async ({ page }) => {
|
||
try {
|
||
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||
await healStarterHome(page);
|
||
await setScale(page, WORLD);
|
||
} finally {
|
||
if (bCtx) await bCtx.close();
|
||
bCtx = null;
|
||
}
|
||
});
|
||
|
||
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||
test.afterAll(async ({ browser }) => {
|
||
const ctx = await browser.newContext();
|
||
const page = await ctx.newPage();
|
||
try {
|
||
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||
// Verify through the same surface the game uses — mint a local session just for this read.
|
||
const sessionId = await mintPlayerSession(page);
|
||
await applySession(page, sessionId);
|
||
const s = (await getState(page)) as StateBody;
|
||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||
const b = await mintPlayerB(browser);
|
||
bCtx = b.ctx;
|
||
|
||
await waitForGroundedFleet(page);
|
||
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||
|
||
const dest = await nearestToHub(page);
|
||
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||
await setFuelFull(page, STARTER);
|
||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||
|
||
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||
let prev = (await getState(page)).simState.clockMs;
|
||
for (let i = 0; i < 3; i++) {
|
||
await sleep(500);
|
||
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||
const now = s.simState.clockMs;
|
||
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||
prev = now;
|
||
}
|
||
|
||
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||
await setScale(page, SCALE);
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||
timeout: 240_000,
|
||
message: "waiting for A's plane to land",
|
||
})
|
||
.toBe('available');
|
||
|
||
const frozen1 = await getState(page);
|
||
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||
const frozen2 = await getState(page);
|
||
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||
});
|
||
|
||
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||
const b = await mintPlayerB(browser);
|
||
bCtx = b.ctx;
|
||
|
||
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||
await waitForGroundedFleet(page);
|
||
|
||
const sA = await getState(page); // A's (local session's) view of the shared world
|
||
const sB = await getState(b.page); // B's view — same world…
|
||
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||
|
||
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||
expect(starter.ownerId).toBe('local');
|
||
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||
|
||
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyForeign.status()).toBe(403);
|
||
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||
expect(sellForeign.status()).toBe(403);
|
||
|
||
// …and B cannot act on A's.
|
||
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyA.status()).toBe(403);
|
||
|
||
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||
// so nothing else can touch either economy between reads.
|
||
const cA = sA.account.cash;
|
||
const cB = sB.account.cash;
|
||
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||
expect(grant.status()).toBe(200);
|
||
const granted = ((await grant.json()) as StateBody).account.cash;
|
||
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||
const sBAfter = await getState(b.page);
|
||
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||
|
||
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||
for (const s of [sA, sBAfter]) {
|
||
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||
}
|
||
});
|