feat: v0.8.0 — player-actions API: accept/dispatch/buy/sell/refuel endpoints (tick-first, full-snapshot responses), spawn coverage rule, test create-contract hook

This commit is contained in:
2026-09-17 13:54:13 +00:00
parent 96835e935d
commit aa70eb5286
25 changed files with 1843 additions and 8 deletions
@@ -0,0 +1,65 @@
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-accept-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'accept.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/contracts/${id}/accept`), { params: Promise.resolve({ id }) });
describe('POST /api/contracts/:id/accept', () => {
it('returns the full state snapshot with the contract accepted', async () => {
const { db } = await getGameDb();
const open = (await db.select().from(schema.contracts)).find((c) => c.status === 'open')!;
const res = await post(open.id);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
// Response convention: the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: Array<Record<string, unknown>>;
contracts: Array<{ id: string; status: string }>;
};
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(body.contracts.find((c) => c.id === open.id)?.status).toBe('accepted');
// Persisted, not just in the response.
const row = (await db.select().from(schema.contracts).where(eq(schema.contracts.id, open.id)))[0];
expect(row.status).toBe('accepted');
});
it('rejects a non-open contract with 409', async () => {
const { db } = await getGameDb();
// Deterministic regardless of which contract the first test accepted.
await db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, 'contract-2'));
const res = await post('contract-2');
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/completed/);
});
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/);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { acceptContract, performAction } from '@/lib/game/actions';
// POST /api/contracts/:id/accept (v0.8.0) — claim an open contract: status flips
// open → accepted. 404 unknown id; 409 if the contract is not 'open' (accepted /
// completed / expired). No body. On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => acceptContract(inst, id));
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { PLANE_TYPES } from '@/lib/game/catalog';
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-buy-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) }));
describe('POST /api/market/buy-plane', () => {
it('rejects a missing model, unknown fields and unknown models with 400', async () => {
for (const body of [{}, { model: 7 }, { bogus: 1 }, { model: 'boeing747' }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('rejects the purchase with 409 when cash is below the price (seeded €20k < €200k)', async () => {
const res = await post({ model: 'atr72' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i);
// Cash untouched by the failed attempt.
const account = (await (await getGameDb()).db.select().from(schema.account))[0];
expect(account.cash).toBe(20_000);
});
it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => {
const { db } = await getGameDb();
// Single-row account table — top up cash so the purchase can succeed.
await db.update(schema.account).set({ cash: 500_000 });
const res = await post({ model: 'atr72' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
account: { cash: number };
planes: Array<{ id: string; model: string; status: string; fuel: number; locationIcao: string }>;
};
expect(body.account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const bought = body.planes.filter((p) => p.model === 'atr72');
expect(bought.length).toBe(2); // starter + the new one
const fresh = bought.find((p) => p.id !== 'plane-starter')!;
expect(fresh.status).toBe('available');
expect(fresh.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
expect(fresh.locationIcao).toBe('EDDF'); // home base delivery
});
});
+31
View File
@@ -0,0 +1,31 @@
import { buyPlane, performAction } from '@/lib/game/actions';
// POST /api/market/buy-plane (v0.8.0) — body { model: string }. Buys a catalog
// aircraft at full price; it arrives 'available' with a full tank at the home base
// (EDDF). 400 missing/non-string model or unknown catalog model; 409 insufficient
// funds. On success returns the full state snapshot (new plane + reduced cash).
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'model') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.model !== 'string' || input.model.length === 0) {
return Response.json({ error: 'model must be a non-empty string' }, { status: 400 });
}
const model = input.model;
return performAction((inst) => buyPlane(inst, model));
}
+76
View File
@@ -0,0 +1,76 @@
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 { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
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-sell-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) }));
describe('POST /api/market/sell-plane', () => {
it('rejects a missing planeId and unknown fields with 400', async () => {
for (const body of [{}, { planeId: 7 }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('rejects an unknown id with 404', async () => {
const res = await post({ planeId: 'ghost' });
expect(res.status).toBe(404);
});
it('rejects selling the last remaining plane with 409 (seeded DB has only the starter)', async () => {
const res = await post({ planeId: 'plane-starter' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/last remaining/);
});
it('rejects selling a flying plane with 409', async () => {
const { db } = await getGameDb();
// A second, grounded plane so "flying" is the reason for rejection.
await db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null,
});
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
const res = await post({ planeId: 'plane-extra' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/flying/);
});
it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => {
const { db } = await getGameDb();
// The previous test left 'plane-extra' in the DB (now flying) — ground it.
await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra'));
const cashBefore = (await db.select().from(schema.account))[0].cash;
const res = await post({ planeId: 'plane-extra' });
expect(res.status).toBe(200);
const body = (await res.json()) as { account: { cash: number }; planes: Array<{ id: string }> };
const payout = Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore + payout, 6);
expect(body.planes.map((p) => p.id)).not.toContain('plane-extra'); // row deleted
expect(body.planes.map((p) => p.id)).toContain('plane-starter');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { performAction, sellPlane } from '@/lib/game/actions';
// POST /api/market/sell-plane (v0.8.0) — body { planeId: string }. Sells a grounded
// plane for 50% of its catalog price and deletes the row. 400 missing/non-string
// planeId or unknown fields; 404 unknown id; 409 if the plane is flying or it is
// the last remaining plane. On success returns the full state snapshot.
export async function POST(req: Request) {
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'planeId') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.planeId !== 'string' || input.planeId.length === 0) {
return Response.json({ error: 'planeId must be a non-empty string' }, { status: 400 });
}
const planeId = input.planeId;
return performAction((inst) => sellPlane(inst, planeId));
}
@@ -0,0 +1,97 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { POST as acceptPost } from '../../../contracts/[id]/accept/route';
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-dispatch-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'dispatch.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}/dispatch`, { method: 'POST', body: JSON.stringify(body) }), {
params: Promise.resolve({ planeId }),
});
describe('POST /api/planes/:planeId/dispatch', () => {
it('rejects a missing/non-string contractId and unknown fields with 400', async () => {
for (const body of [{}, { contractId: 42 }, { bogus: 1 }]) {
const res = await post('plane-starter', body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
});
it('rejects dispatching an OPEN contract (no accept) with 409 — the e2e "dispatch without accept" path', async () => {
const { db } = await getGameDb();
// A known open contract at the starter plane's home base.
await db.insert(schema.contracts).values({
id: 'c-open-at-home', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'open', planeId: null, createdSimTime: Date.now(),
});
const res = await post('plane-starter', { contractId: 'c-open-at-home' });
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/open/);
});
it('rejects unknown plane and contract ids with 404', async () => {
const { db } = await getGameDb();
// Contract checks run before plane checks, so the ghost-plane case needs an
// ACCEPTED contract to reach the plane lookup.
await db.insert(schema.contracts).values({
id: 'c-acc-ghost', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: Date.now() + 3_600_000, status: 'accepted', planeId: null, createdSimTime: Date.now(),
});
const ghostPlane = await post('ghost-plane', { contractId: 'c-acc-ghost' });
expect(ghostPlane.status).toBe(404);
const ghostContract = await post('plane-starter', { contractId: 'nope' });
expect(ghostContract.status).toBe(404);
});
it('accepts then dispatches: snapshot shows the plane flying with departure set and the contract bound', async () => {
const { db } = await getGameDb();
// Accept the home-base contract through its own route, then dispatch.
const acceptRes = await acceptPost(
new Request('http://localhost/api/contracts/c-open-at-home/accept'),
{ params: Promise.resolve({ id: 'c-open-at-home' }) },
);
expect(acceptRes.status).toBe(200);
const res = await post('plane-starter', { contractId: 'c-open-at-home' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
planes: Array<{ id: string; status: string; departureSimTime: number | null; locationIcao: string }>;
contracts: Array<{ id: string; status: string; planeId: string | null }>;
};
const plane = body.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.status).toBe('flying');
expect(typeof plane.departureSimTime).toBe('number'); // departure set at dispatch time
expect(plane.locationIcao).toBe('EDDF');
const contract = body.contracts.find((c) => c.id === 'c-open-at-home')!;
expect(contract.status).toBe('accepted');
expect(contract.planeId).toBe('plane-starter');
// A second dispatch of the same (now bound, still accepted) contract by the
// flying plane is rejected — the plane is no longer available.
const again = await post('plane-starter', { contractId: 'c-open-at-home' });
expect(again.status).toBe(409);
});
});
@@ -0,0 +1,35 @@
import { dispatchPlane, performAction } from '@/lib/game/actions';
// POST /api/planes/:planeId/dispatch (v0.8.0) — body { contractId: string }.
// Binds an ACCEPTED contract to a grounded plane at its origin; the v0.7.0
// finalize path then flies it and pays out on arrival. 400 missing/non-string
// contractId or unknown fields; 404 unknown plane/contract id; 409 wrong contract
// status, plane not available, plane not at origin, insufficient fuel. On success
// returns the full state snapshot (plane already 'flying' with departure set).
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
let body: unknown;
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'contractId') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (typeof input.contractId !== 'string' || input.contractId.length === 0) {
return Response.json({ error: 'contractId must be a non-empty string' }, { status: 400 });
}
const contractId = input.contractId;
return performAction((inst) => dispatchPlane(inst, planeId, contractId));
}
@@ -0,0 +1,77 @@
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 { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
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) => POST(new Request(`http://localhost/api/planes/${planeId}/refuel`), { 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(), contractId: 'c-x' })
.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, contractId: 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);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { performAction, refuelPlane } from '@/lib/game/actions';
// POST /api/planes/:planeId/refuel (v0.8.0) — no body. Fills a grounded plane to
// maxFuel at the fuel price per missing liter; deducts the cost from cash. 404
// unknown id; 409 if the plane is flying or cash is insufficient. On success
// returns the full state snapshot (fuel back to max, cash reduced by the cost).
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
return performAction((inst) => refuelPlane(inst, planeId));
}
+89
View File
@@ -0,0 +1,89 @@
import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generateContract, mulberry32 } from '@/lib/game/seed';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.8.0) — lets the e2e suite create an open contract with a
// specified origin and/or destination (and optional deadline offset), so the full
// accept → dispatch → fly loop can be exercised on a known, short route without
// waiting for random spawns. Disabled unless FLIGHT_TEST_HOOKS=1 (set in
// docker-compose.yml for the e2e instance only); every other deployment 404s here.
// Sibling of /api/test/expire-contract — same gate, same conventions.
//
// POST /api/test/create-contract
// body { originIcao?: string, destIcao?: string, deadlineOffsetMs?: number }
// - ICACOs must exist in the airport dataset and differ (400 otherwise);
// omitted endpoints are picked at random by generateContract.
// - deadlineOffsetMs defaults to a normal 6–72h game-time window (NOT the short
// hook deadline of expire-contract) — pass an explicit value to override.
// Returns 201 + the created contract row.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
for (const key of ['originIcao', 'destIcao'] as const) {
if (input[key] !== undefined && typeof input[key] !== 'string') {
return Response.json({ error: `${key} must be a string` }, { status: 400 });
}
}
if (input.deadlineOffsetMs !== undefined && (typeof input.deadlineOffsetMs !== 'number' || !Number.isFinite(input.deadlineOffsetMs))) {
return Response.json({ error: 'deadlineOffsetMs must be a finite number' }, { status: 400 });
}
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before "now" for the new contract
const fc = await loadAirportsGeoJson();
const airports = fc.features.map((f) => ({
icao: f.properties.icao,
name: f.properties.name,
lat: f.properties.lat,
lon: f.properties.lon,
}));
const knownIcas = new Set(airports.map((a) => a.icao));
for (const key of ['originIcao', 'destIcao'] as const) {
const v = input[key];
if (typeof v === 'string' && !knownIcas.has(v)) {
return Response.json({ error: `unknown airport ${v}` }, { status: 400 });
}
}
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generateContract({
id: randomUUID(),
airports,
rng,
nowMs: sim.clockMs,
deadlineOffsetMs: input.deadlineOffsetMs as number | undefined,
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
});
await inst.db.insert(schema.contracts).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}