// Run after HBuilderX MP-WEIXIN compilation; execute the actual generated service with isolated API doubles. const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const repo = path.resolve(__dirname, '..'); const sourcePath = path.join(repo, 'packages/finance/services/report-export.uts'); const builtPath = path.resolve(process.argv[2] || path.join(repo, 'unpackage/dist/dev/mp-weixin/packages/finance/services/report-export.js')); assert.ok(fs.statSync(builtPath).mtimeMs >= fs.statSync(sourcePath).mtimeMs, 'Compile MP-WEIXIN before running this test'); const source = fs.readFileSync(builtPath, 'utf8'); const maxSize = 20 * 1024 * 1024; const pdf = Buffer.from('%PDF-1.7\n' + 'x'.repeat(2000) + '\n%%EOF\n'); let checks = 0; function environment() { const state = { token: 'synthetic-local-only', requests: [], opened: [], deleted: [], reads: [], files: new Map(), appShow: [], cleared: 0, aborted: 0, unlinkFails: false }; class JsonObject { constructor(value) { Object.assign(this, value); } getString(key) { return typeof this[key] === 'string' ? this[key] : null; } } class UtsType { static initProps(options) { return options; } } const api = { getSession: () => state.token ? new JsonObject({ token: state.token }) : null, clearSession: () => { state.cleared++; state.token = ''; } }; const config = { API_BASE_URL: 'https://isolated.invalid/api/v1' }; const manager = { unlinkSync(file) { if (state.unlinkFails) throw Error('simulated unlink failure'); assert.ok(state.files.has(file), 'Delete only a known temporary download'); state.files.delete(file); state.deleted.push(file); }, statSync: file => { if (state.statFails) throw Error('simulated stat failure'); return { size: state.size ?? state.files.get(file).length }; }, readFileSync(file, encoding, start = 0, length) { if (state.readFails) throw Error('simulated read failure'); state.reads.push({ encoding, start, length }); return state.files.get(file).subarray(start, length == null ? undefined : start + length).toString(encoding === 'utf8' ? 'utf8' : 'ascii'); } }; const uni = { getFileSystemManager: () => manager, onAppShow: callback => state.appShow.push(callback), downloadFile(options) { state.requests.push(options); state.request = options; return { abort() { state.aborted++; options.fail({ errMsg: 'downloadFile:fail abort' }); }, onHeadersReceived(callback) { state.headers = callback; }, onProgressUpdate(callback) { state.progress = callback; } }; }, openDocument(options) { state.opened.push(options); } }; const exports = {}; vm.runInNewContext(source, { exports, require(module) { if (module.endsWith('/common/vendor.js')) return { index: uni, UTSJSONObject: JsonObject, UTS: { UTSType: UtsType, JSON: { parse: text => new JsonObject(JSON.parse(text)) } } }; if (module.endsWith('/services/api.js')) return api; if (module.endsWith('/services/config.js')) return config; throw Error('Unexpected generated dependency: ' + module); } }, { filename: builtPath }); state.begin = (id = '45', revision = '72') => { const operation = exports.downloadReportPdf(id, revision); operation.result = operation.done.then(message => ({ ok: true, message }), error => ({ ok: false, message: error.message })); return operation; }; state.finish = (bytes = pdf, status = 200, mime = 'application/pdf', header = {}) => { state.headers({ header: { 'Content-Type': mime, 'Content-Length': String(bytes.length), ...header } }); const file = 'wxfile://tmp/isolated-' + (state.requests.length) + '.pdf'; state.files.set(file, bytes); state.request.success({ tempFilePath: file, statusCode: status }); return file; }; state.config = config; return state; } async function rejected(state, operation, message) { const result = await operation.result; assert.equal(result.ok, false); assert.match(result.message, message); assert.equal(state.opened.length, 0, 'Invalid or stale file must never reach the viewer'); } async function test(label, callback) { await callback(); checks++; console.log('PASS: ' + label); } async function main() { await test('authenticated historical download, bounded validation and preview menu', async () => { const s = environment(), op = s.begin(); assert.equal(s.request.url, 'https://isolated.invalid/api/v1/assessments/45/report.pdf?revisionId=72'); assert.equal(s.request.header.Authorization, 'Bearer synthetic-local-only'); assert.ok(!s.request.url.includes(s.token)); assert.equal(s.request.timeout, 45000); const file = s.finish(); assert.equal(s.opened[0].filePath, file); assert.equal(s.opened[0].fileType, 'pdf'); assert.equal(s.opened[0].showMenu, true); assert.ok(s.reads.every(read => read.length <= 1024)); op.cancel(); assert.equal(s.aborted, 0, 'Native preview must survive parent onHide'); s.opened[0].success(); assert.equal((await op.result).ok, true); assert.ok(s.files.has(file), 'Keep temporary file while previewing'); s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0, 'Remove file on return to app'); }); await test('default revision and next export cleanup', async () => { const s = environment(), first = s.begin('45', ''); assert.ok(!s.request.url.includes('?')); const file = s.finish(); s.opened[0].success(); await first.result; const second = s.begin(); assert.ok(s.deleted.includes(file)); second.cancel(); await second.result; }); for (const [id, revision] of [['0', '72'], ['45', '0'], ['45', '72&token=bad'], ['bad', '']]) { await test('invalid assessment/revision ' + id + '/' + revision, async () => { const s = environment(), op = s.begin(id, revision); await rejected(s, op, /编号无效/); assert.equal(s.requests.length, 0); }); } await test('no session does not download', async () => { const s = environment(); s.token = ''; const op = s.begin(); await rejected(s, op, /登录已过期/); assert.equal(s.requests.length, 0); }); await test('HTTPS required for mini program', async () => { const s = environment(); s.config.API_BASE_URL = 'http://isolated.invalid'; const op = s.begin(); await rejected(s, op, /HTTPS/); assert.equal(s.requests.length, 0); }); await test('401 clears only the current session and deletes error file', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json'); await rejected(s, op, /登录已过期/); assert.equal(s.cleared, 1); assert.equal(s.files.size, 0); }); await test('late 401 cannot clear another account', async () => { const s = environment(), op = s.begin(); s.token = 'another-synthetic-token'; s.finish(Buffer.from('{"msg":"expired"}'), 401, 'application/json'); await rejected(s, op, /状态已变化/); assert.equal(s.cleared, 0); assert.equal(s.token, 'another-synthetic-token'); assert.equal(s.files.size, 0); }); for (const status of [404, 409, 503]) { await test('JSON ' + status + ' is shown as error, never opened as PDF', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"报告暂不可用"}'), status, 'application/json; charset=utf-8'); await rejected(s, op, /报告暂不可用/); assert.equal(s.files.size, 0); }); } await test('JSON with HTTP200 is not a PDF', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{"msg":"错误回包"}'), 200, 'application/json'); await rejected(s, op, /错误回包/); }); await test('HTML or incorrect MIME cannot reach viewer', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'text/html'); await rejected(s, op, /暂不可下载/); }); await test('malformed JSON safely fails and cleans file', async () => { const s = environment(), op = s.begin(); s.finish(Buffer.from('{broken'), 503, 'application/json'); await rejected(s, op, /内容异常/); assert.equal(s.files.size, 0); }); for (const [label, bytes] of [['wrong PDF signature', Buffer.from('HTML!' + 'x'.repeat(100) + '%%EOF')], ['missing EOF', Buffer.from('%PDF-' + 'x'.repeat(100))], ['empty body', Buffer.alloc(0)], ['tiny body', Buffer.from('%PDF-%%EOF')]]) { await test(label, async () => { const s = environment(), op = s.begin(); s.finish(bytes); await rejected(s, op, /不是完整PDF/); assert.equal(s.files.size, 0); }); } await test('declared length mismatch rejects truncated binary', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Length': pdf.length + 1 }); await rejected(s, op, /不是完整PDF/); }); await test('compressed content length need not equal decoded file', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'application/pdf', { 'Content-Encoding': 'gzip', 'Content-Length': '500' }); s.opened[0].success(); assert.equal((await op.result).ok, true); }); await test('oversize header aborts before file read', async () => { const s = environment(), op = s.begin(); s.headers({ header: { 'Content-Length': maxSize + 1 } }); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); assert.equal(s.reads.length, 0); }); for (const progress of [{ totalBytesWritten: maxSize + 1, totalBytesExpectedToWrite: 0 }, { totalBytesWritten: 0, totalBytesExpectedToWrite: maxSize + 1 }]) { await test('oversize progress aborts download', async () => { const s = environment(), op = s.begin(); s.progress(progress); await rejected(s, op, /文件过大/); assert.equal(s.aborted, 1); }); } await test('oversize actual file rejected before reading', async () => { const s = environment(), op = s.begin(); s.size = maxSize + 1; s.finish(); await rejected(s, op, /文件过大/); assert.equal(s.reads.length, 0); }); await test('exactly 20MiB accepted with bounded reads', async () => { const s = environment(), op = s.begin(), bytes = Buffer.alloc(maxSize, 'x'); bytes.write('%PDF-1.7\n'); bytes.write('%%EOF\n', maxSize - 6); s.progress({ totalBytesWritten: maxSize, totalBytesExpectedToWrite: maxSize }); s.finish(bytes); s.opened[0].success(); assert.equal((await op.result).ok, true); assert.equal(s.aborted, 0); assert.equal(s.reads.reduce((sum, read) => sum + read.length, 0), 1029); }); await test('case insensitive MIME and missing content length accepted', async () => { const s = environment(), op = s.begin(); s.finish(pdf, 200, 'APPLICATION/PDF; charset=binary', { 'Content-Length': '' }); s.opened[0].success(); assert.equal((await op.result).ok, true); }); await test('cancellation rejects immediately and cleans late successful response', async () => { const s = environment(), op = s.begin(); op.cancel(); await rejected(s, op, /下载已取消/); s.finish(); assert.equal(s.opened.length, 0); assert.equal(s.files.size, 0); }); await test('account switch during progress aborts', async () => { const s = environment(), op = s.begin(); s.token = 'other'; s.progress({ totalBytesWritten: 1, totalBytesExpectedToWrite: 10 }); await rejected(s, op, /状态已变化/); assert.equal(s.aborted, 1); }); await test('account switch before preview success rejects and cleans', async () => { const s = environment(), op = s.begin(); s.finish(); s.token = 'other'; s.opened[0].success(); const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /状态已变化/); assert.equal(s.files.size, 0); }); await test('preview failure cleans file and does not report success', async () => { const s = environment(), op = s.begin(); s.finish(); s.opened[0].fail(); const result = await op.result; assert.equal(result.ok, false); assert.match(result.message, /无法打开/); assert.equal(s.files.size, 0); }); for (const flag of ['statFails', 'readFails']) { await test('filesystem ' + flag + ' fails safely', async () => { const s = environment(), op = s.begin(); s[flag] = true; s.finish(); await rejected(s, op, /文件读取失败/); assert.equal(s.files.size, 0); }); } for (const [reason, expected] of [['downloadFile:fail url not in domain list', /域名未配置/], ['downloadFile:fail timeout', /生成超时/], ['downloadFile:fail connection reset', /检查网络/]]) { await test(reason, async () => { const s = environment(), op = s.begin(); s.request.fail({ errMsg: reason }); await rejected(s, op, expected); }); } await test('temporary unlink failure is retried on foreground return', async () => { const s = environment(), op = s.begin(); s.unlinkFails = true; s.finish(Buffer.from('bad')); await rejected(s, op, /不是完整PDF/); assert.equal(s.files.size, 1); s.unlinkFails = false; s.appShow.forEach(callback => callback()); assert.equal(s.files.size, 0); }); console.log(`PASS: ${checks} generated MP-WEIXIN PDF export checks. No network or database access.`); } main().catch(error => { console.error(error); process.exitCode = 1; });