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')); }); });