Files
client-web/src/modules/order-management/receiptDownload.test.mjs
T

191 lines
9.4 KiB
JavaScript

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, accessTokenIdentity: value => value, 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/)
})