test(web): 前端 Store 单元测试 + patient_service tracing 补全
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

Store 测试 (71 个):
- auth.test.ts: 22 tests — 登录/登出/权限/JWT解析/localStorage持久化
- app.test.ts: 24 tests — 主题切换/侧边栏/配置加载/状态隔离
- message.test.ts: 25 tests — 未读计数/消息列表/SSE连接/标记已读

Tracing 补全:
- create_patient: 身份证号重复时 warn 日志
- update_patient/delete_patient: 版本冲突时 warn 日志含 expected/actual
This commit is contained in:
iven
2026-05-03 09:58:13 +08:00
parent 84afeaf9f2
commit 9d07ea0be0
4 changed files with 1227 additions and 2 deletions

View File

@@ -0,0 +1,260 @@
/**
* App Store 单元测试
*
* 覆盖 useAppStore 的状态管理逻辑:
* - 初始状态值
* - 主题切换与持久化
* - 侧边栏折叠状态
* - 远程主题配置加载
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// --- Mock localStorage ---
const localStorageStore: Record<string, string> = {}
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')
})
})

View File

@@ -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<string, string> = {};
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, unknown> = {}): 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> = {}): 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> = {}): 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([]);
});
});
});

View File

@@ -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([])
})
})

View File

@@ -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()));