test(appointments): verify stable batches and current HTTP contracts

This commit is contained in:
2026-10-15 18:13:52 +08:00
parent 204ae10737
commit caff693ad8
5 changed files with 335 additions and 55 deletions
+18 -12
View File
@@ -15,6 +15,11 @@ const ok = r => r?.http === 200 && id(r.body?.code) === '200'
const refused = (r, status) => (r?.http === status || r?.http === 200) && id(r?.body?.code) === `A0${status}` && r.body?.data == null
const sort = rows => rows.map(row => JSON.stringify(row)).sort()
const source = row => ({ customerId: id(row.customerId), sourceKey: row.sourceKey, destinationKey: row.destinationKey, packageCount: Number(row.packageCount) })
const count = value => {
if (typeof value !== 'number' && !(typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value))) return NaN
const n = Number(value)
return Number.isSafeInteger(n) && n >= 0 ? n : NaN
}
export function plan() {
return { environment: 'TEST', execute: false, entry: 'scripts/appointment-work-test-env.mjs',
@@ -30,16 +35,16 @@ export function plan() {
cases: [{ name: 'customer', containerBookingId: '<id>', type: 'DELIVERY', flow: 'TRANSFER', requirementKey: 'DISPATCH:<id>', batchKey: 'DISPATCH:<id>', batchNo: '<dispatch business number>',
expected: { executionStatus: 'PENDING', planStatus: 'UNPLANNED', dateBasis: 'NONE', calendarDate: null }, containerIds: ['<id>'] }],
batches: [{ case: 'customer', kind: 'CUSTOMER', dispatchNo: '<business number>', packageCount: 1, pallets: [{ planPalletId: '<id>', packageCount: 1 }] }],
sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '<active IAM user id>', followupDate: '<YYYY-MM-DD>', noBooking: true } },
sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '<active platform user authUserId (Auth subject ID)>', followupDate: '<YYYY-MM-DD>', noBooking: true } },
fixtureNotes: ['FBA batch entries instead use kind=FBA, appointmentNo, sources:[{customerId,sourceKey,destinationKey,packageCount}], minimumReadyCandidates (0 allowed).',
'sharedTrips:[{case,tripNo,tripId,containerIds,dispatches:[{dispatchNo,customerId,packageCount}]}]. The case must select TRANSPORT.',
'retired:[{workItemKey,containerBookingId,replacementCases:[name]}] checks absence from active container work and exact named replacements in old detail.',
'For COMPLETED cases provide expected.actual and calendarDate/dateBasis=ACTUAL; completed rows are loaded with their date filter.',
'workflow requires one pending item exposing SAVE_FOLLOWUP; noBooking additionally requires editable CUSTOMER DELIVERY and no original appointment plan.'],
minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 1 --output reports/<new-producer>/producer.json',
minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 4 --output reports/<new-producer>/producer.json',
notes:'Uses its existing ADMIN_TEST_* and ADMIN_PDA_* configuration. Dependency closure creates a new customer/order, measurement/loading/customs/outbound and formal ship-node evidence. Select only the current report createdResources.phase3CarrierScheduleQuery.containerBookingId. First acceptance can target its UNKNOWN ROUTE work with workflow.noBooking=false; this does not prove stable batches.',
batchPrerequisites:'Then use current formal route, cargo-verification/FBA appointment or PRIVATE receiving/pre-arrange APIs to prepare new stable batches; shared trip and completion stages need actual original dispatch/fleet/PDA execution before a new read report.',
existingProducerBlocker:'phase4-overseas-inbound-operations.mjs currently cancels FBA by PUT details.appointmentStatus=CANCELLED; the current service rejects this and requires the dedicated cancellation operation. Do not use that whole module as a verified batch producer until its owner fixes the contract.' },
batchProducer:'Select phase4-overseas-inbound-operations with the same four-box arguments to exercise independent cargo verification, dedicated DELETE cancellation, split/merge conservation, immutable batch identity and replacement history. It also runs prerequisite PDA and reversible inventory writes; offline checks do not establish HTTP readiness. Retain samples and obtain the corresponding environment/cancellation authorization before execution.' },
remaining: ['This package does not create sailed containers, appointments, dispatches, stock or fleet trips, cancel original businesses, pick/depart/sign, or run internal synchronization.',
'Prepare/advance those samples with existing formal producers; run a new read report after each state transition. This is not end-to-end execution proof.',
'PRIVATE per-order pick quantity conservation and DIRECT receiving-write rejection require the original PDA/dispatch producer; this reader checks public batch identity/pallet quantities and completed facts only.',
@@ -97,16 +102,17 @@ export function validateFixture(f, config, now = Date.now()) {
}
export function validatePage(page, number, size = 100) {
requireFact(page && Number.isSafeInteger(page.total) && page.total >= 0 && page.page === number && page.size === size && page.pages === Math.ceil(page.total / size)
&& Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, page.total - (number - 1) * size)), 'Incomplete work page')
const total = count(page?.total)
requireFact(page && Number.isSafeInteger(total) && count(page.page) === number && count(page.size) === size && count(page.pages) === Math.ceil(total / size)
&& Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, total - (number - 1) * size)), 'Incomplete work page')
return page.records
}
export function assertItem(item, c) {
requireFact(item?.workItemKey && item.type === c.type && item.flow === c.flow, `Case ${c.name}: wrong work identity`)
for (const key of ['requirementKey','batchKey','batchNo']) if (Object.hasOwn(c, key)) assert.equal(item[key], c[key], `Case ${c.name}: ${key}`)
assert.deepEqual(item.containers.map(r => id(r.containerBookingId)).sort(), [...c.containerIds].sort(), `Case ${c.name}: current container scope`)
for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(item[key], value, `Case ${c.name}: ${key}`) }
if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual, null, 'Unfinished work must not invent completion')
for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(['calendarDate','actual','timezone'].includes(key) ? item[key] ?? null : item[key], value, `Case ${c.name}: ${key}`) }
if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual ?? null, null, 'Unfinished work must not invent completion')
if (c.flow === 'DIRECT') requireFact(item.type !== 'WAREHOUSE_RECEIPT', 'DIRECT cannot create warehouse receipt work')
return item
}
@@ -125,8 +131,8 @@ export async function verify(context, fixture, config) {
for (let page = 1; page <= 100; page++) {
const query = new URLSearchParams({ view: 'ALL', page: id(page), size: '100', containerBookingId: container, ...(date ? { date } : {}) })
const response = await data('GET', `${WORK}/items?${query}`), next = validatePage(response, page)
requireFact(total == null || total === response.total, 'Work scope changed during pagination'); total = response.total
result.push(...next); if (page >= response.pages) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result }
requireFact(total == null || total === count(response.total), 'Work scope changed during pagination'); total = count(response.total)
result.push(...next); if (page >= count(response.pages)) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result }
}
throw Error('Work pagination limit exceeded')
}
@@ -134,7 +140,7 @@ export async function verify(context, fixture, config) {
check('anonymous cannot read work', [401,403].includes(anonymous?.http) && anonymous.body?.data == null)
check('customer cannot read platform work', refused(await context.request('merchant', 'GET', `${WORK}/summary`), 403))
const summary = await data('GET', `${WORK}/summary`)
check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(summary[k])&&summary[k]>=0))
check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(count(summary[k]))))
for (const c of fixture.cases) {
const current = await rows(c.containerBookingId, c.expected.executionStatus === 'COMPLETED' ? c.expected.calendarDate : null)
if (c.flow === 'DIRECT') check(`${c.name}: no active warehouse receiving branch`, !current.some(i => i.type === 'WAREHOUSE_RECEIPT'))
@@ -188,7 +194,7 @@ export async function verify(context, fixture, config) {
check('list-only platform cannot write followup', refused(await context.request('reader','POST',`${path}/commands`,command),403))
assert.deepEqual(await data('GET',path),before,'Denied write changed original work')
const result = await data('POST',`${path}/commands`,command), saved = await data('GET',path)
check('followup resolves the active IAM owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate)
check('followup resolves the active platform Auth subject owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate)
assert.deepEqual(result.item.actual,before.item.actual,'Followup invented execution'); assert.equal(result.item.executionStatus,before.item.executionStatus)
check('one command creates one history entry',saved.history.length===before.history.length+1)
assert.deepEqual(await data('POST',`${path}/commands`,command),result,'Idempotent replay changed response')
@@ -199,7 +205,7 @@ export async function verify(context, fixture, config) {
if(w.noBooking) {
check('explicit customer requirement is editable',before.item.type==='DELIVERY' && saved.requirement && saved.item.allowedActions.some(a=>a.code==='SAVE_REQUIREMENT'))
const updated=await data('POST',`${path}/commands`,{command:'SAVE_REQUIREMENT',expectedVersion:saved.item.version,idempotencyKey:`AW-${config.runId}-NOBOOK`,payload:{target:null,bookingRequired:false,receivingRequirements:'本轮测试客户仓按到货接收,无需外部预约',reason:`明确收货要求 ${config.runId}`}})
check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual===null)
check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual==null)
}
}
return { checks, resolved: Object.fromEntries([...resolved].map(([name,d])=>[name,{workItemKey:d.item.workItemKey,version:d.item.version,batchKey:d.item.batchKey,batchNo:d.item.batchNo,executionStatus:d.item.executionStatus}])) }