v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s

This commit is contained in:
2026-09-26 16:27:00 +00:00
parent ba2c36a3f8
commit ec26f9f366
25 changed files with 436 additions and 148 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const model = input.model;
return performAction(req, (inst) => buyPlane(inst, model));
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
}
+1 -1
View File
@@ -60,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
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,
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
});
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const planeId = input.planeId;
return performAction(req, (inst) => sellPlane(inst, planeId));
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
}
+1 -1
View File
@@ -9,5 +9,5 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction(req, (inst) => abandonPackage(inst, id));
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
}
const planeId = input.planeId;
return performAction(req, (inst) => loadPackage(inst, id, planeId));
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction(req, (inst) => flyPlane(inst, planeId, destIcao));
return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
}
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const index = input.index as number;
return performAction(req, (inst) => cancelQueuedLeg(inst, planeId, index));
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction(req, (inst) => queueLeg(inst, planeId, destIcao));
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
}
+1 -1
View File
@@ -8,5 +8,5 @@ import { performAction, repairPlane } from '@/lib/game/actions';
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
return performAction(req, (inst) => repairPlane(inst, planeId));
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
}
+5
View File
@@ -56,6 +56,11 @@ describe('POST /api/register', () => {
expect(Number(playerRow[2])).toBe(STARTING_CASH);
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
+12 -1
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// v1.1.0 (ISS-107) — POST /api/register
// body { username: string, password: string } (both required; unknown fields 400).
@@ -64,6 +65,16 @@ export async function POST(req: Request) {
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
await persist(inst);
const sessionId = await createSession(inst, id);
+27 -7
View File
@@ -36,32 +36,52 @@ describe('POST /api/test/mint-player-session', () => {
expect(res.status).toBe(404);
});
it('creates the default test account + player row and mints a session when enabled', async () => {
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post(); // no body → DEFAULT_USERNAME
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
// The session resolves to the EXISTING admin user — no new users/players rows are created.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe('admin-kamilkrzywda');
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
const inst = await getGameDb();
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
expect(userRow.length).toBe(1);
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('is idempotent — re-minting an existing username mints another session, no duplicate rows', async () => {
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const count = async () => Number((await getGameDb()).raw.exec("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")[0].values[0][0]);
expect(await count()).toBe(1);
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
const res = await post();
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
expect(await count()).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
});
it('rejects bad usernames and unknown fields with 400', async () => {
+15 -3
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
@@ -11,10 +12,13 @@ import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// other deployment 404s here, like its /api/test/* siblings.
//
// POST /api/test/mint-player-session
// body { username?: string } — defaults to 'e2e-tester'; must match the register rules when given.
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
// with the seeded world's planes by id, so the minted session must resolve to the local player
// that owns them after the owner_id backfill); a custom username creates a fresh test account
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
// Returns { sessionId }.
const DEFAULT_USERNAME = 'e2e-tester';
const DEFAULT_USERNAME = 'kamilkrzywda';
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
// server-side); a fixed value keeps the row debuggable from the admin panel.
const TEST_PASSWORD = 'e2e-mint-password-42';
@@ -70,6 +74,14 @@ export async function POST(req: Request) {
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
userId = id;
}
const sessionId = await createSession(inst, userId); // persists the rows + session together