/** * 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) }) })