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'); });