Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -3,9 +3,74 @@ import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase5-terminal-drayage.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
|
||||
const marker = 'offline-terminal'
|
||||
const containerBookingId = '9223372036854775806'
|
||||
const containerNo = 'TEST0000001'
|
||||
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN']
|
||||
const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return']
|
||||
let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] }
|
||||
const history = []
|
||||
const commands = new Map()
|
||||
let conflicts = 0
|
||||
let replays = 0
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' })
|
||||
const context = {
|
||||
marker, token: 'offline-only',
|
||||
createdResources: {
|
||||
phase4InboundProducerFixtures: { marker, containers: [{ containerBookingId, containerNo,
|
||||
shippingReady: true, carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }] },
|
||||
phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] },
|
||||
},
|
||||
apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data },
|
||||
assert(_module, name, _response, passed) { assert.ok(passed, name) },
|
||||
addStep(_module, name, _response, passed) { assert.ok(passed, name) },
|
||||
addCoverageGap() { assert.fail('unexpected missing fixture') },
|
||||
async request(method, path, body, _token, headers) {
|
||||
if (path.includes('/suppliers?')) return response({ records: [{ id: '9223372036854775804', name: 'Offline fleet', status: 'ACTIVE' }] })
|
||||
if (method === 'GET') {
|
||||
if (path.endsWith('/history')) return response(history)
|
||||
if (path.includes('?containerBookingIds=')) return response([state])
|
||||
return response(state)
|
||||
}
|
||||
const actionPath = path.split('/').at(-1)
|
||||
const index = paths.indexOf(actionPath)
|
||||
assert.ok(index >= 0)
|
||||
const field = index < 2 ? 'reason' : 'remark'
|
||||
const forbidden = index < 2 ? 'remark' : 'reason'
|
||||
assert.equal(Object.hasOwn(body, forbidden), false, `${actionPath} must not send ${forbidden}`)
|
||||
const key = headers['Idempotency-Key']
|
||||
const saved = commands.get(key)
|
||||
if (saved) {
|
||||
if (JSON.stringify(saved.body) === JSON.stringify(body)) { replays++; return response(saved.state) }
|
||||
assert.deepEqual(body, { ...saved.body, [field]: `${marker}-DIFFERENT` })
|
||||
conflicts++
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
assert.equal(body[field], marker)
|
||||
assert.equal(body.expectedVersion, state.version)
|
||||
state = { ...state, version: state.version + 1 }
|
||||
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
|
||||
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
|
||||
if (index === 2) Object.assign(state, { status: 'UNLOADED', actualPickupAt: body.actualAt, unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
|
||||
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
|
||||
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
|
||||
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
|
||||
return response(state)
|
||||
},
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(commands.size, 4)
|
||||
assert.equal(replays, 4)
|
||||
assert.equal(conflicts, 4)
|
||||
assert.equal(state.version, 4)
|
||||
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
|
||||
})
|
||||
|
||||
test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
|
||||
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
|
||||
|
||||
@@ -46,12 +46,15 @@ async function command(context, target, action, path, body) {
|
||||
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
|
||||
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
|
||||
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
const changed = path === 'fleet' ? { ...body, reason: `${body.reason}-DIFFERENT` }
|
||||
const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` }
|
||||
: { ...body, reason: `${body.reason}-DIFFERENT` }
|
||||
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
|
||||
changed, context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict,
|
||||
conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const afterConflict = await api(context, 'GET', `${action}冲突后回读聚合`, `${BASE}/${target.containerBookingId}`)
|
||||
context.assert(MODULE, `${action}冲突后聚合零漂移`, afterConflict.response,
|
||||
JSON.stringify(afterConflict.data) === JSON.stringify(first.data), trace(afterConflict.response), OPTIONS)
|
||||
return first
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ export async function run(context) {
|
||||
|
||||
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
|
||||
const picked = await command(context, target, '确认实际提柜', 'pickup', {
|
||||
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, reason: context.marker,
|
||||
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response,
|
||||
picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt
|
||||
@@ -115,7 +118,7 @@ export async function run(context) {
|
||||
const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
||||
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
|
||||
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
|
||||
expectedVersion: Number(picked.data.version), actualAt: returnedAt, reason: context.marker,
|
||||
expectedVersion: Number(picked.data.version), actualAt: returnedAt, remark: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response,
|
||||
returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt
|
||||
@@ -135,6 +138,10 @@ export async function run(context) {
|
||||
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
|
||||
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
|
||||
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
|
||||
context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response,
|
||||
['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) =>
|
||||
history.data?.some((item) => item.action === action && item.reason === context.marker)),
|
||||
trace(history.response), OPTIONS)
|
||||
context.createdResources.phase5TerminalDrayage = {
|
||||
...context.createdResources.phase5TerminalDrayage,
|
||||
containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo,
|
||||
|
||||
Reference in New Issue
Block a user