Files
Flight-Ops/app/api/planes/[planeId]/refuel/route.test.ts
T
kamilkrzywda a801311fc5
CI / build + unit + e2e (pull_request) Failing after 5m28s
feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.

Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.

OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
2026-09-19 12:24:34 +00:00

162 lines
6.9 KiB
TypeScript
Raw 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 fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-refuel-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'refuel.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (planeId: string, body?: unknown) =>
POST(
new Request(`http://localhost/api/planes/${planeId}/refuel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
}),
{ params: Promise.resolve({ planeId }) },
);
describe('POST /api/planes/:planeId/refuel', () => {
it('rejects an unknown id with 404', async () => {
const res = await post('ghost');
expect(res.status).toBe(404);
});
it('fills a partially empty tank and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
const { db } = await getGameDb();
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
const res = await post('plane-starter');
expect(res.status).toBe(200);
const body = (await res.json()) as {
account: { cash: number };
planes: Array<{ id: string; fuel: number; maxFuel: number }>;
};
const cost = Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // back to full
});
it('rejects a flying plane with 409', async () => {
const { db } = await getGameDb();
await db.update(schema.planes)
.set({ status: 'flying', departureSimTime: Date.now(), destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter');
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
// Ground it again for later tests.
await db.update(schema.planes)
.set({ status: 'available', departureSimTime: null, destIcao: null })
.where(eq(schema.planes.id, 'plane-starter'));
});
it('rejects the fill-up with 409 when cash is below the cost', async () => {
const { db } = await getGameDb();
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
await db.update(schema.account).set({ cash: 10 }); // cost is ~4960
const res = await post('plane-starter');
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
});
it('accepts an explicit {mode:"full"} body exactly like no body', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor
await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const res = await post('plane-starter', { mode: 'full' });
expect(res.status).toBe(200);
const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> };
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
});
it('rejects an unknown mode with 400', async () => {
const res = await post('plane-starter', { mode: 'half' });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/mode/);
});
it('rejects forFlight without a packageId with 400', async () => {
const res = await post('plane-starter', { mode: 'forFlight' });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/packageId/);
});
it('rejects an unknown body field with 400', async () => {
const res = await post('plane-starter', { liters: 100 });
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/);
});
it('rejects invalid JSON with 400', async () => {
const res = await POST(
new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }),
{ params: Promise.resolve({ planeId: 'plane-starter' }) },
);
expect(res.status).toBe(400);
});
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
await db.insert(schema.packages).values({
id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4,
deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(),
});
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
account: { cash: number };
planes: Array<{ id: string; fuel: number }>;
};
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
// Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
});
});