2026-10-17 22:44:09 +08:00
|
|
|
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'/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-17 22:58:40 +08:00
|
|
|
function setup(token = 'test-session', baseUrl) {
|
2026-10-17 22:44:09 +08:00
|
|
|
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,
|
2026-10-17 22:58:40 +08:00
|
|
|
...(baseUrl === undefined ? {} : { API_BASE_URL: baseUrl }),
|
2026-10-17 22:44:09 +08:00
|
|
|
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('头像经专用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]
|
2026-10-17 22:58:40 +08:00
|
|
|
assert.equal(upload.url, 'http://101.43.139.56/hera/api/user/avatar')
|
2026-10-17 22:44:09 +08:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-17 22:58:40 +08:00
|
|
|
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'/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-17 22:44:09 +08:00
|
|
|
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: '<html>unauthorized</html>' })
|
|
|
|
|
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\)/)
|
|
|
|
|
})
|