Files
juzi/taobao/test/frontend-session-product.test.cjs
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

378 lines
24 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const base = path.resolve(__dirname, '..');
function apiFixture(handler, saved) {
const storage = new Map(saved ? [['juzi.taobao.session.v1', saved]] : []);
const app = { globalData: { profile: null, photos: null, payload: null } };
const calls = [], module = { exports: {} }; let authCalls = 0;
vm.runInNewContext(fs.readFileSync(path.join(base, 'utils/api.js'), 'utf8'), {
module, Promise, Date, Math, setTimeout: (fn) => setTimeout(fn, 0), getApp: () => app,
my: { authorize(opts) { authCalls++; opts.success({}); },
setStorageSync({ key, data }) { storage.set(key, data); },
getStorageSync({ key }) { return { data: storage.get(key) }; },
removeStorageSync({ key }) { storage.delete(key); } },
require(name) {
if (name === '../config') return { transport: 'cloud', assetBase: 'https://assets.example/' };
if (name === './transport') return { request(route, body, token) {
calls.push({ route, body: JSON.parse(JSON.stringify(body)), token });
return Promise.resolve().then(() => handler(route.replace('/api/taobao/', ''), body, token));
} };
throw Error('unexpected dependency ' + name);
}
});
return { api: module.exports, app, calls, storage, authCalls: () => authCalls };
}
function sessions(route) {
if (route === 'challenge') return { ok: true, challenge: 'one-use' };
if (route === 'guest-session') return { ok: true, token: 'guest-token', user: { id: 'guest-a', kind: 'taobao_guest' } };
if (route === 'session' || route === 'me') return { ok: true, token: 'account-token', user: { id: 'account-a', kind: 'taobao' } };
if (route === 'logout') return { ok: true };
throw Error('unexpected route ' + route);
}
function page(file, api, extra = {}, dependencies = {}) {
let definition; const timers = [], nav = [], toasts = [], storage = [];
const context = { Page(value) { definition = value; }, console, Promise, Date,
setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout(id) { timers[id - 1] = null; },
my: { navigateTo: v => nav.push(v), redirectTo: v => nav.push(v),
showToast: v => toasts.push(v), setStorageSync: v => storage.push(v), getStorageSync: () => ({}) },
getApp: () => ({ globalData: {} }),
require(name) {
if (/utils\/api(?:\.js)?$/.test(name)) return api;
if (Object.hasOwn(dependencies, name)) return dependencies[name];
return require(path.resolve(path.dirname(path.join(base, file)), name));
}, ...extra };
vm.runInNewContext(fs.readFileSync(path.join(base, file), 'utf8'), context);
const p = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), setData(patch, cb) {
for (const [key, value] of Object.entries(patch)) {
const parts = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let target = this.data;
for (const part of parts.slice(0, -1)) target = target[part]; target[parts.at(-1)] = value;
}
if (cb) cb();
} });
return { p, timers, nav, toasts, storage, context };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('guest requires consent, skips account authorization, never persists, and cold start clears the experience', async () => {
const f = apiFixture(sessions, { token: 'old-account', user: { id: 'old' } });
await assert.rejects(f.api.loginGuest(false), /同意/);
await f.api.loginGuest(true);
assert.equal(f.authCalls(), 0); assert.equal(f.api.isGuest(), true); assert.equal(f.api.isSignedIn(), false);
assert.equal(f.storage.size, 0);
f.app.globalData.profile = { age: 30 }; f.app.globalData.photos = ['face']; f.app.globalData.payload = { result: {} };
assert.equal((await f.api.restore()).kind, 'taobao_guest');
assert.equal(f.calls.some(v => v.route.endsWith('/me')), false, 'product round trip keeps guest in memory');
f.api.resetForLaunch();
assert.equal(f.api.hasSession(), false); assert.equal(f.app.globalData.payload, null);
assert.equal(await f.api.restore(), null); assert.equal(f.storage.size, 0);
});
test('explicit guest entry retires cached account verification before guest network work and ignores every late /me outcome', async t => {
for (const timing of ['before', 'challenge-pending', 'guest-session-pending', 'after']) {
for (const outcome of ['success', '401']) await t.test(timing + ' / ' + outcome, async () => {
let finishMe, finishChallenge, finishGuest;
const f = apiFixture(route => {
if (route === 'me') return new Promise(resolve => { finishMe = resolve; });
if (route === 'challenge') return new Promise(resolve => { finishChallenge = resolve; });
if (route === 'guest-session') return new Promise(resolve => { finishGuest = resolve; });
throw Error('unexpected route ' + route);
}, { token: 'old-formal-token', user: { id: 'old-account', kind: 'taobao' } });
f.app.globalData.profile = { real_age: 30 }; f.app.globalData.photos = ['old-private-photo']; f.app.globalData.payload = { old: true };
const checking = f.api.restore().then(value => ({ value }), error => ({ error }));
await flush();
const respondMe = () => finishMe(outcome === 'success'
? { ok: true, user: { id: 'old-account', kind: 'taobao' } }
: { ok: false, http_status: 401, message: 'old account expired' });
if (timing === 'before') {
respondMe();
const checked = await checking;
if (outcome === 'success') assert.equal(checked.value.id, 'old-account');
else assert.equal(checked.error.status, 401);
}
const entering = f.api.loginGuest(true);
// These guarantees apply synchronously on the user's confirmed choice,
// even while a previous account check or the new challenge is pending.
assert.equal(f.api.user(), null); assert.equal(f.api.hasSession(), false); assert.equal(f.api.isSignedIn(), false);
assert.equal(f.storage.size, 0); assert.equal(f.app.globalData.profile, null);
assert.equal(f.app.globalData.photos, null); assert.equal(f.app.globalData.payload, null); assert.equal(f.authCalls(), 0);
await flush();
if (timing === 'challenge-pending') {
respondMe(); const checked = await checking; assert.equal(checked.error.canceled, true);
assert.equal(f.api.hasSession(), false);
}
finishChallenge({ ok: true, challenge: 'guest-only-challenge' }); await flush();
if (timing === 'guest-session-pending') {
respondMe(); const checked = await checking; assert.equal(checked.error.canceled, true);
assert.equal(f.api.hasSession(), false);
}
finishGuest({ ok: true, token: 'new-guest-token', user: { id: 'new-guest', kind: 'taobao_guest' } });
await entering;
if (timing === 'after') {
respondMe(); const checked = await checking; assert.equal(checked.error.canceled, true);
}
assert.equal(f.api.user().id, 'new-guest'); assert.equal(f.api.isGuest(), true); assert.equal(f.api.isSignedIn(), false);
assert.equal(f.storage.size, 0); assert.equal(f.authCalls(), 0);
assert.equal((await f.api.restore()).id, 'new-guest');
assert.equal(f.calls.filter(call => call.route.endsWith('/me')).length, 1, 'guest restore never rechecks the old formal account');
assert.equal(f.calls.find(call => call.route.endsWith('/me')).token, 'old-formal-token');
const guestCalls = f.calls.filter(call => /\/(challenge|guest-session)$/.test(call.route));
assert.equal(guestCalls.length, 2); assert(guestCalls.every(call => call.token === ''), 'neither guest handshake request inherits a formal Bearer');
});
}
});
test('a rejected guest consent leaves account restore intact, while failed confirmed guest entry cannot resurrect that account', async t => {
await t.test('consent is checked before clearing the existing account', async () => {
let finishMe;
const f = apiFixture(route => route === 'me' ? new Promise(resolve => { finishMe = resolve; }) : sessions(route),
{ token: 'saved-formal-token', user: { id: 'old-account', kind: 'taobao' } });
const restoring = f.api.restore(); await flush();
await assert.rejects(f.api.loginGuest(false), /同意/); assert.equal(f.storage.size, 1);
finishMe({ ok: true, user: { id: 'old-account', kind: 'taobao' } }); await restoring;
assert.equal(f.api.isSignedIn(), true); assert.equal(f.calls.length, 1); assert.equal(f.authCalls(), 0);
});
for (const outcome of ['success', '401']) await t.test('guest failure then old /me ' + outcome, async () => {
let finishMe;
const f = apiFixture(route => {
if (route === 'me') return new Promise(resolve => { finishMe = resolve; });
if (route === 'guest-session') return { ok: false, http_status: 503, message: 'guest temporarily unavailable' };
return sessions(route);
}, { token: 'saved-formal-token', user: { id: 'old-account', kind: 'taobao' } });
const checking = f.api.restore().then(value => ({ value }), error => ({ error })); await flush();
await assert.rejects(f.api.loginGuest(true), /guest temporarily unavailable/);
finishMe(outcome === 'success' ? { ok: true, user: { id: 'old-account', kind: 'taobao' } }
: { ok: false, http_status: 401, message: 'old account expired' });
assert.equal((await checking).error.canceled, true);
assert.equal(f.api.user(), null); assert.equal(f.api.hasSession(), false); assert.equal(f.storage.size, 0);
assert.equal(await f.api.restore(), null); assert.equal(f.authCalls(), 0);
assert(f.calls.filter(call => /\/(challenge|guest-session)$/.test(call.route)).every(call => call.token === ''));
});
});
test('restored account is not signed in before server verification; expiry clears cached identity and report', async () => {
let resolveMe;
const f = apiFixture(route => route === 'me' ? new Promise(r => { resolveMe = r; }) : sessions(route),
{ token: 'saved-token', user: { id: 'stale', kind: 'taobao' } });
const restore = f.api.restore();
assert.equal(f.api.user(), null); assert.equal(f.api.isSignedIn(), false);
await flush(); resolveMe({ ok: false, http_status: 401, message: '登录已过期' });
await assert.rejects(restore, /过期/);
assert.equal(f.storage.size, 0); assert.equal(f.api.user(), null); assert.equal(f.app.globalData.photos, null);
});
test('network failure during restore does not present an unverified cached account as logged in', async () => {
const f = apiFixture(() => { throw Error('offline'); }, { token: 'saved', user: { id: 'cached' } });
await assert.rejects(f.api.restore(), /offline/);
assert.equal(f.api.isSignedIn(), false); assert.equal(f.api.user(), null);
assert.equal(f.storage.size, 1, 'network outage does not revoke the saved account');
});
test('ending guest experience immediately clears all local content even if revocation cannot connect', async () => {
const f = apiFixture(route => route === 'logout' ? new Promise(() => {}) : sessions(route));
await f.api.loginGuest(true); f.app.globalData.photos = ['private']; f.app.globalData.payload = { private: true };
await f.api.endGuest();
assert.equal(f.api.user(), null); assert.equal(f.app.globalData.photos, null); assert.equal(f.app.globalData.payload, null);
});
test('identity change rejects delayed old-account responses and clears transient report data', async () => {
let resolveOld;
const f = apiFixture(route => route === 'slow' ? new Promise(r => { resolveOld = r; }) : sessions(route));
await f.api.login(true); f.app.globalData.payload = { account: 'a' };
const oldRequest = f.api.request('slow'); await flush();
await f.api.loginGuest(true); resolveOld({ ok: true, secret: 'old-account-report' });
await assert.rejects(oldRequest, err => err.canceled === true);
assert.equal(f.app.globalData.payload, null); assert.equal(f.api.isGuest(), true);
});
test('guest profile stays in memory and guest history does not request account history', () => {
const api = { user: () => ({ id: 'guest-a', kind: 'taobao_guest' }), assetUrl: p => p, request() { throw Error('must not request history'); } };
let reads = 0; const app = { globalData: {} };
const profile = page('pages/profile/profile.js', api, { getApp: () => app,
my: { getStorageSync() { reads++; return {}; }, setStorageSync() { throw Error('guest profile must not persist'); }, navigateTo() {} } });
profile.p.onLoad(); assert.equal(reads, 0);
profile.p.setData({ birth: '1996-01', age: 30, genderText: '女', regionText: '北京' }); profile.p.onStartCamera();
assert.equal(app.globalData.profile.real_age, 30);
const history = page('pages/history/history.js', api); history.p.onShow(); history.p.load();
assert.equal(history.p.data.guest, true); assert.equal(history.p.data.items.length, 0);
assert.match(history.p.data.identityNote, /不保存/);
});
test('login agreement gates both entry modes and guest entry uses guest-session flow', async () => {
let guestCalls = 0;
const p = page('pages/login/login.js', { assetUrl: v => v, restore: async () => null,
loginGuest: async () => { guestCalls++; }, login: async () => { throw Error('wrong entry'); } });
p.p.onLoad({ switch: '1' }); p.p.onShow(); p.p.onGuest(); assert.equal(guestCalls, 0);
p.p.onToggleAgree(); p.p.onGuest(); p.p.onGuest(); await flush();
assert.equal(guestCalls, 1); assert.equal(p.nav[0].url, '/pages/splash/splash');
});
test('synchronous guest startup failures release the button and permit a deliberate retry', async () => {
let calls = 0;
const p = page('pages/login/login.js', {assetUrl:v=>v, loginGuest() { if (++calls === 1) throw Error('游客连接失败'); return Promise.resolve(); }});
p.p.onLoad({switch:'1'}); p.p.onShow(); p.p.onToggleAgree();
p.p.onGuest(); assert.equal(p.p.data.busy,true); await flush();
assert.equal(p.p.data.busy,false); assert.match(p.p.data.message,/游客连接失败/);
p.p.onGuest(); await flush(); assert.equal(calls,2); assert.equal(p.nav.length,1);
});
test('guest entry does not wait for cached-account restore or let its late success redirect the page', async () => {
let finishRestore, finishGuest, restores = 0, guests = 0;
const p = page('pages/login/login.js', { assetUrl: v => v,
restore() { restores++; return new Promise(resolve => { finishRestore = resolve; }); },
loginGuest() { guests++; return new Promise(resolve => { finishGuest = resolve; }); },
login() { throw Error('guest must not request account login'); } });
p.p.onLoad(); p.p.onShow(); assert.equal(p.p.data.checking, true);
p.p.onToggleAgree(); p.p.onGuest(); p.p.onGuest();
assert.equal(guests, 1); assert.equal(p.p.data.checking, false); assert.equal(p.p.data.busy, true);
p.p.onShow(); assert.equal(restores, 1, 'foregrounding must not replace an intentional guest entry');
finishRestore({ id: 'old-account', kind: 'taobao' }); await flush();
assert.equal(p.nav.length, 0); assert.match(p.p.data.message, /正在开始游客体验/);
finishGuest({ id: 'guest-a', kind: 'taobao_guest' }); await flush();
assert.equal(p.nav.length, 1); assert.equal(p.nav[0].url, '/pages/splash/splash'); assert.equal(p.p.data.busy, false);
const markup = fs.readFileSync(path.join(base, 'pages/login/login.axml'), 'utf8');
assert.match(markup, /disabled="{{busy}}"[^>]*onTap="onGuest"/);
});
test('late restore errors cannot overwrite a failed guest entry or send the visitor to account authorization', async () => {
let failRestore;
const p = page('pages/login/login.js', { assetUrl: v => v,
restore: () => new Promise((resolve, reject) => { failRestore = reject; }),
loginGuest: async () => { throw Error('游客连接暂不可用,请重试'); },
login() { throw Error('must not authorize a visitor'); } });
p.p.onLoad(); p.p.onShow(); p.p.onToggleAgree(); p.p.onGuest(); await flush();
assert.equal(p.p.data.busy, false); assert.match(p.p.data.message, /游客连接暂不可用/);
failRestore(Object.assign(Error('old account expired'), { status: 401 })); await flush();
assert.match(p.p.data.message, /游客连接暂不可用/); assert.equal(p.nav.length, 0); assert.equal(p.p.data.checking, false);
});
test('all recommendation tiers open the one confirmed item, block repeat taps and keep report state on return', () => {
for (const tier of [1, 2, 3]) {
const opened = []; const user = { id: 'guest-a', kind: 'taobao_guest' };
const api = { assetUrl: v => v, user: () => user };
const r = page('pages/report/report.js', api, { my: { tb: { openDetail: v => opened.push(v) }, showToast() {} } });
r.p.ownerId = user.id; r.p.alive = true;
r.p.setData({ product: { tier }, frontPhoto: 'private-front', cards: [{ index: 1 }], guest: true });
r.p.onMore(); r.p.onMore(); assert.equal(opened.length, 1); assert.equal(opened[0].itemId, '1090510453077');
r.p.onHide(); r.p.onShow();
assert.equal(r.p.data.cards[0].index, 1); assert.equal(r.p.data.frontPhoto, 'private-front'); assert.equal(r.p.data.guest, true);
r.p.onMore(); assert.equal(opened.length, 2, 'return enables a fresh deliberate tap'); r.p.onUnload();
}
});
test('product API absence and failed navigation give feedback and allow retry', () => {
const api = { assetUrl: v => v, user: () => ({ id: 'a' }) };
const absent = page('pages/report/report.js', api); absent.p.setData({ product: { tier: 1 } }); absent.p.onMore();
assert.match(absent.toasts[0].content, /手机淘宝/);
let navCalls = 0; const toasts = [];
const failed = page('pages/report/report.js', api, { my: { tb: { openDetail(opts) { navCalls++; opts.fail({}); } }, showToast: v => toasts.push(v) } });
failed.p.setData({ product: { tier: 2 } }); failed.p.onMore(); failed.p.onMore();
assert.equal(navCalls, 2); assert.match(toasts[0].content, /无法打开/);
});
test('report ownership guard also clears a guest report after switching identity', () => {
const api = { assetUrl: v => v, user: () => ({ id: 'new-owner', kind: 'taobao' }) };
const r = page('pages/report/report.js', api); r.p.ownerId = 'guest-old';
r.p.photos = ['private']; r.p.payload = { private: true }; r.p.setData({ frontPhoto: 'private', cards: [{}] });
r.p.onShow(); assert.equal(r.p.data.frontPhoto, ''); assert.equal(r.p.photos.length, 0); assert.equal(r.p.payload, null);
assert.equal(r.nav[0].url, '/pages/login/login');
});
test('lost submission response reuses request_id/photo refs, and resuming a known job never resubmits analysis', async () => {
let submissions = 0, failFirst = true; const bodies = [];
const f = apiFixture((route, body) => {
if (route === 'photo') return { ok: true };
if (route === 'skin-analyze') { submissions++; bodies.push(JSON.parse(JSON.stringify(body))); if (failFirst) { failFirst = false; throw Error('response lost'); } return { ok: true, job: 'original-job' }; }
if (route === 'analysis') return { ok: true, status: 'complete', payload: { ok: true, result: {} } };
return sessions(route);
});
await f.api.loginGuest(true); const attempt = f.api.createAnalysisAttempt();
await assert.rejects(f.api.analyze(['data:image/jpeg;base64,YQ==', 'data:image/jpeg;base64,Yg==', 'data:image/jpeg;base64,Yw=='], { real_age: 30 }, {}, attempt), /response lost/);
await f.api.resumeAnalysis(attempt, {});
assert.equal(submissions, 2); assert.deepEqual(bodies[0], bodies[1]);
assert.equal(f.calls.filter(c => c.route.endsWith('/photo')).length, 3, 'stable refs are not reuploaded');
await f.api.resumeAnalysis(attempt, {}); assert.equal(submissions, 2);
await f.api.login(true); await assert.rejects(f.api.resumeAnalysis(attempt, {}), err => err.canceled === true);
});
test('cold launch route is the standalone login page', () => {
const project = JSON.parse(fs.readFileSync(path.join(base, 'app.json'), 'utf8'));
assert.equal(project.pages[0], 'pages/login/login');
assert.equal(project.pages.length, 6);
const build = JSON.parse(fs.readFileSync(path.join(base, 'mini.project.json'), 'utf8'));
for (const route of ['pages/mvp/mvp', 'pages/camtest/camtest', 'pages/camnative/camnative']) {
assert(!project.pages.includes(route));
const directory = route.slice(0, route.lastIndexOf('/')) + '/**';
assert(build.exclude.includes(directory)); assert(build.uploadExclude.includes(directory));
}
});
test('login has no diagnostic entry even if an old development flag is enabled', () => {
for (const [environment, cameraDebug] of [['development', false], ['development', true], ['production', true]]) {
const p = page('pages/login/login.js', { assetUrl: v => v }, {}, { '../../config': { environment, cameraDebug } });
assert.equal(p.p.onCameraTest, undefined);
assert.equal(p.nav.length, 0);
const markup = fs.readFileSync(path.join(base, 'pages/login/login.axml'), 'utf8');
assert.doesNotMatch(markup, /showCameraTest|onCameraTest|相机测试/);
}
});
test('foreground account revalidation gates business requests and clears a mismatched host identity', async () => {
let resolveMe, reportRequests = 0;
const f = apiFixture(route => {
if (route === 'me') return new Promise(resolve => { resolveMe = resolve; });
if (route === 'reports') { reportRequests++; return { ok: true, items: [] }; }
return sessions(route);
});
await f.api.login(true); f.app.globalData.photos = ['private'];
f.api.suspendSession();
const pending = f.api.request('reports'); await flush();
assert.equal(f.api.isSignedIn(), false); assert.equal(reportRequests, 0);
resolveMe({ ok: false, http_status: 401, message: '当前淘宝账号已变化' });
await assert.rejects(pending, /变化/);
assert.equal(f.api.user(), null); assert.equal(f.app.globalData.photos, null); assert.equal(reportRequests, 0);
});
test('a verification response from before host backgrounding cannot restore old identity', async () => {
const pending = [];
const f = apiFixture(route => route === 'me' ? new Promise(resolve => pending.push(resolve)) : sessions(route));
await f.api.login(true);
const oldCheck = f.api.restore(); await flush();
f.api.suspendSession(); const currentCheck = f.api.restore(); await flush();
pending[1]({ ok: true, user: { id: 'account-a', kind: 'taobao' } }); await currentCheck;
pending[0]({ ok: true, user: { id: 'stale-account', kind: 'taobao' } });
await assert.rejects(oldCheck, err => err.canceled === true);
assert.equal(f.api.user().id, 'account-a');
});
test('signed report stays hidden on revalidation network error and retry restores its unchanged tab/photo state', async () => {
let offline = true; const opens = [];
const user = { id: 'account-a', kind: 'taobao' };
const api = { assetUrl: v => v, user: () => user, restore: async () => { if (offline) throw Error('offline'); return user; } };
const r = page('pages/report/report.js', api, { my: { tb: { openDetail: opts => opens.push(opts) }, showToast() {} } });
r.p.ownerId = user.id; r.p.alive = true;
r.p.setData({ cards: [{ index: 1 }], frontPhoto: 'private-photo', product: { tier: 1 } });
const check = r.p.onShow(); assert.equal(r.p.data.identityChecking, true);
r.p.onMore(); assert.equal(opens.length, 0);
await check;
assert.match(r.p.data.identityError, /无法确认/); assert.equal(r.p.data.frontPhoto, 'private-photo');
assert.equal(r.p.data.cards[0].index, 1);
offline = false; await r.p.onRetryIdentity();
assert.equal(r.p.data.identityError, ''); assert.equal(r.p.data.identityChecking, false);
assert.equal(r.p.data.frontPhoto, 'private-photo'); assert.equal(r.p.data.cards[0].index, 1);
r.p.onMore(); assert.equal(opens.length, 1); r.p.onUnload();
});
test('profile 401 clears private fields and history never loads before current account verification', async () => {
const user = { id: 'account-a', kind: 'taobao' };
let requests = 0;
const api = { assetUrl: v => v, user: () => user,
restore: async () => { const err = Error('account changed'); err.status = 401; throw err; },
request() { requests++; return Promise.resolve({ ok: true, items: [] }); } };
const p = page('pages/profile/profile.js', api); p.p.ownerId = user.id;
p.p.setData({ birth: '1996-01', birthText: '1996年1月', genderText: '女', regionText: '北京' });
await p.p.onShow(); assert.equal(p.p.data.birth, ''); assert.equal(p.nav[0].url, '/pages/login/login');
const h = page('pages/history/history.js', api); await h.p.onShow();
assert.equal(requests, 0); assert.equal(h.nav[0].url, '/pages/login/login');
});