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' const source = readFileSync(new URL('./components/ProcurementDecisionDialog.vue', import.meta.url), 'utf8') const { descriptor } = parse(source) const code = ts.transpileModule(compileScript(descriptor, { id: 'decision-test' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() } let key = 0 function harness({ decision = 'confirm', api = {} } = {}) { const props = vue.reactive({ order: { orderNo: 'PO-1', productName: '商品', purchaseQuantity: 10 }, decision }) const auth = vue.reactive({ token: 'customer-A', hasPermission: () => true }) const calls = [], events = [], disposers = [], created = [], revoked = [] const workflow = { orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM', version: 7, receivedQuantity: 8, files: [{ fileId: 'F-1', fileName: 'photo.png' }], receipts: [{ id: 'R-1', quantity: 5, fileIds: ['F-1'], receivedAt: '' }, { id: 'R-2', quantity: 3, fileIds: ['F-2'], receivedAt: '' }] } const defaults = { getProcurementWorkflow: async () => workflow, getProcurementFile: async () => ({ type: 'image/png' }), confirmProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'CONFIRMED' } }, rejectProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'RECEIVING' } }, } const context = { exports: {}, Error, URL: { createObjectURL: () => { const url = `blob:${created.length}`; created.push(url); return url }, revokeObjectURL: url => revoked.push(url) }, require(name) { if (name === 'vue') return { ...vue, onBeforeUnmount: callback => disposers.push(callback) } if (name === 'element-plus') return { ElMessage: { success: () => {} } } 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 === '../procurementSubmission') return { procurementRequestKey: () => `key-${++key}` } if (name === '../procurementPresentation') return { dateTime: value => value } throw new Error(`unexpected import ${name}`) } } runInNewContext(code, context) const state = context.exports.default.setup(props, { expose: () => {}, emit: (...args) => events.push(args) }) return { state, auth, calls, events, created, revoked, workflow, dispose: () => disposers.forEach(callback => callback()) } } test('both decisions load receipt photos and batch quantities with preview URL cleanup', async () => { for (const decision of ['confirm', 'reject']) { const h = harness({ decision }) try { await flush() assert.equal(h.state.workflow.value.receivedQuantity, 8) assert.equal(h.state.workflow.value.receipts.length, 2) assert.equal(Object.keys(h.state.photos.value).length, 2) assert.equal(h.state.canSubmit.value, true) assert.equal(h.state.fileName('F-1'), 'photo.png') assert.match(source, /order.purchaseQuantity/) assert.match(source, /receipt.quantity/) assert.match(source, / { let finish, calls = [] const h = harness({ api: { confirmProcurement: (orderNo, command) => { calls.push({ orderNo, ...command }); return new Promise(resolve => { finish = resolve }) } } }) try { await flush() const pending = h.state.submitDecision(); await h.state.submitDecision() assert.equal(calls.length, 1); assert.equal(calls[0].version, 7); assert.equal(calls[0].orderNo, 'PO-1') finish({ status: 'CONFIRMED' }); await pending assert.deepEqual(h.events, [['changed']]) } finally { h.dispose() } }) test('rejection requires a trimmed reason and retries unchanged payload with the same key', async () => { const calls = [] const h = harness({ decision: 'reject', api: { rejectProcurement: async (orderNo, command) => { calls.push({ orderNo, ...command }); throw new Error('网络失败') } } }) try { await flush() for (const reason of ['', ' ', 'x'.repeat(1001)]) { h.state.reason.value = reason; await h.state.submitDecision() assert.match(h.state.error.value, /请填写驳回原因/) } assert.equal(calls.length, 0) h.state.reason.value = ' 照片不清晰 ' await h.state.submitDecision(); await h.state.submitDecision() assert.equal(calls.length, 2); assert.equal(calls[0].reason, '照片不清晰'); assert.equal(calls[0].version, 7) assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) assert.equal(h.state.reason.value, ' 照片不清晰 '); assert.equal(h.events.length, 0) h.state.reason.value = '数量不符'; await h.state.submitDecision() assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey) } finally { h.dispose() } }) test('confirmation retry preserves the loaded evidence and idempotency key', async () => { const calls = [] const h = harness({ api: { confirmProcurement: async (_, command) => { calls.push(command); throw new Error('版本已更新,请刷新') } } }) try { await flush(); await h.state.submitDecision(); await h.state.submitDecision() assert.equal(calls.length, 2); assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) assert.equal(h.state.workflow.value.version, 7); assert.equal(Object.keys(h.state.photos.value).length, 2) assert.equal(h.state.saving.value, false); assert.equal(h.events.length, 0) } finally { h.dispose() } }) test('successful rejection sends the trimmed reason and signals list refresh', async () => { const h = harness({ decision: 'reject' }) try { await flush(); h.state.reason.value = ' 数量不符 '; await h.state.submitDecision() assert.equal(h.calls[0].reason, '数量不符'); assert.equal(h.calls[0].version, 7) assert.deepEqual(h.events, [['changed']]) } finally { h.dispose() } }) test('photo loading failures can be retried before a decision is submitted', async () => { let fail = true const h = harness({ api: { getProcurementFile: async () => { if (fail) throw new Error('failed'); return { type: 'image/png' } } } }) try { await flush(); await h.state.submitDecision() assert.equal(h.calls.length, 0); assert.match(h.state.photoError.value, /照片加载失败/) fail = false; await h.state.load(); await h.state.submitDecision() assert.equal(h.calls.length, 1) } finally { h.dispose() } }) test('stale status, revoked permission and changed account cannot submit decisions', async () => { const stale = harness({ api: { getProcurementWorkflow: async () => ({ status: 'CONFIRMED', receipts: [] }) } }) try { await flush(); await stale.state.submitDecision(); assert.equal(stale.calls.length, 0) } finally { stale.dispose() } const h = harness() try { await flush(); h.auth.hasPermission = () => false; await h.state.submitDecision(); assert.equal(h.calls.length, 0) h.auth.hasPermission = () => true; h.auth.token = 'customer-B'; await h.state.submitDecision(); assert.equal(h.calls.length, 0) } finally { h.dispose() } }) test('late photo responses after closing do not allocate preview URLs', async () => { const pending = [] const h = harness({ api: { getProcurementFile: () => new Promise(resolve => pending.push(resolve)) } }) await flush(); h.dispose() pending.forEach(resolve => resolve({ type: 'image/png' })); await flush() assert.equal(h.created.length, 0); assert.equal(h.events.length, 0) })