123 lines
9.3 KiB
JavaScript
123 lines
9.3 KiB
JavaScript
import test from 'node:test'
|
|||
|
|
import assert from 'node:assert/strict'
|
||
|
|
import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs'
|
||
|
|
|
||
|
|
const journal = () => {
|
||
|
|
const state = {}, saves = []
|
||
|
|
return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } }
|
||
|
|
}
|
||
|
|
test('uncertain writes retain original intent and cannot replay', async () => {
|
||
|
|
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
|
||
|
|
let count = 0
|
||
|
|
const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') }
|
||
|
|
await assert.rejects(run('create', { path: '/create' }, execute), /network/)
|
||
|
|
await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/)
|
||
|
|
assert.equal(count, 1)
|
||
|
|
})
|
||
|
|
test('completed writes reuse exact saved result and reject another binding', async () => {
|
||
|
|
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
|
||
|
|
const first = await run('create', { path: '/create' }, async () => ({ id: '17' }))
|
||
|
|
assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first)
|
||
|
|
assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/)
|
||
|
|
})
|
||
|
|
test('business rejection retains diagnostic evidence and cannot count as success', () => {
|
||
|
|
assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }),
|
||
|
|
error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't')
|
||
|
|
assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } }))
|
||
|
|
})
|
||
|
|
|
||
|
|
function fixture() {
|
||
|
|
const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' },
|
||
|
|
payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2,
|
||
|
|
lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] },
|
||
|
|
domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' },
|
||
|
|
carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' },
|
||
|
|
estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } }
|
||
|
|
const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' }
|
||
|
|
const checkpoint = journal(), calls = []
|
||
|
|
const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} }
|
||
|
|
let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00'
|
||
|
|
let measured = false
|
||
|
|
const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } })
|
||
|
|
context.request = async (method, path, body, token, headers) => {
|
||
|
|
calls.push({ method, path, body, token, headers })
|
||
|
|
let data
|
||
|
|
if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } }
|
||
|
|
else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } }
|
||
|
|
else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }],
|
||
|
|
processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 }
|
||
|
|
else if (path.includes('/measurements/approve')) { measured = true; data = {} }
|
||
|
|
else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 }
|
||
|
|
else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 }
|
||
|
|
else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' }
|
||
|
|
else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 }
|
||
|
|
else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } }
|
||
|
|
else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] }
|
||
|
|
else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} }
|
||
|
|
else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }]
|
||
|
|
else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 }
|
||
|
|
else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] }
|
||
|
|
else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } }
|
||
|
|
else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } }
|
||
|
|
else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] }
|
||
|
|
else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' }
|
||
|
|
else if (path.includes('/tasks/80/batch')) data = { version: 2 }
|
||
|
|
else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
|
||
|
|
else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 }
|
||
|
|
else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 }
|
||
|
|
else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 }
|
||
|
|
else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } }
|
||
|
|
else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] }
|
||
|
|
else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier()
|
||
|
|
else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() }
|
||
|
|
else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 }
|
||
|
|
else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 }
|
||
|
|
else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt }
|
||
|
|
else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {}
|
||
|
|
else throw Error(`unhandled ${method} ${path}`)
|
||
|
|
return { http: 200, body: { code: '200', data } }
|
||
|
|
}
|
||
|
|
context.requestMultipart = async (method, path, form, token, headers) => {
|
||
|
|
assert.ok(form instanceof FormData)
|
||
|
|
calls.push({ method, path, token, headers, multipart: true })
|
||
|
|
let data
|
||
|
|
if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 }
|
||
|
|
else if (path.includes('/files/measurements/')) data = { fileId: '41' }
|
||
|
|
else if (path.includes('/documents/upload')) data = { bookingVersion: 1 }
|
||
|
|
else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' }
|
||
|
|
else throw Error(`unhandled upload ${path}`)
|
||
|
|
return { http: 200, body: { code: '200', data } }
|
||
|
|
}
|
||
|
|
context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') })
|
||
|
|
return { input, order, context, checkpoint, calls }
|
||
|
|
}
|
||
|
|
|
||
|
|
test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => {
|
||
|
|
const value = fixture(), result = await runDomesticStage(value)
|
||
|
|
assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED')
|
||
|
|
assert.equal(result.inboundId, '20')
|
||
|
|
assert.equal(result.packageCount, 2)
|
||
|
|
const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch'))
|
||
|
|
assert.equal(batch.body.items[0].pickedPackageCount, 2)
|
||
|
|
assert.equal(batch.token, value.context.pdaToken)
|
||
|
|
const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm'))
|
||
|
|
assert.equal(weight.body.adjustedWeightValue, 10)
|
||
|
|
assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7)
|
||
|
|
assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5)
|
||
|
|
assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path)))
|
||
|
|
assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE'))
|
||
|
|
})
|
||
|
|
test('tax mode and customer identity fail closed before unrelated business writes', async () => {
|
||
|
|
const value = fixture()
|
||
|
|
value.input.payload.taxMode = 'SELF_DECLARATION'
|
||
|
|
await assert.rejects(runDomesticStage(value), /买单报关/)
|
||
|
|
assert.equal(value.calls.length, 0)
|
||
|
|
})
|
||
|
|
test('a timeout at first business write preserves intent and stops all later actions', async () => {
|
||
|
|
const value = fixture(), request = value.context.request
|
||
|
|
value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args)
|
||
|
|
await assert.rejects(runDomesticStage(value), /timeout/)
|
||
|
|
assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT')
|
||
|
|
assert.equal(Object.keys(value.checkpoint.state.commands).length, 1)
|
||
|
|
})
|