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, vi } from 'vitest'; import { openGameDb, type GameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { requiredFuelFor } from '@/lib/sim/movement'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { HUB_ICAO } from './seed'; import { abandonPackage, buyPlane, cancelQueuedLeg, flyPlane, GameActionError, loadPackage, queueLeg, refuelPlane, sellPlane, } from './actions'; // Action tests run against throwaway DB files (openGameDb) with fake timers so // the seeded sim clock is a known constant. Actions are called directly (no tick — // engine.runAction() wraps them in production; its lock/tick behavior is covered // by the route tests and e2e). let dir: string; const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open beforeAll(async () => { vi.useFakeTimers(); vi.setSystemTime(T0); dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-actions-')); }); afterAll(async () => { vi.useRealTimers(); await fs.rm(dir, { recursive: true, force: true }); }); async function freshDb(name: string): Promise { vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now" return openGameDb(path.join(dir, name)); } function expectActionError(promise: Promise, status: number, messagePart?: RegExp) { return promise.then( () => { throw new Error(`expected GameActionError(${status}) but the action succeeded`); }, (err) => { expect(err).toBeInstanceOf(GameActionError); expect((err as GameActionError).status).toBe(status); if (messagePart) expect((err as GameActionError).message).toMatch(messagePart); }, ); } // Insert a known OPEN package EDDF → LEMD for load tests. async function insertOpenPackage(inst: GameDb, id = 'p-open', over: Partial = {}): Promise { await inst.db.insert(schema.packages).values({ id, originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4, deadlineSimTime: T0 + 24 * 3_600_000, status: 'open', planeId: null, createdSimTime: T0, ...over, }); } describe('loadPackage', () => { it('binds an open package to a grounded plane at its origin', async () => { const inst = await freshDb('load-ok.db'); await insertOpenPackage(inst); await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0]; expect(row.status).toBe('loaded'); expect(row.planeId).toBe('plane-starter'); }); it.each(['loaded', 'delivered', 'expired', 'abandoned'] as const)( 'rejects a %s package with 409 (must be open)', async (status) => { const inst = await freshDb(`load-${status}.db`); await insertOpenPackage(inst, 'p-x', { status }); await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status)); }, ); it('rejects an unknown package id with 404', async () => { const inst = await freshDb('load-punknown.db'); await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404); }); it('rejects an unknown plane id with 404', async () => { const inst = await freshDb('load-ghost.db'); await insertOpenPackage(inst); await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404); }); it('rejects a flying plane with 409', async () => { const inst = await freshDb('load-flying.db'); await insertOpenPackage(inst); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409); }); it('rejects a plane that is not at the origin with 409', async () => { const inst = await freshDb('load-notatorigin.db'); await insertOpenPackage(inst); // originates at EDDF await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter')); await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/); }); it('rejects loading when the plane has no free slot (capacity from catalog)', async () => { const inst = await freshDb('load-full.db'); await insertOpenPackage(inst, 'p-new'); // Fill all atr72 slots with loaded cargo… for (let i = 0; i < PLANE_TYPES.atr72.capacity; i++) { await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); } await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i); }); it('counts expired packages as occupying their slot too', async () => { const inst = await freshDb('load-expiredslot.db'); await insertOpenPackage(inst, 'p-new'); // capacity − 1 loaded + 1 expired = full. for (let i = 0; i < PLANE_TYPES.atr72.capacity - 1; i++) { await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter' }); } await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter' }); await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no free slots/i); }); }); describe('flyPlane', () => { it('sets flying + departure at current clock + dest_icao + origin coords', async () => { const inst = await freshDb('fly-ok.db'); await flyPlane(inst, 'plane-starter', 'LEMD'); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.destIcao).toBe('LEMD'); expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls const fc = await loadAirportsGeoJson(); const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; expect(plane.lat).toBe(origin.lat); expect(plane.lon).toBe(origin.lon); // Fuel is frozen at the departure value while flying (live fuel is analytic). expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); }); it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => { const inst = await freshDb('fly-unpause.db'); // The last landing pauses the sim at whatever scale was in use. await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 }); await flyPlane(inst, 'plane-starter', 'LEMD'); const sim = (await inst.db.select().from(schema.simState))[0]; expect(sim.paused).toBe(false); // dispatch auto-resumes… expect(sim.timeScale).toBe(250); // …with the stored speed preserved }); it('rejects a flying plane with 409', async () => { const inst = await freshDb('fly-flying.db'); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409); }); it('rejects an unknown plane id with 404', async () => { const inst = await freshDb('fly-ghost.db'); await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404); }); it('rejects an ICAO not in the airport dataset with 400', async () => { const inst = await freshDb('fly-unknownicao.db'); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); }); it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => { const inst = await freshDb('fly-nofuel.db'); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter')); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i); }); }); describe('queueLeg (v0.25.0, ISS-043)', () => { it('appends legs to the queue in order and persists them as JSON', async () => { const inst = await freshDb('qleg-ok.db'); await queueLeg(inst, 'plane-starter', 'LEMD'); let plane = (await inst.db.select().from(schema.planes))[0]; expect(JSON.parse(plane.queue)).toEqual(['LEMD']); await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route plane = (await inst.db.select().from(schema.planes))[0]; expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved }); it('works on a flying plane too — the active leg is untouched, nothing auto-starts', async () => { const inst = await freshDb('qleg-flying.db'); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await queueLeg(inst, 'plane-starter', 'EHAM'); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); // still on its active leg… expect(plane.destIcao).toBe('LEMD'); // …unchanged — queueing never starts a flight expect(JSON.parse(plane.queue)).toEqual(['EHAM']); }); it('rejects an unknown plane id with 404', async () => { const inst = await freshDb('qleg-ghost.db'); await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404); }); it('rejects an ICAO not in the airport dataset with 400', async () => { const inst = await freshDb('qleg-unknownicao.db'); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); }); }); describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { it('removes the leg at the given index and keeps the rest in order', async () => { const inst = await freshDb('qcancel-ok.db'); await queueLeg(inst, 'plane-starter', 'LEMD'); await queueLeg(inst, 'plane-starter', 'EHAM'); await queueLeg(inst, 'plane-starter', 'EDDV'); await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg) const plane = (await inst.db.select().from(schema.planes))[0]; expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']); }); it('rejects an out-of-range index with 400', async () => { const inst = await freshDb('qcancel-oob.db'); await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400); await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400); }); it('rejects cancelling from an empty queue with 400', async () => { const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400); }); it('rejects an unknown plane id with 404', async () => { const inst = await freshDb('qcancel-ghost.db'); await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404); }); }); describe('abandonPackage', () => { // A loaded package created at T0 with a 10h window; the seeded sim clock is T0. const REWARD = 1_000; const WINDOW_MS = 10 * 3_600_000; async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') { const inst = await freshDb(name); // Always insert OPEN and load it through the real action… await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS }); await loadPackage(inst, 'p-ab', 'plane-starter'); // …then flip to expired in place (what the tick would do once the deadline passes). if (status === 'expired') { await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab')); } return inst; } it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => { const inst = await setupLoaded('ab-start.db'); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100); const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; expect(row.status).toBe('abandoned'); expect(row.planeId).toBeNull(); // slot freed const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore - penalty, 6); }); it('scales to exactly 37.5% at the midpoint of the window', async () => { const inst = await setupLoaded('ab-mid.db'); await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100); }); it('caps at exactly 60% at and past the deadline', async () => { for (const [name, clock] of [ ['ab-deadline.db', T0 + WINDOW_MS], ['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline ] as const) { const inst = await setupLoaded(name); await inst.db.update(schema.simState).set({ clockMs: clock }); const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100); } }); it('is free for an already-expired package', async () => { const inst = await setupLoaded('ab-expired.db', 'expired'); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(0); expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; expect(row.status).toBe('abandoned'); }); it('floors cash at €0 — no debt', async () => { const inst = await setupLoaded('ab-floor.db'); await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10 const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(150); expect((await inst.db.select().from(schema.account))[0].cash).toBe(0); }); it('rejects abandoning while the plane is in flight with 409', async () => { const inst = await setupLoaded('ab-flying.db'); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i); }); it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => { const inst = await freshDb(`ab-${status}.db`); await insertOpenPackage(inst, 'p-x', { status }); await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status)); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('ab-unknown.db'); await expectActionError(abandonPackage(inst, 'nope'), 404); }); }); describe('buyPlane', () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => { const inst = await freshDb('buy-ok.db'); await inst.db.update(schema.account).set({ cash: 500_000 }); const before = (await inst.db.select().from(schema.planes)).length; const { planeId } = await buyPlane(inst, 'atr72'); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); const planes = await inst.db.select().from(schema.planes); expect(planes.length).toBe(before + 1); const bought = planes.find((p) => p.id === planeId)!; expect(bought.model).toBe('atr72'); expect(bought.status).toBe('available'); expect(bought.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // full tank on delivery expect(bought.maxFuel).toBe(PLANE_TYPES.atr72.maxFuel); expect(bought.locationIcao).toBe(HUB_ICAO); const fc = await loadAirportsGeoJson(); const hub = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; expect(bought.lat).toBe(hub.lat); expect(bought.lon).toBe(hub.lon); }); it('rejects an unknown catalog model with 400', async () => { const inst = await freshDb('buy-unknown.db'); await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/); }); it('rejects the purchase when cash is below the price with 409', async () => { const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); }); }); describe('sellPlane', () => { it('pays out SELL_FRACTION of the catalog price and deletes the row', async () => { const inst = await freshDb('sell-ok.db'); // A second plane so the starter is not "the last remaining one". await inst.db.insert(schema.planes).values({ id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, }); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { payout } = await sellPlane(inst, 'plane-extra'); expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore + payout, 6); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); expect(ids).not.toContain('plane-extra'); // row deleted expect(ids).toContain('plane-starter'); }); it('rejects selling a flying plane with 409', async () => { const inst = await freshDb('sell-flying.db'); await inst.db.insert(schema.planes).values({ id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0, }); await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); }); it('rejects selling the last remaining plane with 409', async () => { const inst = await freshDb('sell-last.db'); // only the starter exists await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('sell-unknown.db'); await expectActionError(sellPlane(inst, 'ghost'), 404); }); }); describe('refuelPlane', () => { it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => { const inst = await freshDb('refuel-ok.db'); await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter'); expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore - cost, 6); }); it('is a free no-op when the tank is already full', async () => { const inst = await freshDb('refuel-full.db'); // seeded starter is full const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter'); expect(cost).toBe(0); expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); }); it('rejects a flying plane with 409', async () => { const inst = await freshDb('refuel-flying.db'); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/); }); it('rejects the fill-up when cash is below the cost with 409', async () => { const inst = await freshDb('refuel-poor.db'); await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960 await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('refuel-unknown.db'); await expectActionError(refuelPlane(inst, 'ghost'), 404); }); // --- forFlight mode (v0.16.0) --- it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => { const inst = await freshDb('refuel-forflight-ok.db'); await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD await loadPackage(inst, 'p-ff', 'plane-starter'); 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); // Partial tank below the requirement so L > 0. const startFuel = Math.round(required / 2); await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' }); expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore - cost, 6); }); it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => { const inst = await freshDb('refuel-ff-flyafter.db'); await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD await loadPackage(inst, 'p-fa', 'plane-starter'); 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); // Empty-ish tank: well below the leg requirement. await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter')); await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' }); // The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target). await flyPlane(inst, 'plane-starter', 'LEMD'); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.destIcao).toBe('LEMD'); }); it('forFlight is a free no-op when the tank already covers the leg', async () => { const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' }); await loadPackage(inst, 'p-ff2', 'plane-starter'); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' }); expect(cost).toBe(0); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); }); it('forFlight rejects a package that is not loaded on this plane with 409', async () => { const inst = await freshDb('refuel-ff-open.db'); // open — never loaded await insertOpenPackage(inst, 'p-x'); await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409); const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' }); await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409); }); it('forFlight rejects an expired package with 409', async () => { const inst = await freshDb('refuel-ff-expired.db'); await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' }); await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409); }); it('forFlight rejects an unknown package id with 404', async () => { const inst = await freshDb('refuel-ff-unknown.db'); await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404); }); it('forFlight rejects a missing packageId with 400', async () => { const inst = await freshDb('refuel-ff-nopkg.db'); await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400); }); it('forFlight rejects a leg that does not fit even a full tank with 409', async () => { const inst = await freshDb('refuel-ff-nofit.db'); // UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range. await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' }); await loadPackage(inst, 'p-far', 'plane-starter'); await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter')); await expectActionError( refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }), 409, /does not fit/i, ); }); });