const test = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const ts = require('typescript') const root = path.resolve(__dirname, '..') const fullProfile = { id: 9, nickname: '测试用户', gender: '女', birthday: '1998-06-18', birthHour: '辰时', province: '北京市', city: '北京市', district: '朝阳区', remainingEdits: 4, avatarUrl: '', elementReport: null } test('编辑页所有编译样式均限定页面,默认头像不复用我的元素水滴类', () => { const runner = require.resolve('@tarojs/webpack5-runner') const sass = require(require.resolve('sass', { paths: [runner] })) const postcss = require(require.resolve('postcss', { paths: [runner] })) const css = sass.renderSync({ file: path.join(root, 'src/pages/profile-edit/index.scss') }).css.toString() let checked = 0 postcss.parse(css).walkRules(rule => { rule.selectors.forEach(selector => { assert.match(selector, /^\.edit-page(?:\s|[.:#>+~\[]|$)/, `编辑页样式泄漏: ${selector}`) checked++ }) }) assert.ok(checked > 40) assert.match(css, /\.edit-page \.edit-water-mascot/) assert.doesNotMatch(css, /\.water-mascot\b/) const source = fs.readFileSync(path.join(root, 'src/pages/profile-edit/index.tsx'), 'utf8') assert.match(source, /className='edit-water-mascot'/) assert.doesNotMatch(source, /className='water-mascot'/) }) function setup(token = 'test-session', baseUrl) { let profile = { ...fullProfile } const storage = { token } const calls = [], routes = [] const taro = { default: { getStorageSync: key => storage[key], setStorageSync: (key, value) => { storage[key] = value }, removeStorageSync: key => { delete storage[key] }, request: async options => { calls.push(options) if (options.method === 'PUT') profile = { ...profile, ...options.data, remainingEdits: profile.remainingEdits - 1 } return { statusCode: 200, data: { code: 0, data: { ...profile } } } }, uploadFile: async options => { calls.push(options) profile = { ...profile, avatarUrl: 'https://files.example.test/file/access?token=test-file-token' } return { statusCode: 200, data: JSON.stringify({ code: 0, data: profile }) } }, switchTab: async options => { routes.push(options.url) }, redirectTo: async options => { routes.push(options.url) } } } const cache = {} function load(name) { if (cache[name]) return cache[name] const exports = {} cache[name] = exports vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, `src/${name}.ts`), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, { exports, process: { env: { TARO_ENV: 'h5' } }, window: { location: { search: '' } }, URLSearchParams, ...(baseUrl === undefined ? {} : { API_BASE_URL: baseUrl }), require: id => id === '@tarojs/taro' ? taro : load(path.posix.normalize(path.posix.join(path.posix.dirname(name), id))) }) return exports } return { service: load('services/request'), editing: load('utils/profileEditing'), account: load('services/account'), taro: taro.default, storage, calls, routes } } test('无登录凭证不发送个人资料请求;401清除缓存并同步所有订阅页面', async () => { const missing = setup('') await assert.rejects(missing.service.api.profile(), /请先登录/) assert.equal(missing.calls.length, 0) const ctx = setup() await ctx.service.api.profile() const updates = [] const unsubscribe = ctx.service.subscribeProfile(profile => updates.push(profile)) ctx.taro.request = async () => ({ statusCode: 401, data: { code: 401, message: '请重新登录' } }) await assert.rejects(ctx.service.api.profile(true), /失效|登录/) assert.equal(ctx.storage.token, undefined) assert.equal(ctx.service.getCachedProfile(), null) assert.equal(updates.at(-1), null) unsubscribe() }) test('生日阳历日期与农历显示偏好一起保存并从接口回显', async () => { const ctx = setup() const original = await ctx.service.api.profile() const form = { ...ctx.editing.toEditableProfile(original), birthdayCalendar: 'lunar' } const saved = await ctx.editing.saveProfileEdits(original, form, '', ctx.service.api, () => {}) const request = ctx.calls.find(call => call.method === 'PUT') assert.equal(request.data.birthday, original.birthday) assert.equal(request.data.birthdayCalendar, 'lunar') assert.equal(saved.profile.birthdayCalendar, 'lunar') const reloaded = ctx.editing.toEditableProfile(await ctx.service.api.profile(true)) assert.equal(reloaded.birthdayCalendar, 'lunar') assert.equal(reloaded.birthday, original.birthday) const before = ctx.calls.length const unchanged = await ctx.editing.saveProfileEdits(saved.profile, reloaded, '', ctx.service.api, () => {}) assert.equal(unchanged.changed, false) assert.equal(ctx.calls.length, before) }) test('登录校验只复用同token的有效资料,账号切换与退出后立即失效', async () => { const ctx = setup() assert.equal(ctx.service.getFreshCachedProfile(), null) await ctx.service.api.profile() assert.equal(ctx.service.getFreshCachedProfile().id, fullProfile.id) ctx.storage.token = 'another-session' assert.equal(ctx.service.getFreshCachedProfile(), null) ctx.service.clearLogin() assert.equal(ctx.service.getFreshCachedProfile(), null) }) test('头像经专用COS后端接口上传,Bearer字段正确,禁止H5跨域Cookie', async () => { const ctx = setup() const updates = [] ctx.service.subscribeProfile(profile => updates.push(profile)) const profile = await ctx.service.api.uploadAvatar('blob:local-avatar') const upload = ctx.calls[0] assert.equal(upload.url, 'http://101.43.139.56/hera/api/user/avatar') assert.equal(upload.name, 'file') assert.equal(upload.filePath, 'blob:local-avatar') assert.equal(upload.header.Authorization, 'Bearer test-session') assert.equal(upload.header['content-type'], undefined) assert.equal(upload.withCredentials, false) assert.equal(profile.avatarUrl, updates.at(-1).avatarUrl) assert.equal(ctx.service.getCachedProfile().avatarUrl, profile.avatarUrl) assert.equal((await ctx.service.api.profile()).avatarUrl, profile.avatarUrl) assert.equal(ctx.calls.length, 1) }) test('默认部署地址为远端hera前缀,登录资料上传与相对文件路径一致且允许本地覆盖', async () => { const ctx = setup() await ctx.service.api.profile() await ctx.service.api.wxLogin('stub-code') assert.equal(ctx.calls[0].url, 'http://101.43.139.56/hera/api/user/profile') assert.equal(ctx.calls[1].url, 'http://101.43.139.56/hera/api/auth/wx-login') assert.equal(ctx.service.resolveAssetUrl('/file/access?token=test-only'), 'http://101.43.139.56/hera/file/access?token=test-only') assert.equal(ctx.service.resolveAssetUrl('https://files.example.test/image.png'), 'https://files.example.test/image.png') const local = setup('local-session', 'http://127.0.0.1:8080/') await local.service.api.profile() await local.service.api.uploadAvatar('blob:test-only') assert.equal(local.calls[0].url, 'http://127.0.0.1:8080/api/user/profile') assert.equal(local.calls[1].url, 'http://127.0.0.1:8080/api/user/avatar') const config = fs.readFileSync(path.join(root, 'config/index.ts'), 'utf8') assert.match(config, /process\.env\.HERA_API_BASE_URL \|\| 'http:\/\/101\.43\.139\.56\/hera'/) }) test('上传失败不覆盖已有头像;HTML 401也清理会话', async () => { const ctx = setup() await ctx.service.api.profile() ctx.taro.uploadFile = async () => ({ statusCode: 400, data: JSON.stringify({ code: 400, message: '仅支持 JPG、PNG 或 WebP 头像' }) }) await assert.rejects(ctx.service.api.uploadAvatar('bad.gif'), /仅支持/) assert.equal(ctx.service.getCachedProfile().avatarUrl, '') ctx.taro.uploadFile = async () => ({ statusCode: 401, data: 'unauthorized' }) await assert.rejects(ctx.service.api.uploadAvatar('test.png'), /失效|登录/) assert.equal(ctx.storage.token, undefined) }) test('新注册空资料保持空值,老用户直接保存token进入我的', async () => { const ctx = setup('') await ctx.account.finishAccountLogin({ token: 'new-session', user: { ...fullProfile, birthday: null, birthHour: null, gender: '未知', province: null, city: null, district: null } }) assert.equal(ctx.storage.token, 'new-session') assert.equal(ctx.service.getCachedProfile().birthday, '') assert.equal(ctx.editing.toEditableProfile(ctx.service.getCachedProfile()).gender, '') assert.equal(ctx.routes.at(-1), '/pages/profile-edit/index?mode=register') await ctx.account.finishAccountLogin({ token: 'returning-session', user: fullProfile }) assert.equal(ctx.routes.at(-1), '/pages/my/index') assert.equal(ctx.service.getCachedProfile().id, 9) }) test('单独更新头像不调用资料PUT,且不受资料额度为0限制', async () => { const ctx = setup() let personalWrites = 0 const initial = { ...fullProfile, remainingEdits: 0 } const result = await ctx.editing.saveProfileEdits(initial, ctx.editing.toEditableProfile(initial), 'avatar.png', { uploadAvatar: async () => ({ ...initial, avatarUrl: '/file/access?token=fixture' }), updateProfile: async () => { personalWrites++; return initial } }, () => {}) assert.equal(personalWrites, 0) assert.equal(result.profile.remainingEdits, 0) assert.equal(result.changed, true) }) test('头像上传失败不消费个人资料额度;先上传再保存且只PUT一次', async () => { const ctx = setup() const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' } const order = [] const writer = { uploadAvatar: async () => { order.push('upload'); throw new Error('COS不可用') }, updateProfile: async () => { order.push('put'); return { ...fullProfile, ...form, remainingEdits: 3 } } } await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => {}), /COS/) assert.deepEqual(order, ['upload']) order.length = 0 writer.uploadAvatar = async () => { order.push('upload'); return { ...fullProfile, avatarUrl: '/avatar.png' } } await ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, () => order.push('sync')) assert.deepEqual(order, ['upload', 'sync', 'put']) }) test('头像成功而资料失败会明确提示;重试不再次上传文件', async () => { const ctx = setup() const form = { ...ctx.editing.toEditableProfile(fullProfile), nickname: '新昵称' } let uploads = 0, writes = 0, uploaded const writer = { uploadAvatar: async () => { uploads++; return { ...fullProfile, avatarUrl: '/saved.png' } }, updateProfile: async () => { writes++; throw new Error('资料暂不可用') } } await assert.rejects(ctx.editing.saveProfileEdits(fullProfile, form, 'avatar.png', writer, profile => { uploaded = profile }), /头像已更新,个人资料未保存/) writer.updateProfile = async () => { writes++; return { ...uploaded, ...form } } await ctx.editing.saveProfileEdits(uploaded, form, '', writer, () => {}) assert.equal(uploads, 1); assert.equal(writes, 2) }) test('不发送未更改的个人资料;校验非法日期、缺失信息和昵称', async () => { const ctx = setup() let writes = 0 const form = ctx.editing.toEditableProfile(fullProfile) const unchanged = await ctx.editing.saveProfileEdits(fullProfile, form, '', { uploadAvatar: async () => fullProfile, updateProfile: async () => { writes++; return fullProfile } }, () => {}) assert.equal(unchanged.changed, false); assert.equal(writes, 0) for (const patch of [{ nickname: '' }, { gender: '' }, { birthday: '2026-02-30' }, { birthday: '2999-01-01' }]) { assert.throws(() => ctx.editing.validateProfileForm({ ...form, ...patch })) } }) test('三个位置共用头像组件,编辑失败不回退假资料,不再使用dev-code登录', () => { for (const page of ['home', 'my', 'profile-edit']) { const source = fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8') assert.match(source, /UserAvatar/) assert.doesNotMatch(source, /setProfile\(previewProfile\)/) } for (const page of ['home', 'my']) assert.match(fs.readFileSync(path.join(root, `src/pages/${page}/index.tsx`), 'utf8'), /subscribeProfile/) assert.doesNotMatch(fs.readFileSync(path.join(root, 'src/pages/login/index.tsx'), 'utf8'), /dev-code/) assert.match(fs.readFileSync(path.join(root, 'src/components/UserAvatar.tsx'), 'utf8'), /api.profile\(true\)/) })