115 lines
5.6 KiB
TypeScript
115 lines
5.6 KiB
TypeScript
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<Uint8Array>).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<string, unknown>;
|
|
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<Record<string, unknown>>;
|
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
|
for (const key of ['ownerId', 'ownerName']) {
|
|
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
|
}
|
|
const pkgs = snap.packages as Array<Record<string, unknown>>;
|
|
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<Uint8Array>).getReader();
|
|
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
|
|
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
|
|
|
|
// Reads the next `data:` event off a stream and parses its snapshot.
|
|
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
|
const { value } = await reader.read();
|
|
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
|
|
};
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|