fix(member-cancel): 修复注销成功后仍能回退查看旧账号信息的问题
1. 注销申请成功后立即调用clearLoginState清除登录态,避免残留账号信息 2. 所有跳转均改用reLaunch模式清空页面栈,防止用户通过回退键回到历史页面 3. 新增完整的注销流程行为测试用例覆盖所有场景
This commit is contained in:
@@ -28,29 +28,32 @@
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatus() {
|
||||
this.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
}
|
||||
this.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
success() {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
success() {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -69,11 +69,13 @@ export default {
|
||||
url: '/membercancel/api/membercancel/apply',
|
||||
success: rres => {
|
||||
if (rres.code >= 0) {
|
||||
// 注销申请提交成功后立刻清空登录态,
|
||||
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
|
||||
this.clearLoginState();
|
||||
if (options.clearStateAndRedirect) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
} else {
|
||||
this.$util.redirectTo('/pages_tool/member/cancelstatus');
|
||||
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
|
||||
}
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
|
||||
528
tests/member-cancel-flow.test.js
Normal file
528
tests/member-cancel-flow.test.js
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 账号注销流程 — 行为测试
|
||||
* ============================================================================
|
||||
*
|
||||
* 修复背景 (用户报告):
|
||||
*
|
||||
* 账号注销页面提示注销成功后, 用户用标题栏回退键回到会员中心,
|
||||
* 发现账号及信息还在 —— 也就是说页面栈里残留了注销前的页面, 且
|
||||
* token / 会员信息在跳转前没被清掉.
|
||||
*
|
||||
* 修复方案 (在 member-public.js / cancelsuccess.vue 里):
|
||||
*
|
||||
* 1. submitCancelApply 申请成功 → 立刻 clearLoginState() (不等后续页面)
|
||||
* 2. submitCancelApply 后续跳转 → 用 'reLaunch' 模式 (清空页面栈, 防回退)
|
||||
* 3. cancelsuccess.vue 所有"去登录页"分支 → 用 'reLaunch' 模式
|
||||
*
|
||||
* 测什么 (按用户故事):
|
||||
*
|
||||
* 场景 A: 注销申请提交成功 → 立即清登录态
|
||||
* 场景 B: 注销申请提交成功 → 用 reLaunch 跳 cancelstatus (清栈)
|
||||
* 场景 C: 注销申请提交成功 → clearStateAndRedirect=true → 用 reLaunch 跳登录页 (清栈)
|
||||
* 场景 D: 注销申请提交失败 → 不清登录态, 只 toast
|
||||
* 场景 E: 注销申请提交失败 → 不调用 redirectTo (避免把用户带跑)
|
||||
* 场景 F: 用户在 modal 点取消 → 不发请求, 不清登录态
|
||||
* 场景 G: clearLoginState 顺序: token / memberInfo / cart / localStorage 都清
|
||||
* 场景 H: cancelsuccess.vue onLoad → token 已空 → reLaunch 跳登录页
|
||||
* 场景 I: cancelsuccess.vue getStatus 检到 status=1 → reLaunch 跳登录页
|
||||
* 场景 J: cancelsuccess.vue 用户点"完成" → reLaunch 跳登录页
|
||||
* 场景 K (静态): member-public.js / cancelsuccess.vue 源码里真的用了 'reLaunch'
|
||||
* 场景 L (回归): clearLoginState 必须早于 redirectTo, 不允许反过来
|
||||
*
|
||||
* 跑法:
|
||||
*
|
||||
* node --test tests/member-cancel-flow.test.js
|
||||
*
|
||||
* 设计原则 (跟其他测试保持一致):
|
||||
*
|
||||
* ✓ 每个测试对应一个真实用户故事, 不测代码结构
|
||||
* ✓ 失败 = 用户能感知到的 bug
|
||||
* ✓ 通过 mock 模拟 Vue 组件 this + uni 全局 + $store + $util
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// =============================================================
|
||||
// 复用: 加载 member-public.js
|
||||
// member-public.js 是 ESM `export default { methods: {...} }`,
|
||||
// vm 跑需要去掉 export default, 把 methods 暴露成普通对象。
|
||||
// =============================================================
|
||||
function loadMemberPublic(opts = {}) {
|
||||
const SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 把 `export default {...}` 替换成 `module.exports = {...}`,
|
||||
// 然后用 vm 跑 (避免 new Function 直接执行对象字面量体报错)
|
||||
const vm = require('node:vm');
|
||||
const moduleObj = { exports: {} };
|
||||
// 沙箱里的 uni: 优先用 opts.uni (测试时由 createThisMock 注入),
|
||||
// 否则挂 noop, 保证源码能跑通不抛 ReferenceError.
|
||||
const wrapped = SRC.replace(/export\s+default\s+/, 'module.exports = ');
|
||||
const sandbox = {
|
||||
module: moduleObj,
|
||||
exports: moduleObj.exports,
|
||||
console,
|
||||
uni: opts.uni || {
|
||||
showModal: () => {},
|
||||
removeStorageSync: () => {},
|
||||
setStorageSync: () => {},
|
||||
getStorageSync: () => null,
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(wrapped, sandbox, { filename: 'member-public.js' });
|
||||
const exported = sandbox.module.exports;
|
||||
if (!exported || !exported.methods) {
|
||||
throw new Error('member-public.js 未导出 methods 字段');
|
||||
}
|
||||
return exported.methods;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Mock 工具
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 创建 Vue 组件 this 的 mock.
|
||||
* 真实代码里 this 访问: $store.commit / $store.dispatch / $util.redirectTo / $api.sendRequest / $lang
|
||||
*/
|
||||
function createThisMock({ showModalChoice = 'confirm', apiCode = 0 } = {}) {
|
||||
const calls = {
|
||||
commit: [], // [type, payload][]
|
||||
dispatch: [], // [type][]
|
||||
redirectTo: [], // [{to, param, mode}][]
|
||||
showToast: [], // [{title}][]
|
||||
api: [], // [{url, success}][]
|
||||
showModal: [], // [{title, content, success}][]
|
||||
removeStorage: [],// [key][]
|
||||
lang: [], // [key][]
|
||||
};
|
||||
|
||||
const store = {
|
||||
commit(type, payload) { calls.commit.push([type, payload]); },
|
||||
dispatch(type) { calls.dispatch.push([type]); },
|
||||
};
|
||||
|
||||
// $util.redirectTo 接收 (to, param, mode), 我们记原始参数
|
||||
const util = {
|
||||
LOGIN_PAGE_URL: '/pages_tool/login/login',
|
||||
MEMBER_PAGE_URL: '/pages_tool/member/index',
|
||||
redirectTo(to, param, mode) {
|
||||
calls.redirectTo.push({ to, param: param || {}, mode: mode || 'navigateTo' });
|
||||
},
|
||||
showToast(opts) { calls.showToast.push(opts || {}); },
|
||||
};
|
||||
|
||||
// $api.sendRequest: 用 options.success 回调. 支持 mock 一个异步行为 (setTimeout 0)
|
||||
const api = {
|
||||
sendRequest(opts) {
|
||||
calls.api.push({ url: opts.url, data: opts.data });
|
||||
// 默认: 模拟成功. 立即同步回调
|
||||
if (typeof opts.success === 'function') {
|
||||
const res = { code: apiCode, message: 'mock-error', data: {} };
|
||||
opts.success(res);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// uni.showModal: 默认用户点确认 (showModalChoice='confirm')
|
||||
const uni = {
|
||||
showModal(opts) {
|
||||
calls.showModal.push({ title: opts.title, content: opts.content });
|
||||
if (typeof opts.success === 'function') {
|
||||
if (showModalChoice === 'confirm') opts.success({ confirm: true });
|
||||
else opts.success({ confirm: false });
|
||||
}
|
||||
},
|
||||
removeStorageSync(key) { calls.removeStorage.push(key); },
|
||||
setStorageSync(key, val) { /* noop for cancel flow */ },
|
||||
getStorageSync(key) { return null; },
|
||||
};
|
||||
|
||||
// $lang: 简单返回 key
|
||||
const $lang = (key) => {
|
||||
calls.lang.push(key);
|
||||
return key;
|
||||
};
|
||||
|
||||
const mockThis = {
|
||||
$store: store,
|
||||
$util: util,
|
||||
$api: api,
|
||||
$lang,
|
||||
// 把 mock 出来的 uni 也挂上 this.uni (Vue 组件里 this.uni 通常等同于全局 uni),
|
||||
// 同时让 loadMemberPublic() 能拿到这个对象 (因为源码里直接用 uni 全局).
|
||||
uni: uni,
|
||||
};
|
||||
|
||||
return { mockThis, calls, uni };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 memberPublic.methods 装到一个 mock this 上, 然后通过 mockThis.xxx() 调用方法.
|
||||
* 这是模拟 Vue mixin 注入 methods 后, 组件里 this.xxx() 的调用方式.
|
||||
*
|
||||
* 重要: 因为 member-public.js 源码里直接引用全局 uni (不是 this.uni),
|
||||
* 加载沙箱时必须把 mockThis 同款 uni 也塞进 vm 沙箱, 否则 uni.showModal
|
||||
* / uni.removeStorageSync 走的是 noop, 行为记录就丢了.
|
||||
*
|
||||
* 用法: bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }))
|
||||
*/
|
||||
function bindMethods(mockThis, methodsObj) {
|
||||
for (const name of Object.keys(methodsObj)) {
|
||||
mockThis[name] = methodsObj[name].bind(mockThis);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 场景 A: 注销申请成功 → 立即清登录态
|
||||
// =============================================================
|
||||
test('场景 A: 注销申请成功 → 立即 clearLoginState() (不等后续页面)', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.submitCancelApply();
|
||||
|
||||
// 申请 URL 被发了
|
||||
assert.equal(calls.api.length, 1);
|
||||
assert.equal(calls.api[0].url, '/membercancel/api/membercancel/apply');
|
||||
|
||||
// ★ 关键修复: clearLoginState 必须先于 redirectTo 被调用
|
||||
// 检查顺序: 第一个 commit 必须是 setToken
|
||||
const firstCommit = calls.commit[0];
|
||||
assert.ok(firstCommit, '提交成功后应该有 commit 调用');
|
||||
assert.equal(firstCommit[0], 'setToken',
|
||||
'提交成功后第一个 commit 应是 setToken(立即清登录态),实际是 ' + firstCommit[0]);
|
||||
assert.equal(firstCommit[1], '', 'setToken 应清成空串');
|
||||
|
||||
// setMemberInfo 也必须被清
|
||||
const memberCommit = calls.commit.find(([t]) => t === 'setMemberInfo');
|
||||
assert.ok(memberCommit, 'clearLoginState 应同时清 setMemberInfo');
|
||||
assert.equal(memberCommit[1], '');
|
||||
|
||||
// emptyCart 也必须派发
|
||||
assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'),
|
||||
'clearLoginState 应派发 emptyCart');
|
||||
|
||||
// localStorage 也必须清
|
||||
assert.ok(calls.removeStorage.includes('authInfo'),
|
||||
'clearLoginState 应清 authInfo localStorage');
|
||||
assert.ok(calls.removeStorage.includes('privacyAgreed'),
|
||||
'clearLoginState 应清 privacyAgreed localStorage');
|
||||
|
||||
// ★ 关键修复: 跳转必须用 reLaunch (清空页面栈)
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'提交成功后跳转模式应是 reLaunch (清空页面栈), 实际是 ' + calls.redirectTo[0].mode);
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/member/cancelstatus');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 B: clearStateAndRedirect=true → reLaunch 到登录页
|
||||
// =============================================================
|
||||
test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登录页', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.submitCancelApply({ clearStateAndRedirect: true });
|
||||
|
||||
// clearLoginState 必须执行
|
||||
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
|
||||
'submitCancelApply(clearStateAndRedirect) 必须清 token');
|
||||
// 跳转必须是 reLaunch
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'clearStateAndRedirect 模式跳转必须是 reLaunch');
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
|
||||
'clearStateAndRedirect 模式应跳到登录页');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 C: 注销申请失败 → 不清登录态, 只 toast
|
||||
// =============================================================
|
||||
test('场景 C: 注销申请失败 → 不清登录态, 不跳转, 只 toast', () => {
|
||||
const { mockThis, calls } = createThisMock({ apiCode: -1 });
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.submitCancelApply();
|
||||
|
||||
// 申请失败 → 不应该有 commit (登录态不清)
|
||||
assert.equal(calls.commit.length, 0,
|
||||
'申请失败时不应清登录态, 实际 commit 次数: ' + calls.commit.length);
|
||||
|
||||
// 不应有 redirectTo
|
||||
assert.equal(calls.redirectTo.length, 0,
|
||||
'申请失败时不应跳转');
|
||||
|
||||
// 应有 toast 提示
|
||||
assert.equal(calls.showToast.length, 1, '申请失败应 toast 提示');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 D: 用户在 modal 点取消 → 不发请求, 不清登录态
|
||||
// =============================================================
|
||||
test('场景 D: 用户点 modal 取消 → 不发请求, 不清登录态', () => {
|
||||
const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' });
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.submitCancelApply();
|
||||
|
||||
// 没发申请
|
||||
assert.equal(calls.api.length, 0, '点取消不应发注销申请');
|
||||
// 没清登录态
|
||||
assert.equal(calls.commit.length, 0, '点取消不应清登录态');
|
||||
// 没跳转
|
||||
assert.equal(calls.redirectTo.length, 0, '点取消不应跳转');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 E: 自定义 title / content 透传给 modal
|
||||
// =============================================================
|
||||
test('场景 E: submitCancelApply({title, content}) → 自定义文案透传给 modal', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.submitCancelApply({ title: '自定义标题', content: '自定义内容' });
|
||||
|
||||
assert.equal(calls.showModal.length, 1);
|
||||
assert.equal(calls.showModal[0].title, '自定义标题');
|
||||
assert.equal(calls.showModal[0].content, '自定义内容');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 F: clearLoginState 单独调用也能正确清 (供其他组件复用)
|
||||
// =============================================================
|
||||
test('场景 F: clearLoginState 单独被调 → token / memberInfo / cart / storage 全清', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.clearLoginState();
|
||||
|
||||
// 顺序: token → memberInfo → emptyCart → removeStorage
|
||||
assert.deepEqual(
|
||||
calls.commit.map(([t]) => t),
|
||||
['setToken', 'setMemberInfo'],
|
||||
'clearLoginState 必须先 commit setToken 再 setMemberInfo',
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.dispatch.map(([t]) => t),
|
||||
['emptyCart'],
|
||||
'clearLoginState 必须 dispatch emptyCart',
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.removeStorage,
|
||||
['authInfo', 'privacyAgreed'],
|
||||
'clearLoginState 必须清 authInfo + privacyAgreed',
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 G (静态分析): member-public.js submitCancelApply 源码里真的用了 'reLaunch'
|
||||
// =============================================================
|
||||
test('场景 G (静态): member-public.js submitCancelApply 源码里 redirectTo 调用必须是 reLaunch 模式', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 找到 submitCancelApply 函数体
|
||||
const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/);
|
||||
assert.ok(m, '应能找到 submitCancelApply 函数定义');
|
||||
const body = m[1];
|
||||
|
||||
// 关键修复: 函数体里所有 redirectTo 都必须用 reLaunch (防止回退到历史页面)
|
||||
// 提取所有 this.$util.redirectTo( 调用
|
||||
const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
|
||||
assert.ok(redirectCalls.length >= 2,
|
||||
'submitCancelApply 至少应有 2 处 redirectTo (login / cancelstatus), 实际 ' + redirectCalls.length);
|
||||
|
||||
for (const call of redirectCalls) {
|
||||
assert.match(call, /['"]reLaunch['"]/,
|
||||
'submitCancelApply 内的 redirectTo 必须用 reLaunch 模式, 实际: ' + call.trim());
|
||||
}
|
||||
|
||||
// 关键修复: clearLoginState 必须在 redirectTo 之前调用
|
||||
const clearIdx = body.indexOf('this.clearLoginState()');
|
||||
const firstRedirectIdx = body.indexOf('this.$util.redirectTo(');
|
||||
assert.ok(clearIdx > -1, 'submitCancelApply 必须调用 clearLoginState()');
|
||||
assert.ok(firstRedirectIdx > -1, 'submitCancelApply 必须调用 redirectTo');
|
||||
assert.ok(clearIdx < firstRedirectIdx,
|
||||
'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + firstRedirectIdx);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 H (静态分析): cancelsuccess.vue 所有 redirectTo 都用 'reLaunch'
|
||||
// =============================================================
|
||||
test('场景 H (静态): cancelsuccess.vue 跳登录页的 redirectTo 必须全部用 reLaunch', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancelsuccess.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 提取所有 this.$util.redirectTo( 调用
|
||||
const calls = src.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
|
||||
assert.ok(calls.length >= 2,
|
||||
'cancelsuccess.vue 应至少有 2 处 redirectTo (onLoad 无 token 分支 + getStatus + success), 实际 ' + calls.length);
|
||||
|
||||
// 每处都应该是 reLaunch
|
||||
for (const c of calls) {
|
||||
// 跳登录页的必须是 reLaunch
|
||||
if (c.includes('LOGIN_PAGE_URL')) {
|
||||
assert.match(c, /['"]reLaunch['"]/,
|
||||
'cancelsuccess.vue 跳 LOGIN_PAGE_URL 必须用 reLaunch, 实际: ' + c.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// 同时确认: 全文件没有对 LOGIN_PAGE_URL 的 redirectTo 不带 reLaunch
|
||||
// 用反向断言: 不应该有 LOGIN_PAGE_URL 但没有 reLaunch 的 redirectTo
|
||||
const loginRedirects = calls.filter(c => c.includes('LOGIN_PAGE_URL'));
|
||||
const badOnes = loginRedirects.filter(c => !/['"]reLaunch['"]/.test(c));
|
||||
assert.equal(badOnes.length, 0,
|
||||
'cancelsuccess.vue 跳登录页不能有非 reLaunch 的 redirectTo, 违规: ' + JSON.stringify(badOnes));
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 I (行为模拟): cancelsuccess.vue 风格的组件 — getStatus 检到 status=1 → reLaunch
|
||||
//
|
||||
// 这里我们重新模拟一个简化的 cancelsuccess.vue 行为, 因为 .vue 文件无法直接 vm 跑
|
||||
// (uni-app 编译产物含模板, 没法在 node 里直接 require). 我们提取其核心逻辑验证:
|
||||
// onLoad → if (!storeToken) reLaunch(LOGIN_PAGE_URL)
|
||||
// getStatus success → if (status==1) { clearLoginState; reLaunch(LOGIN_PAGE_URL) }
|
||||
// success → { clearLoginState; reLaunch(LOGIN_PAGE_URL) }
|
||||
// =============================================================
|
||||
test('场景 I (行为模拟): cancelsuccess.vue getStatus 检到 status=1 → clearLoginState + reLaunch', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
// 定制 $api.sendRequest: 让 /membercancel/api/membercancel/info 返回 status=1
|
||||
mockThis.$api.sendRequest = (opts) => {
|
||||
calls.api.push({ url: opts.url });
|
||||
if (typeof opts.success === 'function') {
|
||||
opts.success({ code: 0, data: { status: 1 } });
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟 cancelsuccess.vue 的 getStatus 方法 (跟源码逻辑一致)
|
||||
mockThis.getStatus = function () {
|
||||
mockThis.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: (res) => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
mockThis.clearLoginState();
|
||||
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
mockThis.getStatus();
|
||||
|
||||
// ★ 关键修复: 检到 status=1 必须 (1) 清登录态 (2) reLaunch
|
||||
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
|
||||
'status=1 必须清 token');
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'status=1 必须用 reLaunch 跳转 (防止用户回退到历史页面看到旧会员信息)');
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 J (行为模拟): status != 1 时不重定向, 让用户留在成功页 (避免误跳)
|
||||
// =============================================================
|
||||
test('场景 J (行为模拟): cancelsuccess.vue getStatus status != 1 → 不清登录态, 不跳转', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
// 模拟 status=0 (审核中)
|
||||
mockThis.$api.sendRequest = (opts) => {
|
||||
calls.api.push({ url: opts.url });
|
||||
opts.success({ code: 0, data: { status: 0 } });
|
||||
};
|
||||
|
||||
mockThis.getStatus = function () {
|
||||
mockThis.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: (res) => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
mockThis.clearLoginState();
|
||||
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
mockThis.getStatus();
|
||||
|
||||
// status=0 → 不应清登录态, 不应跳转 (让用户继续看 cancelstatus / cancelsuccess 页)
|
||||
assert.equal(calls.commit.length, 0,
|
||||
'status != 1 不应清登录态');
|
||||
assert.equal(calls.redirectTo.length, 0,
|
||||
'status != 1 不应跳转');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 K (行为模拟): onLoad — storeToken 为空时直接 reLaunch 跳登录页 (兜底分支)
|
||||
// =============================================================
|
||||
test('场景 K (行为模拟): cancelsuccess.vue onLoad — storeToken 已空时直接 reLaunch 到登录页', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
// 模拟 storeToken = false 的组件
|
||||
Object.defineProperty(mockThis, 'storeToken', { get: () => false, configurable: true });
|
||||
|
||||
// 模拟 cancelsuccess.vue onLoad 逻辑 (跟当前修复后的源码一致)
|
||||
function onLoad() {
|
||||
if (!mockThis.storeToken) {
|
||||
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
} else {
|
||||
mockThis.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
onLoad();
|
||||
|
||||
// 不应发请求 (没 token 直接跳登录)
|
||||
assert.equal(calls.api.length, 0, '无 token 不应发请求');
|
||||
// 必须 reLaunch 到登录页
|
||||
assert.equal(calls.redirectTo.length, 1, '无 token 必须 reLaunch');
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch', '必须用 reLaunch 模式');
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', '必须跳登录页');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 L (回归): 不允许"先 redirectTo 再 clearLoginState" (顺序错会留漏洞)
|
||||
// =============================================================
|
||||
test('场景 L (回归): submitCancelApply 的源码顺序 — clearLoginState 必须早于 redirectTo', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/);
|
||||
assert.ok(m);
|
||||
const body = m[1];
|
||||
|
||||
// 拿到所有 this.clearLoginState() 和 this.$util.redirectTo( 的位置
|
||||
const idxClear = [];
|
||||
const idxRedirect = [];
|
||||
const re = /(this\.clearLoginState\(\)|this\.\$util\.redirectTo\()/g;
|
||||
let mm;
|
||||
while ((mm = re.exec(body)) !== null) {
|
||||
if (mm[1].startsWith('this.clearLoginState')) idxClear.push(mm.index);
|
||||
else idxRedirect.push(mm.index);
|
||||
}
|
||||
assert.ok(idxClear.length >= 1, 'submitCancelApply 内至少 1 次 clearLoginState');
|
||||
assert.ok(idxRedirect.length >= 2, 'submitCancelApply 内至少 2 次 redirectTo');
|
||||
|
||||
// 第一个 clearLoginState 必须早于第一个 redirectTo
|
||||
assert.ok(idxClear[0] < idxRedirect[0],
|
||||
'第一个 clearLoginState(' + idxClear[0] + ') 必须早于第一个 redirectTo(' + idxRedirect[0] + ')');
|
||||
});
|
||||
Reference in New Issue
Block a user