214 lines
17 KiB
JavaScript
214 lines
17 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createFleetCrossCustomerFixture, preparationContract } 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 = '') {
|
|
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' }] }
|
|
}
|
|
const cargo = () => trip.dispatches.map(binding => {
|
|
const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d)
|
|
return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`,
|
|
destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 }
|
|
})
|
|
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')) 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([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo,
|
|
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }])
|
|
}
|
|
if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` })
|
|
if (path.endsWith('/draft-locks/lock')) return response(null)
|
|
if (path.endsWith('/pre-arrange')) {
|
|
const n = Number(body.planPalletIds[0].slice(-1))
|
|
const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }],
|
|
packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 }
|
|
dispatches.push(dispatch); return response(dispatch)
|
|
}
|
|
if (path.endsWith('/master-data/vehicles')) 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.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) }
|
|
if (path.includes('/fleet/trips/TRIP')) {
|
|
if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() })
|
|
if (method !== 'GET') {
|
|
assert.equal(body.recordVersion, trip.trip.recordVersion)
|
|
trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1)
|
|
}
|
|
if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId })
|
|
else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } })
|
|
else if (path.endsWith('/plan')) trip.trip.stops = body.stops
|
|
else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED'
|
|
return response(trip)
|
|
}
|
|
const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n]
|
|
const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' }
|
|
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] })
|
|
if (path.endsWith('/transfer-delivery')) {
|
|
assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30)
|
|
return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 })
|
|
}
|
|
if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) }
|
|
if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] })
|
|
if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch)
|
|
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, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
|
assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2)
|
|
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2)
|
|
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)
|
|
}
|
|
})
|