Files
lilishop-ui/buyer/tests/request-auth.test.cjs
2026-11-09 16:06:33 +08:00

195 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 买家端请求认证回归:验证真实 Axios 拦截链、并发刷新和已提示异常边界。
* @author Bulbasaur
* @since 2026-11-09
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
/** 加载业务源码,替换浏览器与界面依赖;请求拦截和重试仍使用真实 Axios。 */
function harness({ expired = false, refresh = async () => ({ success: false }), adapter } = {}) {
const values = new Map();
const jwt = (exp) => 'header.' + Buffer.from(JSON.stringify({ exp })).toString('base64url') + '.sig';
if (expired) values.set('accessToken', jwt(1));
values.set('refreshToken', 'refresh-old');
const Storage = { getItem: k => values.get(k), setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
const helperSource = fs.readFileSync(path.join(__dirname, '../src/utils/requestError.js'), 'utf8').replace(/export /g, '');
const helper = new Function(helperSource + '; return { markHandledRequestError, isHandledRequestError, catchHandledRequestError, setupRequestErrorHandling };')();
let prompts = 0, refreshCalls = 0, closes = 0, opens = 0;
const messages = [], sent = [];
let dismiss, promptOptions;
const Modal = { confirm(options) { prompts++; promptOptions = options; return new Promise(resolve => { dismiss = resolve; }); } };
const router = { currentRoute: { value: { path: '/home/MyOrder', query: { tag: 'ALL' } } }, push: async () => {} };
const axiosProxy = { ...axios, create(options) { return axios.create({ ...options, adapter: async config => {
sent.push(config);
return adapter ? adapter(config, sent.length) : { data: { success: true }, status: 200, config, headers: {} };
} }); } };
const source = fs.readFileSync(path.join(__dirname, '../src/plugins/request.js'), 'utf8')
.replace(/^import .*;\r?\n/gm, '').replace(/export default function request/, 'function request').replace(/export const /g, 'const ');
const names = ['axios','markHandledRequestError','isHandledRequestError','ElLoading','Message','Modal','Storage','router','useAppStore','handleRefreshToken','uuidv4','require','process','BASE','window','setTimeout'];
const request = new Function(...names, source + '; return request;')(
axiosProxy, helper.markHandledRequestError, helper.isHandledRequestError,
{ service() { opens++; return { close() { closes++; } }; } }, { error: m => messages.push(m) }, Modal, Storage, router,
() => ({ setCartNum() {} }), async () => { refreshCalls++; return refresh(jwt); }, () => 'uuid', require,
{ env: { NODE_ENV: 'development' } }, { API_DEV: { buyer: '/api' } }, { atob: v => Buffer.from(v,'base64').toString('binary') }, fn => fn()
);
return { request, values, helper, sent, messages, get prompts() { return prompts; }, get refreshCalls() { return refreshCalls; }, get opens() { return opens; }, get closes() { return closes; }, dismiss: () => dismiss(), get promptOptions() { return promptOptions; } };
}
/** 构造后端拒绝登录的 HTTP 响应。 */
function unauthorized(config) {
throw new axios.AxiosError('unauthorized', 'ERR_BAD_REQUEST', config, null, { status: 401, data: {}, config });
}
test('并发过期请求只刷新一次,返回配置并各发送一次业务请求', async () => {
const h = harness({ expired: true, refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new-refresh' } };
} });
const results = await Promise.all([1,2,3].map(() => h.request({ url: '/orders', needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 3);
assert.ok(results.every(r => r.success));
assert.equal(h.prompts, 0);
});
test('刷新失败保持拒绝、只弹一次登录框并清理身份与加载', async () => {
const h = harness({ expired: true });
let successes = 0;
const results = await Promise.allSettled([1,2,3].map(() => h.request({ url: '/orders', needToken: true, loading: true }).then(() => { successes++; })));
assert.equal(successes, 0);
assert.equal(h.sent.length, 0);
assert.equal(h.prompts, 1);
assert.equal(h.closes, h.opens);
assert.equal(h.values.get('accessToken'), undefined);
assert.ok(results.every(r => r.status === 'rejected' && h.helper.isHandledRequestError(r.reason)));
assert.equal(h.messages.length, 0);
h.dismiss();
await new Promise(resolve => setImmediate(resolve));
await assert.rejects(h.request({ url: '/orders', needToken: true, adapter: unauthorized }));
assert.equal(h.prompts, 2);
assert.equal(h.refreshCalls, 1);
});
test('401 重试上限防止刷新死循环,POST 请求体保持一致', async () => {
const h = harness({ adapter: unauthorized, refresh: async jwt => ({ success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } }) });
await assert.rejects(h.request({ url: '/orders', method: 'post', data: { amount: 12 }, headers: { 'Content-Type': 'application/json' }, needToken: true, loading: true }), e => e.code === 'AUTH_EXPIRED');
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 2);
assert.equal(h.sent[0].data, '{"amount":12}');
assert.equal(h.sent[1].data, h.sent[0].data);
assert.equal(h.opens, h.closes);
assert.equal(h.prompts, 1);
});
test('HTTP 200 内认证失败进入同一处理,刷新接口不递归', async () => {
const h = harness({ adapter: async config => ({ config, status: 200, data: { code: 20004 }, headers: {} }) });
await assert.rejects(h.request({ url: '/orders' }), e => e.code === 'AUTH_EXPIRED');
const skipped = await h.request({ url: '/refresh', skipAuthHandler: true, skipMessage: true });
assert.equal(skipped.code, 20004);
assert.equal(h.refreshCalls, 1);
});
test('网络失败只提示一次,skipMessage 与程序错误不登记为已处理', async () => {
const h = harness({ adapter: config => { throw new axios.AxiosError('timeout', 'ECONNABORTED', config); } });
await assert.rejects(h.request({ url: '/orders' }), e => h.helper.isHandledRequestError(e));
assert.deepEqual(h.messages, ['连接超时,请稍候再试!']);
await assert.rejects(h.request({ url: '/refresh', skipMessage: true }), e => !h.helper.isHandledRequestError(e));
const bug = new TypeError('bug');
const b = harness({ adapter: () => { throw bug; } });
await assert.rejects(b.request({ url: '/orders' }), e => e === bug);
});
test('全局兜底和开发遮罩仅过滤已提示错误,未知错误仍上报', () => {
const h = harness();
const handled = h.helper.markHandledRequestError(new Error('登录已过期'));
const bug = new TypeError('bug');
const listeners = {};
const app = { config: {} };
h.helper.setupRequestErrorHandling(app, { addEventListener: (name, fn) => { listeners[name] = fn; } });
let prevented = 0;
listeners.unhandledrejection({ reason: handled, preventDefault: () => prevented++ });
listeners.unhandledrejection({ reason: bug, preventDefault: () => prevented++ });
assert.equal(prevented, 1);
assert.doesNotThrow(() => app.config.errorHandler(handled));
assert.throws(() => app.config.errorHandler(bug), e => e === bug);
assert.throws(() => h.helper.catchHandledRequestError(bug), e => e === bug);
const filter = require('../vue.config.js').devServer.client.overlay.runtimeErrors;
assert.equal(filter(handled), false);
assert.equal(filter(bug), true);
});
test('未登录不刷新 undefined,公开接口保持匿名可访问', async () => {
const h = harness({ adapter: config => {
if (config.needToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
} });
h.values.delete('refreshToken');
await assert.rejects(h.request({ url: '/orders', needToken: true }), e => e.code === 'AUTH_EXPIRED');
const result = await h.request({ url: '/goods', optionalToken: true });
assert.equal(result.success, true);
assert.equal(h.refreshCalls, 0);
assert.equal(h.prompts, 1);
});
test('HTTP 401 并发共用刷新,表单 POST 重试不重复编码', async () => {
const h = harness({
adapter: config => {
if (!config.headers.accessToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
},
refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } };
},
});
await Promise.all([1,2].map(() => h.request({ url: '/orders', method: 'post', data: { name: 'a b' }, needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 4);
assert.ok(h.sent.every(c => c.data === 'name=a%20b'));
});
/** 从真实 Vue 页面提取查询函数,验证失败收尾与请求乱序,界面依赖用响应式状态桩替换。 */
function listHarness(file, apiName) {
const { parse } = require('@vue/compiler-sfc');
const parser = require('@babel/parser');
const source = parse(fs.readFileSync(path.join(__dirname, '../src/pages/home', file), 'utf8')).descriptor.scriptSetup.content;
const declaration = parser.parse(source, { sourceType: 'module' }).program.body.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'getList');
const state = { spinShow: { value: false }, queryFailed: { value: false }, orderList: { value: [] }, list: { value: [] }, total: { value: 0 } };
const calls = [];
const api = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
const getList = new Function(...Object.keys(state), 'params', apiName, 'catchHandledRequestError',
'let listRequestVersion = 0; ' + source.slice(declaration.start, declaration.end) + '; return getList;')(
...Object.values(state), { pageNumber: 1 }, api, () => {}
);
return { ...state, calls, getList };
}
for (const [file, api] of [['orderCenter/MyOrder.vue','getOrderList'], ['orderCenter/AfterSale.vue','afterSaleList'], ['userCenter/MyTracks.vue','tracksList']]) {
test(file + ' 失败结束加载并可重试,旧查询不能覆盖新列表', async () => {
const h = listHarness(file, api);
h.getList();
assert.equal(h.spinShow.value, true);
h.calls[0].reject(new Error('network'));
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, true);
h.getList();
h.getList();
h.calls[2].resolve({ success: true, result: { records: [{ id: 'new' }], total: 1 } });
await new Promise(resolve => setImmediate(resolve));
h.calls[1].resolve({ success: true, result: { records: [{ id: 'old' }], total: 20 } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, false);
assert.equal(h.total.value, 1);
const list = file.includes('MyTracks') ? h.list : h.orderList;
assert.deepEqual(list.value, [{ id: 'new' }]);
});
}