Files
ai-kefu-admin/admin/src/api/__tests__/auth-store.test.ts
T
浅念 3973abea8b feat: API层与认证流(client拦截/守卫/登录改密实装/域API骨架/vitest)
- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
  (login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
  冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
  可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
  must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
  会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
  快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 16:28:11 +08:00

212 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* auth store 单元测试(Phase 3 Task 3)
*
* 覆盖:
* - login 成功存 token/role/tenant_slug/must_change_password/username(含模板侧同步)
* - logout:调 API + 清空状态 + 模板侧 logOut(API 失败也照常本地清)
* - localStorage 持久化(all-kefu 前缀键;新建 pinia 实例模拟刷新恢复)
* - handleSessionExpired / handleMustChangePassword / changePassword
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApp, defineComponent } from 'vue'
import { createPersistedState } from 'pinia-plugin-persistedstate'
const mocks = vi.hoisted(() => ({
fetchLogin: vi.fn(),
fetchLogout: vi.fn(),
fetchChangePassword: vi.fn(),
userStore: {
logOut: vi.fn(),
setLoginStatus: vi.fn(),
setToken: vi.fn(),
setUserInfo: vi.fn(),
checkAndClearWorktabs: vi.fn(),
info: {}
},
routerPush: vi.fn(),
routerReplace: vi.fn(),
currentRoute: { value: { path: '/' } }
}))
vi.mock('@/api/auth', () => ({
fetchLogin: mocks.fetchLogin,
fetchLogout: mocks.fetchLogout,
fetchChangePassword: mocks.fetchChangePassword
}))
vi.mock('@/store/modules/user', () => ({
useUserStore: () => mocks.userStore
}))
vi.mock('@/router', () => ({
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
}))
import { useAuthStore } from '@/store/modules/auth'
const LOGIN_RESPONSE = {
token: 'tok-abc',
role: 'super',
tenant_slug: null,
must_change_password: true
} as const
/**
* 等价应用的持久化 pinia:
* pinia.use() 在未安装 app 时(_a 为空)插件只进 toBeInstalled 队列,
* 必须 app.use(pinia) 才真正生效——真实应用经 initStore(main.ts)如此;
* 测试里用一次性 app 复刻同一装配,否则持久化插件静默不运行。
*/
function freshPersistedPinia() {
const pinia = createPinia()
pinia.use(createPersistedState())
createApp(defineComponent(() => () => null)).use(pinia)
setActivePinia(pinia)
return pinia
}
describe('auth store(src/store/modules/auth.ts)', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
mocks.currentRoute.value.path = '/'
})
it('login 成功:存 token/role/tenantSlug/mustChangePassword/username 并同步模板侧', async () => {
freshPersistedPinia()
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
const store = useAuthStore()
await store.login({ username: 'admin', password: 'admin123' })
expect(store.token).toBe('tok-abc')
expect(store.role).toBe('super')
expect(store.tenantSlug).toBeNull()
expect(store.mustChangePassword).toBe(true)
expect(store.username).toBe('admin')
expect(store.isAuthenticated).toBe(true)
// 模板侧(user store)同步:token / 登录标记 / 用户信息(菜单过滤用 roles)
expect(mocks.userStore.setToken).toHaveBeenCalledWith('tok-abc', '')
expect(mocks.userStore.setLoginStatus).toHaveBeenCalledWith(true)
expect(mocks.userStore.setUserInfo).toHaveBeenCalled()
const info = mocks.userStore.setUserInfo.mock.calls[0][0]
expect(info.roles).toEqual(['super'])
expect(info.userName).toBe('admin')
})
it('login 失败:状态保持空、错误向上抛', async () => {
freshPersistedPinia()
mocks.fetchLogin.mockRejectedValue(new Error('用户名或密码错误'))
const store = useAuthStore()
await expect(store.login({ username: 'a', password: 'b' })).rejects.toThrow('用户名或密码错误')
expect(store.token).toBe('')
expect(store.isAuthenticated).toBe(false)
expect(mocks.userStore.setLoginStatus).not.toHaveBeenCalled()
})
it('logout:调登出 API + 清空状态 + 模板侧 logOut', async () => {
freshPersistedPinia()
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
mocks.fetchLogout.mockResolvedValue({ ok: true })
const store = useAuthStore()
await store.login({ username: 'admin', password: 'x' })
await store.logout()
expect(mocks.fetchLogout).toHaveBeenCalledTimes(1)
expect(store.token).toBe('')
expect(store.role).toBe('')
expect(store.tenantSlug).toBeNull()
expect(store.username).toBe('')
expect(store.mustChangePassword).toBe(false)
expect(store.isAuthenticated).toBe(false)
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
})
it('logout 时后端已不可达:仍完成本地清理(best effort)', async () => {
freshPersistedPinia()
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
mocks.fetchLogout.mockRejectedValue(new Error('network down'))
const store = useAuthStore()
await store.login({ username: 'admin', password: 'x' })
await expect(store.logout()).resolves.toBeUndefined()
expect(store.token).toBe('')
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
})
it('localStorage 持久化:all-kefu 前缀键;新 pinia 实例(模拟刷新)恢复会话', async () => {
freshPersistedPinia()
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE, role: 'tenant', tenant_slug: 'jiancai', must_change_password: false })
await useAuthStore().login({ username: 'jcadmin', password: 'x' })
// 持久化键带 all-kefu 前缀
expect(localStorage.getItem('all-kefu-auth')).toBeTruthy()
// 模拟页面刷新:全新 pinia + 插件,从 localStorage 恢复
freshPersistedPinia()
const restored = useAuthStore()
expect(restored.token).toBe('tok-abc')
expect(restored.role).toBe('tenant')
expect(restored.tenantSlug).toBe('jiancai')
expect(restored.username).toBe('jcadmin')
expect(restored.mustChangePassword).toBe(false)
expect(restored.isAuthenticated).toBe(true)
})
it('handleSessionExpired:清空会话并委托模板侧 logOut(401 全局处理路径)', () => {
freshPersistedPinia()
const store = useAuthStore()
store.token = 'tok'
store.role = 'super'
store.username = 'admin'
store.handleSessionExpired()
expect(store.token).toBe('')
expect(store.isAuthenticated).toBe(false)
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
})
it('handleMustChangePassword:置标志并跳强制改密页;已在改密页不重复跳', async () => {
freshPersistedPinia()
const store = useAuthStore()
await store.handleMustChangePassword()
expect(store.mustChangePassword).toBe(true)
await vi.waitFor(() => {
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
})
// 已在改密页:不再跳
mocks.routerReplace.mockClear()
mocks.currentRoute.value.path = '/auth/force-change-password'
await store.handleMustChangePassword()
expect(mocks.routerReplace).not.toHaveBeenCalled()
})
it('changePassword 成功:清 must_change 标志', async () => {
freshPersistedPinia()
mocks.fetchChangePassword.mockResolvedValue({ ok: true, must_change_password: false })
const store = useAuthStore()
store.mustChangePassword = true
await store.changePassword({ old_password: 'old', new_password: 'new-pass-123' })
expect(mocks.fetchChangePassword).toHaveBeenCalledWith({
old_password: 'old',
new_password: 'new-pass-123'
})
expect(store.mustChangePassword).toBe(false)
})
})