import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { parse, compileScript } from '@vue/compiler-sfc'; import ts from 'typescript'; import * as vue from 'vue'; const flowSource = await readFile(new URL('../src/utils/login-flow.ts', import.meta.url), 'utf8'); const pageSource = await readFile( new URL('../src/pages/account/login.vue', import.meta.url), 'utf8', ); const { descriptor } = parse(pageSource, { filename: 'login.vue' }); const script = compileScript(descriptor, { id: 'login-test' }); function evaluate(source, dependencies, runtime = {}) { const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, }); const exports = {}; new Function('require', 'exports', 'setInterval', 'clearInterval', 'uni', outputText)( (name) => { if (name in dependencies) return dependencies[name]; throw new Error('Unexpected dependency: ' + name); }, exports, runtime.setInterval, runtime.clearInterval, runtime.uni, ); return exports; } function fixture(capabilities = { sms_login: true, test_phone_login: true }) { const posts = [], messages = [], timers = new Map(), accepted = [], navigations = []; let timerId = 0; let unmount; const client = { post: (path, payload, authenticated) => new Promise((resolve, reject) => posts.push({ path, payload, authenticated, resolve, reject }), ), }; const format = { toast: (message) => messages.push(message), go() {} }; const flow = evaluate(flowSource, { '../api/client': client, './format': format }); const state = evaluate( script.content, { vue: { ...vue, onUnmounted: (callback) => { unmount = callback; }, }, '@dcloudio/uni-app': { onShow() {} }, '../../stores/session': { session: vue.reactive({ configuration: { capabilities } }), loadConfiguration() {}, acceptLogin: (login) => accepted.push(login), accountPage: () => '/pages/home/index', }, '../../api/client': client, '../../utils/format': format, '../../utils/login-flow': flow, }, { setInterval: (callback) => { timers.set(++timerId, callback); return timerId; }, clearInterval: (id) => timers.delete(id), uni: { reLaunch: (options) => navigations.push(options.url) }, }, ).default.setup({}, { expose() {} }); return { ...flow, state, posts, messages, timers, accepted, navigations, unmount }; } test('real phone numbers request SMS and use SMS login', async () => { for (const phone of ['13800138000', '13900139000']) { const app = fixture(); const send = app.requestPhoneCode(phone); assert.equal(app.posts.length, 1); assert.equal(app.posts[0].path, '/auth/send-sms'); assert.deepEqual(app.posts[0].payload, { phone }); assert.equal(app.posts[0].authenticated, false); app.posts[0].resolve({ sent: true, expires_in: 120 }); assert.equal(await send, true); const login = app.loginByPhoneCode(phone, '728415'); assert.equal(app.posts[1].path, '/auth/phone-login'); assert.deepEqual(app.posts[1].payload, { phone, code: '728415' }); assert.equal(app.posts[1].authenticated, false); app.posts[1].resolve({ user_id: '42' }); assert.equal((await login).user_id, '42'); } }); test('all three test accounts log in without SMS or a verification code', async () => { for (const phone of ['13012345678', '13012345679', '13012345680']) { const app = fixture(); assert.equal(await app.requestPhoneCode(phone), false); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /无需获取短信/); app.state.phone.value = phone; app.state.agreed.value = true; const login = app.state.loginByPhone(); assert.equal(app.posts.length, 1); assert.equal(app.posts[0].path, '/auth/test-phone'); assert.deepEqual(app.posts[0].payload, { phone }); assert.equal(app.posts[0].authenticated, false); app.posts[0].resolve({ user_id: '42' }); await login; assert.equal(app.accepted.length, 1); } }); test('get code works before agreement, serializes taps, and starts countdown only after success', async () => { const app = fixture(); app.state.phone.value = '13800138000'; app.state.phoneCode.value = '123456'; assert.equal(app.state.agreed.value, false); const send = app.state.sendCode(); await app.state.sendCode(); assert.equal(app.posts.length, 1); assert.equal(app.state.sendingCode.value, true); assert.equal(app.state.codeCountdown.value, 0); app.posts[0].resolve({ sent: true, expires_in: 120 }); await send; assert.equal(app.state.sendingCode.value, false); assert.equal(app.state.phoneCode.value, ''); assert.equal(app.state.codeCountdown.value, 60); assert.match(app.messages.at(-1), /查看手机短信/); await app.state.sendCode(); assert.equal(app.posts.length, 1, 'countdown prevents resending'); for (let i = 0; i < 60; i++) [...app.timers.values()].forEach((tick) => tick()); assert.equal(app.state.codeCountdown.value, 0); assert.equal(app.timers.size, 0); const resend = app.state.sendCode(); assert.equal(app.posts.length, 2); app.posts[1].resolve({ sent: true }); await resend; app.unmount(); assert.equal(app.timers.size, 0); }); test('failed SMS shows the server error, releases the button and permits retry without countdown', async () => { const app = fixture(); app.state.phone.value = '13800138000'; const send = app.state.sendCode(); app.posts[0].reject(new Error('短信发送失败,请稍后再试')); await send; assert.match(app.messages.at(-1), /短信发送失败/); assert.equal(app.state.sendingCode.value, false); assert.equal(app.state.codeCountdown.value, 0); assert.equal(app.timers.size, 0); const retry = app.state.sendCode(); assert.equal(app.posts.length, 2); app.posts[1].resolve({ sent: true }); await retry; app.unmount(); }); test('test capability enables test login without enabling real SMS', async () => { const app = fixture({ test_phone_login: true }); app.state.phone.value = '13012345678'; assert.equal(app.state.phoneLoginAvailable.value, true); await app.state.sendCode(); assert.equal(app.posts.length, 0); assert.equal(app.state.codeCountdown.value, 0); assert.match(app.messages.at(-1), /无需获取短信/); app.state.phone.value = '13800138000'; await app.state.sendCode(); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /短信登录暂未开放/); }); test('disabled test login cannot fall through to SMS even when SMS is enabled', async () => { const app = fixture({ sms_login: true, test_phone_login: false }); app.state.phone.value = '13012345678'; app.state.phoneCode.value = '66666'; app.state.agreed.value = true; await app.state.loginByPhone(); await app.state.sendCode(); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /未开启测试账号登录/); }); test('phone login is unavailable only when both capabilities are disabled', () => { assert.equal( fixture({ sms_login: false, test_phone_login: false }).state.phoneLoginAvailable.value, false, ); }); test('invalid numbers are explained without sending requests', async () => { const app = fixture(); app.state.phone.value = '12345'; await app.state.sendCode(); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /正确的手机号码/); }); test('real login requires agreement, forwards the code and serializes taps', async () => { const app = fixture(); app.state.phone.value = '13800138000'; app.state.phoneCode.value = '728415'; await app.state.loginByPhone(); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /同意服务说明/); app.state.agreed.value = true; const login = app.state.loginByPhone(); await app.state.loginByPhone(); assert.equal(app.posts.length, 1); assert.equal(app.posts[0].path, '/auth/phone-login'); assert.deepEqual(app.posts[0].payload, { phone: '13800138000', code: '728415' }); const session = { user_id: '42', access_token: 'token', identities: [] }; app.posts[0].resolve(session); await login; assert.deepEqual(app.accepted, [session]); assert.deepEqual(app.navigations, ['/pages/home/index']); assert.equal(app.state.busy.value, false); }); test('real phone numbers still require a six-digit SMS code', async () => { const app = fixture(); app.state.agreed.value = true; app.state.phone.value = '13800138000'; app.state.phoneCode.value = '66666'; await app.state.loginByPhone(); app.state.phoneCode.value = ''; await app.state.loginByPhone(); assert.equal(app.posts.length, 0); assert.match(app.messages.at(-1), /6位短信验证码/); }); test('wrong code is reported without accepting a session or navigating away', async () => { const app = fixture(); app.state.phone.value = '13800138000'; app.state.phoneCode.value = '123456'; app.state.agreed.value = true; const login = app.state.loginByPhone(); app.posts[0].reject(new Error('验证码错误')); await login; assert.match(app.messages.at(-1), /验证码错误/); assert.equal(app.accepted.length, 0); assert.equal(app.navigations.length, 0); assert.equal(app.state.busy.value, false); });