Files
lingxi/apps/web/src/api/identity.spec.ts
T
Stev_Wang 6661c42e84
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 补齐部门管理(两级组织树、停用启用)
背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
2026-11-03 15:01:07 +08:00

206 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});