212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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)
|
|||
|
|
})
|
|||
|
|
})
|