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'; import { prepareCommand, mayHaveCommitted } from '../src/utils/command-core.mjs'; const source = await readFile(new URL('../src/pages/account/rewards.vue', import.meta.url), 'utf8'); const { descriptor } = parse(source, { filename: 'rewards.vue' }); const script = compileScript(descriptor, { id: 'promotion-test' }); const resourceSource = await readFile( new URL('../src/composables/useResource.ts', import.meta.url), 'utf8', ); const commandSource = await readFile( new URL('../src/composables/useCommand.ts', import.meta.url), 'utf8', ); function evaluate(source, dependencies) { const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, }); const exports = {}; new Function('require', 'exports', outputText)((name) => { if (name in dependencies) return dependencies[name]; if (name.endsWith('.vue')) return {}; throw new Error('Unexpected dependency: ' + name); }, exports); return exports; } const flush = () => new Promise((resolve) => setImmediate(resolve)); class ApiFailure extends Error { constructor(message, status = 0) { super(message); this.status = status; } } function fixture() { const hooks = {}, storage = new Map(), posts = []; const session = vue.reactive({ token: '', userId: '', epoch: 0, active: null }); const code = 'AABBCCDDEEFF00112233'; globalThis.uni = { getStorageSync: (key) => storage.get(key) || '', setStorageSync: (key, value) => storage.set(key, value), removeStorageSync: (key) => storage.delete(key), stopPullDownRefresh() {}, showToast() {}, }; const response = { enabled: true, invite_code: code, total_buckets: 0, total: 0, items: [], bound: null, policy: {}, }; const client = { ApiFailure, get: async () => response, post: (path, payload) => new Promise((resolve, reject) => posts.push({ path, payload, resolve, reject })), }; const format = { toast() {} }; const resource = evaluate(resourceSource, { vue, '../stores/session': { session } }); const command = evaluate(commandSource, { vue, '../stores/session': { session }, '../api/client': client, '../utils/command-core.mjs': { prepareCommand, mayHaveCommitted }, '../utils/format': format, }); const state = evaluate(script.content, { vue, '@dcloudio/uni-app': { onLoad: (fn) => (hooks.load = fn), onShow: (fn) => (hooks.show = fn), onShareAppMessage: (fn) => (hooks.share = fn), }, '../../api/client': client, '../../stores/session': { session, resourcePath: () => `/enterprises/${session.active.resource_id}`, canManageEnterprise: () => session.active?.role === 'enterprise_admin', }, '../../composables/useResource': resource, '../../composables/useCommand': command, '../../utils/format': format, }).default.setup({}, { expose() {} }); const login = () => Object.assign(session, { token: 'a'.repeat(64), userId: 'promotion-user', active: { role: 'enterprise_admin', resource_id: 42 }, }); return { state, hooks, storage, posts, session, login, code }; } test('shared invitation survives login and does not bind until explicitly confirmed', async () => { const app = fixture(); app.hooks.load({ invite_code: app.code.toLowerCase() }); app.hooks.show(); assert.equal(app.state.inviteInput.value, app.code); assert.equal(app.posts.length, 0); app.login(); app.hooks.show(); await flush(); assert.equal(app.posts.length, 0, 'opening a shared page must never write attribution'); app.state.bind(); app.state.bind(); assert.equal(app.posts.length, 1, 'double confirmation is serialized'); assert.equal(app.posts[0].path, '/enterprises/42/promotion/bind'); assert.equal(app.posts[0].payload.invite_code, app.code); assert.match(app.posts[0].payload.request_key, /^[A-Za-z0-9_-]{8,80}$/); app.posts[0].resolve({ id: 1 }); await flush(); assert.equal( app.storage.has('water.promotion-invite'), false, 'clear pending invitation only after success', ); }); test('invalid share parameters cannot create or bind an invitation', async () => { const app = fixture(); app.hooks.load({ invite_code: 'https://untrusted.example/?token=anything' }); app.login(); app.hooks.show(); await flush(); assert.equal(app.state.inviteInput.value, ''); app.state.inviteInput.value = 'invalid'; app.state.bind(); assert.equal(app.posts.length, 0); assert.equal(app.storage.has('water.promotion-invite'), false); }); test('an unknown binding result retains its payload and cannot be replaced by another inviter', async () => { const app = fixture(); app.login(); app.state.inviteInput.value = app.code; app.state.bind(); const original = app.posts[0].payload; app.posts[0].reject(new ApiFailure('网络超时')); await flush(); assert.equal(app.state.binding.pending, true); app.state.inviteInput.value = '00112233445566778899'; app.state.bind(); await flush(); assert.equal(app.posts.length, 1, 'changed inviter must not replace unresolved binding'); const retry = app.state.binding.retry(); assert.deepEqual(app.posts[1].payload, original); app.posts[1].resolve({ id: 1, replayed: true }); await retry; }); test('shared links contain only the public enterprise code and clear it when identity changes', async () => { const app = fixture(); app.login(); app.hooks.show(); await flush(); assert.equal(app.hooks.share().path, '/pages/account/rewards?invite_code=' + app.code); assert.equal(app.hooks.share().path.includes(app.session.token), false); app.session.epoch++; assert.equal(app.state.data.value, null); assert.equal(app.hooks.share().path, '/pages/account/rewards'); });