From dbdd7bb066f9c26f5c512215c4c3f5496208e06f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 17:21:58 +0800 Subject: [PATCH] test(fleet): isolate pick commands per customer dispatch --- scripts/fleet-capacity-pool-test-env.mjs | 26 +++++++-- scripts/fleet-capacity-pool-test-env.test.mjs | 54 ++++++++++++++++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index a0a3f130f..200917cbf 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -58,6 +58,22 @@ export function assertSingleCommercial(before, after) { && before.customerId === after.customerId, 'COMMERCIAL_REPLAY_IDENTITY_CHANGED') } +export function poolPickScope(context, member) { + requireThat(context?.marker && /^[1-9]\d*$/.test(id(member?.customerId)) && member?.dispatchNo, + 'EXACT_CUSTOMER_DISPATCH_PICK_SCOPE_REQUIRED') + const scoped = Object.create(context) + // pickAll keys only include its context marker and item/position indexes; the PDA key scope is customer-wide. + scoped.marker = `POOL-PICK-${createHash('sha256').update(JSON.stringify([context.marker, id(member.customerId), member.dispatchNo])).digest('hex').slice(0, 32)}` + return scoped +} + +export async function pickPoolDispatch(context, token, member, pick = pickAll) { + const scoped = poolPickScope(context, member) + const passed = await pick(scoped, token, member.dispatchNo) + requireThat(passed === true, `PDA_PICK_FAILED:${member.dispatchNo}`) + return scoped.marker +} + export function shrinkingVehicleRequest(vehicle) { requireThat(vehicle?.id && Number.isInteger(Number(vehicle.recordVersion)) && Number(vehicle.maxPalletSpaces) > 1 && Number(vehicle.maxLoadKg) > 0 && Number(vehicle.maxVolumeCbm) > 0, 'COMPLETE_OWNED_VEHICLE_SNAPSHOT_REQUIRED') @@ -167,16 +183,16 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo) const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}` const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex') - const check = (label, ok) => { context.assert(MODULE, label, null, Boolean(ok), '', { scenario: MODULE, deploymentBlocker: true }); requireThat(ok, label) } + const check = (label, ok, response = null) => { context.assert(MODULE, label, response, Boolean(ok), '', { scenario: MODULE, deploymentBlocker: true }); requireThat(ok, label) } const raw = (method, path, body, token = context.token) => context.request(method, path, body, token) const request = async (method, path, body, token = context.token) => { const response = await raw(method, path, body, token) - check(`${method} ${path}`, success(response) && response.requestId && response.traceId) + check(`${method} ${path}`, success(response) && response.requestId && response.traceId, response) return response.body.data } const reject = async (label, method, path, body, token) => { const response = await raw(method, path, body, token) - check(label, denied(response) && response.requestId && response.traceId) + check(label, denied(response) && response.requestId && response.traceId, response) return response } const permissions = await request('GET', `${IAM}/permissions`) @@ -303,8 +319,10 @@ async function poolScenario(context, fixture, { execute = false, readInventory, detail = await request('GET', tripPath) for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }) const members = [...fixture.members, ...poolMembers] + proof.pickScopes = [] for (const member of members) { - await pickAll(context, fixture.pdaToken, member.dispatchNo) + const namespace = await pickPoolDispatch(context, fixture.pdaToken, member) + proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace }) const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken) check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages))) } diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 5832fefd1..864e27010 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, - shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, POOL_COVERAGE, POOL_REMAINING } from './fleet-capacity-pool-test-env.mjs' + shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING } from './fleet-capacity-pool-test-env.mjs' test('default and explicit plans make no network requests or invoke the mutating runner', async () => { const original = globalThis.fetch @@ -106,3 +106,55 @@ test('LocalDateTime transport time keeps actual local wall clock while Instant d assert.equal(new LosAngelesDate(instant).toISOString(), instant) assert.throws(() => poolLocalDateTime(new Date('invalid')), /VALID_LOCAL_DATE_REQUIRED/) }) + +test('PDA namespace belongs to the exact run, customer and dispatch while parent evidence stays shared', () => { + const context = { marker: 'ORIGINAL-RUN', steps: [], createdResources: {} }, first = { customerId: '9', dispatchNo: 'DS-A' } + const a = poolPickScope(context, first), replay = poolPickScope(context, first) + assert.equal(a.marker, replay.marker) + assert.notEqual(a.marker, poolPickScope(context, { ...first, dispatchNo: 'DS-B' }).marker) + assert.notEqual(a.marker, poolPickScope(context, { ...first, customerId: '10' }).marker) + assert.notEqual(a.marker, poolPickScope({ ...context, marker: 'OTHER-RUN' }, first).marker) + assert.equal(a.steps, context.steps) + assert.equal(a.createdResources, context.createdResources) + assert.equal(context.marker, 'ORIGINAL-RUN') + assert.throws(() => poolPickScope(context, { dispatchNo: 'DS-A' }), /PICK_SCOPE_REQUIRED/) +}) + +test('real pickAll helper handles two same-customer dispatches with separate pick/complete keys and exact replays', async () => { + const operations = new Map(), requests = [], steps = [], picked = new Map() + const dispatches = ['DS-A', 'DS-B'] + const context = { + marker: 'SAME-CUSTOMER-RUN', steps, createdResources: {}, + addStep(_module, _name, _response, passed) { steps.push(passed) }, + assert(_module, _name, _response, passed) { assert.equal(Boolean(passed), true) }, + addCoverageGap() { assert.fail('unexpected coverage gap') }, + async request(method, path, body) { + const dispatchNo = path.split('/dispatches/')[1].split('/')[0], index = dispatches.indexOf(dispatchNo) + assert.ok(index >= 0) + const response = data => ({ http: 200, body: { code: '200', data }, requestId: 'test', traceId: 'test' }) + if (method === 'GET') return response({ items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1, + pickedPackages: picked.get(dispatchNo) || 0, positions: [{ id: `${index + 100}`, availablePackages: 1 }] }] }) + requests.push({ method, path, body: structuredClone(body) }) + const prior = operations.get(body.idempotencyKey), payload = JSON.stringify([path, body]) + if (prior) { + if (prior.payload !== payload) return { http: 200, body: { code: 'A0409', message: 'Idempotency key was reused with a different request' } } + return response({ ...prior.result, replayed: true }) + } + let result + if (path.endsWith('/pick')) { picked.set(dispatchNo, 1); result = { pickedPackages: 1 } } + else result = { status: 'READY_TO_DEPART', plannedPackages: 1, pickedPackages: 1, remainingPackages: 0, replayed: false } + operations.set(body.idempotencyKey, { payload, result }) + return response(result) + }, + } + for (const dispatchNo of dispatches) await pickPoolDispatch(context, 'test-pda', { customerId: '9', dispatchNo }) + assert.equal(operations.size, 4) + assert.equal(requests.length, 8) + assert.ok(steps.every(Boolean)) + for (let index = 0; index < requests.length; index += 2) assert.deepEqual(requests[index], requests[index + 1]) +}) + +test('failed helper result stops the pool stage immediately instead of appending a misleading generic failure', async () => { + const context = { marker: 'R', steps: [] } + await assert.rejects(pickPoolDispatch(context, 'test', { customerId: '9', dispatchNo: 'DS-A' }, async () => false), /PDA_PICK_FAILED:DS-A/) +})