187 lines
9.1 KiB
TypeScript
187 lines
9.1 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 { eq } from 'drizzle-orm';
|
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
|
import { haversineKm } from '@/lib/geo';
|
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
|
import { createSession, hashPassword } from '@/lib/auth';
|
|
import * as schema from '@/lib/db/schema';
|
|
import { getGameDb } from '@/lib/db/client';
|
|
import { GET } from './route';
|
|
|
|
let dir: string;
|
|
let sessionId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Point the app's 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-state-'));
|
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db');
|
|
// v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user
|
|
// (no password needed: createSession only inserts into sessions; the guard joins players on
|
|
// username → the 'local' row).
|
|
sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
|
});
|
|
|
|
const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } });
|
|
|
|
afterAll(async () => {
|
|
delete process.env.FLIGHT_DB_PATH;
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('GET /api/state', () => {
|
|
it('returns 200 with the full snapshot shape', async () => {
|
|
const res = await GET(req());
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('application/json');
|
|
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
expect(Object.keys(body).sort()).toEqual([
|
|
'account',
|
|
'catalog',
|
|
'fuelPricePerL',
|
|
'packages',
|
|
'planes',
|
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
|
'sellFraction',
|
|
'simState',
|
|
]);
|
|
|
|
const account = body.account as { cash: number; reputation: number };
|
|
expect(typeof account.cash).toBe('number');
|
|
expect(typeof account.reputation).toBe('number');
|
|
|
|
const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
|
|
expect(typeof sim.clockMs).toBe('number');
|
|
|
|
const planes = body.planes as Array<Record<string, unknown>>;
|
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
|
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
|
}
|
|
expect(typeof planes[0].locationName).toBe('string');
|
|
// The seeded starter is grounded — no flight destination yet.
|
|
expect(planes[0].destIcao).toBeNull();
|
|
expect(planes[0].destName).toBeNull();
|
|
|
|
const packages = body.packages as Array<Record<string, unknown>>;
|
|
expect(packages.length).toBeGreaterThan(0);
|
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
|
expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
|
|
}
|
|
|
|
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
|
// fixture airports' coordinates for every package in the snapshot.
|
|
const fc = await loadAirportsGeoJson();
|
|
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
|
for (const pkg of packages) {
|
|
const from = coords.get(String(pkg.originIcao));
|
|
const to = coords.get(String(pkg.destIcao));
|
|
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
|
|
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
|
|
}
|
|
});
|
|
|
|
it('is stable across repeated calls (same seeded DB)', async () => {
|
|
const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
|
const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>;
|
|
expect((await b).account.cash).toBe((await a).account.cash);
|
|
});
|
|
|
|
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
|
|
const body = ((await GET(req())).json()) as Promise<{
|
|
catalog: Array<Record<string, unknown>>;
|
|
fuelPricePerL: number;
|
|
sellFraction: number;
|
|
}>;
|
|
const snap = await body;
|
|
|
|
// One entry per PLANE_TYPES model, values copied straight from the catalog.
|
|
expect(snap.catalog.map((e) => e.model).sort()).toEqual(Object.keys(PLANE_TYPES).sort());
|
|
for (const [model, type] of Object.entries(PLANE_TYPES)) {
|
|
const entry = snap.catalog.find((e) => e.model === model)!;
|
|
expect(entry.label).toBe(type.name);
|
|
expect(entry.price).toBe(type.price);
|
|
expect(entry.maxFuel).toBe(type.maxFuel);
|
|
expect(entry.burnPerKm).toBe(type.burnPerKm);
|
|
expect(entry.speedKmh).toBe(type.speedKmh);
|
|
expect(entry.maxPayloadKg).toBe(type.maxPayloadKg); // v0.27.0 payload kg (ATR 72-600: 4800)
|
|
}
|
|
|
|
// Economy constants — the single source of truth stays lib/game/catalog.ts.
|
|
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
|
|
expect(snap.sellFraction).toBe(SELL_FRACTION);
|
|
});
|
|
|
|
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
|
|
const inst = await getGameDb();
|
|
// A second account with a distinct economy — rows minted like /api/register does.
|
|
const id = 'test-player-b';
|
|
await inst.db.insert(schema.users).values({
|
|
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
|
});
|
|
await inst.db.insert(schema.players).values({
|
|
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
|
|
});
|
|
const sid = await createSession(inst, id);
|
|
|
|
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { account: { cash: number; reputation: number } };
|
|
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
|
|
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
|
|
});
|
|
|
|
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
|
|
const inst = await getGameDb();
|
|
// A second player with a plane of their own — rows minted like /api/register does.
|
|
const id = 'test-player-c';
|
|
await inst.db.insert(schema.users).values({
|
|
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
|
});
|
|
await inst.db.insert(schema.players).values({
|
|
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
|
|
});
|
|
const type = PLANE_TYPES.atr72;
|
|
await inst.db.insert(schema.planes).values({
|
|
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
|
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
|
});
|
|
|
|
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
|
|
const starter = body.planes.find((p) => p.id === 'plane-starter');
|
|
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
|
|
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
|
|
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
|
|
expect(foreign?.ownerId).toBe(id);
|
|
expect(foreign?.ownerName).toBe('e2e-owner-c');
|
|
});
|
|
|
|
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
|
|
const inst = await getGameDb();
|
|
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
|
|
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
|
const snapBefore = await before;
|
|
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
|
|
|
|
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
|
|
await inst.db.update(schema.packages)
|
|
.set({ status: 'loaded', planeId: 'plane-test-c' })
|
|
.where(eq(schema.packages.id, String(openPkg.id)));
|
|
|
|
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
|
const after = await snap;
|
|
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
|
|
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
|
|
for (const pkg of after.packages) { // …and every unassigned package stays null.
|
|
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
|
|
}
|
|
});
|
|
});
|