From 394d51d82793fdac6d02ad2a9686cfeca9ef7a80 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 21 Sep 2026 01:28:41 +0000 Subject: [PATCH] =?UTF-8?q?Reject=20duplicate=20queue=20legs=20=E2=80=94?= =?UTF-8?q?=20same=20route=20can=20no=20longer=20be=20queued=20twice=20(IS?= =?UTF-8?q?S-065,=20v0.34.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ e2e/queue.spec.ts | 4 ++++ lib/game/actions.test.ts | 31 +++++++++++++++++++++++++++++++ lib/game/actions.ts | 9 +++++++++ lib/openapi.test.ts | 2 +- lib/openapi.ts | 5 +++++ package.json | 2 +- 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651253c..5cbb9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## v0.34.0 — 2026-09-21 01:26 +- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses) + ## v0.33.0 — 2026-09-21 00:45 - **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes - **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 3c5ccde..64df588 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -233,6 +233,10 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => { }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks + // ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409. + const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } }); + expect(dupQueue.status()).toBe(409); + // ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go // posts to /queue and the leg starts when it lands. await page.getByTestId('relocate-plane').selectOption('plane-starter'); diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 0401bf9..a98f4bb 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -261,6 +261,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => { const inst = await freshDb('qleg-unknownicao.db'); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); }); + + it('rejects a destination already in the queue with 409 (ISS-065)', async () => { + const inst = await freshDb('qleg-dup.db'); + await queueLeg(inst, 'plane-starter', 'LEMD'); + + await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended + }); + + it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => { + const inst = await freshDb('qleg-dupflying.db'); + await inst.db.update(schema.planes) + .set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' }) + .where(eq(schema.planes.id, 'plane-starter')); + + await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended + }); + + it('still appends a different known airport with success (ISS-065 sanity)', async () => { + const inst = await freshDb('qleg-dupother.db'); + await queueLeg(inst, 'plane-starter', 'LEMD'); + await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined(); + + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); + }); }); describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { diff --git a/lib/game/actions.ts b/lib/game/actions.ts index fa5a614..a7932e7 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -153,7 +153,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): throw new GameActionError(400, `unknown airport ${destIcao}`); } + // ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a + // leg already in the queue would only produce pointless repeated hops on landing. + if (plane.status === 'flying' && plane.destIcao === destIcao) { + throw new GameActionError(409, `already flying to ${destIcao}`); + } + const queue = parseIcaoQueue(plane.queue); + if (queue.includes(destIcao)) { + throw new GameActionError(409, `${destIcao} is already in the flight queue`); + } queue.push(destIcao); await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId)); await persist(inst); diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index cb9a7e7..fbc0b36 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -202,7 +202,7 @@ describe('OpenAPI spec', () => { expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); - expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying + expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0 expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); diff --git a/lib/openapi.ts b/lib/openapi.ts index 300a31b..fdc8361 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -260,6 +260,11 @@ export const openApiSpec = { description: 'Unknown plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, + 409: { + description: + 'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, }, }, }, diff --git a/package.json b/package.json index d92d78e..9aa47ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.33.0", + "version": "0.34.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0