Files
client-web/src/modules/cross-border/procurement.test.mjs
T
2026-10-30 17:59:48 +08:00

319 lines
21 KiB
JavaScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { runInNewContext } from 'node:vm'
import ts from 'typescript'
import * as vue from 'vue'
import { parse, compileScript } from 'vue/compiler-sfc'
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
let nextKey = 0
const crypto = { randomUUID: () => `test-key-${++nextKey}` }
function moduleFrom(source, require = () => { throw new Error('unexpected import') }) {
const context = { exports: {}, require, URL, Error, crypto }
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
runInNewContext(code, context)
return context.exports
}
const presentation = moduleFrom(readFileSync(new URL('./procurementPresentation.ts', import.meta.url), 'utf8'))
const submission = moduleFrom(readFileSync(new URL('./procurementSubmission.ts', import.meta.url), 'utf8'), name => {
if (name === './procurementPresentation') return presentation
throw new Error(`unexpected import ${name}`)
})
const normalized = value => JSON.parse(JSON.stringify(value))
const draft = () => ({ ...presentation.emptyProcurementDraft(), purchaseCategory: 'Sample', productName: 'Test product', productSpecification: 'A',
productUrl: 'https://example.com/product', purchaseQuantity: '3', budgetUnitPrice: '0.29', receivingAddress: '测试省测试市测试街 1 号', contactName: '测试收件人', contactPhone: '13800000000' })
const fee = (purpose, walletStatus, amount) => ({ purpose, walletStatus, amount, currency: 'CNY' })
test('budget distinguishes unknown from zero, preserves cents, and requires a real telephone/address', () => {
assert.equal(presentation.draftBudgetTotal(draft()), '0.87')
const valid = presentation.buildProcurementCreate(draft(), 'Authenticated Company', 'same-key')
assert.equal(valid.customerName, 'Authenticated Company')
assert.equal(valid.budgetUnitPrice, 0.29)
for (const value of ['', '-1', '1.001', '90071992547409.91']) assert.throws(() => presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: value }, 'Company', 'key'), /预算/)
assert.equal(presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: '0' }, 'Company', 'key').budgetUnitPrice, 0)
for (const patch of [{ contactPhone: 'wechat-name' }, { contactPhone: 'a@test.invalid' }, { receivingAddress: '' }, { purchaseQuantity: '1.5' }]) {
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), ...patch }, 'Company', 'key'))
}
assert.equal(presentation.measurement(20, 2), '20.00')
assert.equal(presentation.measurement('5.125', 3), '5.125')
assert.equal(presentation.measurement('1.234', 2), '待核对')
assert.equal(presentation.money(null), '—')
assert.equal(presentation.money(0), 'CNY 0.00')
assert.equal(presentation.productHref('javascript:alert(1)'), undefined)
assert.equal(presentation.productHref('https://example.com/item'), 'https://example.com/item')
})
test('successful net debit excludes the original estimate and counts only successful refunds and differences', () => {
const result = presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'SETTLED', '100'), fee('INTERNATIONAL_FREIGHT', 'SETTLED', '100'),
fee('ADJUSTMENT', 'SETTLED', '20'), fee('ADJUSTMENT', 'SETTLED', '10'), fee('REFUND', 'REFUNDED', '20'), fee('REFUND', 'REFUND_PENDING', '5')])
assert.deepEqual(normalized(result), { paid: '130.00', refunded: '20.00', net: '110.00', estimatedHold: '0.00' })
assert.equal(presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'RELEASE_PENDING', '100')]).estimatedHold, '100.00')
assert.equal(presentation.feeTotals([fee('DUTY', 'SETTLED', 'unknown')]), null)
assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'FROZEN', 100)), '已冻结,未扣款')
assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'SETTLED', 100)), '原预估已结清')
assert.equal(presentation.feeStatus(fee('DUTY', null, 100)), '待核定')
})
test('new requests get unique keys without restoring stored submissions', () => {
assert.notEqual(submission.procurementRequestKey(crypto), submission.procurementRequestKey(crypto))
})
class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } }
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
function viewHarness({ createPage = false, detailPage = false, api = {}, storage = new Map() } = {}) {
const props = vue.reactive({ createPage, detailPage })
const route = vue.reactive({ params: { orderNo: detailPage ? 'PO-1' : '' } })
const auth = vue.reactive({ token: 'identity-A', user: { tenantName: 'Company' }, profile: { tenantName: 'Company' }, hasPermission: () => true })
const pushes = [], dispose = []
const defaults = { getProcurementWorkflow: async orderNo => ({ orderNo, version: 1, status: 'WAIT_CUSTOMER_CONFIRM', receipts: [], outbounds: [] }), confirmProcurement: async () => ({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }), listProcurements: async () => ({ records: [], total: 0 }), getProcurement: async orderNo => ({ order: { orderNo } }), createProcurement: async () => ({ order: { orderNo: 'PO-CREATED' } }) }
const { descriptor } = parse(readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8'))
const source = compileScript(descriptor, { id: 'procurement-test' }).content
const context = { exports: {}, Error, crypto, sessionStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key) },
require(name) {
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => dispose.push(callback), watch: (...args) => { const stop = vue.watch(...args); dispose.push(stop); return stop } }
if (name === 'vue-router') return { useRoute: () => route, useRouter: () => ({ push: async target => pushes.push(target) }) }
if (name === 'element-plus') return { ElMessage: { success: () => {} }, ElMessageBox: { confirm: async () => {}, prompt: async (...args) => { const value = api.rejectionReason ?? '照片不清晰'; const valid = args[2].inputValidator(value); if (valid !== true) throw new Error(valid); return { value } } } }
if (name === '@/api/types') return { ApiError }
if (name === '@/api/session') return { accessTokenIdentity: token => token }
if (name === '@/modules/auth/public') return { useAuthStore: () => auth }
if (name === '../api/orderBudgets') return { requireOrderBudget: api.requireOrderBudget || (async () => {}) }
if (name === '../api/procurement') return { ...defaults, ...api }
if (name === '../procurementPresentation') return presentation
if (name === '../procurementSubmission') return submission
if (name.endsWith('.vue')) return { default: {} }
throw new Error(`unexpected view import ${name}`)
},
}
runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, context)
const state = context.exports.default.setup(props, { expose: () => {} })
state.formRef.value = {
validate: async () => { await new Schema(state.formRules).validate(state.form); return true },
clearValidate: () => {},
}
return { state, props, route, auth, pushes, storage, dispose: () => dispose.forEach(callback => callback()) }
}
test('procurement required fields validate individually and blank submissions never call the API', async () => {
const calls = []
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); return { order: { orderNo: 'PO-VALID' } } } } })
try {
const fields = ['procurementDirection', 'productName', 'productUrl', 'purchaseQuantity', 'budgetUnitPrice', 'currency', 'contactName', 'contactPhone', 'receivingAddress']
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
for (const field of fields) {
assert.match(source, new RegExp(`<el-form-item[^>]*prop="${field}"`))
const schema = new Schema({ [field]: harness.state.formRules[field] })
await assert.rejects(schema.validate({ [field]: '' }), error => Boolean(error.fields[field]))
}
await assert.rejects(new Schema(harness.state.formRules).validate({ ...draft(), productName: ' ' }))
await harness.state.submit()
assert.equal(calls.length, 0)
assert.equal(harness.state.submitting.value, false)
assert.equal(harness.state.submitError.value, '')
Object.assign(harness.state.form, draft(), { budgetUnitPrice: '0' })
await harness.state.submit()
assert.equal(calls.length, 1)
assert.equal(calls[0].budgetUnitPrice, 0)
assert.deepEqual(normalized(harness.pushes), [{ name: 'cross-border-procurement' }])
} finally { harness.dispose() }
})
test('create summary follows the current form, preserves cents and clears with the draft', () => {
const harness = viewHarness({ createPage: true })
try {
assert.equal(harness.state.summaryQuantity.value, '1')
assert.equal(harness.state.summaryUnitPrice.value, '—')
assert.equal(harness.state.budgetTotal.value, null)
assert.equal(harness.state.summaryCurrencyName.value, '美元')
Object.assign(harness.state.form, draft())
assert.equal(harness.state.summaryQuantity.value, '3')
assert.equal(harness.state.summaryUnitPrice.value, '0.29')
assert.equal(harness.state.budgetTotal.value, '0.87')
harness.state.form.currency = 'CNY'
assert.equal(harness.state.summaryCurrencyName.value, '人民币')
assert.equal(presentation.money(harness.state.budgetTotal.value, harness.state.form.currency), 'CNY 0.87')
harness.state.form.budgetUnitPrice = '0'
assert.equal(harness.state.summaryUnitPrice.value, '0.00')
assert.equal(harness.state.budgetTotal.value, '0.00')
harness.state.resetForm()
assert.equal(harness.state.summaryQuantity.value, '1')
assert.equal(harness.state.summaryUnitPrice.value, '—')
assert.equal(harness.state.budgetTotal.value, null)
assert.equal(harness.state.summaryCurrencyName.value, '美元')
} finally { harness.dispose() }
})
test('create summary never retains a stale budget when quantity or price becomes invalid', () => {
const harness = viewHarness({ createPage: true })
try {
Object.assign(harness.state.form, draft())
for (const quantity of ['', '0', '-1', '1.5', '9007199254740992']) {
harness.state.form.purchaseQuantity = quantity
assert.equal(harness.state.summaryQuantity.value, '—')
assert.equal(harness.state.budgetTotal.value, null)
assert.equal(harness.state.form.purchaseQuantity, quantity)
}
harness.state.form.purchaseQuantity = '3'
assert.equal(harness.state.budgetTotal.value, '0.87')
for (const price of ['', '-1', '1.001', 'invalid']) {
harness.state.form.budgetUnitPrice = price
assert.equal(harness.state.summaryUnitPrice.value, '—')
assert.equal(harness.state.budgetTotal.value, null)
assert.equal(harness.state.form.budgetUnitPrice, price)
}
harness.state.form.budgetUnitPrice = '0.29'
assert.equal(harness.state.budgetTotal.value, '0.87')
} finally { harness.dispose() }
})
test('detail has one in-flight request and an older response cannot overwrite another order', async () => {
const calls = [], pending = new Map()
const harness = viewHarness({ detailPage: true, api: { getProcurement: orderNo => { calls.push(orderNo); return new Promise(resolve => pending.set(orderNo, resolve)) } } })
try {
assert.deepEqual(calls, ['PO-1'])
await harness.state.loadDetail(); assert.equal(calls.length, 1)
harness.route.params.orderNo = 'PO-2'; await flush(); assert.deepEqual(calls, ['PO-1', 'PO-2'])
pending.get('PO-1')({ order: { orderNo: 'PO-1' } }); await flush(); assert.equal(harness.state.detail.value, null)
pending.get('PO-2')({ order: { orderNo: 'PO-2' } }); await flush(); assert.equal(harness.state.detail.value.order.orderNo, 'PO-2')
assert.equal(harness.state.loading.value, false)
} finally { harness.dispose() }
})
test('timeout releases the form and another click submits the edited current values', async () => {
const calls = []
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } })
try {
Object.assign(harness.state.form, draft()); await harness.state.submit()
assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0)
harness.state.form.productName = 'changed'; await harness.state.submit()
assert.equal(calls[1].productName, 'changed'); assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.equal(harness.state.submitting.value, false)
} finally { harness.dispose() }
})
test('submission double-click sends once and definitive validation rejection permits correction', async () => {
let reject, calls = 0
const harness = viewHarness({ createPage: true, api: { createProcurement: () => { calls++; return new Promise((_, failure) => { reject = failure }) } } })
try {
Object.assign(harness.state.form, draft()); const first = harness.state.submit(); await harness.state.submit(); await flush(); assert.equal(calls, 1)
reject(new ApiError('invalid', 'A0422')); await first
assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0)
} finally { harness.dispose() }
})
test('pagination batches one request and switching account clears old rows before the new response', async () => {
const calls = []; let resolveSecond
const harness = viewHarness({ api: { listProcurements: async params => {
calls.push(normalized(params)); if (calls.length < 3) return { records: [{ orderNo: 'PO-A' }], total: 100 }
return new Promise(resolve => { resolveSecond = resolve })
} } })
try {
await flush(); assert.equal(calls.length, 1)
harness.state.query.page = 2; harness.state.query.size = 50; await flush(); assert.equal(calls.length, 2)
harness.auth.token = 'identity-B'; await vue.nextTick(); assert.equal(harness.state.rows.value.length, 0); assert.equal(harness.state.total.value, 0)
resolveSecond({ records: [{ orderNo: 'PO-B' }], total: 1 }); await flush(); assert.equal(harness.state.rows.value[0].orderNo, 'PO-B')
assert.ok(calls.every(params => !('customerId' in params) && !('tenantId' in params)))
} finally { harness.dispose() }
})
test('consecutive timeout and permission failures never persist or freeze a submission', async () => {
let attempt = 0
const harness = viewHarness({ createPage: true, api: { createProcurement: async () => { throw new ApiError('failed', ++attempt === 1 ? 'TIMEOUT' : 'A0403') } } })
try {
Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit()
assert.equal(attempt, 2); assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0)
harness.state.form.productName = 'editable'; assert.equal(harness.state.form.productName, 'editable')
} finally { harness.dispose() }
})
test('purchase category is optional while its length remains bounded', () => {
const payload = presentation.buildProcurementCreate({ ...draft(), purchaseCategory: ' ' }, 'Company', 'key')
assert.equal(payload.purchaseCategory, '')
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/)
})
test('domestic purchase creates an export order and displays the actual origin and destination regions', () => {
const payload = presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'DOMESTIC_TO_OVERSEAS', receivingAddress: 'US, CA, Los Angeles, test destination' }, 'Authenticated Company', 'export-key')
assert.equal(payload.procurementDirection, 'DOMESTIC_TO_OVERSEAS')
assert.equal(payload.receivingAddress, 'US, CA, Los Angeles, test destination')
assert.equal(presentation.statusLabel('RECEIVING', payload.procurementDirection), '收货中')
assert.equal(presentation.statusLabel('DELIVERING', payload.procurementDirection), '派送中')
assert.equal(presentation.statusLabel('WAIT_CUSTOMER_CONFIRM', payload.procurementDirection), '待客户确认')
assert.equal(presentation.statusLabel('COMPLETED'), '签收完成')
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'INVALID' }, 'Company', 'key'), /采购方向/)
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
assert.match(source, /v-model="form.procurementDirection"/)
assert.match(source, /regionText\('最终国内收件资料', form.procurementDirection\)/)
})
test('unchanged create retries reuse the idempotency key', async () => {
const calls = []
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } })
try {
Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit()
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.equal(harness.pushes.length, 0)
} finally { harness.dispose() }
})
test('bill-settled manual fees show review results without counting a balance debit', () => {
const bill = determination => ({ ...fee('OTHER', 'NOT_REQUIRED', '100'), determination })
assert.equal(presentation.feeStatus(bill('PENDING')), '账单结算 · 待审核')
assert.equal(presentation.feeStatus(bill('APPROVED')), '账单结算 · 已确认')
assert.equal(presentation.feeStatus(bill('REJECTED')), '账单结算 · 已拒绝')
assert.equal(presentation.feeTone(bill('REJECTED')), 'danger')
assert.equal(presentation.feeTotals([bill('PENDING'), bill('APPROVED'), bill('REJECTED')]).paid, '0.00')
})
test('procurement creates immediately while funding is asynchronous and exposes funding states', async () => {
let creates = 0
const harness = viewHarness({ createPage: true, api: { createProcurement: async () => { creates++; return { order: { orderNo: 'CG-PENDING', status: 'FUNDING_PENDING' } } } } })
try {
Object.assign(harness.state.form, draft()); await harness.state.submit()
assert.equal(creates, 1); assert.equal(harness.state.submitError.value, '')
assert.equal(harness.state.submittedOrder.value, 'CG-PENDING')
assert.equal(presentation.statusLabel('FUNDING_PENDING'), '订单受理中')
assert.equal(presentation.statusLabel('FUNDING_FAILED'), '订单处理失败')
} finally { harness.dispose() }
})
test('confirmation and rejection are list actions only while waiting for customer confirmation', async () => {
let status = 'WAIT_CUSTOMER_CONFIRM'
const harness = viewHarness({ api: { listProcurements: async () => ({ records: [{ orderNo: 'PO-1', status, purchaseQuantity: 10 }], total: 1 }) } })
try {
await flush()
const actions = harness.state.rowActions(harness.state.rows.value[0])
assert.deepEqual(normalized(actions.map(action => action.key)), ['detail', 'reject', 'confirm'])
actions[1].run(); assert.equal(harness.state.decision.value, 'reject')
assert.equal(harness.state.decisionTarget.value.orderNo, 'PO-1')
status = 'CONFIRMED'; harness.state.decisionChanged(); await flush()
assert.equal(harness.state.decisionTarget.value, null)
assert.deepEqual(normalized(harness.state.rowActions(harness.state.rows.value[0]).map(action => action.key)), ['detail'])
harness.auth.hasPermission = () => false
assert.equal(harness.state.rowActions({ orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM' }).length, 1)
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
const detailHeader = source.slice(source.indexOf('<header class="procurement-heading">'), source.indexOf('</header>', source.indexOf('<header class="procurement-heading">')))
assert.doesNotMatch(detailHeader, /确认采购结果|驳回/)
} finally { harness.dispose() }
})
test('product links must be nonblank and use HTTP or HTTPS before budget reservation', async () => {
for (const productUrl of ['', ' ', 'javascript:alert(1)', 'invalid', 'https://' ]) {
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), productUrl }, 'Company', 'key'), /商品链接/)
}
let budgets = 0, creates = 0
const harness = viewHarness({ createPage: true, api: { requireOrderBudget: async () => { budgets++ }, createProcurement: async () => { creates++ } } })
try {
for (const productUrl of ['', ' ']) {
Object.assign(harness.state.form, draft(), { productUrl }); await harness.state.submit()
}
assert.equal(budgets, 0); assert.equal(creates, 0); assert.equal(harness.pushes.length, 0)
} finally { harness.dispose() }
})