398 lines
20 KiB
JavaScript
398 lines
20 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import vm from 'node:vm';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const require = createRequire(import.meta.url);
|
|
const vue = require('vue');
|
|
const plain = (value) => JSON.parse(JSON.stringify(value));
|
|
const deferred = () => {
|
|
let resolvePromise;
|
|
const promise = new Promise(resolve => { resolvePromise = resolve; });
|
|
return { promise, resolve: resolvePromise };
|
|
};
|
|
|
|
// Execute actual store/API modules and page setup scripts with real Vue
|
|
// reactivity. Only native uni APIs and lifecycle scheduling are substituted.
|
|
function runtime(initial = {}) {
|
|
const storage = new Map(Object.entries({ token: 'session-A', identifier: 'A', ...initial }));
|
|
const calls = [];
|
|
const requests = [];
|
|
const mounted = [];
|
|
const shown = [];
|
|
const hidden = [];
|
|
const unmounted = [];
|
|
const timers = [];
|
|
const sessionStorage = new Map();
|
|
const api = {};
|
|
let options = {};
|
|
const uni = {
|
|
getStorageSync: key => storage.get(key) ?? '',
|
|
setStorageSync: (key, value) => storage.set(key, plain(value)),
|
|
removeStorageSync: key => storage.delete(key),
|
|
clearStorageSync: () => storage.clear(),
|
|
request: request => { requests.push(request); },
|
|
showToast: value => calls.push({ kind: 'toast', ...value }),
|
|
showModal: value => calls.push({ kind: 'modal', ...value }),
|
|
reLaunch: value => calls.push({ kind: 'reLaunch', ...value }),
|
|
redirectTo: value => calls.push({ kind: 'redirectTo', ...value }),
|
|
navigateTo: value => calls.push({ kind: 'navigateTo', ...value }),
|
|
navigateBack: () => calls.push({ kind: 'navigateBack' })
|
|
};
|
|
const context = vm.createContext({
|
|
uni, Intl, Date, URLSearchParams,
|
|
console: { log() {}, error() {}, warn() {} },
|
|
setTimeout: callback => { timers.push(callback); return timers.length; },
|
|
setInterval: () => 1, clearInterval() {},
|
|
getCurrentPages: () => [{ options }],
|
|
sessionStorage: { getItem: key => sessionStorage.get(key) || '', removeItem: key => sessionStorage.delete(key), setItem: (key, value) => sessionStorage.set(key, value) },
|
|
window: { location: { search: '', pathname: '/', hash: '' }, history: { replaceState() {} } },
|
|
defineProps: () => ({}), defineEmits: () => () => {}
|
|
});
|
|
const cache = new Map();
|
|
const synthetic = (key, values) => {
|
|
if (!cache.has(key)) {
|
|
cache.set(key, new vm.SyntheticModule(Object.keys(values), function () {
|
|
for (const [name, value] of Object.entries(values)) this.setExport(name, value);
|
|
}, { context, identifier: key }));
|
|
}
|
|
return cache.get(key);
|
|
};
|
|
function moduleFor(path, expose = []) {
|
|
if (cache.has(path)) return cache.get(path);
|
|
let source = readFileSync(path, 'utf8');
|
|
if (path.endsWith('.vue')) {
|
|
source = source.match(/<script setup>([\s\S]*?)<\/script>/)[1];
|
|
if (expose.length) source += `\nexport { ${expose.join(', ')} };`;
|
|
}
|
|
const module = new vm.SourceTextModule(source, { context, identifier: path });
|
|
cache.set(path, module);
|
|
return module;
|
|
}
|
|
async function linker(specifier, parent) {
|
|
if (specifier === 'vue') return synthetic('vue', {
|
|
...vue,
|
|
onMounted: cb => mounted.push(cb), onUnmounted: cb => unmounted.push(cb)
|
|
});
|
|
if (specifier === '@dcloudio/uni-app') return synthetic('uni-app', {
|
|
onShow: cb => shown.push(cb), onHide: cb => hidden.push(cb), onUnload: cb => unmounted.push(cb)
|
|
});
|
|
if (specifier === '@/api/index.js') return synthetic('api-index', {
|
|
...api, deviceApi: {}, hasActiveOrPendingPackage: async () => true
|
|
});
|
|
if (specifier === '@/utils/payment.js') return synthetic('payment', {
|
|
consumePendingPaymentRefresh: () => false, PAYMENT_REFRESH_TARGETS: { HOME: 'home' }
|
|
});
|
|
if (specifier === '@/utils/wxsdk.js') return synthetic('wxsdk', {
|
|
initWxConfig: async () => {}, wxScan: async () => {}, isInWechat: () => false
|
|
});
|
|
if (specifier === './env.js') return synthetic('env', { BASE_URL: 'https://example.invalid' });
|
|
if (specifier.endsWith('.vue')) return synthetic(specifier, { default: {} });
|
|
const path = specifier.startsWith('@/') ? resolve(root, specifier.slice(2)) : resolve(dirname(parent.identifier), specifier);
|
|
return moduleFor(path);
|
|
}
|
|
async function load(path, expose) {
|
|
const module = moduleFor(resolve(root, path), expose);
|
|
if (module.status === 'unlinked') await module.link(linker);
|
|
if (module.status === 'linked') await module.evaluate();
|
|
return module.namespace;
|
|
}
|
|
const reply = (request, data = {}, code = 0, statusCode = 200, msg = 'success') => {
|
|
request.success({ statusCode, data: { code, msg, data } });
|
|
};
|
|
return { storage, sessionStorage, context, calls, requests, mounted, shown, hidden, unmounted, timers, api, load, reply,
|
|
setOptions: value => { options = value; }, tick: () => vue.nextTick() };
|
|
}
|
|
|
|
test('phone binding modes and success updates distinguish all four cases', async () => {
|
|
const r = runtime();
|
|
const { getPhoneBindingMode: mode, getBindingSuccessState: success, optionalBoolean } = await r.load('utils/phone-bind.js');
|
|
assert.equal(mode('', false), 'customer-asset');
|
|
assert.equal(mode('13800000001', false), 'asset');
|
|
assert.equal(mode('', true), 'customer');
|
|
assert.equal(mode('13800000001', true), 'none');
|
|
assert.equal(mode(null, false), 'unknown');
|
|
assert.equal(optionalBoolean(undefined), null);
|
|
assert.equal(optionalBoolean('false'), null);
|
|
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').customerPhone, undefined);
|
|
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').assetPhoneIsOwn, false);
|
|
assert.equal(success({}, '13900000002', 'customer').assetPhoneBound, undefined);
|
|
assert.equal(success({}, '13900000002', 'customer-asset').assetPhoneBound, true);
|
|
});
|
|
|
|
test('login facts are persisted per token/asset; customer phone changes never migrate assets', async () => {
|
|
const r = runtime({ phoneBindStatus: { A: { bound: true, phone: '13900000002' } } });
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
assert.equal(user.state.assetPhoneBound, null);
|
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: true });
|
|
user.setCustomerPhone('13800000001');
|
|
user.completePhoneBinding('13900000002', 'asset');
|
|
assert.equal(user.state.customerPhone, '13800000001');
|
|
assert.equal(user.state.assetPhoneBound, true);
|
|
assert.equal(user.state.assetPhoneIsOwn, false);
|
|
user.completeCustomerPhoneChange('13700000003');
|
|
assert.equal(user.state.customerPhone, '13700000003');
|
|
assert.equal(user.state.assetPhoneBound, true);
|
|
assert.equal(user.state.assetPhoneIsOwn, null);
|
|
const reloaded = runtime(Object.fromEntries(r.storage));
|
|
assert.equal((await reloaded.load('store/user.js')).useUserStore().state.assetPhoneBound, true);
|
|
user.setIdentifier('B');
|
|
assert.equal(user.state.assetPhoneBound, null);
|
|
assert.equal(user.state.customerPhone, null);
|
|
assert.equal(r.storage.has('phoneBindStatus'), false);
|
|
user.setToken('session-B');
|
|
assert.equal(user.state.needBindPhone, null);
|
|
user.clearUser();
|
|
assert.equal(r.storage.has('customerAssetPhoneState'), false);
|
|
});
|
|
|
|
test('login routing honors server guidance even with a phone bound to someone else', async () => {
|
|
for (const required of [false, true]) {
|
|
const r = runtime({ token: '' });
|
|
r.sessionStorage.set('assetToken', 'mock-asset-token');
|
|
r.context.window.location.search = '?code=mock-code';
|
|
r.api.wechatApi = {};
|
|
r.api.authApi = { wechatLogin: async () => ({ token: 'new-session', asset_phone_bound: true, asset_phone_is_own: false, need_bind_phone: required }) };
|
|
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
|
|
await page.handleWechatCallback();
|
|
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, required ? '/pages/bind/bind?fromLogin=true' : '/pages/index/index');
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
assert.equal(user.state.assetPhoneBound, true);
|
|
assert.equal(user.state.assetPhoneIsOwn, false);
|
|
assert.equal(user.state.needBindPhone, required);
|
|
}
|
|
});
|
|
|
|
test('expiry boundaries and Shanghai rendering preserve server values, including zero', async () => {
|
|
const r = runtime();
|
|
const { normalizeExpiryReminder, getShanghaiDay, formatShanghaiExpiry } = await r.load('utils/expiry-reminder.js');
|
|
for (const days of [0, 3, 4, 7, 8, 15]) {
|
|
const level = days <= 3 ? 'red' : days <= 7 ? 'purple' : 'pink';
|
|
assert.equal(normalizeExpiryReminder({ remaining_days: days, level }).remaining_days, days);
|
|
}
|
|
for (const value of [undefined, null, {}, { remaining_days: -1, level: 'red' }, { remaining_days: 16, level: 'pink' }]) {
|
|
assert.equal(normalizeExpiryReminder(value), null);
|
|
}
|
|
assert.equal(getShanghaiDay(new Date('2026-10-12T15:59:59Z')), '2026-10-12');
|
|
assert.equal(getShanghaiDay(new Date('2026-10-12T16:00:00Z')), '2026-10-13');
|
|
assert.match(formatShanghaiExpiry('2026-10-12T16:00:00Z'), /2026.*10.*13.*00:00/);
|
|
assert.equal(formatShanghaiExpiry('2026-10-13T12:30:00'), '2026-10-13 12:30:00');
|
|
});
|
|
|
|
test('shared asset-info receiver keeps a child-first reminder and rejects stale sessions/phones', async () => {
|
|
const r = runtime();
|
|
const { assetApi } = await r.load('api/modules/asset.js');
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
|
const first = assetApi.getInfo('A');
|
|
const second = assetApi.getInfo('A');
|
|
r.reply(r.requests[0], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 0, level: 'red', expires_at: '2026-10-13T16:00:00Z' } });
|
|
await first;
|
|
r.reply(r.requests[1], { bound_phone: '13800000001' });
|
|
await second;
|
|
assert.equal(asset.state.expiryReminder.reminder.remaining_days, 0);
|
|
asset.markExpiryReminderShown();
|
|
asset.dismissExpiryReminder();
|
|
const repeated = assetApi.getInfo('A');
|
|
r.reply(r.requests[2], { expiry_reminder: { remaining_days: 0, level: 'red' } });
|
|
await repeated;
|
|
assert.equal(asset.state.expiryReminder, null);
|
|
const oldPhoneResponse = assetApi.getInfo('A');
|
|
user.completeCustomerPhoneChange('13700000003');
|
|
r.reply(r.requests[3], { bound_phone: '13800000001' });
|
|
await oldPhoneResponse;
|
|
assert.equal(user.state.customerPhone, '13700000003');
|
|
const late = assetApi.getInfo('A');
|
|
user.setIdentifier('B');
|
|
r.reply(r.requests[4], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 3, level: 'red' } });
|
|
await late;
|
|
assert.equal(asset.state.expiryReminder, null);
|
|
assert.equal(user.state.customerPhone, null);
|
|
const b = assetApi.getInfo('B');
|
|
r.reply(r.requests[5], { expiry_reminder: { remaining_days: 8, level: 'pink' } });
|
|
await b;
|
|
assert.equal(asset.state.expiryReminder.identifier, 'B');
|
|
});
|
|
|
|
test('notification APIs use POST read-all and retain PUT single-read without new parameters', async () => {
|
|
const r = runtime();
|
|
const { notificationApi } = await r.load('api/modules/notification.js');
|
|
const all = notificationApi.markAllRead();
|
|
assert.equal(r.requests[0].method, 'POST');
|
|
assert.match(r.requests[0].url, /\/notifications\/read-all$/);
|
|
r.reply(r.requests[0]); await all;
|
|
const one = notificationApi.markRead(12);
|
|
assert.equal(r.requests[1].method, 'PUT');
|
|
assert.deepEqual(plain(r.requests[1].data), { id: 12 });
|
|
r.reply(r.requests[1]); await one;
|
|
const list = notificationApi.getList(1, 20);
|
|
assert.deepEqual(plain(r.requests[2].data), { page: 1, page_size: 20 });
|
|
r.reply(r.requests[2]); await list;
|
|
});
|
|
|
|
test('1192 disables repeated submission, while 1184 keeps the backend error', async () => {
|
|
for (const code of [1192, 1184]) {
|
|
const r = runtime();
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
|
|
user.setCustomerPhone('13800000001');
|
|
let submits = 0;
|
|
r.api.authApi = { bindPhone: async () => { submits++; throw { code, msg: '手机号已被其他客户绑定' }; } };
|
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
|
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'assetConflict']);
|
|
await r.mounted[0]();
|
|
page.bind.phone = '13900000002'; page.bind.code = '123456';
|
|
await page.bindPhone();
|
|
if (code === 1192) {
|
|
await page.bindPhone();
|
|
assert.equal(submits, 1);
|
|
assert.equal(page.assetConflict.value, true);
|
|
assert.match(r.calls.find(call => call.kind === 'modal').content, /联系管理员/);
|
|
} else {
|
|
assert.equal(page.assetConflict.value, false);
|
|
assert.equal(r.calls.find(call => call.kind === 'toast').title, '手机号已被其他客户绑定');
|
|
}
|
|
assert.equal(user.state.assetPhoneBound, false);
|
|
assert.equal(user.state.customerPhone, '13800000001');
|
|
}
|
|
});
|
|
|
|
test('asset-only binding accepts a different phone; mandatory binding does not consume asset info', async () => {
|
|
const r = runtime();
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
|
|
user.setCustomerPhone('13800000001');
|
|
let submitted;
|
|
r.api.authApi = { bindPhone: async (phone, code) => { submitted = { phone, code }; } };
|
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
|
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'bindingMode']);
|
|
await r.mounted[0]();
|
|
assert.equal(page.bindingMode.value, 'asset');
|
|
page.bind.phone = '13900000002'; page.bind.code = '123456';
|
|
await page.bindPhone();
|
|
assert.deepEqual(submitted, { phone: '13900000002', code: '123456' });
|
|
assert.equal(user.state.customerPhone, '13800000001');
|
|
assert.equal(user.state.assetPhoneBound, true);
|
|
for (const assetBound of [true, false]) {
|
|
const mandatory = runtime();
|
|
mandatory.setOptions({ fromLogin: 'true' });
|
|
const mandatoryUser = (await mandatory.load('store/user.js')).useUserStore();
|
|
mandatoryUser.setLoginPhoneState({ asset_phone_bound: assetBound, asset_phone_is_own: false, need_bind_phone: true });
|
|
mandatory.api.authApi = { bindPhone: async () => {} };
|
|
mandatory.api.assetApi = { getInfo: async () => { throw new Error('Mandatory page must not consume expiry reminder'); } };
|
|
const mandatoryPage = await mandatory.load('pages/bind/bind.vue', ['bind', 'bindPhone']);
|
|
await mandatory.mounted[0]();
|
|
mandatoryPage.bind.phone = '13900000002'; mandatoryPage.bind.code = '123456';
|
|
await mandatoryPage.bindPhone();
|
|
assert.equal(mandatory.calls.find(call => call.kind === 'reLaunch').url, '/pages/login/login');
|
|
assert.equal(mandatory.storage.get('identifier'), 'A');
|
|
assert.equal(mandatory.storage.has('token'), false);
|
|
}
|
|
});
|
|
|
|
test('change-phone page updates only the confirmed customer phone', async () => {
|
|
const r = runtime();
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
user.setLoginPhoneState({ asset_phone_bound: true, asset_phone_is_own: true, need_bind_phone: false });
|
|
user.setCustomerPhone('13800000001');
|
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
|
r.api.authApi = { changePhone: async () => ({ phone: '13900000002' }) };
|
|
const page = await r.load('pages/change-phone/change-phone.vue', ['form', 'submit']);
|
|
await r.mounted[0]();
|
|
page.form.oldCode = '123456'; page.form.newPhone = '13900000002'; page.form.newCode = '654321';
|
|
await page.submit();
|
|
assert.equal(user.state.customerPhone, '13900000002');
|
|
assert.equal(user.state.assetPhoneBound, true);
|
|
assert.equal(user.state.assetPhoneIsOwn, null);
|
|
assert.equal(page.form.oldPhone, '13900000002');
|
|
assert.equal(r.storage.has('phoneBindStatus'), false);
|
|
});
|
|
|
|
test('home shows shared reminder once and excludes only expiry from old notification popups', async () => {
|
|
const r = runtime();
|
|
r.api.assetApi = { getInfo: async () => ({ identifier: 'A' }) };
|
|
r.api.notificationApi = {
|
|
getList: async () => ({ items: [{ id: 1, category: 'expiry', is_read: false }, { id: 2, category: 'approval', is_read: false }] }),
|
|
getUnreadCount: async () => ({ count: 2 }), markRead: async () => {}
|
|
};
|
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
|
asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' });
|
|
const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'loadUnreadNotifications', 'notificationItems', 'closeExpiryReminder']);
|
|
r.shown[0](); await r.tick();
|
|
assert.equal(page.visibleExpiryReminder.value.remaining_days, 0);
|
|
await page.loadUnreadNotifications();
|
|
assert.deepEqual(plain(page.notificationItems.value).map(item => item.id), [2]);
|
|
page.closeExpiryReminder();
|
|
r.hidden[0](); r.shown[0](); await r.tick();
|
|
assert.equal(page.visibleExpiryReminder.value, null);
|
|
});
|
|
|
|
test('notification center refreshes server scope after read-all; failures keep unread items', async () => {
|
|
for (const fail of [false, true]) {
|
|
const r = runtime();
|
|
let readAll = false;
|
|
r.api.notificationApi = {
|
|
getList: async () => ({ items: [{ id: 1, is_read: readAll, category: 'expiry' }], total: 1 }),
|
|
getUnreadCount: async () => ({ count: readAll ? 0 : 1 }),
|
|
markAllRead: async () => { if (fail) throw new Error('offline'); readAll = true; }
|
|
};
|
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
|
|
await page.loadNotifications(); await page.loadUnreadCount();
|
|
await page.markAllRead();
|
|
assert.equal(page.notifications[0].is_read, !fail);
|
|
assert.equal(page.unreadCount.value, fail ? 1 : 0);
|
|
}
|
|
});
|
|
|
|
test('notification center rejects an old asset response after switching context', async () => {
|
|
const r = runtime();
|
|
const oldList = deferred();
|
|
r.api.notificationApi = { getList: () => oldList.promise, getUnreadCount: async () => ({ count: 0 }) };
|
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'notifications']);
|
|
const pending = page.loadNotifications();
|
|
r.storage.set('identifier', 'B');
|
|
oldList.resolve({ items: [{ id: 1, category: 'expiry' }], total: 1 });
|
|
await pending;
|
|
assert.equal(page.notifications.length, 0);
|
|
});
|
|
|
|
test('asset A read-all does not locally mark asset B expiry notifications read', async () => {
|
|
const r = runtime();
|
|
const read = new Map([['A', false], ['B', false]]);
|
|
r.api.notificationApi = {
|
|
getList: async () => ({ items: [{ id: r.storage.get('identifier'), category: 'expiry', is_read: read.get(r.storage.get('identifier')) }], total: 1 }),
|
|
getUnreadCount: async () => ({ count: read.get(r.storage.get('identifier')) ? 0 : 1 }),
|
|
markAllRead: async () => read.set(r.storage.get('identifier'), true)
|
|
};
|
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
|
|
await page.loadNotifications(); await page.loadUnreadCount(); await page.markAllRead();
|
|
assert.equal(page.notifications[0].is_read, true);
|
|
r.storage.set('identifier', 'B');
|
|
await r.shown[0]();
|
|
assert.equal(page.notifications[0].id, 'B');
|
|
assert.equal(page.notifications[0].is_read, false);
|
|
assert.equal(page.unreadCount.value, 1);
|
|
});
|
|
|
|
test('401 clears phone/reminder state, but late 401 cannot log out a new session', async () => {
|
|
const r = runtime();
|
|
const request = (await r.load('utils/request.js')).default;
|
|
const user = (await r.load('store/user.js')).useUserStore();
|
|
const stale = request({ url: '/old' });
|
|
user.setToken('session-B');
|
|
r.reply(r.requests[0], {}, 1, 401);
|
|
await assert.rejects(stale);
|
|
assert.equal(user.state.token, 'session-B');
|
|
user.setLoginPhoneState({ asset_phone_bound: true, need_bind_phone: false });
|
|
const expired = request({ url: '/current' });
|
|
r.reply(r.requests[1], {}, 1, 401);
|
|
await assert.rejects(expired);
|
|
assert.equal(user.state.token, '');
|
|
assert.equal(user.state.assetPhoneBound, null);
|
|
assert.equal(r.storage.has('customerAssetPhoneState'), false);
|
|
});
|