- feat(web): ClassList.tsx 对接 update/deactivate/reset-code API - 编辑班级: PUT /diary/classes/:id - 停用班级: PATCH /diary/classes/:id/deactivate (Popconfirm 确认) - 重置班级码: POST /diary/classes/:id/reset-code (Popconfirm 确认) - 数据源改用 listAll() 获取所有班级 - fix(web): JournalList.tsx 班级筛选改用 classApi.listAll() - fix(app): EditorPage 加载已有日记数据 (journalId 非空时) - 从 Isar 恢复笔画/元素/标签/心情/标题 - _EditorView 改为 StatefulWidget + initState 加载 - chore(web): HMS 遗留代码清理 - 删除 api/copilot.ts, healthFixtures.ts, healthHandlers.ts - AuditLogViewer 资源类型替换为日记模块类型 - auth.test.ts / renderWithProviders 权限码 health.* → diary.* - docs: 确认 M6 NotificationService 为误报 (已在 3 处调用)
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
/**
|
||
* 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(['diary.journal.read', 'diary.class.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(['diary.journal.read', 'diary.class.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: ['diary.journal.read'],
|
||
});
|
||
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 = ['diary.journal.read', 'diary.class.manage', 'diary.topic.assign'];
|
||
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 = ['diary.journal.read'];
|
||
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(['diary.journal.read']);
|
||
// 不设置 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([]);
|
||
});
|
||
});
|
||
});
|