test(order): verify channel removal using customer-owned price books

This commit is contained in:
2026-10-03 00:46:00 +08:00
parent 4cd51a4955
commit 0319bb51a0
2 changed files with 293 additions and 8 deletions
+176 -7
View File
@@ -16,7 +16,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID, createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { inflateRawSync } from 'node:zlib'
import { inflateRawSync, deflateRawSync } from 'node:zlib'
const args = parseArgs(process.argv.slice(2))
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
@@ -103,6 +103,7 @@ try {
await orderSubmissionBalanceGateModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await orderSubmissionChannelGateModule()
await notificationInboxModule()
await accountSecurityModule()
report.success = report.steps.every((step) => step.passed)
@@ -2102,7 +2103,7 @@ function orderZipRead(bytes) {
assertEqual(content.length, length, 'ZIP uncompressed size')
assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum')
assertTrue(!entries.has(name), 'unique ZIP entry')
entries.set(name, content.toString('utf8'))
entries.set(name, /\.(xml|rels)$/i.test(name) ? content.toString('utf8') : Buffer.from(content))
offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32)
}
return entries
@@ -2112,20 +2113,23 @@ function orderCrc(bytes) {
for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) }
return (crc ^ 0xffffffff) >>> 0
}
function orderZipWrite(entries) {
function orderZipWrite(entries, compress = false) {
const local = [], directory = []
let offset = 0
for (const [name, text] of entries) {
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
const packed = compress ? deflateRawSync(data) : data
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
header.writeUInt16LE(compress ? 8 : 0, 8)
header.writeUInt32LE(crc, 14); header.writeUInt32LE(packed.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(compress ? 8 : 0, 10)
central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
local.push(header, filename, data); directory.push(central, filename)
offset += header.length + filename.length + data.length
local.push(header, filename, packed); directory.push(central, filename)
offset += header.length + filename.length + packed.length
}
const central = Buffer.concat(directory), end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10)
@@ -2171,6 +2175,41 @@ function importRollbackRows(validFirst, validSecond) {
const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] }
}
function channelGateWorkbook(sourceBytes, reference, channelCode) {
const matches = reference.channels.filter(channel => channel.channelCode === channelCode)
assertEqual(matches.length, 1, 'one authoritative target channel')
const sourceSheetName = matches[0].sourceSheetName
assertPresent(sourceSheetName, 'target channel source sheet')
const removedCodes = reference.channels.filter(channel => channel.sourceSheetName === sourceSheetName).map(channel => channel.channelCode)
assertTrue(reference.channels.some(channel => !removedCodes.includes(channel.channelCode)), 'B must retain another real channel')
const entries = orderZipRead(sourceBytes), workbook = entries.get('xl/workbook.xml')
assertTrue(typeof workbook === 'string', 'source workbook XML')
let removed = 0
const updated = workbook.replace(/<sheet\b[^>]*\/>/g, element => {
const name = element.match(/\bname="([^"]*)"/)?.[1]
if (name !== undefined && orderXmlDecode(name) === sourceSheetName) { removed++; return '' }
return element
})
assertEqual(removed, 1, 'remove exactly the authoritative channel sheet from workbook directory')
// Preserve all parts and relationships byte-for-byte. The SAX price importer enumerates only workbook-listed sheets.
entries.set('xl/workbook.xml', updated)
return { bytes: orderZipWrite(entries, true), sourceSheetName, removedCodes }
}
function channelGateMappings(detail, reference) {
const mapping = (kind, keyField, idField) => {
const keys = [...new Set(detail.rates.filter(rate => kind === 'origin' || rate.destinationType === 'OVERSEAS').map(rate => rate[keyField]))]
return keys.map(key => {
assertPresent(key, `price ${kind} source label`)
const ids = [...new Set(reference.rates.filter(rate => (kind === 'origin' || rate.destinationType === 'OVERSEAS')
&& rate[keyField] === key).map(rate => String(rate[idField] ?? '')))]
assertTrue(ids.length === 1 && /^[1-9]\d*$/.test(ids[0]), `unique published ${kind} mapping for ${key}`)
return kind === 'origin' ? { originWarehouseCode: key, warehouseId: ids[0] } : { sourceDestinationName: key, warehouseId: ids[0] }
})
}
assertTrue(Array.isArray(detail.rates) && detail.rates.length > 0, 'nonempty parsed prices required')
return { originMappings: mapping('origin','originWarehouseCode','originWarehouseId'),
overseasMappings: mapping('overseas','destinationName','destinationWarehouseId') }
}
// END ORDER ACCEPTANCE HELPERS
async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) {
@@ -2464,6 +2503,136 @@ async function orderSubmissionBalanceGateModule() {
}
}
async function orderSubmissionChannelGateModule() {
const module='order-channel-gate', base='/api/platform/v1/fba-pricing/carrier-rates/price-books'
const customerId=String(report.resources.tenantId)
assertTrue(/^[1-9]\d*$/.test(customerId), 'customer-specific price book must never use platform scope zero')
assertTrue(report.steps.some(step=>step.module==='registration' && step.name==='enterprise self-registration'
&& step.passed===true && String(step.evidence?.tenantId)===customerId), 'only current-run registered customer is eligible')
assertEqual(report.orderSubmissionGates?.balance?.restoration,'RESTORED_WITH_AUDIT','negative funds must already be restored')
const {masterData,payload}=orderAcceptanceFixture
const proof=report.orderSubmissionGates.channel={status:'PREPARING',customerId,channelCode:payload.channelCode,versions:[]}
await apiStep(module,'verify current customer before private pricing writes',{
path:'/api/merchant/v1/iam/tenant/profile',token:merchantToken,
},({json})=>{requireSuccess(json);assertEqual(String(json.data?.id),customerId,'current customer');assertEqual(json.data.status,'ACTIVE','eligible customer')},()=>({customerId}))
const list=async(scope,label)=>{
const response=await apiStep(module,label,{path:`${base}?customerId=${scope}`,token:platformToken},({json})=>{
requireSuccess(json);assertTrue(Array.isArray(json.data),'price version list required')
assertTrue(json.data.every(version=>String(version.customerId)===String(scope)),'exact price-book customer scope')
},({json})=>({customerId:scope,versionCount:json.data.length,versionsSha256:orderDigest(json.data),
activeVersionIds:json.data.filter(version=>version.status==='ACTIVE').map(version=>version.id)}))
return response.json.data
}
const publicBefore=await list('0','freeze public price-book versions without changing them')
assertEqual((await list(customerId,'require no previous customer-specific price books')).length,0,'do not replace any existing customer private version')
const active=publicBefore.filter(version=>version.status==='ACTIVE' && String(version.id)===String(masterData.channel.priceBookVersionId))
assertEqual(active.length,1,'selected route must originate from one active public source')
let reference
await apiStep(module,'read authoritative active price source and mappings',{
path:`${base}/${active[0].id}`,token:platformToken,
},({json})=>{
requireSuccess(json);reference=json.data
assertEqual(String(reference?.version?.customerId),'0','read-only source is public')
assertEqual(String(reference.version.id),String(active[0].id),'exact active source')
assertEqual(reference.version.status,'ACTIVE','valid reusable source')
assertTrue(Array.isArray(reference.channels)&&reference.channels.length>1,'source must retain channels after removing target sheet')
},({json})=>({versionId:json.data.version.id,sourceSha256:json.data.version.sourceSha256}))
let sourceBytes
await apiStep(module,'reuse existing valid XLSX source for private A',{
path:`${base}/${reference.version.id}/source`,token:platformToken,responseType:'bytes',
},({status,bytes})=>{
assertEqual(status,200,'source download HTTP');sourceBytes=bytes
assertEqual(createHash('sha256').update(bytes).digest('hex'),reference.version.sourceSha256,'source SHA-256 matches published version')
},({bytes})=>({sizeBytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')}))
const replacement=channelGateWorkbook(sourceBytes,reference,payload.channelCode)
proof.sourceVersionId=reference.version.id;proof.sourceSheetName=replacement.sourceSheetName;proof.removedChannelCodes=replacement.removedCodes
const codesA=reference.channels.map(channel=>channel.channelCode).sort()
const codesB=codesA.filter(code=>!replacement.removedCodes.includes(code))
const channelList=async(versionId,containsTarget,label)=>{
const response=await apiStep(module,label,{
path:`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${masterData.receiving.id}`,token:merchantToken,
},({json})=>{
requireSuccess(json);assertTrue(Array.isArray(json.data),'client channel list must be a successful array')
assertEqual(json.data.some(channel=>channel.channelCode===payload.channelCode),containsTarget,'target channel visibility')
assertTrue(json.data.every(channel=>String(channel.priceBookVersionId)===String(versionId)),'client must read private version, never fall back to public')
},({json})=>({customerId,versionId,channelCodes:json.data.map(channel=>channel.channelCode)}))
return response.json.data
}
const publish=async(bytes,label,expectedCodes)=>{
const sha256=createHash('sha256').update(bytes).digest('hex'), form=new FormData()
form.append('file',new Blob([bytes],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),`gate-${marker}-${label}.xlsx`)
let parsed
await apiStep(module,`upload customer-only version ${label}`,{
method:'POST',path:`${base}?customerId=${customerId}`,token:platformToken,body:form,
},({json})=>{
requireSuccess(json);parsed=json.data
assertEqual(String(parsed?.version?.customerId),customerId,'uploaded version exclusively belongs to new customer')
assertPresent(parsed.version.id,'uploaded version identity');assertEqual(parsed.version.status,'PENDING_CONFIRMATION','upload stays pending')
assertEqual(parsed.version.sourceSha256,sha256,'uploaded source digest')
assertOrderUnchanged(expectedCodes,[...parsed.channels.map(channel=>channel.channelCode)].sort())
},({json})=>({customerId,versionId:json.data?.version?.id,sourceSha256:sha256}))
const entry={label,id:String(parsed.version.id),customerId,sourceSha256:sha256,status:'PENDING_CONFIRMATION'}
proof.versions.push(entry)
const mappings=channelGateMappings(parsed,reference)
await apiStep(module,`recheck exact ownership immediately before publishing ${label}`,{
path:`${base}/${entry.id}`,token:platformToken,
},({json})=>{
requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'publication ownership')
assertEqual(String(json.data.version.id),entry.id,'publication version');assertEqual(json.data.version.sourceSha256,sha256,'publication source')
assertEqual(json.data.version.status,'PENDING_CONFIRMATION','publication precondition')
},()=>({customerId,versionId:entry.id}))
await apiStep(module,`publish private ${label} with source-verified warehouse mappings`,{
method:'POST',path:`${base}/${entry.id}/publish`,token:platformToken,body:mappings,
},({json})=>{
requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'published scope')
assertEqual(String(json.data.version.id),entry.id,'published identity');assertEqual(json.data.version.status,'ACTIVE','published status')
assertEqual(Number(json.data.version.blockingErrorCount),0,'no ignored publication blockers')
assertOrderUnchanged(expectedCodes,[...json.data.channels.map(channel=>channel.channelCode)].sort())
assertTrue(Array.isArray(json.data.rates)&&json.data.rates.length>0,'remaining real rates')
},()=>({customerId,versionId:entry.id,mappings}))
entry.status='ACTIVE'
return entry.id
}
try {
const a=await publish(sourceBytes,'A',codesA)
proof.clientChannelsA=await channelList(a,true,'client sees target channel in private A')
const gatePayload={...structuredClone(payload),customerRemark:`CHANNEL-GATE-${marker}`}
const created=await apiStep(module,'create fresh draft while target channel is available in A',{
method:'POST',path:'/api/merchant/v1/orders/first-leg',token:merchantToken,body:gatePayload,
headers:{'Idempotency-Key':newIdempotencyKey('channel-gate-create')},
},({json})=>{requireSuccess(json);assertPresent(json.data,'channel-gate draft')},({json})=>({orderId:json.data}))
const orderId=created.json.data;proof.orderId=orderId;report.resources.orderIds.push(orderId)
const before=await ownedOrderSnapshot(module,'freeze original A draft version and full facts',orderId)
assertEqual(before.order.status,'DRAFT','unfulfilled channel gate fixture')
assertEqual(before.order.channelCode,payload.channelCode,'original draft channel')
const fulfillment=await submissionGateFulfillmentSnapshot(before.order,'before private channel removal')
const commands=Object.fromEntries(['merchant','platform'].map(role=>[role,orderMutationCommand(before,{},newIdempotencyKey(`removed-channel-${role}`))]))
proof.commands=commands
const b=await publish(replacement.bytes,'B',codesB)
proof.clientChannelsB=await channelList(b,false,'client target channel is removed in private B')
const own=await list(customerId,'verify A superseded only within this customer and B active')
assertTrue(own.length===2 && own.some(version=>String(version.id)===a&&version.status==='SUPERSEDED')
&& own.some(version=>String(version.id)===b&&version.status==='ACTIVE'),'exact customer A to B replacement')
for(const role of ['merchant','platform']) {
await apiStep(module,`${role} original draft rejects now-unavailable channel`,{
method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
token:role==='merchant'?merchantToken:platformToken,...commands[role],
},({json,status})=>{
assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0409','unavailable channel conflict')
assertEqual(json?.message,'所选渠道已下线,请重新选择','channel-specific rejection, not version or qualification conflict')
},()=>({customerId,orderId,expectedVersion:commands[role].body.expectedVersion,channelCode:payload.channelCode}))
await ownedOrderSnapshot(module,`${role} channel rejection leaves original order unchanged`,orderId,before)
assertOrderUnchanged(fulfillment,await submissionGateFulfillmentSnapshot(before.order,`${role} channel rejection`))
}
proof.status='SUBMISSIONS_REJECTED'
} finally {
assertOrderUnchanged(publicBefore,await list('0','verify every public price-book version unchanged'))
proof.publicVersionsUnchanged=true
}
proof.status='REJECTIONS_VERIFIED'
report.orderSubmissionGates.remaining=report.orderSubmissionGates.remaining.filter(item=>!item.startsWith('Channel-offline requires'))
}
async function importOrderSet(name, expectedIds) {
const ids = [], seen = new Set()
let total = null