173 lines
12 KiB
JavaScript
173 lines
12 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import { runInNewContext } from 'node:vm'
|
|
import ts from 'typescript'
|
|
import * as vue from 'vue'
|
|
import { parse, compileScript } from 'vue/compiler-sfc'
|
|
|
|
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',
|
|
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('request recovery preserves content and identity while malformed recovery cannot create a replacement', () => {
|
|
const original = presentation.buildProcurementCreate(draft(), 'Company', 'fixed-key')
|
|
assert.deepEqual(normalized(submission.restoreProcurementSubmission(JSON.stringify(original))), normalized(original))
|
|
assert.throws(() => submission.restoreProcurementSubmission('{}'))
|
|
assert.throws(() => submission.restoreProcurementSubmission(JSON.stringify({ ...original, currency: 'EUR' })))
|
|
assert.equal(submission.definitelyRejected({ code: 'TIMEOUT' }), false)
|
|
assert.equal(submission.definitelyRejected({ status: 500 }), false)
|
|
assert.equal(submission.definitelyRejected({ status: 409 }), false)
|
|
assert.equal(submission.definitelyRejected({ status: 400 }), true)
|
|
assert.equal(submission.procurementRequestKey({ getRandomValues: target => { target.set([1, 2, 3, 4]); return target } }), 'proc-00000001000000020000000300000004')
|
|
})
|
|
|
|
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 = { 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: () => {} } }
|
|
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/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: () => {} })
|
|
return { state, props, route, auth, pushes, storage, dispose: () => dispose.forEach(callback => callback()) }
|
|
}
|
|
|
|
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 retries the exact original body after reload and does not accept changed form data', async () => {
|
|
const storage = new Map(), calls = []
|
|
const first = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } })
|
|
try {
|
|
Object.assign(first.state.form, draft()); await first.state.submit(); assert.equal(calls.length, 1); assert.ok(first.state.pending.value)
|
|
first.state.form.productName = 'changed'; await first.state.submit(); assert.deepEqual(calls[1], calls[0])
|
|
} finally { first.dispose() }
|
|
const recovered = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); return { order: { orderNo: 'PO-SAME' } } } } })
|
|
try {
|
|
assert.equal(recovered.state.form.productName, 'Test product'); await recovered.state.submit(); assert.deepEqual(calls[2], calls[0])
|
|
assert.equal(storage.size, 0); assert.equal(recovered.pushes[0].params.orderNo, 'PO-SAME')
|
|
} finally { recovered.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(); assert.equal(calls, 1)
|
|
reject(new ApiError('invalid', 'A0422')); await first
|
|
assert.equal(harness.state.pending.value, null); 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('replay rejection cannot discard an earlier unknown creation outcome', async () => {
|
|
const calls = []; let attempt = 0
|
|
const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => {
|
|
calls.push(normalized(payload)); attempt++
|
|
if (attempt === 1) throw new ApiError('timeout', 'TIMEOUT')
|
|
throw new ApiError('permission revoked', 'A0403')
|
|
} } })
|
|
try {
|
|
Object.assign(harness.state.form, draft()); await harness.state.submit()
|
|
const saved = [...harness.storage.values()][0]; await harness.state.submit()
|
|
assert.equal([...harness.storage.values()][0], saved); assert.ok(harness.state.pending.value)
|
|
assert.deepEqual(calls[1], calls[0])
|
|
} 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.equal(submission.restoreProcurementSubmission(JSON.stringify(payload)).purchaseCategory, '')
|
|
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/)
|
|
})
|