Files
Flight-Ops/app/api/test/create-package/route.ts
kamilkrzywda b2034ebc52
CI / build + unit + e2e (pull_request) Successful in 14m25s
v1.0.0: players table + migration from singleton account (ISS-105)
2026-09-24 14:26:34 +00:00

110 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.8.0, renamed create-contract → create-package in v0.12.0) —
// lets the e2e suite create an open package with a specified origin and/or
// destination (and optional deadline offset), so the full load → fly loop can be
// exercised on a known, short route without waiting for random spawns. Disabled
// unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance
// only); every other deployment 404s here. Sibling of /api/test/expire-package —
// same gate, same conventions.
//
// POST /api/test/create-package
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number, weightKg?: number }
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generatePackage.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// hook deadline of expire-package) — pass an explicit value to override.
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
// flows that load several packages can stay under the plane's payload deterministically.
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
// e2e specs that assert exact reward/rep values stay deterministic.
// Returns 201 + the created package row.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
for (const key of ['originIcao', 'destIcao'] as const) {
if (input[key] !== undefined && typeof input[key] !== 'string') {
return Response.json({ error: `${key} must be a string` }, { status: 400 });
}
}
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
}
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
}
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
}
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before "now" for the new package
const fc = await loadAirportsGeoJson();
const airports = fc.features.map((f) => ({
icao: f.properties.icao,
name: f.properties.name,
lat: f.properties.lat,
lon: f.properties.lon,
}));
const knownIcas = new Set(airports.map((a) => a.icao));
for (const key of ['originIcao', 'destIcao'] as const) {
const v = input[key];
if (typeof v === 'string' && !knownIcas.has(v)) {
return Response.json({ error: `unknown airport ${v}` }, { status: 400 });
}
}
const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// as engine spawns; never force a fixed value here.
const player = await getLocalPlayer(inst);
const created = generatePackage({
id: randomUUID(),
airports,
rng,
nowMs: sim.clockMs,
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null),
reputation: player.reputation,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}