test: extend eight-business acceptance flows and offline contracts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
|
||||
@@ -22,12 +23,88 @@ test('fixed partner warehouse is reused and never retired', async () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
|
||||
|
||||
test('卡车请求先持久化,正常与失败响应均留有证据', async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (context.createdResources.phase4OverseasBusiness?.truck?.fulfillment) {
|
||||
const last = context.savedTruckEvidence.at(-1)?.requests.at(-1)
|
||||
if (path.includes('/us-truck-orders/TK1')) {
|
||||
assert.equal(last?.status, 'PREPARED')
|
||||
assert.equal(last?.method, method)
|
||||
}
|
||||
}
|
||||
return original(method, path, ...args)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(context.savedTruckEvidence.at(-1).status, 'HTTP_VERIFIED')
|
||||
assert.ok(context.savedTruckEvidence.some(e => e.requests.some(r => r.expectedRejection && r.status === 'REJECTED')))
|
||||
})
|
||||
|
||||
test('卡车检查点写入失败不发送请求,不能误记未知网络结果', async () => {
|
||||
const proof = { marker: 'BATCH', orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
|
||||
let calls = 0
|
||||
const context = { marker: 'BATCH', createdResources: { phase4OverseasBusiness: { truck: proof } },
|
||||
request: async () => { calls++; throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } }
|
||||
await assert.rejects(verifyUsTruckFulfillment(context, proof), /disk full/)
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(proof.fulfillment.requests[0].status, 'PREPARED')
|
||||
assert.equal(proof.fulfillment.status, 'FAILED')
|
||||
})
|
||||
|
||||
test('卡车默认落盘禁止新进程用同批次覆盖失败证据', async () => {
|
||||
const marker = `TRUCK-OFFLINE-${randomUUID()}`
|
||||
const file = new URL(`../../reports/us-truck-checkpoints/${marker}.json`, import.meta.url)
|
||||
let calls = 0
|
||||
const make = () => {
|
||||
const proof = { marker, orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
|
||||
return { proof, context: { marker, createdResources: { phase4OverseasBusiness: { truck: proof } },
|
||||
request: async () => { calls++; throw Error('connection lost') } } }
|
||||
}
|
||||
try {
|
||||
const first = make()
|
||||
await assert.rejects(verifyUsTruckFulfillment(first.context, first.proof), /connection lost/)
|
||||
const saved = fs.readFileSync(file, 'utf8')
|
||||
assert.equal(JSON.parse(saved).fulfillment.requests[0].status, 'UNKNOWN_RESULT')
|
||||
const second = make()
|
||||
await assert.rejects(verifyUsTruckFulfillment(second.context, second.proof), /EEXIST/)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(fs.readFileSync(file, 'utf8'), saved)
|
||||
} finally {
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file)
|
||||
}
|
||||
})
|
||||
|
||||
for (const action of ['receive-pallets', 'allocate-pallets']) {
|
||||
for (const fault of ['accepted', 'unknown', 'wrong rejection', 'drift']) {
|
||||
test(`卡车 ${action} 无效命令拒绝校验: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let rejected = false
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
if (method === 'POST' && path.includes(`/${action}?`) && body?.idempotencyKey?.endsWith('-invalid')) {
|
||||
rejected = true
|
||||
if (fault === 'accepted') return response({})
|
||||
if (fault === 'unknown') return { http: 0 }
|
||||
if (fault === 'wrong rejection') return response(null, 'A0403')
|
||||
}
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (fault === 'drift' && rejected && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
|
||||
result.body.data.summary.version++
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.method === 'POST' && c.path.includes(`/${action}?`) && !c.body.idempotencyKey.endsWith('-invalid')), false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay drift']) {
|
||||
test(`truck fulfillment stops and retains original order on ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let receipts = 0
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (method === 'POST' && path.includes('/receive-pallets?')) {
|
||||
if (method === 'POST' && path.includes('/receive-pallets?') && !args[0]?.idempotencyKey?.endsWith('-invalid')) {
|
||||
receipts += 1
|
||||
if (fault === 'network') return { http: 0 }
|
||||
const result = structuredClone(await original(method, path, ...args))
|
||||
@@ -48,6 +125,47 @@ for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['accepted', 'wrong code', 'missing trace', 'detail drift']) {
|
||||
test(`卡车部分分配待派车拒绝检查失败即停止: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
let rejected = false
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (body?.idempotencyKey?.endsWith('prepare-dispatch-partial')) {
|
||||
rejected = true
|
||||
if (fault === 'accepted') result.body.code = 200
|
||||
if (fault === 'wrong code') result.body.code = 'A0400'
|
||||
if (fault === 'missing trace') delete result.traceId
|
||||
} else if (rejected && fault === 'detail drift' && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
|
||||
result.body.data.summary.version++
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['premature complete', 'unselected allocated', 'selected unchanged']) {
|
||||
test(`卡车部分分配拒绝错误结果: ${fault}`, async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
context.request = async (method, path, body, ...args) => {
|
||||
const result = await original(method, path, body, ...args)
|
||||
if (method === 'POST' && path.includes('/allocate-pallets?') && body?.idempotencyKey?.endsWith('-0')) {
|
||||
if (fault === 'premature complete') result.body.data.summary.status = 'ALLOCATED'
|
||||
if (fault === 'unselected allocated') result.body.data.pallets[1].status = 'ALLOCATED'
|
||||
if (fault === 'selected unchanged') result.body.data.pallets[0].status = 'RECEIVED'
|
||||
}
|
||||
return result
|
||||
}
|
||||
await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
|
||||
assert.equal(context.calls.some(c => c.path.includes('/prepare-dispatch?')), false)
|
||||
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
|
||||
const context = fixture(), request = context.request.bind(context), pages = []
|
||||
context.options = { fixedCustomerId: '10001' }
|
||||
@@ -95,6 +213,11 @@ function fixture(options = {}) {
|
||||
|
||||
const context = {
|
||||
marker, calls, steps, gaps, blockers,
|
||||
savedTruckEvidence: [],
|
||||
async checkpoint() {
|
||||
if (this.createdResources.phase4OverseasBusiness?.truck?.fulfillment)
|
||||
this.savedTruckEvidence.push(structuredClone(this.createdResources.phase4OverseasBusiness.truck.fulfillment))
|
||||
},
|
||||
context: { merchantQualificationFixture: credentials },
|
||||
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
|
||||
async request(...args) {
|
||||
@@ -176,21 +299,32 @@ function fixture(options = {}) {
|
||||
dispatch: ['PENDING_DISPATCH', 'DISPATCHED'], 'start-transit': ['DISPATCHED', 'IN_TRANSIT'], complete: ['IN_TRANSIT', 'COMPLETED'] }
|
||||
const action = requestPath.match(/\/us-truck-orders\/TK1\/([^?]+)\?/)?.[1]
|
||||
if (method === 'POST' && stages[action]) {
|
||||
if (action === 'prepare-dispatch' && state.truck.status === 'PARTIALLY_ALLOCATED') {
|
||||
assert.equal(body.version, state.truck.version)
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
const existing = state.truckStatuses.get(body.idempotencyKey)
|
||||
if (existing) return response(truckDetail())
|
||||
const [from, to] = stages[action]
|
||||
let [from, to] = stages[action]
|
||||
if (action === 'allocate-pallets') {
|
||||
from = state.truck.allocatedPalletCount === 0 ? 'WAITING_CONSOLIDATION' : 'PARTIALLY_ALLOCATED'
|
||||
to = state.truck.allocatedPalletCount === 0 ? 'PARTIALLY_ALLOCATED' : 'ALLOCATED'
|
||||
}
|
||||
assert.equal(state.truck.status, from)
|
||||
assert.equal(body.version, state.truck.version)
|
||||
if (action === 'receive-pallets') {
|
||||
assert.deepEqual(body.palletPackageCounts, [2])
|
||||
assert.equal(body.actualPalletCount, 1)
|
||||
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 2, status: 'RECEIVED' }]
|
||||
state.truck.receivedPalletCount = 1
|
||||
if (body.palletPackageCounts.reduce((sum, count) => sum + count, 0) !== body.packageCount) return response(null, 'A0400')
|
||||
assert.deepEqual(body.palletPackageCounts, [1, 1])
|
||||
assert.equal(body.actualPalletCount, 2)
|
||||
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 1, status: 'RECEIVED' },
|
||||
{ id: '702', palletNo: 'TK1-P002', packageCount: 1, status: 'RECEIVED' }]
|
||||
state.truck.receivedPalletCount = 2
|
||||
}
|
||||
if (action === 'allocate-pallets') {
|
||||
assert.deepEqual(body.palletIds, ['701'])
|
||||
state.truckPallets[0].status = 'ALLOCATED'
|
||||
state.truck.allocatedPalletCount = 1
|
||||
if (new Set(body.palletIds).size !== body.palletIds.length) return response(null, 'A0400')
|
||||
assert.deepEqual(body.palletIds, [state.truck.allocatedPalletCount === 0 ? '701' : '702'])
|
||||
state.truckPallets.find(row => row.id === body.palletIds[0]).status = 'ALLOCATED'
|
||||
state.truck.allocatedPalletCount++
|
||||
}
|
||||
state.truck.status = to; state.truck.version += 1
|
||||
state.truckHistories.push({ fromStatus: from, toStatus: to, idempotencyKey: body.idempotencyKey })
|
||||
@@ -325,7 +459,9 @@ test('procurement creation review boundaries and transport cancellation paths pa
|
||||
assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey)
|
||||
|
||||
const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders'))
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 19)
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 24)
|
||||
assert.equal(truckCalls.filter(call => call.body?.idempotencyKey?.endsWith('prepare-dispatch-partial')).length, 1)
|
||||
assert.equal(truckCalls.filter(call => call.method === 'POST' && call.body?.idempotencyKey?.endsWith('-invalid')).length, 2)
|
||||
assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
|
||||
const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?'))
|
||||
assert.equal(truckReviews.length, 4)
|
||||
@@ -356,6 +492,8 @@ test('procurement creation review boundaries and transport cancellation paths pa
|
||||
assert.equal(registry.truck.finalStatus, 'COMPLETED')
|
||||
assert.equal(registry.truck.retained, true)
|
||||
assert.equal(registry.truck.fulfillment.status, 'HTTP_VERIFIED')
|
||||
assert.equal(registry.truck.fulfillment.partialAllocationVerified, true)
|
||||
assert.equal(registry.truck.fulfillment.pallets.length, 2)
|
||||
assert.equal(registry.looseboard.finalStatus, 'PENDING_ACCEPTANCE')
|
||||
assert.equal(registry.looseboard.retained, true)
|
||||
assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
|
||||
Reference in New Issue
Block a user