|
|
|
@@ -0,0 +1,190 @@
|
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
import test from 'node:test'
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
import ts from 'typescript'
|
|
|
|
|
import axios from 'axios'
|
|
|
|
|
import { ref, computed } from 'vue'
|
|
|
|
|
import { mkdtemp, writeFile, utimes, rm } from 'node:fs/promises'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { verifyReceiptFile, acceptReceiptDownload } from './receiptBrowserAcceptance.mjs'
|
|
|
|
|
|
|
|
|
|
// Execute production HTTP, API and SFC setup code. Only transport, browser saving
|
|
|
|
|
// and UI feedback are replaced; this is not a browser/server isolation test.
|
|
|
|
|
function compile(path, dependencies, globals = {}) {
|
|
|
|
|
let source = readFileSync(new URL(path, import.meta.url), 'utf8')
|
|
|
|
|
if (path.endsWith('.vue')) {
|
|
|
|
|
source = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
|
|
|
|
+ '\nexport { downloadReceipt, loadReceipts, receiptFiles, downloadingFileId, receiptsError, feeColumns }'
|
|
|
|
|
}
|
|
|
|
|
source = source.replace('import.meta.env.VITE_API_BASE_URL', "'/api'")
|
|
|
|
|
const code = ts.transpileModule(source, { compilerOptions: {
|
|
|
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true,
|
|
|
|
|
} }).outputText
|
|
|
|
|
const exports = {}
|
|
|
|
|
new Function('require', 'exports', ...Object.keys(globals), code)((id) => {
|
|
|
|
|
assert.ok(id in dependencies, `Unexpected dependency ${id}`)
|
|
|
|
|
return dependencies[id]
|
|
|
|
|
}, exports, ...Object.values(globals))
|
|
|
|
|
return exports
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = Buffer.from([0, 255, 13, 10, 128, 1, 2, 3])
|
|
|
|
|
const file = { fileId: 'file/1', fileName: '签收证明 #1.bin', sizeBytes: payload.length }
|
|
|
|
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
|
|
|
|
|
|
|
|
test('delivery detail fee column renders formal fee names without changing unknown values', () => {
|
|
|
|
|
const { component } = harness()
|
|
|
|
|
const column = component.feeColumns.find(item => item.prop === 'feeType')
|
|
|
|
|
for (const [input, expected] of [
|
|
|
|
|
['DELIVERY_FEE', '派送费'], ['LOADING_FEE', '装车费'], ['APPOINTMENT_FEE', '预约费'],
|
|
|
|
|
['', '—'], [null, '—'], [undefined, '—'], ['OTHER_FEE', 'OTHER_FEE'], ['特殊服务费', '特殊服务费'],
|
|
|
|
|
]) assert.equal(column.formatter({}, {}, input), expected)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function harness({ token = 'customer-A', rejectDownload, rejectList, pending, clickError } = {}) {
|
|
|
|
|
const requests = [], saved = [], errors = [], revoked = [], urls = new Map()
|
|
|
|
|
const http = compile('../../api/http.ts', {
|
|
|
|
|
axios: { ...axios, create: config => axios.create({ ...config, adapter: async config => {
|
|
|
|
|
requests.push({ url: config.url, authorization: config.headers.get('Authorization'), responseType: config.responseType })
|
|
|
|
|
const binary = config.url.endsWith('/download')
|
|
|
|
|
const status = binary ? rejectDownload : rejectList
|
|
|
|
|
if (status) {
|
|
|
|
|
const response = { status, data: new Blob([JSON.stringify({ code: String(status), message: '无权访问签收文件' })], { type: 'application/json' }), headers: {}, config }
|
|
|
|
|
throw new axios.AxiosError('denied', 'ERR_BAD_REQUEST', config, null, response)
|
|
|
|
|
}
|
|
|
|
|
if (binary && pending) await pending
|
|
|
|
|
return { status: 200, headers: {}, config, data: binary
|
|
|
|
|
? new Blob([payload], { type: 'application/octet-stream' })
|
|
|
|
|
: { code: '200', message: '成功', data: { deliveryNo: 'DL/A', files: [file] } } }
|
|
|
|
|
} }) },
|
|
|
|
|
'@chongchong/shared': { parseApiJsonResponse: value => value },
|
|
|
|
|
'@/api/session': { getAccessToken: () => token, getRefreshToken: () => '', persistTokenPair() {}, clearSessionTokens() {} },
|
|
|
|
|
'@/api/errorDetails': compile('../../api/errorDetails.ts', {}),
|
|
|
|
|
'@/api/types': compile('../../api/types.ts', {}),
|
|
|
|
|
'@/api/crossTabRefresh': compile('../../api/crossTabRefresh.ts', {}),
|
|
|
|
|
})
|
|
|
|
|
const api = compile('./api/deliveryOrders.ts', { '@/api/http': http })
|
|
|
|
|
const component = compile('./views/DeliveryOrderDetailView.vue', {
|
|
|
|
|
vue: { ref, computed, onMounted() {} },
|
|
|
|
|
'vue-router': { useRouter: () => ({ push() {} }) },
|
|
|
|
|
'element-plus': { ElMessage: { error: message => errors.push(message) } },
|
|
|
|
|
dayjs: () => {}, '../api/deliveryOrders': api,
|
|
|
|
|
}, {
|
|
|
|
|
defineProps: () => ({ deliveryNo: 'DL/A' }),
|
|
|
|
|
URL: { createObjectURL(blob) { const url = `blob:${urls.size}`; urls.set(url, blob); return url }, revokeObjectURL(url) { revoked.push(url); urls.delete(url) } },
|
|
|
|
|
document: { createElement(tag) {
|
|
|
|
|
assert.equal(tag, 'a')
|
|
|
|
|
return { href: '', download: '', click() {
|
|
|
|
|
if (clickError) throw clickError
|
|
|
|
|
assert.ok(urls.has(this.href), 'blob must still exist when saving is requested')
|
|
|
|
|
saved.push({ fileName: this.download, blob: urls.get(this.href) })
|
|
|
|
|
} }
|
|
|
|
|
} },
|
|
|
|
|
})
|
|
|
|
|
return { component, requests, saved, errors, revoked, urls }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('C63 real handler passes exact binary bytes and Unicode filename to browser save', async () => {
|
|
|
|
|
const h = harness()
|
|
|
|
|
await h.component.loadReceipts()
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.equal(h.saved.length, 1)
|
|
|
|
|
assert.equal(h.saved[0].fileName, file.fileName)
|
|
|
|
|
const bytes = Buffer.from(await h.saved[0].blob.arrayBuffer())
|
|
|
|
|
assert.deepEqual(bytes, payload)
|
|
|
|
|
assert.equal(sha256(bytes), sha256(payload))
|
|
|
|
|
const changed = Buffer.from(payload); changed[0] ^= 1
|
|
|
|
|
assert.equal(changed.length, bytes.length)
|
|
|
|
|
assert.notEqual(sha256(bytes), sha256(changed), 'same size is not content evidence')
|
|
|
|
|
assert.deepEqual(h.requests.map(r => r.url), ['/merchant/v1/last-leg/delivery-orders/DL%2FA/documents/receipt', '/merchant/v1/last-leg/delivery-orders/DL%2FA/documents/receipt/files/file%2F1/download'])
|
|
|
|
|
assert.equal(h.requests[1].responseType, 'blob')
|
|
|
|
|
assert.equal(h.requests[1].authorization, 'Bearer customer-A')
|
|
|
|
|
assert.equal(h.component.downloadingFileId.value, '')
|
|
|
|
|
assert.equal(h.revoked.length, 1)
|
|
|
|
|
assert.equal(h.urls.size, 0)
|
|
|
|
|
assert.deepEqual(h.errors, [])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const status of [403, 404, 500]) {
|
|
|
|
|
test(`C63 HTTP ${status} never saves an error body and releases busy state`, async () => {
|
|
|
|
|
const h = harness({ rejectDownload: status })
|
|
|
|
|
await h.component.loadReceipts()
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.equal(h.saved.length, 0)
|
|
|
|
|
assert.equal(h.urls.size, 0)
|
|
|
|
|
assert.equal(h.revoked.length, 0)
|
|
|
|
|
assert.equal(h.errors.length, 1)
|
|
|
|
|
assert.equal(h.component.downloadingFileId.value, '')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('C63 unknown file and duplicate in-flight clicks do not issue another download', async () => {
|
|
|
|
|
let finish
|
|
|
|
|
const h = harness({ pending: new Promise(resolve => { finish = resolve }) })
|
|
|
|
|
await h.component.loadReceipts()
|
|
|
|
|
await h.component.downloadReceipt('not-in-receipt-list')
|
|
|
|
|
assert.equal(h.requests.length, 1)
|
|
|
|
|
const first = h.component.downloadReceipt(file.fileId)
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
finish()
|
|
|
|
|
await first
|
|
|
|
|
assert.equal(h.requests.length, 2)
|
|
|
|
|
assert.equal(h.saved.length, 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('C63 denied customer list clears prior file choices; denied download uses current customer session', async () => {
|
|
|
|
|
const h = harness({ token: 'customer-B', rejectList: 403, rejectDownload: 403 })
|
|
|
|
|
h.component.receiptFiles.value = [file]
|
|
|
|
|
await h.component.loadReceipts()
|
|
|
|
|
assert.equal(h.component.receiptFiles.value.length, 0)
|
|
|
|
|
assert.ok(h.component.receiptsError.value)
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.equal(h.requests.length, 1)
|
|
|
|
|
// Simulate a stale visible choice: the real API must still use B's session.
|
|
|
|
|
h.component.receiptFiles.value = [file]
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.ok(h.requests.every(r => r.authorization === 'Bearer customer-B'))
|
|
|
|
|
assert.equal(h.saved.length, 0)
|
|
|
|
|
assert.equal(h.errors.length, 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('C63 browser save exception releases object URL and permits retry', async () => {
|
|
|
|
|
const h = harness({ clickError: new Error('save failed') })
|
|
|
|
|
await h.component.loadReceipts()
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.equal(h.saved.length, 0)
|
|
|
|
|
assert.deepEqual(h.errors, ['save failed'])
|
|
|
|
|
assert.equal(h.revoked.length, 1)
|
|
|
|
|
assert.equal(h.component.downloadingFileId.value, '')
|
|
|
|
|
await h.component.downloadReceipt(file.fileId)
|
|
|
|
|
assert.equal(h.requests.length, 3)
|
|
|
|
|
assert.equal(h.errors.length, 2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('C63 disk evidence rejects same-size corruption, stale files and wrong filenames', async t => {
|
|
|
|
|
const directory = await mkdtemp(join(tmpdir(), 'c63-download-'))
|
|
|
|
|
t.after(() => rm(directory, { recursive: true, force: true }))
|
|
|
|
|
const path = join(directory, file.fileName)
|
|
|
|
|
const expected = { fileName: file.fileName, sizeBytes: payload.length, sha256: sha256(payload) }
|
|
|
|
|
const startedAt = Date.now() - 1000
|
|
|
|
|
await writeFile(path, payload)
|
|
|
|
|
assert.equal((await verifyReceiptFile(path, expected, startedAt)).passed, true)
|
|
|
|
|
const changed = Buffer.from(payload); changed[0] ^= 1
|
|
|
|
|
await writeFile(path, changed)
|
|
|
|
|
const corrupted = await verifyReceiptFile(path, expected, startedAt)
|
|
|
|
|
assert.equal(corrupted.checks.size, true)
|
|
|
|
|
assert.equal(corrupted.checks.content, false)
|
|
|
|
|
assert.equal(corrupted.passed, false)
|
|
|
|
|
await writeFile(path, payload)
|
|
|
|
|
await utimes(path, new Date(0), new Date(0))
|
|
|
|
|
assert.equal((await verifyReceiptFile(path, expected, startedAt)).checks.currentRun, false)
|
|
|
|
|
const wrongPath = join(directory, 'wrong-name.bin')
|
|
|
|
|
await writeFile(wrongPath, payload)
|
|
|
|
|
assert.equal((await verifyReceiptFile(wrongPath, expected, startedAt)).checks.fileName, false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('C63 browser evidence refuses missing fixture hash before interacting', async () => {
|
|
|
|
|
await assert.rejects(acceptReceiptDownload({}, { deliveryNo: 'DL/A', fileName: file.fileName }), /SHA-256/)
|
|
|
|
|
})
|