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 { 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-abandon-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db'); }); afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); }); const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), { params: Promise.resolve({ id }), }); // Insert a LOADED package bound to the starter plane with a long deadline. Returns // the exact created/deadline times so tests can compute the penalty from the // snapshot's clock (robust against however much real time the test itself takes). async function insertLoadedPackage(id: string, reward = 1_000) { const { db } = await getGameDb(); const sim = (await db.select().from(schema.simState))[0]; const createdSimTime = sim.clockMs; const deadlineSimTime = createdSimTime + 24 * 3_600_000; await db.insert(schema.packages).values({ id, originIcao: 'EDDF', destIcao: 'LEMD', reward, cargoType: 'Mail', cargoCount: 4, deadlineSimTime, status: 'loaded', planeId: 'plane-starter', createdSimTime, }); return { createdSimTime, deadlineSimTime }; } // Mirrors lib/game/penalty.ts (same formula → identical floats). function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number { const frac = Math.min(1, Math.max(0, (clockMs - createdSimTime) / (deadlineSimTime - createdSimTime))); return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100; } describe('POST /api/packages/:id/abandon', () => { it('rejects an unknown id with 404', async () => { const res = await post('nope'); expect(res.status).toBe(404); expect(((await res.json()) as { error: string }).error).toMatch(/not found/); }); it('rejects an open package with 409 (nothing loaded to abandon)', async () => { const { db } = await getGameDb(); await db.insert(schema.packages).values({ id: 'p-open', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(), createdRealMs: Date.now(), // v0.65.0 — stay open past the pre-action tick }); const res = await post('p-open'); expect(res.status).toBe(409); expect(((await res.json()) as { error: string }).error).toMatch(/open/); }); it('rejects abandoning while the plane is in flight with 409', async () => { await insertLoadedPackage('p-flying'); 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('p-flying'); expect(res.status).toBe(409); expect(((await res.json()) as { error: string }).error).toMatch(/in flight/i); // 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('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => { const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor const { db } = await getGameDb(); const cashBefore = (await db.select().from(schema.account))[0].cash; const res = await post('p-ab'); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('application/json'); // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. const body = (await res.json()) as { account: { cash: number }; simState: { clockMs: number }; // ISS-117 packages: Array<{ id: string; status: string; planeId: string | null }>; }; expect(Object.keys(body).sort()).toEqual([ 'account', 'catalog', 'fuelPricePerL', 'packages', 'planes', 'sellFraction', 'simState', ]); // Cash reduced by exactly the penalty at the snapshot's clock (15% floor + a // sliver of elapsed time — computed with the same formula as the server). const expectedPenalty = penaltyFor(1_000, times.createdSimTime, times.deadlineSimTime, body.simState.clockMs); expect(expectedPenalty).toBeGreaterThanOrEqual(150); // at/above the floor… expect(body.account.cash).toBeCloseTo(cashBefore - expectedPenalty, 6); // …and exact const abandoned = body.packages.find((p) => p.id === 'p-ab')!; expect(abandoned.status).toBe('abandoned'); expect(abandoned.planeId).toBeNull(); // slot freed in the response too // Persisted, not just in the response. const row = (await db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; expect(row.status).toBe('abandoned'); expect(row.planeId).toBeNull(); // slot freed // Abandoning it again is rejected — the package is no longer loaded/expired. const again = await post('p-ab'); expect(again.status).toBe(409); }); });