test(order): verify qualification and isolated negative balance gates

This commit is contained in:
2026-10-03 00:27:45 +08:00
parent c0c6e21833
commit 9803dcfd2c
2 changed files with 232 additions and 9 deletions
+160 -9
View File
@@ -91,6 +91,7 @@ let temporaryOwnerPassword
try {
await registrationModule()
await platformReviewerLogin()
await merchantBeforeApprovalModule()
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
@@ -99,6 +100,7 @@ try {
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderSubmissionBalanceGateModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await notificationInboxModule()
@@ -219,13 +221,29 @@ async function merchantBeforeApprovalModule() {
assertEqual(String(json.data), String(orderId), 'replayed order id')
}, ({ json }) => ({ orderId: json.data, replayed: true }))
const qualificationBefore = await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId)
const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'before qualification rejection')
await apiStep('order-before-approval', 'submit is blocked before approval', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')),
...orderMutationCommand(qualificationBefore, {}, newIdempotencyKey('pre-approval-submit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
await ownedOrderSnapshot('order-before-approval', 'qualification rejection preserves complete order facts', orderId, qualificationBefore)
assertOrderUnchanged(qualificationFulfillmentBefore,
await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'after qualification rejection'))
report.qualificationRejectionEvidence = { orderId, orderSha256: orderDigest(qualificationBefore), fulfillmentSha256: orderDigest(qualificationFulfillmentBefore) }
}
async function platformReviewerLogin() {
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
}
async function qualificationAndReviewModule() {
@@ -305,14 +323,6 @@ async function qualificationAndReviewModule() {
assertEqual(json.data?.status, 'PENDING', 'tenant status')
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization`
for (let attempt = 0; attempt < 30; attempt += 1) {
const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken })
@@ -2313,6 +2323,147 @@ async function orderMutationEvidenceModule() {
]
}
async function submissionGateFulfillmentSnapshot(order, name) {
const module = 'order-submission-gates', customerId = String(report.resources.tenantId)
let task
await apiStep(module, `${name}: exact fulfillment task`, {
path: `/api/platform/v1/first-leg/tasks/by-order/${order.id}?tenantId=${customerId}`, token: platformToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'task lookup HTTP')
assertTrue(['200','A0404'].includes(String(json?.code)), 'task lookup must succeed or explicitly not exist')
task = String(json.code) === 'A0404' ? null : json.data ?? null
if (task) {
assertEqual(String(task.orderId), String(order.id), 'task source order')
assertEqual(String(task.tenantId), customerId, 'task customer')
}
}, () => ({ orderId: order.id, taskSha256: orderDigest(task) }))
const inbounds = []
let total
for (let page = 1; ; page++) {
await apiStep(module, `${name}: inbound page ${page}`, {
path: `/api/platform/inbounds?tenantId=${customerId}&orderNo=${encodeURIComponent(order.orderNo)}&page=${page}&size=200`, token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'inbound records required')
assertTrue(Number.isSafeInteger(Number(json.data.total)) && Number(json.data.total) >= 0, 'inbound total required')
if (total === undefined) total = Number(json.data.total)
assertEqual(Number(json.data.total), total, 'stable inbound total')
assertEqual(Number(json.data.page), page, 'inbound page identity')
for (const row of json.data.records) {
assertEqual(row.orderNo, order.orderNo, 'exact inbound source')
assertEqual(String(row.tenantId), customerId, 'inbound customer')
assertTrue(!inbounds.some(before => String(before.id) === String(row.id)), 'no duplicate inbound across pages')
inbounds.push(row)
}
assertEqual(json.data.records.length, Math.min(200, Math.max(0, total - (page - 1) * 200)), 'complete inbound page')
}, () => ({ orderId: order.id, total, collected: inbounds.length, snapshotSha256: orderDigest(inbounds) }))
if (page * 200 >= total) break
}
return { task, inbounds }
}
async function orderSubmissionBalanceGateModule() {
const module = 'order-submission-gates', customerId = String(report.resources.tenantId)
const proof = report.orderSubmissionGates = { customerId, balance: { status: 'PREPARING' }, remaining: [
'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.',
'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.',
] }
await apiStep(module, 'guard newly registered customer ownership before financial fixture', {
path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.id), customerId, 'current primary client identity')
assertEqual(json.data?.status, 'ACTIVE', 'approved active customer')
assertTrue(report.steps.some(step => step.module === 'registration' && step.name === 'enterprise self-registration'
&& step.passed === true && String(step.evidence?.tenantId) === customerId), 'only this run newly registered customer may be adjusted')
}, () => ({ customerId, scope: 'current-run-registration-only' }))
const readWallet = async label => {
const response = await apiStep(module, label, { path: '/api/client/finance/v1/account', token: merchantToken }, ({ json }) => {
requireSuccess(json); assertTrue(Number.isSafeInteger(json.data?.version), 'wallet version required')
assertEqual(json.data.currencyCode, 'CNY', 'isolated wallet currency')
for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) {
assertTrue(json.data[field] != null && Number.isFinite(Number(json.data[field])), `wallet ${field} required`)
}
assertEqual(json.data.creditActive, false, 'no active credit may be altered')
}, ({ json }) => ({ customerId, account: json.data }))
return response.json.data
}
const readLedger = async label => {
const response = await apiStep(module, label, { path: '/api/client/finance/v1/account/ledger?page=1&size=200', token: merchantToken }, ({ json }) => {
requireSuccess(json); assertTrue(Array.isArray(json.data?.records), 'ledger records required')
assertEqual(Number(json.data.total), json.data.records.length, 'entire isolated ledger fits one page')
}, ({ json }) => ({ total: json.data.total, ledgerSha256: orderDigest(json.data.records) }))
return response.json.data.records
}
const baseline = await readWallet('freeze pristine account')
for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(baseline[field]), 0, `pristine ${field}`)
assertEqual((await readLedger('require no prior ledger')).length, 0, 'never debit an existing funded account')
const payload = { ...structuredClone(orderAcceptanceFixture.payload), customerRemark: `BALANCE-GATE-${marker}` }
const created = await apiStep(module, 'create only one fresh balance-gate draft', {
method:'POST', path:'/api/merchant/v1/orders/first-leg', token:merchantToken, body:payload,
headers:{'Idempotency-Key':newIdempotencyKey('balance-gate-create')},
}, ({json})=>{requireSuccess(json);assertPresent(json.data,'gate order')}, ({json})=>({orderId:json.data}))
const orderId=created.json.data
report.resources.orderIds.push(orderId);proof.balance.orderId=orderId
const before = await ownedOrderSnapshot(module,'freeze rejected-submit draft facts',orderId)
assertEqual(before.order.status,'DRAFT','unfulfilled balance fixture')
const fulfillmentBefore = await submissionGateFulfillmentSnapshot(before.order,'before balance rejection')
const occurredDate = new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())
const referenceNo = `BALANCE-GATE-${marker}`.slice(0,128)
const adjust = async (type, account, suffix) => {
const command={headers:{'Idempotency-Key':newIdempotencyKey(`balance-${suffix}`)},body:{type,amount:0.01,occurredDate,paymentMethod:'OTHER',
referenceNo,remark:`${marker} isolated order gate ${suffix}`,expectedAccountVersion:account.version}}
proof.balance[`${suffix}Command`]=command
await apiStep(module,`isolated wallet ${suffix}`,{method:'POST',path:`/api/platform/finance/v1/accounts/${customerId}/adjustments`,
token:platformToken,...command},({json})=>requireSuccess(json),({json})=>({customerId,ledger:json.data}))
}
let attempted=false
try {
attempted=true
await adjust('DEDUCTION',baseline,'deduct')
const negative=await readWallet('verify actual balance minus one cent')
assertEqual(Number(negative.actualBalance),-0.01,'negative actual funds, not insufficient available credit')
assertEqual(negative.version,baseline.version+1,'one intentional deduction')
const negativeLedger=await readLedger('freeze deduction ledger')
assertEqual(negativeLedger.length,1,'only one fixture deduction')
for(const role of ['merchant','platform']) {
const command=orderMutationCommand(before,{},newIdempotencyKey(`negative-balance-${role}`))
await apiStep(module,`${role} negative actual balance rejects submission`,{
method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
token:role==='merchant'?merchantToken:platformToken,...command,
},({json,status})=>{assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0403','balance gate code');
assertEqual(json?.message,'账户实际余额为负,请充值后再下单','balance-specific reason, not qualification rejection')},()=>({orderId,customerId,expectedVersion:command.body.expectedVersion}))
await ownedOrderSnapshot(module,`${role} rejected order aggregate unchanged`,orderId,before)
const after=await submissionGateFulfillmentSnapshot(before.order,`${role} rejection`)
assertOrderUnchanged(fulfillmentBefore,after)
assertOrderUnchanged(negative,await readWallet(`${role} rejection leaves wallet unchanged`))
assertOrderUnchanged(negativeLedger,await readLedger(`${role} rejection leaves ledger unchanged`))
}
proof.balance.status='REJECTIONS_VERIFIED'
} finally {
if(attempted) {
const current=await readWallet('resolve isolated deduction before restoring')
const ledger=await readLedger('resolve exact fixture ledger before restoring')
if(Number(current.actualBalance)===0 && current.version===baseline.version && ledger.length===0) proof.balance.restoration='DEDUCTION_NOT_APPLIED'
else {
assertEqual(Number(current.actualBalance),-0.01,'restore only exact isolated negative balance')
assertEqual(current.version,baseline.version+1,'restore requires unchanged deduction version')
assertTrue(ledger.length===1 && ledger[0].sourceType==='MANUAL_DEDUCTION' && ledger[0].referenceNo===referenceNo
&& Number(ledger[0].amount)===-0.01,'restore only this run proven deduction; no blind credit')
await adjust('RECHARGE',current,'restore')
const restored=await readWallet('verify restored zero balance')
for(const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(restored[field]),Number(baseline[field]),`restored ${field}`)
assertEqual(restored.version,baseline.version+2,'one deduction and one restoring credit')
const finalLedger=await readLedger('verify exact two fixture ledger entries')
assertEqual(finalLedger.length,2,'no extra wallet movements')
assertTrue(finalLedger.every(row=>row.referenceNo===referenceNo) && finalLedger.filter(row=>row.sourceType==='MANUAL_DEDUCTION'&&Number(row.amount)===-0.01).length===1
&& finalLedger.filter(row=>row.sourceType==='MANUAL_RECHARGE'&&Number(row.amount)===0.01).length===1,'exact balanced fixture movements')
proof.balance.restoration='RESTORED_WITH_AUDIT'
}
}
}
}
async function importOrderSet(name, expectedIds) {
const ids = [], seen = new Set()
let total = null