import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createSession, hashPassword } from '@/lib/auth'; import * as schema from '@/lib/db/schema'; import { getGameDb } from '@/lib/db/client'; import { getPlayer } from '@/lib/db/localPlayer'; import { tickNow } from '@/lib/sim/engine'; import { GET } from './route'; let dir: string; let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so // this test never touches ./db/flight.db. getGameDb() reads the env var on first call, and // vitest gives each test file its own module registry (fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db'); // v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user. sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); }); describe('GET /api/events', () => { // v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query // param — this test exercises exactly that path (requireAuthSse). const req = () => new Request(`http://localhost/api/events?session=${sessionId}`); it('rejects an unauthenticated request with 401', async () => { const res = await GET(new Request('http://localhost/api/events')); expect(res.status).toBe(401); expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i); }); it('streams SSE with the current snapshot as the first event', async () => { const res = await GET(req()); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('cache-control')).toBe('no-cache'); expect(res.headers.get('x-accel-buffering')).toBe('no'); const reader = (res.body as ReadableStream).getReader(); try { const { value } = await reader.read(); // first chunk: the initial snapshot event const text = new TextDecoder().decode(value); expect(text.startsWith('data: ')).toBe(true); const snap = JSON.parse(text.slice('data: '.length)) as Record; expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number'); // v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state — // planes carry owner info and packages carry carrierName, so pushed frames can never drift. const planes = snap.planes as Array>; expect(planes.length).toBeGreaterThanOrEqual(1); for (const key of ['ownerId', 'ownerName']) { expect(planes[0], `plane.${key}`).toHaveProperty(key); } const pkgs = snap.packages as Array>; for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName'); } finally { await reader.cancel(); // → stream cancel → unsubscribe from the registry } }); it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => { const inst = await getGameDb(); // A second player with a distinct economy + their own session (rows minted like /api/register). const idB = 'test-player-events-b'; await inst.db.insert(schema.users).values({ id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(), }); await inst.db.insert(schema.players).values({ id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3, }); const sidB = await createSession(inst, idB); const localCash = (await getPlayer(inst, 'local')).cash; // Two live streams: the seeded local player and player B — both subscribed under their own ids. const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`)); const readerA = (resA.body as ReadableStream).getReader(); const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`)); const readerB = (resB.body as ReadableStream).getReader(); // Reads the next `data:` event off a stream and parses its snapshot. const readFrame = async (reader: ReadableStreamDefaultReader) => { const { value } = await reader.read(); return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record; }; try { // Drain both connect frames (each already carries its own account row)… const connectA = await readFrame(readerA); const connectB = await readFrame(readerB); expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …then one tick — the broadcast projects per connected player id and fans each frame out to its group only. await tickNow(); const pushA = await readFrame(readerA); const pushB = await readFrame(readerB); expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row… expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over } finally { await readerA.cancel(); // → stream cancel → unsubscribe from the registry await readerB.cancel(); } }); });