This commit is contained in:
301
scripts/tests/changePassword.test.mjs
Normal file
301
scripts/tests/changePassword.test.mjs
Normal file
@@ -0,0 +1,301 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||
import ts from 'typescript'
|
||||
|
||||
const componentPath = 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue'
|
||||
const componentSource = await readFile(componentPath, 'utf8')
|
||||
const messages = []
|
||||
const requests = []
|
||||
let rememberedClears = 0
|
||||
let localStateClears = 0
|
||||
let response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||
let pendingRequest
|
||||
|
||||
function moduleUrl(source, imports = {}) {
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }
|
||||
})
|
||||
const resolved = outputText.replace(/from ['"]([^'"]+)['"]/g, (_match, name) => {
|
||||
return `from '${imports[name] || import.meta.resolve(name)}'`
|
||||
})
|
||||
return `data:text/javascript;base64,${Buffer.from(resolved).toString('base64')}`
|
||||
}
|
||||
|
||||
// 真实请求层使用模拟 Axios adapter:不会访问网络或修改真实账号密码。
|
||||
const axios = (await import('axios')).default
|
||||
const previousAdapter = axios.defaults.adapter
|
||||
|
||||
globalThis.__passwordTest = {
|
||||
message: (type, value) => messages.push({ type, value }),
|
||||
clearRemembered: () => rememberedClears++,
|
||||
store: {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: '',
|
||||
clearLocalState: () => localStateClears++
|
||||
}
|
||||
}
|
||||
const uiUrl = moduleUrl(`
|
||||
export const ElButton = {}, ElDialog = {}, ElForm = {}, ElFormItem = {}, ElInput = {}
|
||||
export const ElMessage = {
|
||||
success: (value) => globalThis.__passwordTest.message('success', value),
|
||||
error: (value) => globalThis.__passwordTest.message('error', value)
|
||||
}
|
||||
`)
|
||||
const storeUrl = moduleUrl('export const useUserStore = () => globalThis.__passwordTest.store')
|
||||
const statusUrl = moduleUrl(await readFile('src/utils/http/status.ts', 'utf8'))
|
||||
const errorUrl = moduleUrl(await readFile('src/utils/business/apiError.ts', 'utf8'))
|
||||
const idUrl = moduleUrl(await readFile('src/utils/business/id.ts', 'utf8'))
|
||||
const httpSource = (await readFile('src/utils/http/index.ts', 'utf8'))
|
||||
.replace('import.meta.env.DEV', 'true')
|
||||
.replace('import.meta.env.VITE_API_URL', "''")
|
||||
const httpUrl = moduleUrl(httpSource, {
|
||||
'element-plus': uiUrl,
|
||||
'@/store/modules/user': storeUrl,
|
||||
'./status': statusUrl,
|
||||
'@/utils/business/apiError': errorUrl,
|
||||
'@/utils/business/id': idUrl
|
||||
})
|
||||
// 导入时才创建 Axios 实例,因此临时保留模拟 adapter 到模块初始化结束。
|
||||
const mockAdapter = async (config) => {
|
||||
requests.push(config)
|
||||
if (pendingRequest) await pendingRequest
|
||||
const result = { ...response, headers: { 'content-type': 'application/json' }, config }
|
||||
if (response.status >= 400) {
|
||||
throw new axios.AxiosError('Mock HTTP error', 'ERR_BAD_RESPONSE', config, undefined, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
axios.defaults.adapter = mockAdapter
|
||||
await import(httpUrl)
|
||||
axios.defaults.adapter = previousAdapter
|
||||
const baseUrl = moduleUrl(await readFile('src/api/BaseService.ts', 'utf8'), {
|
||||
'@/utils/http': httpUrl
|
||||
})
|
||||
const authUrl = moduleUrl(await readFile('src/api/modules/auth.ts', 'utf8'), {
|
||||
'../BaseService': baseUrl
|
||||
})
|
||||
const { AuthService } = await import(authUrl)
|
||||
const { descriptor } = parse(componentSource, { filename: componentPath })
|
||||
const script = compileScript(descriptor, { id: 'password-test' })
|
||||
const { default: Dialog } = await import(
|
||||
moduleUrl(script.content, {
|
||||
'element-plus': uiUrl,
|
||||
'vue-i18n': moduleUrl('export const useI18n = () => ({ t: (key) => key })'),
|
||||
'@/api/modules/auth': authUrl,
|
||||
'@/utils/http/status': statusUrl,
|
||||
'@/utils/auth/rememberPassword': moduleUrl(
|
||||
'export const clearRememberedCredentials = () => globalThis.__passwordTest.clearRemembered()'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
function createDialog() {
|
||||
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||
pendingRequest = undefined
|
||||
requests.length = 0
|
||||
messages.length = 0
|
||||
rememberedClears = 0
|
||||
const props = reactive({ modelValue: true })
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() =>
|
||||
Dialog.setup(props, {
|
||||
expose: () => {},
|
||||
emit: (_event, value) => {
|
||||
props.modelValue = value
|
||||
}
|
||||
})
|
||||
)
|
||||
let clearedValidation = 0
|
||||
async function validateField(field) {
|
||||
const value = state.formData[field]
|
||||
for (const rule of state.rules.value[field]) {
|
||||
if (rule.required && !value) throw new Error(rule.message)
|
||||
if (rule.min !== undefined && value.length < rule.min) throw new Error(rule.message)
|
||||
if (rule.max !== undefined && value.length > rule.max) throw new Error(rule.message)
|
||||
if (rule.validator) {
|
||||
await new Promise((resolve, reject) => {
|
||||
rule.validator(rule, value, (error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
state.formRef.value = {
|
||||
validate: async () => {
|
||||
for (const field of Object.keys(state.rules.value)) await validateField(field)
|
||||
return true
|
||||
},
|
||||
validateField,
|
||||
clearValidate: () => clearedValidation++
|
||||
}
|
||||
Object.assign(state.formData, {
|
||||
oldPassword: 'old',
|
||||
newPassword: 'new-password',
|
||||
confirmPassword: 'new-password'
|
||||
})
|
||||
return { state, props, scope, validationClears: () => clearedValidation }
|
||||
}
|
||||
|
||||
test('密码长度 7/33 位阻止提交,8/32 位允许提交,旧密码不受新长度限制', async () => {
|
||||
for (const length of [7, 8, 32, 33]) {
|
||||
const { state, props, scope } = createDialog()
|
||||
state.formData.newPassword = 'a'.repeat(length)
|
||||
state.formData.confirmPassword = 'a'.repeat(length)
|
||||
await state.handleSubmit()
|
||||
assert.equal(requests.length, length === 8 || length === 32 ? 1 : 0)
|
||||
assert.equal(props.modelValue, length !== 8 && length !== 32)
|
||||
assert.equal(state.submitting.value, false)
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('密码必填、一致性及确认密码长度校验失败不发送请求', async () => {
|
||||
for (const invalid of [
|
||||
{ oldPassword: '' },
|
||||
{ newPassword: '' },
|
||||
{ confirmPassword: '' },
|
||||
{ confirmPassword: 'different-password' },
|
||||
{ confirmPassword: 'a'.repeat(7) },
|
||||
{ confirmPassword: 'a'.repeat(33) }
|
||||
]) {
|
||||
const { state, scope } = createDialog()
|
||||
Object.assign(state.formData, invalid)
|
||||
await state.handleSubmit()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(state.submitting.value, false)
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('修改成功发送下划线参数及 Bearer 认证、保留密码空格、清理表单但不退出登录', async () => {
|
||||
const { state, props, scope } = createDialog()
|
||||
state.formData.oldPassword = ' old '
|
||||
state.formData.newPassword = state.formData.confirmPassword = ' new-password '
|
||||
await state.handleSubmit()
|
||||
assert.equal(requests[0].method, 'put')
|
||||
assert.equal(requests[0].url, '/api/auth/password')
|
||||
assert.equal(requests[0].headers.get('Authorization'), 'Bearer mock-access-token')
|
||||
assert.deepEqual(JSON.parse(requests[0].data), {
|
||||
old_password: ' old ',
|
||||
new_password: ' new-password '
|
||||
})
|
||||
assert.equal(rememberedClears, 1)
|
||||
assert.equal(localStateClears, 0)
|
||||
assert.equal(props.modelValue, false)
|
||||
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
assert.deepEqual(messages, [{ type: 'success', value: 'changePassword.success' }])
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('服务层丢弃确认密码和其他额外字段', async () => {
|
||||
createDialog().scope.stop()
|
||||
await AuthService.changePassword({
|
||||
old_password: 'old',
|
||||
new_password: 'new-password',
|
||||
confirmPassword: 'unused'
|
||||
})
|
||||
assert.deepEqual(JSON.parse(requests[0].data), {
|
||||
old_password: 'old',
|
||||
new_password: 'new-password'
|
||||
})
|
||||
})
|
||||
|
||||
test('校验或请求未完成时重复提交只产生一次请求,并阻止关闭', async () => {
|
||||
const { state, props, scope } = createDialog()
|
||||
let finishRequest
|
||||
pendingRequest = new Promise((resolve) => {
|
||||
finishRequest = resolve
|
||||
})
|
||||
const first = state.handleSubmit()
|
||||
await state.handleSubmit()
|
||||
await nextTick()
|
||||
assert.equal(state.submitting.value, true)
|
||||
let closed = false
|
||||
state.handleBeforeClose(() => {
|
||||
closed = true
|
||||
})
|
||||
assert.equal(closed, false)
|
||||
assert.equal(props.modelValue, true)
|
||||
finishRequest()
|
||||
await first
|
||||
assert.equal(requests.length, 1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('HTTP 400/500 错误保留弹窗和输入,统一请求层提示一次且允许重试', async () => {
|
||||
for (const status of [400, 500]) {
|
||||
const { state, props, scope } = createDialog()
|
||||
response = { status, data: { code: status, msg: '旧密码错误', data: {} } }
|
||||
await state.handleSubmit()
|
||||
assert.equal(props.modelValue, true)
|
||||
assert.equal(state.formData.oldPassword, 'old')
|
||||
assert.equal(state.submitting.value, false)
|
||||
assert.equal(rememberedClears, 0)
|
||||
assert.equal(messages.length, 1)
|
||||
assert.equal(messages[0].type, 'error')
|
||||
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
|
||||
await state.handleSubmit()
|
||||
assert.equal(props.modelValue, false)
|
||||
assert.equal(requests.length, 2)
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('HTTP 200 业务错误不会误报成功或清除保存的凭证', async () => {
|
||||
const { state, props, scope } = createDialog()
|
||||
response = { status: 200, data: { code: 1001, msg: '旧密码错误', data: {} } }
|
||||
await state.handleSubmit()
|
||||
assert.equal(props.modelValue, true)
|
||||
assert.equal(rememberedClears, 0)
|
||||
assert.deepEqual(messages, [{ type: 'error', value: '旧密码错误' }])
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('关闭后重新打开清空密码及校验状态', async () => {
|
||||
const { state, props, scope, validationClears } = createDialog()
|
||||
state.visible.value = false
|
||||
state.resetForm()
|
||||
props.modelValue = true
|
||||
await nextTick()
|
||||
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
assert.ok(validationClears() > 0)
|
||||
assert.equal(state.submitting.value, false)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('菜单顺序、显隐、标签、键盘提交、窄屏宽度及中英文文案完整', async () => {
|
||||
const header = await readFile('src/components/core/layouts/art-header-bar/index.vue', 'utf8')
|
||||
assert.ok(header.indexOf('topBar.user.userCenter') < header.indexOf('topBar.user.changePassword'))
|
||||
assert.ok(header.indexOf('topBar.user.changePassword') < header.indexOf('topBar.user.lockScreen'))
|
||||
assert.match(header, /userMenuPopover\.value\?\.hide\(\)[\s\S]*showChangePassword\.value = true/)
|
||||
assert.equal(componentSource.match(/show-password/g)?.length, 3)
|
||||
assert.match(componentSource, /width="min\(460px, calc\(100vw - 32px\)\)"/)
|
||||
assert.match(componentSource, /label-position="top"/)
|
||||
assert.match(componentSource, /@submit\.prevent="handleSubmit"/)
|
||||
assert.match(header, /:focus-visible/)
|
||||
for (const language of ['zh', 'en']) {
|
||||
const dictionary = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8'))
|
||||
for (const [, key] of componentSource.matchAll(/t\('changePassword\.([^']+)'\)/g)) {
|
||||
assert.ok(dictionary.changePassword[key], `${language}: ${key}`)
|
||||
}
|
||||
assert.ok(dictionary.topBar.user.changePassword)
|
||||
}
|
||||
})
|
||||
|
||||
test('HTTP 401 沿用统一认证过期处理,仅提示一次并清理本地登录状态', async () => {
|
||||
const { state, scope } = createDialog()
|
||||
response = { status: 401, data: { code: 401, msg: 'unauthorized', data: {} } }
|
||||
await state.handleSubmit()
|
||||
assert.equal(state.submitting.value, false)
|
||||
assert.equal(rememberedClears, 0)
|
||||
assert.deepEqual(messages, [{ type: 'error', value: '登录已过期,请重新登录' }])
|
||||
await new Promise((resolve) => setTimeout(resolve, 1050))
|
||||
assert.equal(localStateClears, 1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.__passwordTest
|
||||
})
|
||||
Reference in New Issue
Block a user