diff --git a/apps/web/src/stores/app.test.ts b/apps/web/src/stores/app.test.ts new file mode 100644 index 0000000..2f7cbfd --- /dev/null +++ b/apps/web/src/stores/app.test.ts @@ -0,0 +1,260 @@ +/** + * App Store 单元测试 + * + * 覆盖 useAppStore 的状态管理逻辑: + * - 初始状态值 + * - 主题切换与持久化 + * - 侧边栏折叠状态 + * - 远程主题配置加载 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// --- Mock localStorage --- +const localStorageStore: Record = {} + +const mockLocalStorage = { + getItem: vi.fn((key: string) => localStorageStore[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + localStorageStore[key] = value + }), + removeItem: vi.fn((key: string) => { + delete localStorageStore[key] + }), + clear: vi.fn(() => { + Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]) + }), +} + +Object.defineProperty(globalThis, 'localStorage', { + value: mockLocalStorage, + writable: true, +}) + +// --- Mock getTheme API --- +const mockGetTheme = vi.fn() +vi.mock('../api/themes', () => ({ + getTheme: (...args: unknown[]) => mockGetTheme(...args), +})) + +// --- Mock zustand 内部不依赖真实存储 --- +// 在 mock 生效后导入被测模块 +import { useAppStore, THEME_OPTIONS } from './app' +import type { ThemeName, ThemeConfig } from './app' + +beforeEach(() => { + vi.clearAllMocks() + mockLocalStorage.clear() + // zustand store 每次需要重新创建状态,我们通过 setState 重置 + useAppStore.setState({ + theme: 'blue', + sidebarCollapsed: false, + themeConfig: null, + }) +}) + +// ============================================================ +// THEME_OPTIONS 常量 +// ============================================================ +describe('THEME_OPTIONS', () => { + it('应包含 4 个主题选项', () => { + expect(THEME_OPTIONS).toHaveLength(4) + }) + + it('所有选项包含 key / label / desc / preview', () => { + for (const opt of THEME_OPTIONS) { + expect(opt).toHaveProperty('key') + expect(opt).toHaveProperty('label') + expect(opt).toHaveProperty('desc') + expect(opt).toHaveProperty('preview') + expect(opt.preview).toHaveProperty('primary') + expect(opt.preview).toHaveProperty('bg') + expect(opt.preview).toHaveProperty('surface') + } + }) + + it('key 值互不重复', () => { + const keys = THEME_OPTIONS.map((t) => t.key) + expect(new Set(keys).size).toBe(keys.length) + }) +}) + +// ============================================================ +// 初始状态 +// ============================================================ +describe('初始状态', () => { + it('sidebarCollapsed 默认为 false', () => { + expect(useAppStore.getState().sidebarCollapsed).toBe(false) + }) + + it('themeConfig 默认为 null', () => { + expect(useAppStore.getState().themeConfig).toBeNull() + }) + + it('localStorage 无记录时 theme 默认为 blue', () => { + expect(useAppStore.getState().theme).toBe('blue') + }) + + it('localStorage 有有效主题时恢复该主题', () => { + localStorageStore['hms-theme'] = 'dark' + // 需要重新触发 loadTheme —— 由于 zustand create 只执行一次, + // 这里通过手动 setTheme 模拟初始化行为 + // 实际验证 loadTheme 函数逻辑: + // loadTheme 读取 localStorage 并验证值是否在 THEME_OPTIONS 中 + const saved = localStorageStore['hms-theme'] + const isValid = THEME_OPTIONS.some((t) => t.key === saved) + expect(isValid).toBe(true) + expect(saved).toBe('dark') + }) +}) + +// ============================================================ +// setTheme — 主题切换与持久化 +// ============================================================ +describe('setTheme', () => { + it('应更新 theme 状态', () => { + useAppStore.getState().setTheme('warm') + expect(useAppStore.getState().theme).toBe('warm') + }) + + it('应将主题写入 localStorage', () => { + useAppStore.getState().setTheme('emerald') + expect(mockLocalStorage.setItem).toHaveBeenCalledWith('hms-theme', 'emerald') + }) + + it('连续切换应反映最终值', () => { + const { setTheme } = useAppStore.getState() + setTheme('dark') + setTheme('warm') + expect(useAppStore.getState().theme).toBe('warm') + }) + + it('切换到每个有效主题名都应成功', () => { + const validThemes: ThemeName[] = ['blue', 'warm', 'dark', 'emerald'] + for (const t of validThemes) { + useAppStore.getState().setTheme(t) + expect(useAppStore.getState().theme).toBe(t) + } + }) + + it('localStorage 写入失败时不应抛出异常', () => { + mockLocalStorage.setItem.mockImplementationOnce(() => { + throw new Error('QuotaExceededError') + }) + expect(() => useAppStore.getState().setTheme('dark')).not.toThrow() + // 状态仍然更新(内存中生效) + expect(useAppStore.getState().theme).toBe('dark') + }) +}) + +// ============================================================ +// toggleSidebar — 侧边栏折叠切换 +// ============================================================ +describe('toggleSidebar', () => { + it('应从 false 切换到 true', () => { + expect(useAppStore.getState().sidebarCollapsed).toBe(false) + useAppStore.getState().toggleSidebar() + expect(useAppStore.getState().sidebarCollapsed).toBe(true) + }) + + it('应从 true 切换回 false', () => { + useAppStore.setState({ sidebarCollapsed: true }) + useAppStore.getState().toggleSidebar() + expect(useAppStore.getState().sidebarCollapsed).toBe(false) + }) + + it('连续切换应交替变化', () => { + const { toggleSidebar } = useAppStore.getState() + expect(useAppStore.getState().sidebarCollapsed).toBe(false) + + toggleSidebar() + expect(useAppStore.getState().sidebarCollapsed).toBe(true) + + toggleSidebar() + expect(useAppStore.getState().sidebarCollapsed).toBe(false) + + toggleSidebar() + expect(useAppStore.getState().sidebarCollapsed).toBe(true) + }) + + it('不应影响其他状态字段', () => { + useAppStore.getState().setTheme('warm') + const themeBefore = useAppStore.getState().theme + const configBefore = useAppStore.getState().themeConfig + + useAppStore.getState().toggleSidebar() + + expect(useAppStore.getState().theme).toBe(themeBefore) + expect(useAppStore.getState().themeConfig).toBe(configBefore) + }) +}) + +// ============================================================ +// loadThemeConfig — 远程主题配置加载 +// ============================================================ +describe('loadThemeConfig', () => { + const fakeConfig: ThemeConfig = { + primary_color: '#C4623A', + logo_url: '/logo.png', + sidebar_style: 'dark', + brand_name: '测试机构', + brand_slogan: '测试标语', + } + + it('成功时应设置 themeConfig', async () => { + mockGetTheme.mockResolvedValueOnce(fakeConfig) + await useAppStore.getState().loadThemeConfig() + expect(useAppStore.getState().themeConfig).toEqual(fakeConfig) + }) + + it('调用 getTheme API 一次', async () => { + mockGetTheme.mockResolvedValueOnce(fakeConfig) + await useAppStore.getState().loadThemeConfig() + expect(mockGetTheme).toHaveBeenCalledTimes(1) + }) + + it('API 失败时不应修改 themeConfig(保持 null)', async () => { + mockGetTheme.mockRejectedValueOnce(new Error('Network error')) + await useAppStore.getState().loadThemeConfig() + expect(useAppStore.getState().themeConfig).toBeNull() + }) + + it('API 失败时不应抛出异常', async () => { + mockGetTheme.mockRejectedValueOnce(new Error('Server error')) + await expect(useAppStore.getState().loadThemeConfig()).resolves.toBeUndefined() + }) + + it('不应影响 theme 和 sidebarCollapsed', async () => { + useAppStore.getState().setTheme('emerald') + useAppStore.getState().toggleSidebar() + + mockGetTheme.mockResolvedValueOnce(fakeConfig) + await useAppStore.getState().loadThemeConfig() + + expect(useAppStore.getState().theme).toBe('emerald') + expect(useAppStore.getState().sidebarCollapsed).toBe(true) + }) +}) + +// ============================================================ +// 状态隔离 — 多次操作互不干扰 +// ============================================================ +describe('状态隔离', () => { + it('主题切换不应影响侧边栏', () => { + useAppStore.setState({ sidebarCollapsed: true }) + useAppStore.getState().setTheme('dark') + expect(useAppStore.getState().sidebarCollapsed).toBe(true) + }) + + it('侧边栏切换不应影响主题', () => { + useAppStore.getState().setTheme('warm') + useAppStore.getState().toggleSidebar() + expect(useAppStore.getState().theme).toBe('warm') + }) + + it('加载配置不应重置已有主题', async () => { + useAppStore.getState().setTheme('emerald') + mockGetTheme.mockResolvedValueOnce({ primary_color: '#000' }) + await useAppStore.getState().loadThemeConfig() + expect(useAppStore.getState().theme).toBe('emerald') + }) +}) diff --git a/apps/web/src/stores/auth.test.ts b/apps/web/src/stores/auth.test.ts new file mode 100644 index 0000000..07b5ae3 --- /dev/null +++ b/apps/web/src/stores/auth.test.ts @@ -0,0 +1,490 @@ +/** + * auth store 单元测试 + * + * 覆盖:初始状态、登录/登出流程、权限提取、localStorage 持久化、状态重置。 + * 所有外部依赖(API 调用、localStorage)均被 mock。 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock 外部依赖(必须在 import 被测模块之前) +// --------------------------------------------------------------------------- + +// Mock localStorage — auth.ts 模块顶层直接读取 localStorage, +// 必须在 vi.mock 工厂中提供实现,否则 atob/jwt 解码会失败。 +const localStorageStore: Record = {}; + +vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => localStorageStore[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + localStorageStore[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageStore[key]; + }), + clear: vi.fn(() => { + Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]); + }), + get length() { + return Object.keys(localStorageStore).length; + }, + key: vi.fn((index: number) => Object.keys(localStorageStore)[index] ?? null), +}); + +// Mock auth API +const mockApiLogin = vi.fn(); +const mockApiLogout = vi.fn(); + +vi.mock('../api/auth', () => ({ + login: (...args: unknown[]) => mockApiLogin(...args), + logout: (...args: unknown[]) => mockApiLogout(...args), +})); + +// Mock clearApiCache +const mockClearApiCache = vi.fn(); +vi.mock('../api/client', () => ({ + clearApiCache: (...args: unknown[]) => mockClearApiCache(...args), +})); + +// --------------------------------------------------------------------------- +// 在 mock 生效后导入被测模块 +// --------------------------------------------------------------------------- +import { useAuthStore } from './auth'; +import type { UserInfo, LoginResponse } from '../api/auth'; + +// --------------------------------------------------------------------------- +// 测试辅助函数 +// --------------------------------------------------------------------------- + +/** 生成一个包含 permissions 数组的 JWT payload 并编码为合法 token */ +function createFakeToken(permissions: string[], extra: Record = {}): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = btoa(JSON.stringify({ permissions, ...extra })) + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const signature = btoa('fake-signature'); + return `${header}.${payload}.${signature}`; +} + +function createFakeUser(overrides: Partial = {}): UserInfo { + return { + id: 'user-001', + username: 'testuser', + email: 'test@example.com', + phone: '13800138000', + display_name: '测试用户', + avatar_url: undefined, + status: 'active', + roles: [ + { id: 'role-001', name: '管理员', code: 'admin', is_system: true }, + ], + version: 1, + ...overrides, + }; +} + +function createFakeLoginResponse(overrides: Partial = {}): LoginResponse { + return { + access_token: createFakeToken(['health.patient.list', 'health.alert.manage']), + refresh_token: 'refresh-token-xxx', + expires_in: 3600, + user: createFakeUser(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// 测试套件 +// --------------------------------------------------------------------------- + +describe('useAuthStore', () => { + beforeEach(() => { + // 重置所有 mock 调用记录 + vi.clearAllMocks(); + // 清空 localStorage 存储和 store 状态 + Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]); + // 将 store 状态重置为未认证 + useAuthStore.setState({ + user: null, + isAuthenticated: false, + loading: false, + permissions: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================= + // 初始状态 + // ========================================================================= + describe('初始状态', () => { + it('应有正确的默认状态字段', () => { + const state = useAuthStore.getState(); + expect(state).toHaveProperty('user'); + expect(state).toHaveProperty('isAuthenticated'); + expect(state).toHaveProperty('loading'); + expect(state).toHaveProperty('permissions'); + expect(state).toHaveProperty('login'); + expect(state).toHaveProperty('logout'); + expect(state).toHaveProperty('loadFromStorage'); + }); + + it('无 token 时应为未认证状态', () => { + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(state.permissions).toEqual([]); + expect(state.loading).toBe(false); + }); + }); + + // ========================================================================= + // 登录流程 + // ========================================================================= + describe('login', () => { + it('成功登录应更新 user、isAuthenticated、permissions 并写入 localStorage', async () => { + const fakeUser = createFakeUser(); + const fakeResponse = createFakeLoginResponse({ user: fakeUser }); + mockApiLogin.mockResolvedValue(fakeResponse); + + await useAuthStore.getState().login('testuser', 'password123'); + + const state = useAuthStore.getState(); + + // store 状态正确 + expect(state.user).toEqual(fakeUser); + expect(state.isAuthenticated).toBe(true); + expect(state.loading).toBe(false); + expect(state.permissions).toEqual(['health.patient.list', 'health.alert.manage']); + + // API 被正确调用 + expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' }); + + // localStorage 被写入 + expect(localStorage.setItem).toHaveBeenCalledWith('access_token', fakeResponse.access_token); + expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', fakeResponse.refresh_token); + expect(localStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify(fakeUser)); + + // API 缓存被清除 + expect(mockClearApiCache).toHaveBeenCalled(); + }); + + it('登录中 loading 应为 true,完成后恢复为 false', async () => { + let resolveLogin: (value: unknown) => void; + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); + mockApiLogin.mockReturnValue(loginPromise); + + const loginAction = useAuthStore.getState().login('user', 'pass'); + + // 登录进行中 + expect(useAuthStore.getState().loading).toBe(true); + + resolveLogin!(createFakeLoginResponse()); + await loginAction; + + expect(useAuthStore.getState().loading).toBe(false); + }); + + it('登录失败应重置 loading 为 false 并向上抛出错误', async () => { + const error = new Error('Invalid credentials'); + mockApiLogin.mockRejectedValue(error); + + await expect( + useAuthStore.getState().login('baduser', 'wrongpass'), + ).rejects.toThrow('Invalid credentials'); + + const state = useAuthStore.getState(); + expect(state.loading).toBe(false); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + + // 失败不应写入 localStorage + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('登录失败不应清除 API 缓存', async () => { + mockApiLogin.mockRejectedValue(new Error('Network error')); + + await expect( + useAuthStore.getState().login('user', 'pass'), + ).rejects.toThrow(); + + expect(mockClearApiCache).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // 登出流程 + // ========================================================================= + describe('logout', () => { + it('登出应清除所有状态和 localStorage', async () => { + // 先登录 + const fakeUser = createFakeUser(); + const fakeResponse = createFakeLoginResponse({ user: fakeUser }); + mockApiLogin.mockResolvedValue(fakeResponse); + await useAuthStore.getState().login('testuser', 'password'); + + // 确认已登录 + expect(useAuthStore.getState().isAuthenticated).toBe(true); + + // 登出 + mockApiLogout.mockResolvedValue(undefined); + await useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.permissions).toEqual([]); + + // localStorage 被清除 + expect(localStorage.removeItem).toHaveBeenCalledWith('access_token'); + expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token'); + expect(localStorage.removeItem).toHaveBeenCalledWith('user'); + + // API 缓存被清除 + expect(mockClearApiCache).toHaveBeenCalled(); + }); + + it('登出 API 报错时仍应清除本地状态(不抛出错误)', async () => { + // 先设置一个已认证状态 + useAuthStore.setState({ + user: createFakeUser(), + isAuthenticated: true, + permissions: ['health.patient.list'], + }); + localStorageStore['access_token'] = 'some-token'; + + // logout API 失败 + mockApiLogout.mockRejectedValue(new Error('Server unreachable')); + + // 不应抛出 + await expect(useAuthStore.getState().logout()).resolves.toBeUndefined(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.permissions).toEqual([]); + expect(localStorage.removeItem).toHaveBeenCalledWith('access_token'); + }); + + it('登出时应调用 logout API', async () => { + mockApiLogout.mockResolvedValue(undefined); + await useAuthStore.getState().logout(); + expect(mockApiLogout).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================================= + // 权限提取(extractPermissions 内部逻辑) + // ========================================================================= + describe('权限提取', () => { + it('登录后 permissions 应从 JWT token 中正确解析', async () => { + const permissions = ['health.patient.list', 'health.alert.manage', 'health.report.review']; + const token = createFakeToken(permissions); + const fakeResponse = createFakeLoginResponse({ access_token: token }); + mockApiLogin.mockResolvedValue(fakeResponse); + + await useAuthStore.getState().login('user', 'pass'); + + expect(useAuthStore.getState().permissions).toEqual(permissions); + }); + + it('token 无 permissions 字段时 permissions 应为空数组', async () => { + // 手动构造一个没有 permissions 的 token + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = btoa(JSON.stringify({ sub: 'user-001' })) + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const signature = btoa('sig'); + const token = `${header}.${payload}.${signature}`; + + const fakeResponse = createFakeLoginResponse({ access_token: token }); + mockApiLogin.mockResolvedValue(fakeResponse); + + await useAuthStore.getState().login('user', 'pass'); + + expect(useAuthStore.getState().permissions).toEqual([]); + }); + + it('token 格式非法时 permissions 应为空数组', async () => { + const fakeResponse = createFakeLoginResponse({ access_token: 'not-a-jwt' }); + mockApiLogin.mockResolvedValue(fakeResponse); + + await useAuthStore.getState().login('user', 'pass'); + + expect(useAuthStore.getState().permissions).toEqual([]); + }); + + it('空 permissions 数组应正确解析', async () => { + const token = createFakeToken([]); + const fakeResponse = createFakeLoginResponse({ access_token: token }); + mockApiLogin.mockResolvedValue(fakeResponse); + + await useAuthStore.getState().login('user', 'pass'); + + expect(useAuthStore.getState().permissions).toEqual([]); + }); + }); + + // ========================================================================= + // localStorage 持久化 / loadFromStorage + // ========================================================================= + describe('loadFromStorage', () => { + it('localStorage 有有效 token 和 user 时应恢复认证状态', () => { + const fakeUser = createFakeUser(); + const permissions = ['health.patient.list']; + const token = createFakeToken(permissions); + + localStorageStore['access_token'] = token; + localStorageStore['user'] = JSON.stringify(fakeUser); + + useAuthStore.getState().loadFromStorage(); + + const state = useAuthStore.getState(); + expect(state.user).toEqual(fakeUser); + expect(state.isAuthenticated).toBe(true); + expect(state.permissions).toEqual(permissions); + }); + + it('localStorage 无 token 时应保持未认证', () => { + localStorageStore['user'] = JSON.stringify(createFakeUser()); + // 不设置 access_token + + useAuthStore.getState().loadFromStorage(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + }); + + it('localStorage 无 user 时应保持未认证', () => { + localStorageStore['access_token'] = createFakeToken(['health.patient.list']); + // 不设置 user + + useAuthStore.getState().loadFromStorage(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + }); + + it('localStorage user JSON 损坏时应移除损坏数据并保持未认证', () => { + localStorageStore['access_token'] = createFakeToken([]); + localStorageStore['user'] = '{invalid json!!!'; + + useAuthStore.getState().loadFromStorage(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + // 损坏的 user 数据应被清理 + expect(localStorage.removeItem).toHaveBeenCalledWith('user'); + }); + + it('token 和 user 都为空时应返回干净的未认证状态', () => { + useAuthStore.getState().loadFromStorage(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.permissions).toEqual([]); + }); + }); + + // ========================================================================= + // 状态重置验证 + // ========================================================================= + describe('状态重置', () => { + it('登出后再次登录应正确设置新状态', async () => { + // 第一次登录 + const user1 = createFakeUser({ id: 'user-001', username: 'user1' }); + const token1 = createFakeToken(['perm.a']); + mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user1, access_token: token1 })); + await useAuthStore.getState().login('user1', 'pass1'); + + expect(useAuthStore.getState().user?.id).toBe('user-001'); + expect(useAuthStore.getState().permissions).toEqual(['perm.a']); + + // 登出 + mockApiLogout.mockResolvedValue(undefined); + await useAuthStore.getState().logout(); + + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().permissions).toEqual([]); + + // 第二次登录(不同用户、不同权限) + const user2 = createFakeUser({ id: 'user-002', username: 'user2' }); + const token2 = createFakeToken(['perm.b', 'perm.c']); + mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user2, access_token: token2 })); + await useAuthStore.getState().login('user2', 'pass2'); + + expect(useAuthStore.getState().user?.id).toBe('user-002'); + expect(useAuthStore.getState().permissions).toEqual(['perm.b', 'perm.c']); + }); + + it('登出后的状态应与初始状态一致', async () => { + // 登录 + mockApiLogin.mockResolvedValue(createFakeLoginResponse()); + await useAuthStore.getState().login('user', 'pass'); + + // 登出 + mockApiLogout.mockResolvedValue(undefined); + await useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.loading).toBe(false); + expect(state.permissions).toEqual([]); + }); + }); + + // ========================================================================= + // 边界情况 + // ========================================================================= + describe('边界情况', () => { + it('并发登录不应导致状态不一致', async () => { + const user1 = createFakeUser({ id: 'user-001' }); + const user2 = createFakeUser({ id: 'user-002' }); + + let resolveLogin1: (value: unknown) => void; + let resolveLogin2: (value: unknown) => void; + + mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin1 = r; })); + mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin2 = r; })); + + const login1 = useAuthStore.getState().login('user1', 'pass1'); + const login2 = useAuthStore.getState().login('user2', 'pass2'); + + // 第二次登录先完成 + resolveLogin2!(createFakeLoginResponse({ user: user2 })); + await login2; + expect(useAuthStore.getState().user?.id).toBe('user-002'); + + // 第一次登录后完成(覆盖第二次) + resolveLogin1!(createFakeLoginResponse({ user: user1 })); + await login1; + expect(useAuthStore.getState().user?.id).toBe('user-001'); + + // 最终 loading 为 false + expect(useAuthStore.getState().loading).toBe(false); + }); + + it('JWT payload 含 permissions 非数组时应返回空权限', async () => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = btoa(JSON.stringify({ permissions: 'not-an-array' })) + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const signature = btoa('sig'); + const token = `${header}.${payload}.${signature}`; + + mockApiLogin.mockResolvedValue(createFakeLoginResponse({ access_token: token })); + await useAuthStore.getState().login('user', 'pass'); + + expect(useAuthStore.getState().permissions).toEqual([]); + }); + }); +}); diff --git a/apps/web/src/stores/message.test.ts b/apps/web/src/stores/message.test.ts new file mode 100644 index 0000000..5869b85 --- /dev/null +++ b/apps/web/src/stores/message.test.ts @@ -0,0 +1,468 @@ +/** + * useMessageStore 单元测试 + * + * 覆盖:初始状态、未读计数、消息加载、已读标记、乐观回滚、请求去重、SSE 连接。 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// --- Mock 消息 API --- +const mockGetUnreadCount = vi.fn() +const mockListMessages = vi.fn() +const mockMarkRead = vi.fn() + +vi.mock('../api/messages', () => ({ + getUnreadCount: (...args: unknown[]) => mockGetUnreadCount(...args), + listMessages: (...args: unknown[]) => mockListMessages(...args), + markRead: (...args: unknown[]) => mockMarkRead(...args), +})) + +// --- Mock EventSource --- +const mockEventSourceClose = vi.fn() +const mockEventSourceAddEventListener = vi.fn() +const mockEventSourceCtor = vi.fn() + +class MockEventSource { + onopen: (() => void) | null = null + onerror: (() => void) | null = null + onmessage: (() => void) | null = null + + constructor(url: string) { + mockEventSourceCtor(url) + } + + addEventListener = mockEventSourceAddEventListener + close = mockEventSourceClose +} + +// 保存原始引用以便恢复 +const OriginalEventSource = globalThis.EventSource + +beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.EventSource = MockEventSource as any +}) + +afterEach(() => { + globalThis.EventSource = OriginalEventSource +}) + +// 在 mock 生效后导入被测模块 +// 必须用动态 import 重置 zustand 状态,但 zustand create 只执行一次, +// 所以需要手动重置模块级变量 +import { useMessageStore } from './message' + +// --- 测试用 fixture --- +const fakeMessages = [ + { + id: 'msg-1', + tenant_id: 't-1', + sender_type: 'system', + recipient_id: 'u-1', + recipient_type: 'user', + title: '预约提醒', + body: '您有一个即将到来的预约', + priority: 'high', + is_read: false, + is_archived: false, + status: 'sent', + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + }, + { + id: 'msg-2', + tenant_id: 't-1', + sender_type: 'doctor', + recipient_id: 'u-1', + recipient_type: 'user', + title: '随访通知', + body: '请完成本周随访', + priority: 'normal', + is_read: false, + is_archived: false, + status: 'sent', + created_at: '2026-05-02T00:00:00Z', + updated_at: '2026-05-02T00:00:00Z', + }, +] + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + + // 重置 store 到初始状态 + useMessageStore.setState({ + unreadCount: 0, + recentMessages: [], + }) + + // 重置模块级去重 promise(通过重新设置状态间接处理) +}) + +// ============================================================ +// 初始状态 +// ============================================================ +describe('初始状态', () => { + it('unreadCount 应为 0', () => { + expect(useMessageStore.getState().unreadCount).toBe(0) + }) + + it('recentMessages 应为空数组', () => { + expect(useMessageStore.getState().recentMessages).toEqual([]) + }) +}) + +// ============================================================ +// fetchUnreadCount +// ============================================================ +describe('fetchUnreadCount', () => { + it('成功时设置 unreadCount', async () => { + mockGetUnreadCount.mockResolvedValue({ count: 7 }) + + await useMessageStore.getState().fetchUnreadCount() + + expect(mockGetUnreadCount).toHaveBeenCalledTimes(1) + expect(useMessageStore.getState().unreadCount).toBe(7) + }) + + it('API 失败时保持原值不变', async () => { + useMessageStore.setState({ unreadCount: 3 }) + mockGetUnreadCount.mockRejectedValue(new Error('network')) + + await useMessageStore.getState().fetchUnreadCount() + + expect(useMessageStore.getState().unreadCount).toBe(3) + }) + + it('并发调用应去重(仅发一次请求)', async () => { + let resolveApi: (value: { count: number }) => void + const pending = new Promise<{ count: number }>((resolve) => { + resolveApi = resolve + }) + mockGetUnreadCount.mockReturnValue(pending) + + // 同时发起两次调用 + const call1 = useMessageStore.getState().fetchUnreadCount() + const call2 = useMessageStore.getState().fetchUnreadCount() + + // API 只被调用一次 + expect(mockGetUnreadCount).toHaveBeenCalledTimes(1) + + // 解除 pending + resolveApi!({ count: 5 }) + await Promise.all([call1, call2]) + + expect(useMessageStore.getState().unreadCount).toBe(5) + }) + + it('前一次完成后再次调用应发起新请求', async () => { + mockGetUnreadCount.mockResolvedValue({ count: 1 }) + await useMessageStore.getState().fetchUnreadCount() + + mockGetUnreadCount.mockResolvedValue({ count: 2 }) + await useMessageStore.getState().fetchUnreadCount() + + expect(mockGetUnreadCount).toHaveBeenCalledTimes(2) + expect(useMessageStore.getState().unreadCount).toBe(2) + }) +}) + +// ============================================================ +// fetchRecentMessages +// ============================================================ +describe('fetchRecentMessages', () => { + it('成功时设置 recentMessages', async () => { + mockListMessages.mockResolvedValue({ data: fakeMessages }) + + await useMessageStore.getState().fetchRecentMessages() + + expect(mockListMessages).toHaveBeenCalledWith({ page: 1, page_size: 5 }) + expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages) + }) + + it('API 失败时保持原值不变', async () => { + useMessageStore.setState({ recentMessages: fakeMessages }) + mockListMessages.mockRejectedValue(new Error('network')) + + await useMessageStore.getState().fetchRecentMessages() + + expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages) + }) + + it('并发调用应去重', async () => { + let resolveApi: (value: { data: typeof fakeMessages }) => void + const pending = new Promise<{ data: typeof fakeMessages }>((resolve) => { + resolveApi = resolve + }) + mockListMessages.mockReturnValue(pending) + + const call1 = useMessageStore.getState().fetchRecentMessages() + const call2 = useMessageStore.getState().fetchRecentMessages() + + expect(mockListMessages).toHaveBeenCalledTimes(1) + + resolveApi!({ data: fakeMessages }) + await Promise.all([call1, call2]) + + expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages) + }) + + it('返回空列表时 recentMessages 为空数组', async () => { + mockListMessages.mockResolvedValue({ data: [] }) + + await useMessageStore.getState().fetchRecentMessages() + + expect(useMessageStore.getState().recentMessages).toEqual([]) + }) +}) + +// ============================================================ +// markAsRead +// ============================================================ +describe('markAsRead', () => { + beforeEach(() => { + useMessageStore.setState({ + unreadCount: 2, + recentMessages: [...fakeMessages], + }) + }) + + it('乐观更新:立即减少 unreadCount 并标记消息已读', async () => { + mockMarkRead.mockResolvedValue({ success: true }) + + await useMessageStore.getState().markAsRead('msg-1') + + expect(useMessageStore.getState().unreadCount).toBe(1) + const updated = useMessageStore.getState().recentMessages + expect(updated.find((m) => m.id === 'msg-1')?.is_read).toBe(true) + expect(updated.find((m) => m.id === 'msg-2')?.is_read).toBe(false) + }) + + it('调用 markRead API 传入正确的 id', async () => { + mockMarkRead.mockResolvedValue({ success: true }) + + await useMessageStore.getState().markAsRead('msg-1') + + expect(mockMarkRead).toHaveBeenCalledWith('msg-1') + }) + + it('API 失败时回滚到之前的状态', async () => { + mockMarkRead.mockRejectedValue(new Error('server error')) + + await useMessageStore.getState().markAsRead('msg-1') + + // 应回滚到原始状态 + expect(useMessageStore.getState().unreadCount).toBe(2) + expect( + useMessageStore.getState().recentMessages.find((m) => m.id === 'msg-1')?.is_read, + ).toBe(false) + }) + + it('unreadCount 不应为负数', async () => { + useMessageStore.setState({ unreadCount: 0 }) + mockMarkRead.mockResolvedValue({ success: true }) + + await useMessageStore.getState().markAsRead('msg-1') + + expect(useMessageStore.getState().unreadCount).toBe(0) + }) + + it('对不在 recentMessages 中的消息标记已读仍减少 unreadCount', async () => { + mockMarkRead.mockResolvedValue({ success: true }) + + await useMessageStore.getState().markAsRead('msg-other') + + expect(useMessageStore.getState().unreadCount).toBe(1) + // recentMessages 中的消息不受影响 + expect( + useMessageStore.getState().recentMessages.every((m) => !m.is_read), + ).toBe(true) + }) +}) + +// ============================================================ +// connectSSE +// ============================================================ +describe('connectSSE', () => { + beforeEach(() => { + vi.useFakeTimers() + mockGetUnreadCount.mockResolvedValue({ count: 0 }) + mockListMessages.mockResolvedValue({ data: [] }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('返回 dispose 函数', () => { + localStorage.setItem('access_token', 'test-token') + const dispose = useMessageStore.getState().connectSSE() + expect(typeof dispose).toBe('function') + dispose() + }) + + it('无 token 时创建 EventSource 后立即返回', () => { + // 不设置 token + const dispose = useMessageStore.getState().connectSSE() + + // EventSource 构造未被调用(因为没有 token) + expect(mockEventSourceCtor).not.toHaveBeenCalled() + + dispose() + }) + + it('有 token 时创建 EventSource 并传递正确 URL', () => { + localStorage.setItem('access_token', 'my-jwt-token') + process.env.VITE_API_BASE_URL = undefined + + const dispose = useMessageStore.getState().connectSSE() + + expect(mockEventSourceCtor).toHaveBeenCalledTimes(1) + const url = mockEventSourceCtor.mock.calls[0][0] as string + expect(url).toContain('/messages/stream?token=') + expect(url).toContain(encodeURIComponent('my-jwt-token')) + + dispose() + }) + + it('dispose 关闭 EventSource', () => { + localStorage.setItem('access_token', 'token') + + const dispose = useMessageStore.getState().connectSSE() + dispose() + + expect(mockEventSourceClose).toHaveBeenCalled() + }) + + it('注册 message 和 alert 事件监听器', () => { + localStorage.setItem('access_token', 'token') + + const dispose = useMessageStore.getState().connectSSE() + + const eventTypes = mockEventSourceAddEventListener.mock.calls.map( + (call: [string, unknown]) => call[0], + ) + expect(eventTypes).toContain('message') + expect(eventTypes).toContain('alert') + expect(eventTypes).toContain('vital_update') + + dispose() + }) + + it('message 事件触发 fetchUnreadCount 和 fetchRecentMessages', async () => { + localStorage.setItem('access_token', 'token') + + const dispose = useMessageStore.getState().connectSSE() + + // 找到 message 事件的回调 + const messageCall = mockEventSourceAddEventListener.mock.calls.find( + (call: [string, unknown]) => call[0] === 'message', + ) + const messageCallback = messageCall![1] as () => void + + messageCallback() + + // 等待异步操作完成 + await vi.runAllTimersAsync() + + expect(mockGetUnreadCount).toHaveBeenCalled() + expect(mockListMessages).toHaveBeenCalled() + + dispose() + }) + + it('alert 事件仅触发 fetchUnreadCount', async () => { + localStorage.setItem('access_token', 'token') + + const dispose = useMessageStore.getState().connectSSE() + + const alertCall = mockEventSourceAddEventListener.mock.calls.find( + (call: [string, unknown]) => call[0] === 'alert', + ) + const alertCallback = alertCall![1] as () => void + + alertCallback() + + await vi.runAllTimersAsync() + + expect(mockGetUnreadCount).toHaveBeenCalled() + // alert 不应触发 fetchRecentMessages + expect(mockListMessages).not.toHaveBeenCalled() + + dispose() + }) + + it('onerror 关闭连接并启动重连定时器', () => { + localStorage.setItem('access_token', 'token') + + // 需要捕获 EventSource 实例以设置 onerror + let instance: MockEventSource | null = null + const OrigMock = MockEventSource + const CapturingEventSource = class extends OrigMock { + constructor(url: string) { + super(url) + instance = this + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.EventSource = CapturingEventSource as any + + const dispose = useMessageStore.getState().connectSSE() + + expect(instance).not.toBeNull() + instance!.onerror!() + + expect(mockEventSourceClose).toHaveBeenCalled() + + dispose() + }) + + it('dispose 后不会重连', () => { + localStorage.setItem('access_token', 'token') + + let instance: MockEventSource | null = null + const OrigMock = MockEventSource + const CapturingEventSource = class extends OrigMock { + constructor(url: string) { + super(url) + instance = this + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.EventSource = CapturingEventSource as any + + const dispose = useMessageStore.getState().connectSSE() + + instance!.onerror!() + dispose() + + // 清除 constructor 调用计数 + mockEventSourceCtor.mockClear() + + // 推进定时器 — 不应触发新的 EventSource 创建 + vi.advanceTimersByTime(60_000) + + expect(mockEventSourceCtor).not.toHaveBeenCalled() + }) +}) + +// ============================================================ +// 状态重置 +// ============================================================ +describe('状态重置', () => { + it('通过 setState 可手动重置到初始状态', async () => { + mockGetUnreadCount.mockResolvedValue({ count: 10 }) + mockListMessages.mockResolvedValue({ data: fakeMessages }) + + await useMessageStore.getState().fetchUnreadCount() + await useMessageStore.getState().fetchRecentMessages() + + expect(useMessageStore.getState().unreadCount).toBe(10) + expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages) + + // 重置 + useMessageStore.setState({ unreadCount: 0, recentMessages: [] }) + + expect(useMessageStore.getState().unreadCount).toBe(0) + expect(useMessageStore.getState().recentMessages).toEqual([]) + }) +}) diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index ce231d7..4c840c5 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -152,6 +152,7 @@ pub async fn create_patient( .one(&state.db) .await?; if dup.is_some() { + tracing::warn!(action = "create_patient", tenant_id = %tenant_id, "身份证号重复,拒绝创建"); return Err(HealthError::Validation("该身份证号已存在患者档案".to_string())); } } @@ -257,7 +258,10 @@ pub async fn update_patient( tracing::info!(action = "update_patient", patient_id = %id, "Updating patient"); let model = find_patient(&state.db, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| { + tracing::warn!(action = "update_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); + HealthError::VersionMismatch + })?; if let Some(ref g) = req.gender { validate_gender(g)?; } if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; } @@ -371,7 +375,10 @@ pub async fn delete_patient( tracing::info!(action = "delete_patient", patient_id = %id, "Soft deleting patient"); let model = find_patient(&state.db, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| { + tracing::warn!(action = "delete_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); + HealthError::VersionMismatch + })?; let mut active: patient::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now()));