import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAuthStore } from '@/stores/auth-store'; import { ApiError, request, setUnauthorizedHandler } from './client'; import { changePassword, createUser, disableUser, enableUser, fetchDepartments, fetchMe, fetchRoles, fetchUserCustomers, login, logout, transferCustomers, updateUser, } from './identity'; import { fetchUsers } from './users'; const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3'; const userSummary = { id: USER_ID, name: '陈默', phoneMasked: '138****2046', account: 'chen.mo', departmentId: GROUP_ID, departmentName: '内容生产中心', groupId: GROUP_ID, groupName: '素材一组', roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }], status: 'active', lastLoginAt: '2026-11-02T09:41:00.000Z', createdAt: '2026-09-30T18:40:00.000Z', }; const me = { user: userSummary, roles: userSummary.roles, permissions: ['user:view'], dataScopes: [{ type: 'all' }], mustChangePassword: false, }; const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); function toUrl(input: string | URL | Request | undefined): string { if (typeof input === 'string') { return input; } if (input instanceof URL) { return input.href; } return input?.url ?? ''; } function calls(): { url: string; init: RequestInit | undefined }[] { const fetchMock = vi.mocked(globalThis.fetch); return fetchMock.mock.calls.map((call) => ({ url: toUrl(call[0]), init: call[1], })); } function lastBody(): unknown { const last = calls().at(-1); const body = last?.init?.body; return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined; } describe('identity 接口封装', () => { beforeEach(() => { vi.restoreAllMocks(); window.localStorage.clear(); useAuthStore.getState().clearSession(); setUnauthorizedHandler(null); useAuthStore.getState().setSession({ accessToken: 'token', user: { id: USER_ID, name: '陈默', account: 'chen.mo' }, permissions: ['user:view'], }); }); it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( jsonResponse({ accessToken: 'a', refreshToken: 'r', expiresIn: 3600, me, }), ); const response = await login({ account: 'chen.mo', password: 'Lx123456' }); expect(response.me.permissions).toEqual(['user:view']); expect(calls()[0]?.init?.method).toBe('POST'); expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' }); }); it('/me、部门与角色列表按共享 schema 解析', async () => { const department = { id: GROUP_ID, name: '内容生产中心', parentId: null, leader: null, status: 'active', sortOrder: 1, memberCount: 19, directMemberCount: 4, createdAt: '2026-09-01T00:00:00.000Z', children: [ { id: USER_ID, name: '素材一组', parentId: GROUP_ID, leader: null, status: 'active', sortOrder: 1, memberCount: 9, directMemberCount: 9, createdAt: '2026-09-01T00:00:00.000Z', children: [], }, ], }; vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(jsonResponse(me)) .mockResolvedValueOnce(jsonResponse({ items: [department] })) .mockResolvedValueOnce(jsonResponse({ items: userSummary.roles })); expect((await fetchMe()).user.name).toBe('陈默'); const departments = await fetchDepartments(); expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']); expect((await fetchRoles())[0]?.name).toBe('素材运营'); }); it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => { vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' })) .mockResolvedValueOnce(jsonResponse({ id: USER_ID })) .mockResolvedValueOnce(new Response(null, { status: 204 })) .mockResolvedValueOnce(new Response(null, { status: 204 })) .mockResolvedValueOnce(new Response(null, { status: 204 })) .mockResolvedValueOnce(new Response(null, { status: 204 })) .mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }])) .mockResolvedValueOnce(new Response(null, { status: 204 })) .mockResolvedValueOnce(new Response(null, { status: 204 })); const list = await fetchUsers({ limit: 10, status: 'active' }); expect(list.next_cursor).toBe('cursor-2'); expect( await createUser({ name: '测试成员', phone: '13900001111', account: 'test.member', password: 'Lx123456', departmentId: GROUP_ID, roleIds: [ROLE_ID], }), ).toBe(USER_ID); await updateUser(USER_ID, { name: '陈默' }); await disableUser(USER_ID, { reason: '离职' }); await enableUser(USER_ID); await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID }); expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居'); await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' }); await logout('refresh-token'); const paths = calls().map((call) => ({ url: call.url.replace('http://127.0.0.1:3000/api/v1', ''), method: call.init?.method ?? 'GET', })); expect(paths).toContainEqual({ url: '/users', method: 'POST' }); expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' }); expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' }); expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' }); expect(paths).toContainEqual({ url: `/users/${USER_ID}/transfer-customers`, method: 'POST', }); expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' }); expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' }); }); it('刷新失败时清理会话并触发跳登录', async () => { const handler = vi.fn(); setUnauthorizedHandler(handler); vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }), ); useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' }); await expect(request('/me')).rejects.toBeInstanceOf(ApiError); expect(handler).toHaveBeenCalled(); expect(useAuthStore.getState().accessToken).toBeNull(); }); });