242 lines
18 KiB
JavaScript
242 lines
18 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createFleetCrossCustomerFixture, preparationContract, safeFixtureCount } from './fleet-cross-customer-fixture.mjs'
|
|
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
|
|
|
// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too.
|
|
function model(fault = '', countPatch = {}) {
|
|
const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = []
|
|
const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' }
|
|
const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30,
|
|
vehiclePlate: 'TEST', driverName: 'TEST', driverPhone: '19900000000' }, dispatches: [] }
|
|
const origin = { warehouse: { id: '10', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'ORIGIN', warehouseName: 'Origin' },
|
|
capabilities: [{ capabilityCode: 'RECEIVING', enabled: fault !== 'disabled receiving' }] }
|
|
const destination = { warehouse: { id: '20', tenantId: '0', warehouseType: 'FBA', status: 'ACTIVE', warehouseCode: 'FBA', warehouseName: 'Destination' },
|
|
addresses: [{ addressLine1: 'TEST street', countryCode: 'US' }] }
|
|
const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
|
|
const buckets = { availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' }
|
|
const stock = transfer => {
|
|
const n = transfers.indexOf(transfer), scope = { customerId: transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo }
|
|
const position = { ...scope, ...buckets, id: `P${n}`, balanceId: `B${n}`, palletId: `PAL${n}`, palletNo: `PALLET${n}` }
|
|
if (fault === 'foreign position') position.customerId = 'OTHER'
|
|
return { balance: { ...scope, ...buckets, id: `B${n}`, inTransitPackages: '0', procurementPackages: '0' }, positions: [position],
|
|
ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo,
|
|
beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] }
|
|
}
|
|
let native
|
|
const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId,
|
|
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
|
|
destination: { key: 'FBA:FBA' } }))
|
|
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
|
|
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
|
|
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
|
|
assert(_module, label, _response, passed) { assert.ok(passed, label) },
|
|
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
|
|
async findAvailablePhone() { return `1990000000${customers.length}` },
|
|
async requestWithBody(method, raw, form) {
|
|
calls.push({ method, raw, multipart: true }); assert.ok(form instanceof FormData)
|
|
assert.match(raw, /^\/api\/platform\/v1\/files\/qualifications\/\d+\/required-package\?/)
|
|
return response({})
|
|
},
|
|
async requestMultipart(method, raw, file, token = this.token) {
|
|
calls.push({ method, raw, token, multipart: true }); assert.ok(file.content.length > 0)
|
|
if (raw.includes('/license?')) return response({ id: 'DRIVER', recordVersion: '1', licenseFileId: 'LICENSE' })
|
|
assert.match(raw, /\/warehouse-transfers\/IN[01]\/measurement-photos\/files$/); assert.equal(token, options.pdaToken)
|
|
return response({ fileId: 'PHOTO', sizeBytes: file.content.length })
|
|
},
|
|
async request(method, raw, body, token = this.token) {
|
|
calls.push({ method, raw, body: copy(body), token })
|
|
const url = new URL(raw, 'https://offline.invalid'), path = url.pathname
|
|
if (path.startsWith('/api/platform/')) assert.equal(token, 'admin-secret')
|
|
if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken)
|
|
if (path.endsWith('/warehouse-admin/10')) return response(origin)
|
|
if (path.endsWith('/warehouse-admin/20')) return response(destination)
|
|
if (path === '/api/platform/v1/iam/tenants') {
|
|
const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n)
|
|
const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark }
|
|
customers.push(customer)
|
|
return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password })
|
|
}
|
|
if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' })
|
|
if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id })))
|
|
if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' })
|
|
if (path.endsWith('/approve')) return response({})
|
|
if (path.endsWith('/qualifications')) return response(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' })))
|
|
if (path === '/api/auth/login') {
|
|
assert.equal(token, '')
|
|
const customer = customers.find(row => row.username === body.loginId); assert.equal(body.password, customer.password)
|
|
return response({ accessToken: `merchant-${customer.id}-secret`, refreshToken: `refresh-${customer.id}-secret` })
|
|
}
|
|
if (path === '/api/auth/logout') {
|
|
assert.equal(body.refreshToken, token.replace('merchant-', 'refresh-'))
|
|
if (fault === 'logout failure' && token.includes('100')) throw new Error('offline logout failure')
|
|
return response(null)
|
|
}
|
|
const merchant = customers.find(row => token === `merchant-${row.id}-secret`)
|
|
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
|
|
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
|
|
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
|
|
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1)
|
|
const transferNo = `IN${transfers.length}`
|
|
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
|
|
status: 'DRAFT', pallets: [] }
|
|
transfers.push(transfer); return response(transfer)
|
|
}
|
|
if (path.includes('/warehouse-transfers/')) {
|
|
const transfer = transfers.find(row => path.includes(`/${row.transferNo}`)), n = transfers.indexOf(transfer)
|
|
assert.ok(transfer)
|
|
if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id)
|
|
if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW'
|
|
else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' }
|
|
else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
|
|
else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` })
|
|
else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' }
|
|
else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' }
|
|
else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH'
|
|
else assert.equal(method, 'GET')
|
|
return response(transfer)
|
|
}
|
|
if (path.includes('/inventory/')) {
|
|
const transfer = transfers.find(row => row.customerId === url.searchParams.get('customerId')), facts = stock(transfer)
|
|
if (path.endsWith('/positions')) return response(facts.positions)
|
|
if (/\/balances\/B[01]$/.test(path)) return response(facts.balance)
|
|
// Match InventoryLedgerView: the public DTO has neither balanceId nor positionId.
|
|
if (path.endsWith('/ledger')) return response({ records: facts.ledger.map(({ balanceId, positionId, ...view }) => view), total: fault === 'truncated ledger' ? 2 : 1 })
|
|
assert.ok(path.endsWith('/balances')); return response({ records: [facts.balance], total: 1 })
|
|
}
|
|
if (path.endsWith('/dispatches/candidates')) {
|
|
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
|
|
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
|
|
return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId,
|
|
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' },
|
|
sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] })
|
|
}
|
|
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
|
|
assert.equal(body.cargo.length, 2); assert.equal(body.warehouseId, '10')
|
|
assert.deepEqual(body.cargo.map(row => row.planPalletId), ['PLAN0', 'PLAN1'])
|
|
assert.ok(body.cargo.every(row => !Object.hasOwn(row, 'customerId')))
|
|
native = { dispatchNo: 'DS-ONE', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: 0,
|
|
counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {},
|
|
documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} }
|
|
trip.dispatches = [{ dispatchNo: native.dispatchNo }]
|
|
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] })
|
|
dispatches.push(native); return response(native)
|
|
}
|
|
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) }
|
|
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
|
|
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
|
|
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
|
|
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
|
|
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion,
|
|
items: fault === 'missing second cargo' ? cargo().slice(0,1) : cargo() })
|
|
if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) {
|
|
if (method === 'PUT') {
|
|
assert.equal(body.recordVersion, native.recordVersion)
|
|
Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId })
|
|
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } }
|
|
native.recordVersion++
|
|
} else if (method === 'POST') {
|
|
assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++
|
|
if (path.endsWith('/submit-review')) native.reviewState = 'DISPATCH_PENDING'
|
|
if (path.endsWith('/dispatch-review')) { native.reviewState = 'LOADING_PENDING'; native.reviews.dispatchReview = { status: 'APPROVED' } }
|
|
if (path.endsWith('/loading-review')) { native.reviewState = 'APPROVED'; native.reviews.loadingReview = { status: 'APPROVED' }; native.stage = 'LOADING' }
|
|
}
|
|
const detail = copy(native)
|
|
if (fault === 'foreign delivery') detail.documents.deliveries = [{ customerId: 'OTHER' }]
|
|
return response(detail)
|
|
}
|
|
throw new Error(`Unexpected offline request ${method} ${path}`)
|
|
},
|
|
}
|
|
options.readInventory = async scope => {
|
|
assert.equal(scope.marker, context.marker); assert.equal(scope.warehouseId, options.warehouseId)
|
|
const facts = stock(transfers.find(row => row.customerId === scope.customerId))
|
|
const raw = { scope: { ...scope, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [facts.balance], positions: facts.positions, ledger: facts.ledger }
|
|
// The physical position table links to its inbound through balance_id; only the public joined DTO has inboundCode.
|
|
for (const position of raw.positions) delete position.inboundCode
|
|
if (fault === 'wrong raw scope') raw.scope.marker = 'OLD'
|
|
if (fault === 'wrong raw ledger position') raw.ledger[0].positionId = 'OTHER'
|
|
if (fault === 'wrong raw ledger balance') raw.ledger[0].balanceId = 'OTHER'
|
|
return copy(raw)
|
|
}
|
|
return { context, options, calls, customers, transfers, trip, parentFixture }
|
|
}
|
|
|
|
test('default preparation is network-free and exposes caller-owned lifecycle', async () => {
|
|
assert.equal(await createFleetCrossCustomerFixture(), preparationContract)
|
|
assert.equal(preparationContract.stage, fixtureContract.stage)
|
|
})
|
|
|
|
test('real helpers build independent current-run customers, receipts and one complete shared trip without picking', async () => {
|
|
const m = model(), oldEnvironment = { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
|
|
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
|
|
assert.equal(fixture.stage, fixtureContract.stage)
|
|
assert.equal(fixture.members.length, 2); assert.notEqual(fixture.members[0].customerId, fixture.members[1].customerId)
|
|
assert.ok(fixture.members.every(row => row.receiptVerified && row.marker === m.context.marker && row.positionId && row.balanceId))
|
|
assert.equal(m.context.context.merchantQualificationFixture, m.parentFixture)
|
|
assert.deepEqual(m.context.createdResources.merchantQualificationFixture, { marker: 'PARENT' })
|
|
assert.equal(m.context.options.customerName, 'Parent customer')
|
|
assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, oldEnvironment)
|
|
assert.notEqual(m.customers[0].marker, m.customers[1].marker)
|
|
const proof = m.context.createdResources.fleetCrossCustomerFixture
|
|
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
|
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
|
assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1)
|
|
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0)
|
|
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
|
|
const persisted = JSON.stringify(m.context.createdResources)
|
|
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
|
|
})
|
|
|
|
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
|
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
|
|
const m = model(fault)
|
|
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
|
|
const proof = m.context.createdResources.fleetCrossCustomerFixture
|
|
assert.equal(proof.status, 'FAILED')
|
|
const logins = m.calls.filter(row => row.raw === '/api/auth/login'), logouts = m.calls.filter(row => row.raw === '/api/auth/logout')
|
|
assert.equal(logouts.length, logins.length)
|
|
assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/')))
|
|
if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0)
|
|
const previousCount = m.calls.length
|
|
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/)
|
|
assert.equal(m.calls.length, previousCount)
|
|
})
|
|
}
|
|
|
|
test('missing explicit warehouse or caller PDA fails before reading or writing APIs', async () => {
|
|
for (const field of ['warehouseId', 'destinationWarehouseId', 'pdaToken', 'readInventory']) {
|
|
const m = model(); delete m.options[field]
|
|
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Missing/)
|
|
assert.equal(m.calls.length, 0)
|
|
}
|
|
})
|
|
|
|
|
|
test('native Jackson Long count strings build the same complete one-main fixture', async () => {
|
|
const m = model('', { packageCount: '2', pickedPackageCount: '0', shippedPackageCount: '0' })
|
|
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
|
|
assert.equal(fixture.dispatchNo, 'DS-ONE')
|
|
assert.equal(fixture.tripNo, 'TRIP')
|
|
assert.equal(fixture.members.length, 2)
|
|
assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo))
|
|
})
|
|
|
|
test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => {
|
|
for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) {
|
|
assert.equal(safeFixtureCount(value), expected)
|
|
}
|
|
for (const value of [null, undefined, true, false, '', ' ', ' 2 ', '02', '+2', '-0', '-1', -1, 1.5, '1.5', '1e2', '2E0', NaN, Infinity, {}, [], Number.MAX_SAFE_INTEGER + 1, '9007199254740992']) {
|
|
assert.throws(() => safeFixtureCount(value), /非负安全整数/)
|
|
}
|
|
})
|
|
|
|
test('invalid created main counts fail before resource preparation or picking', async () => {
|
|
for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) {
|
|
const m = model('', { [field]: field === 'packageCount' ? '2e0' : false })
|
|
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /非负安全整数/)
|
|
assert.equal(m.calls.some(row => row.raw.endsWith('/master-data/vehicles')), false)
|
|
assert.equal(m.calls.some(row => row.raw.endsWith('/pick')), false)
|
|
}
|
|
})
|