diff --git a/docs/reference/api.md b/docs/reference/api.md index 126bdcc97..d90f41129 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -253,7 +253,7 @@ Router: `main/routes/tables.ts`. Full path: `/api/tables`. | `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `number`, `name`, `capacity`, `floor`, `section`, `position_x`, `position_y`, `kitchen_station_id` | - | | `POST` | `/:id/deactivate` | `ROLE_ACCESS.ownerManager` | path: `id` | Soft-deactivates the table rather than deleting it. | | `POST` | `/:id/reactivate` | `ROLE_ACCESS.ownerManager` | path: `id` | - | -| `POST` | `/:id/move-order` | `ROLE_ACCESS.sales` | path: `id`; body: `target_table_id`, `order_id` | - | +| `POST` | `/:id/move-order` | `ROLE_ACCESS.sales` | path: `id`; body: `target_table_id`, `order_id` | `{ order, sourceTable, targetTable }`. `400` when `target_table_id` is missing, matches `id`, or names an inactive table; `404` when either table or the active order is not found; `409` when the destination table has an active or held order or is `reserved`. | | `PATCH` | `/:id/status` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `status`, `reservation_customer_id` | Body `status`, plus `reservation_customer_id` for reservation state. | ### Kitchen stations diff --git a/frontend/e2e/table-move-live.spec.ts b/frontend/e2e/table-move-live.spec.ts index 503adb388..80e54ab0f 100644 --- a/frontend/e2e/table-move-live.spec.ts +++ b/frontend/e2e/table-move-live.spec.ts @@ -407,9 +407,94 @@ test.describe('Table Move UI Live Validation', () => { // Take screenshot 8: Guard against occupied / inactive tables await captureScreenshot(page, '08-occupied-table-disabled.png', testInfo); - // Close modal - await guardModal.getByRole('button', { name: 'Cancel' }).first().click(); + // Destination changed to reserved after initial modal read must be rejected with 409 + const reservedCustomer = { id: 'e2e-customer', name: 'E2E Guest' }; + + const reserveT1Res = await request.patch(`${BASE}/api/tables/${table1.id}/status`, { + headers: authHeaders, + data: { status: 'reserved', reservation_customer_id: reservedCustomer.id }, + }); + expect(reserveT1Res.status()).toBe(200); + + const directReservedMoveRes = await request.post( + `${BASE}/api/tables/${table2.id}/move-order`, + { + headers: authHeaders, + data: { target_table_id: table1.id }, + }, + ); + expect(directReservedMoveRes.status()).toBe(409); + + const candidateT1InGuard = guardModal.getByRole('button', { name: new RegExp(t1Num) }); + await expect(candidateT1InGuard).toBeVisible(); + await candidateT1InGuard.click(); + + const confirmReservedMoveBtn = guardModal.getByRole('button', { + name: new RegExp(`Move to ${t1Num}`), + }); + const [reservedMoveResponse] = await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes(`/api/tables/${table2.id}/move-order`) && + res.request().method() === 'POST', + ), + confirmReservedMoveBtn.click(), + ]); + expect(reservedMoveResponse.status()).toBe(409); + await expect(page.locator('text=Target table is reserved')).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.locator('text=Destination table already has an active order'), + ).not.toBeVisible(); + await expect(guardModal).toBeVisible(); + + const checkTablesReserved = await ( + await request.get(`${BASE}/api/tables`, { headers: authHeaders }) + ).json(); + const checkedReservedT1 = checkTablesReserved.tables.find( + (t: { + id: string; + status: string; + reservation_customer_id?: string | null; + reservation_customer_name?: string | null; + }) => t.id === table1.id, + ); + const checkedOccupiedT2 = checkTablesReserved.tables.find( + (t: { id: string; status: string; activeOrder?: { id: string } }) => t.id === table2.id, + ); + expect(checkedReservedT1.status).toBe('reserved'); + expect(checkedReservedT1.reservation_customer_id).toBe(reservedCustomer.id); + expect(checkedReservedT1.reservation_customer_name).toBe(reservedCustomer.name); + expect(checkedOccupiedT2.status).toBe('occupied'); + expect(checkedOccupiedT2.activeOrder.id).toBe(order1.id); + + // Releasing reservation back to available allows the move to succeed + const releaseT1Res = await request.patch(`${BASE}/api/tables/${table1.id}/status`, { + headers: authHeaders, + data: { status: 'available' }, + }); + expect(releaseT1Res.status()).toBe(200); + + await confirmReservedMoveBtn.click(); + await expect(page.locator(`text=Order moved from ${t2Num} to ${t1Num}`)).toBeVisible({ + timeout: 10_000, + }); await expect(guardModal).not.toBeVisible(); + + const checkTablesAfterRelease = await ( + await request.get(`${BASE}/api/tables`, { headers: authHeaders }) + ).json(); + expect( + checkTablesAfterRelease.tables.find( + (t: { id: string; status: string }) => t.id === table1.id, + ).status, + ).toBe('occupied'); + expect( + checkTablesAfterRelease.tables.find( + (t: { id: string; status: string }) => t.id === table2.id, + ).status, + ).toBe('available'); } finally { // Cleanup created orders and tables if (order1.id) { @@ -424,6 +509,12 @@ test.describe('Table Move UI Live Validation', () => { } for (const tbl of [table1, table2, table3, table4]) { if (tbl.id) { + await request + .patch(`${BASE}/api/tables/${tbl.id}/status`, { + headers: authHeaders, + data: { status: 'available' }, + }) + .catch(() => {}); await request .post(`${BASE}/api/tables/${tbl.id}/deactivate`, { headers: authHeaders }) .catch(() => {}); diff --git a/frontend/src/components/tables/MoveTableModal.tsx b/frontend/src/components/tables/MoveTableModal.tsx index 61b59011c..4dced3471 100644 --- a/frontend/src/components/tables/MoveTableModal.tsx +++ b/frontend/src/components/tables/MoveTableModal.tsx @@ -97,12 +97,16 @@ export default function MoveTableModal({ onSuccess(data); onClose(); } catch (error: unknown) { - const status = (error as { response?: { status?: number } })?.response?.status; + const response = (error as { response?: { status?: number; data?: { error?: string } } }) + ?.response; + const status = response?.status; + const serverError = response?.data?.error; const errorMsg = status === 409 - ? t('targetTableOccupied') - : (error as { response?: { data?: { error?: string } } })?.response?.data?.error || - t('tableMoveFailed'); + ? serverError && /reserved|held/i.test(serverError) + ? serverError + : t('targetTableOccupied') + : serverError || t('tableMoveFailed'); toast.error(errorMsg); } finally { setMoving(false); diff --git a/main/routes/tables.ts b/main/routes/tables.ts index 213eb6ac1..6eae5eccd 100644 --- a/main/routes/tables.ts +++ b/main/routes/tables.ts @@ -577,11 +577,27 @@ router.post( const targetHeldOrder = db .prepare('SELECT id FROM held_orders WHERE table_id = ?') .get(target_table_id); - if (targetHeldOrder || targetTable.status === 'held') { + const currentTargetTable = db + .prepare('SELECT * FROM tables WHERE id = ?') + .get(target_table_id) as { is_active?: number | boolean; status?: string } | undefined; + if (!currentTargetTable) { + throw Object.assign(new Error('Target table not found'), { status: 404 }); + } + if (!currentTargetTable.is_active) { + throw Object.assign(new Error('Target table is not active'), { status: 400 }); + } + if ( + targetHeldOrder || + targetTable.status === 'held' || + currentTargetTable.status === 'held' + ) { const error: any = new Error('Target table has a held order'); error.status = 409; throw error; } + if (targetTable.status === 'reserved' || currentTargetTable.status === 'reserved') { + throw Object.assign(new Error('Target table is reserved'), { status: 409 }); + } const nowStr = now(); db.prepare('UPDATE orders SET table_id = ?, type = ?, updated_at = ? WHERE id = ?').run( diff --git a/tests/e2e-server.cjs b/tests/e2e-server.cjs index 8c6209cb7..1f3e4d43f 100644 --- a/tests/e2e-server.cjs +++ b/tests/e2e-server.cjs @@ -44,16 +44,36 @@ Module._load = function (request, parent, isMain) { const crypto = require('crypto'); const bcrypt = require('bcryptjs'); -const { initDatabase, getDatabase, closeDatabase, beginDatabaseShutdown, waitForDatabaseRequests, now } = require('../dist/main/db'); -const { createExitCodeAwareShutdown, waitForHttpShutdownWork, isShutdownTimeout } = require('../dist/main/shutdown'); +const { + initDatabase, + getDatabase, + closeDatabase, + beginDatabaseShutdown, + waitForDatabaseRequests, + now, +} = require('../dist/main/db'); +const { + createExitCodeAwareShutdown, + waitForHttpShutdownWork, + isShutdownTimeout, +} = require('../dist/main/shutdown'); const { startServer, stopServer, getServerPort } = require('../dist/main/server'); const { startServerApp, stopServerApp, getServerAppPort } = require('../dist/main/server-app'); -const { shutdown: shutdownWhatsApp, requestShutdown: requestWhatsAppShutdown } = require('../dist/main/services/whatsapp'); +const { + shutdown: shutdownWhatsApp, + requestShutdown: requestWhatsAppShutdown, +} = require('../dist/main/services/whatsapp'); const { startStandaloneServers } = require('../dist/main/standalone-startup'); const flatRatePackData = require('./fixtures/synthetic-flat-rate-pack.json'); // Country/currency stay TH/THB (this fixture's configured business country) // so getActiveCountryPack('TH') actually resolves this pack. -const testTaxPack = { ...flatRatePackData, id: 'test-th-pack', country: 'TH', currency: 'THB', publisher: 'FreeOpenSourcePOS' }; +const testTaxPack = { + ...flatRatePackData, + id: 'test-th-pack', + country: 'TH', + currency: 'THB', + publisher: 'FreeOpenSourcePOS', +}; // BUNDLED_COUNTRY_PACKS (main/tax-packs/bundled.ts) only ships the generic // pack — real country packs (e.g. Thailand, India) are separately-published @@ -82,7 +102,15 @@ function installAndActivateTaxPack(db, pack) { active_version_id = excluded.active_version_id, status = 'active', updated_at = excluded.updated_at - `).run(pack.id, pack.publisher, pack.country, pack.jurisdiction, versionId, installedAt, installedAt); + `).run( + pack.id, + pack.publisher, + pack.country, + pack.jurisdiction, + versionId, + installedAt, + installedAt, + ); db.prepare(` INSERT OR REPLACE INTO country_pack_versions ( @@ -90,13 +118,25 @@ function installAndActivateTaxPack(db, pack) { effective_from, effective_to, min_flo_version, published_at, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 'active', ?) `).run( - versionId, pack.id, pack.version, pack.schemaVersion, + versionId, + pack.id, + pack.version, + pack.schemaVersion, JSON.stringify({ - id: pack.id, publisher: pack.publisher, country: pack.country, - jurisdiction: pack.jurisdiction, version: pack.version, publishedAt: pack.publishedAt, + id: pack.id, + publisher: pack.publisher, + country: pack.country, + jurisdiction: pack.jurisdiction, + version: pack.version, + publishedAt: pack.publishedAt, }), - packJson, digest, pack.effectiveFrom, pack.effectiveTo || null, pack.minFloVersion, - pack.publishedAt, installedAt, + packJson, + digest, + pack.effectiveFrom, + pack.effectiveTo || null, + pack.minFloVersion, + pack.publishedAt, + installedAt, ); const insertCategory = db.prepare(` @@ -106,8 +146,13 @@ function installAndActivateTaxPack(db, pack) { `); for (const category of pack.categories) { insertCategory.run( - `${versionId}:category:${category.id}`, versionId, category.id, category.label, - category.defaultBehavior || null, JSON.stringify(category), installedAt, + `${versionId}:category:${category.id}`, + versionId, + category.id, + category.label, + category.defaultBehavior || null, + JSON.stringify(category), + installedAt, ); } @@ -119,9 +164,17 @@ function installAndActivateTaxPack(db, pack) { `); for (const rule of pack.rules) { insertRule.run( - `${versionId}:rule:${rule.id}`, versionId, rule.id, rule.label, rule.type, - rule.rate || null, rule.amount || null, rule.appliesPer || null, - JSON.stringify(rule.baseRuleIds || []), JSON.stringify(rule), installedAt, + `${versionId}:rule:${rule.id}`, + versionId, + rule.id, + rule.label, + rule.type, + rule.rate || null, + rule.amount || null, + rule.appliesPer || null, + JSON.stringify(rule.baseRuleIds || []), + JSON.stringify(rule), + installedAt, ); } } @@ -151,9 +204,11 @@ function assertRequiredPortAvailable(port, service) { } function seedUser(id, email, role) { - getDatabase().prepare( - 'INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)' - ).run(id, `E2E ${role}`, email, bcrypt.hashSync('E2ePass123!', 10), role, now(), now()); + getDatabase() + .prepare( + 'INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)', + ) + .run(id, `E2E ${role}`, email, bcrypt.hashSync('E2ePass123!', 10), role, now(), now()); } function seedPosFixture() { @@ -174,81 +229,120 @@ function seedPosFixture() { // turn taxes on itself rather than rely on a global default. ['taxes_enabled', 'true'], ]) { - db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)') - .run(key, value, createdAt); + db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)').run( + key, + value, + createdAt, + ); } installAndActivateTaxPack(db, testTaxPack); db.prepare( - 'INSERT INTO categories (id, name, sort_order, is_active, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)' + 'INSERT INTO categories (id, name, sort_order, is_active, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)', ).run('e2e-category', 'E2E Menu', 1, createdAt, createdAt); db.prepare( `INSERT INTO products ( id, category_id, name, price, tax_type, tax_category_id, tax_behavior, cb_percent, track_inventory, stock_quantity, is_active, sort_order, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, ).run( - 'e2e-product', 'e2e-category', 'E2E Coffee', 60, 'none', 'standard', 'exclusive', - 0, 0, 999, 1, createdAt, createdAt, + 'e2e-product', + 'e2e-category', + 'E2E Coffee', + 60, + 'none', + 'standard', + 'exclusive', + 0, + 0, + 999, + 1, + createdAt, + createdAt, ); + db.prepare( + `INSERT OR IGNORE INTO customers ( + id, name, phone, country_code, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, 1, ?, ?)`, + ).run('e2e-customer', 'E2E Guest', '+66800000001', '+66', createdAt, createdAt); } let exitRequested = false; let shutdownRequested = false; -const requestStop = createExitCodeAwareShutdown(async () => { - let cleanupFailed = false; - let databaseBlocked = false; - try { await stopServerApp(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] Server App cleanup failed:', error); - if (isShutdownTimeout(error)) throw error; - } - try { await stopServer(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] Main server cleanup failed:', error); - if (isShutdownTimeout(error)) throw error; - } - try { await stopKdsServer(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] KDS server cleanup failed:', error); - if (isShutdownTimeout(error)) throw error; - } - try { await shutdownWhatsApp(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] WhatsApp cleanup failed:', error); - if (isShutdownTimeout(error)) throw error; - } - try { await waitForHttpShutdownWork(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] HTTP handler cleanup failed:', error); - if (isShutdownTimeout(error)) throw error; - } - try { beginDatabaseShutdown(); await waitForDatabaseRequests(); } catch (error) { - cleanupFailed = true; - databaseBlocked = true; - console.error('[E2E] Database request drain failed:', error); - if (isShutdownTimeout(error)) throw error; - } - if (!databaseBlocked) { - try { closeDatabase(); } catch (error) { +const requestStop = createExitCodeAwareShutdown( + async () => { + let cleanupFailed = false; + let databaseBlocked = false; + try { + await stopServerApp(); + } catch (error) { cleanupFailed = true; - console.error('[E2E] Database cleanup failed:', error); + databaseBlocked = true; + console.error('[E2E] Server App cleanup failed:', error); + if (isShutdownTimeout(error)) throw error; } - } - Module._load = originalLoad; - try { fs.rmSync(testDir, { recursive: true, force: true }); } catch (error) { - cleanupFailed = true; - console.error('[E2E] Fixture cleanup failed:', error); - } - return cleanupFailed ? 1 : 0; -}, { - onShutdownRequested: requestWhatsAppShutdown, - onFatalTimeout: () => process.exit(1), -}); + try { + await stopServer(); + } catch (error) { + cleanupFailed = true; + databaseBlocked = true; + console.error('[E2E] Main server cleanup failed:', error); + if (isShutdownTimeout(error)) throw error; + } + try { + await stopKdsServer(); + } catch (error) { + cleanupFailed = true; + databaseBlocked = true; + console.error('[E2E] KDS server cleanup failed:', error); + if (isShutdownTimeout(error)) throw error; + } + try { + await shutdownWhatsApp(); + } catch (error) { + cleanupFailed = true; + databaseBlocked = true; + console.error('[E2E] WhatsApp cleanup failed:', error); + if (isShutdownTimeout(error)) throw error; + } + try { + await waitForHttpShutdownWork(); + } catch (error) { + cleanupFailed = true; + databaseBlocked = true; + console.error('[E2E] HTTP handler cleanup failed:', error); + if (isShutdownTimeout(error)) throw error; + } + try { + beginDatabaseShutdown(); + await waitForDatabaseRequests(); + } catch (error) { + cleanupFailed = true; + databaseBlocked = true; + console.error('[E2E] Database request drain failed:', error); + if (isShutdownTimeout(error)) throw error; + } + if (!databaseBlocked) { + try { + closeDatabase(); + } catch (error) { + cleanupFailed = true; + console.error('[E2E] Database cleanup failed:', error); + } + } + Module._load = originalLoad; + try { + fs.rmSync(testDir, { recursive: true, force: true }); + } catch (error) { + cleanupFailed = true; + console.error('[E2E] Fixture cleanup failed:', error); + } + return cleanupFailed ? 1 : 0; + }, + { + onShutdownRequested: requestWhatsAppShutdown, + onFatalTimeout: () => process.exit(1), + }, +); async function stop(exitCode = 0) { shutdownRequested = true; @@ -262,7 +356,10 @@ async function stop(exitCode = 0) { (async () => { await assertRequiredPortAvailable(expectedPort(process.env.E2E_BASE_URL, 3001), 'Main API'); await assertRequiredPortAvailable(expectedPort(process.env.E2E_KDS_BASE_URL, 3002), 'KDS'); - await assertRequiredPortAvailable(expectedPort(process.env.E2E_SERVER_APP_BASE_URL, 3003), 'Server App'); + await assertRequiredPortAvailable( + expectedPort(process.env.E2E_SERVER_APP_BASE_URL, 3003), + 'Server App', + ); await startStandaloneServers({ initializeDatabase: initDatabase, prepare: () => { @@ -283,7 +380,9 @@ async function stop(exitCode = 0) { }; const actualPorts = { main: getServerPort(), kds: getKdsPort(), serverApp: getServerAppPort() }; if (Object.entries(expectedPorts).some(([key, port]) => actualPorts[key] !== port)) { - throw new Error(`E2E service port mismatch: expected ${JSON.stringify(expectedPorts)}, got ${JSON.stringify(actualPorts)}`); + throw new Error( + `E2E service port mismatch: expected ${JSON.stringify(expectedPorts)}, got ${JSON.stringify(actualPorts)}`, + ); } console.log('[E2E] Main, KDS, and Server App servers ready'); })().catch((error) => { diff --git a/tests/tables-string-ids.test.ts b/tests/tables-string-ids.test.ts index abb23adb3..4b210390a 100644 --- a/tests/tables-string-ids.test.ts +++ b/tests/tables-string-ids.test.ts @@ -861,6 +861,214 @@ async function main() { 'source table retains active order after rejected move', ); + const reactivateTargetRes = await api(baseUrl, '/api/tables/tbl-move-target/reactivate', { + method: 'POST', + headers: authHeader, + }); + assertEqualOrThrow(reactivateTargetRes.status, 200, 'target table can be reactivated'); + + const reserveTargetRes = await api(baseUrl, '/api/tables/tbl-move-target/status', { + method: 'PATCH', + headers: authHeader, + body: { status: 'reserved', reservation_customer_id: 'cust-reservation-1' }, + }); + assertEqualOrThrow(reserveTargetRes.status, 200, 'target table can be reserved with customer'); + + const reservedMove = await api(baseUrl, `/api/tables/${tableId}/move-order`, { + method: 'POST', + headers: authHeader, + body: { target_table_id: 'tbl-move-target' }, + }); + assertEqualOrThrow( + reservedMove.status, + 409, + 'moving order onto a reserved table is rejected with 409', + ); + assertIncludesOrThrow( + reservedMove.data.error, + 'reserved', + 'reserved target table rejection includes clear error message', + ); + + const reservedOverrideMove = await api(baseUrl, `/api/tables/${tableId}/move-order`, { + method: 'POST', + headers: authHeader, + body: { target_table_id: 'tbl-move-target', override: true, force: true }, + }); + assertEqualOrThrow( + reservedOverrideMove.status, + 409, + 'reserved destination move does not allow an override option', + ); + + const orderAfterReservedMove = await api(baseUrl, `/api/orders/${orderId}`, { + headers: authHeader, + }); + assertEqualOrThrow( + orderAfterReservedMove.data.order.table_id, + tableId, + 'order remains on source table after reserved move rejection', + ); + const sourceAfterReservedMove = await api(baseUrl, `/api/tables/${tableId}`, { + headers: authHeader, + }); + assertEqualOrThrow( + sourceAfterReservedMove.data.table.status, + 'occupied', + 'source table remains occupied after reserved move rejection', + ); + assertEqualOrThrow( + sourceAfterReservedMove.data.table.activeOrder.id, + orderId, + 'source table retains active order after reserved move rejection', + ); + const targetAfterReservedMove = await api(baseUrl, '/api/tables/tbl-move-target', { + headers: authHeader, + }); + assertEqualOrThrow( + targetAfterReservedMove.data.table.status, + 'reserved', + 'target table remains reserved after rejected move', + ); + assertEqualOrThrow( + targetAfterReservedMove.data.table.activeOrder, + null, + 'reserved target table has no active order after rejected move', + ); + assertEqualOrThrow( + targetAfterReservedMove.data.table.reservation_customer_id, + 'cust-reservation-1', + 'reserved target table preserves reservation customer ID', + ); + assertEqualOrThrow( + targetAfterReservedMove.data.table.reservation_customer_name, + 'Reserved Guest', + 'reserved target table preserves reservation customer name', + ); + assertEqualOrThrow( + targetAfterReservedMove.data.table.reservation_customer_phone, + '+1 555 123 4567', + 'reserved target table preserves reservation customer phone', + ); + + // Destination changed to reserved after the initial read is checked again inside the move transaction + const clearReserveTargetRes = await api(baseUrl, '/api/tables/tbl-move-target/status', { + method: 'PATCH', + headers: authHeader, + body: { status: 'available' }, + }); + assertEqualOrThrow(clearReserveTargetRes.status, 200, 'target table returned to available'); + + const initialReadRes = await api(baseUrl, '/api/tables/tbl-move-target', { + headers: authHeader, + }); + assertEqualOrThrow( + initialReadRes.data.table.status, + 'available', + 'initial read sees target table as available', + ); + + const reserveAfterInitialReadRes = await api(baseUrl, '/api/tables/tbl-move-target/status', { + method: 'PATCH', + headers: authHeader, + body: { status: 'reserved', reservation_customer_id: 'cust-reservation-1' }, + }); + assertEqualOrThrow( + reserveAfterInitialReadRes.status, + 200, + 'destination changed to reserved after initial read', + ); + + const moveAfterInitialRead = await api(baseUrl, `/api/tables/${tableId}/move-order`, { + method: 'POST', + headers: authHeader, + body: { target_table_id: 'tbl-move-target' }, + }); + assertEqualOrThrow( + moveAfterInitialRead.status, + 409, + 'destination changed to reserved after initial read is rejected with 409', + ); + const targetAfterStaleReadMove = await api(baseUrl, '/api/tables/tbl-move-target', { + headers: authHeader, + }); + assertEqualOrThrow( + targetAfterStaleReadMove.data.table.status, + 'reserved', + 'target table remains reserved when changed after initial read', + ); + assertEqualOrThrow( + targetAfterStaleReadMove.data.table.reservation_customer_id, + 'cust-reservation-1', + 'target table preserves reservation customer when changed after initial read', + ); + + // Also verify in-transaction re-check if targetTable status changes after first SELECT inside withTxn + await api(baseUrl, '/api/tables/tbl-move-target/status', { + method: 'PATCH', + headers: authHeader, + body: { status: 'available' }, + }); + const origPrepare = db.prepare.bind(db); + let injectedMidMoveReservation = false; + db.prepare = ((sql: string) => { + if (!injectedMidMoveReservation && sql.includes('FROM held_orders WHERE table_id = ?')) { + injectedMidMoveReservation = true; + origPrepare( + "UPDATE tables SET status = 'reserved', reservation_customer_id = ?, updated_at = ? WHERE id = ?", + ).run('cust-reservation-1', now(), 'tbl-move-target'); + } + return origPrepare(sql); + }) as typeof db.prepare; + + let midReadReservedMove: { status: number; data: { error?: string } }; + try { + midReadReservedMove = await api(baseUrl, `/api/tables/${tableId}/move-order`, { + method: 'POST', + headers: authHeader, + body: { target_table_id: 'tbl-move-target' }, + }); + } finally { + db.prepare = origPrepare; + } + assertOrThrow(injectedMidMoveReservation, 'mid-move reservation state change was triggered'); + assertEqualOrThrow( + midReadReservedMove.status, + 409, + 'destination changed to reserved after initial in-transaction read is rejected with 409', + ); + const sourceAfterMidReadMove = await api(baseUrl, `/api/tables/${tableId}`, { + headers: authHeader, + }); + assertEqualOrThrow( + sourceAfterMidReadMove.data.table.status, + 'occupied', + 'source table remains occupied when in-transaction re-check rejects move', + ); + + // Valid available-table moves still succeed once reservation is released + await api(baseUrl, '/api/tables/tbl-move-target/status', { + method: 'PATCH', + headers: authHeader, + body: { status: 'available' }, + }); + const availableMoveAfterReserve = await api(baseUrl, `/api/tables/${tableId}/move-order`, { + method: 'POST', + headers: authHeader, + body: { target_table_id: 'tbl-move-target' }, + }); + assertEqualOrThrow( + availableMoveAfterReserve.status, + 200, + 'valid move to available table succeeds after reservation is released', + ); + const restoreMove = await api(baseUrl, '/api/tables/tbl-move-target/move-order', { + method: 'POST', + headers: authHeader, + body: { target_table_id: tableId }, + }); + assertEqualOrThrow(restoreMove.status, 200, 'order restored to tableId'); + // ═══════════════════════════════════════════════════════════════════ // Scenario E: Migration handles NULL IDs // ═══════════════════════════════════════════════════════════════════