CI / build + unit + e2e (pull_request) Failing after 5m28s
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.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
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'));
|
||
});
|
||
});
|