@oj-module/cli 0.2.47 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +237 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +115 -8
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -3,13 +3,14 @@ import type { SystemUsersGetData } from "../../__client/api";
|
|
|
3
3
|
import { PlusOutlined } from "@ant-design/icons";
|
|
4
4
|
import { useUserStore } from "@oj-module/runtime";
|
|
5
5
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { App, Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag } from "antd";
|
|
7
|
-
import { useState } from "react";
|
|
6
|
+
import { App, Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag, TreeSelect, Typography } from "antd";
|
|
7
|
+
import { useMemo, useState } from "react";
|
|
8
8
|
import { useTranslation } from "react-i18next";
|
|
9
9
|
|
|
10
10
|
import { useNavigate } from "react-router";
|
|
11
11
|
|
|
12
|
-
import { systemUsersForbiddenPut, systemUsersGet, systemUsersPasswordPut, systemUsersPost } from "../../__client/api";
|
|
12
|
+
import { systemDepartmentsGet, systemUsersForbiddenPut, systemUsersGet, systemUsersPasswordPut, systemUsersPost } from "../../__client/api";
|
|
13
|
+
import { buildTreeData, subtreeIds } from "../departments/tree";
|
|
13
14
|
import { explainError, isForbidden } from "../errors";
|
|
14
15
|
import { ForbiddenPanel } from "../forbidden";
|
|
15
16
|
|
|
@@ -19,6 +20,7 @@ type UserRow = SystemUsersGetData[number];
|
|
|
19
20
|
* 用户管理(C1):GET /system/users 列表 + 建号 / 禁用切换 / 重置口令。
|
|
20
21
|
* 本人行禁用一切自操作(后端 assertNotSelf 400 兜底,前端不给会失败的按钮,R3);
|
|
21
22
|
* 成员资格入口仅 superadmin 可见(成员资格写是 superadmin-only,规格 §4)。
|
|
23
|
+
* 部门列标出主部门,顶部可按部门筛选(含子部门)——筛选在前端做,名单本来就是一页全量。
|
|
22
24
|
*/
|
|
23
25
|
export default function UsersPage() {
|
|
24
26
|
const { t } = useTranslation();
|
|
@@ -33,8 +35,11 @@ export default function UsersPage() {
|
|
|
33
35
|
const [createForm] = Form.useForm<{ username: string, password: string, displayName?: string, email?: string }>();
|
|
34
36
|
const [pwdTarget, setPwdTarget] = useState<UserRow | null>(null);
|
|
35
37
|
const [pwdForm] = Form.useForm<{ password: string }>();
|
|
38
|
+
const [deptId, setDeptId] = useState<number | null>(null);
|
|
36
39
|
|
|
37
40
|
const usersQuery = useQuery({ queryKey: ["system", "users"], queryFn: systemUsersGet });
|
|
41
|
+
// 与部门页共用同一份缓存键:那边改完部门,这边的筛选树也跟着新
|
|
42
|
+
const deptQuery = useQuery({ queryKey: ["system", "departments"], queryFn: systemDepartmentsGet });
|
|
38
43
|
|
|
39
44
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["system", "users"] });
|
|
40
45
|
|
|
@@ -66,12 +71,34 @@ export default function UsersPage() {
|
|
|
66
71
|
onError: e => message.error(explainError(e, t)),
|
|
67
72
|
});
|
|
68
73
|
|
|
74
|
+
const deptRows = useMemo(() => deptQuery.data ?? [], [deptQuery.data]);
|
|
75
|
+
const deptTree = useMemo(() => buildTreeData(deptRows, d => d.name ?? `#${d.id}`), [deptRows]);
|
|
76
|
+
// 选中部门 + 全部子部门:筛「研发部」要能带出「研发部/前端组」的人
|
|
77
|
+
const scope = useMemo(() => (deptId == null ? null : subtreeIds(deptRows, [deptId])), [deptRows, deptId]);
|
|
78
|
+
const visibleUsers = useMemo(() => {
|
|
79
|
+
const all = usersQuery.data ?? [];
|
|
80
|
+
return scope ? all.filter(u => u.departments.some(d => scope.has(d.id))) : all;
|
|
81
|
+
}, [usersQuery.data, scope]);
|
|
82
|
+
|
|
69
83
|
if (usersQuery.error && isForbidden(usersQuery.error))
|
|
70
84
|
return <ForbiddenPanel t={t} error={usersQuery.error} />;
|
|
71
85
|
|
|
72
86
|
const columns = [
|
|
73
87
|
{ title: t("system:users.columns.username"), dataIndex: "username" },
|
|
74
88
|
{ title: t("system:users.columns.displayName"), dataIndex: "display_name" },
|
|
89
|
+
{ title: t("system:users.columns.position"), dataIndex: "position" },
|
|
90
|
+
{
|
|
91
|
+
title: t("system:users.columns.departments"),
|
|
92
|
+
dataIndex: "departments",
|
|
93
|
+
render: (departments: UserRow["departments"]) => departments.length === 0
|
|
94
|
+
? <Typography.Text type="secondary">—</Typography.Text>
|
|
95
|
+
: departments.map(d => (
|
|
96
|
+
<Tag key={d.id} color={d.isMain ? "blue" : undefined}>
|
|
97
|
+
{d.name ?? `#${d.id}`}
|
|
98
|
+
{d.isMain ? ` · ${t("system:users.mainDept")}` : ""}
|
|
99
|
+
</Tag>
|
|
100
|
+
)),
|
|
101
|
+
},
|
|
75
102
|
{ title: t("system:users.columns.email"), dataIndex: "email" },
|
|
76
103
|
{
|
|
77
104
|
title: t("system:users.columns.roles"),
|
|
@@ -116,13 +143,28 @@ export default function UsersPage() {
|
|
|
116
143
|
|
|
117
144
|
return (
|
|
118
145
|
<Space direction="vertical" style={{ width: "100%", paddingTop: 16 }}>
|
|
119
|
-
<
|
|
120
|
-
{
|
|
121
|
-
|
|
146
|
+
<Space wrap>
|
|
147
|
+
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
|
148
|
+
{t("system:users.create")}
|
|
149
|
+
</Button>
|
|
150
|
+
<TreeSelect
|
|
151
|
+
allowClear
|
|
152
|
+
showSearch
|
|
153
|
+
treeNodeFilterProp="title"
|
|
154
|
+
loading={deptQuery.isLoading}
|
|
155
|
+
style={{ width: 240 }}
|
|
156
|
+
placeholder={t("system:users.filterDept")}
|
|
157
|
+
treeData={deptTree}
|
|
158
|
+
// 树是按 antd Tree 口径拼的(key/title),TreeSelect 认的是 value —— 这里换名映射
|
|
159
|
+
fieldNames={{ label: "title", value: "key", children: "children" }}
|
|
160
|
+
value={deptId ?? undefined}
|
|
161
|
+
onChange={(v?: number) => setDeptId(v ?? null)}
|
|
162
|
+
/>
|
|
163
|
+
</Space>
|
|
122
164
|
<Table
|
|
123
165
|
rowKey="id"
|
|
124
166
|
loading={usersQuery.isLoading}
|
|
125
|
-
dataSource={
|
|
167
|
+
dataSource={visibleUsers}
|
|
126
168
|
columns={columns}
|
|
127
169
|
pagination={{ hideOnSinglePage: true }}
|
|
128
170
|
/>
|
|
@@ -16,6 +16,8 @@ interface AppRow {
|
|
|
16
16
|
env: string
|
|
17
17
|
corp_name: string | null
|
|
18
18
|
status: "active" | "disabled"
|
|
19
|
+
/** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步 */
|
|
20
|
+
auth_mode: number
|
|
19
21
|
installed_at: number
|
|
20
22
|
update_time: number
|
|
21
23
|
}
|
|
@@ -120,6 +122,15 @@ export default function WecomAdminPage() {
|
|
|
120
122
|
},
|
|
121
123
|
{ title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
|
|
122
124
|
{ title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
|
|
125
|
+
{
|
|
126
|
+
// 权限三态:成员授权是「装了也同步不了」的硬约束(企微侧通讯录接口一律 60011),
|
|
127
|
+
// 摆在清单上,服务商不用点进去试一遍才知道
|
|
128
|
+
title: t("system:wecom.col.authMode"),
|
|
129
|
+
dataIndex: "auth_mode",
|
|
130
|
+
render: (v: number) => (v === 0
|
|
131
|
+
? <Tag color="green">{t("system:wecom.authModeAdmin")}</Tag>
|
|
132
|
+
: <Tag color="orange">{t("system:wecom.authModeMember")}</Tag>),
|
|
133
|
+
},
|
|
123
134
|
{
|
|
124
135
|
title: t("system:wecom.col.status"),
|
|
125
136
|
dataIndex: "status",
|
|
@@ -191,6 +202,9 @@ export default function WecomAdminPage() {
|
|
|
191
202
|
)}
|
|
192
203
|
</Card>
|
|
193
204
|
<Card title={t("system:wecom.appsTitle")}>
|
|
205
|
+
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
206
|
+
{t("system:wecom.authModeHint")}
|
|
207
|
+
</Typography.Paragraph>
|
|
194
208
|
<Table<AppRow>
|
|
195
209
|
rowKey={row => `${row.suite_id}:${row.corp_id}:${row.agent_id}`}
|
|
196
210
|
size="small"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// ③ notification:join ON 值条件 + UNION ALL + 字面量列 {lit,as} 的真渲染(场景 41 同款)。
|
|
12
12
|
//
|
|
13
13
|
// 登录账号:fixtures 的 l1tester(src/_platform/fixtures/l1-test-user.sql,仅 oj test 灌入)。
|
|
14
|
-
// 不用 seed 的 admin 等开发账号:初始口令
|
|
14
|
+
// 不用 seed 的 admin 等开发账号:初始口令 aA123456 会随用户在 dev 库改密漂移——
|
|
15
15
|
// L1 绑 seed 账号曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
|
|
16
16
|
//
|
|
17
17
|
// 运行:cd packages/cli/templates/api && ~/.local/share/oj/oj test -c config.yaml -d src -t tests
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { OrgDeps } from "../src/_platform/api/_shared/service/domain/org";
|
|
2
|
+
|
|
3
|
+
// BDD:组织架构领域(0016,方案 A1+A2)——CRUD 基线 + 归属权(D13)+ 保底出口(D14)。
|
|
4
|
+
//
|
|
5
|
+
// 两条最容易回归的红线,用例必须钉死:
|
|
6
|
+
// 1. **判环方向**:把部门挂到自己的子孙下必须 400。实现曾从「目标往下找自己」,
|
|
7
|
+
// 漏判「父挂到孙下」(环是 `id → target → … → id`,即 id 是 target 的**祖先**,
|
|
8
|
+
// 必须沿 target 的父链**往上**找)。漏判后部门树直接成环,前端建树死循环。
|
|
9
|
+
// 2. **归属权(D13)**:origin ≠ local 的行,改名/删除/移动一律 400,且错误文案要能
|
|
10
|
+
// 直接给人看(带来源人话 + 指向「转为本地维护」)。同步与回收只作用于自己 origin。
|
|
11
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
addMember,
|
|
14
|
+
createDepartment,
|
|
15
|
+
deleteDepartment,
|
|
16
|
+
detachToLocal,
|
|
17
|
+
listDepartments,
|
|
18
|
+
listMembers,
|
|
19
|
+
memberCounts,
|
|
20
|
+
moveDepartment,
|
|
21
|
+
|
|
22
|
+
removeMember,
|
|
23
|
+
updateDepartment,
|
|
24
|
+
updateMember,
|
|
25
|
+
upsertDepartment,
|
|
26
|
+
} from "../src/_platform/api/_shared/service/domain/org";
|
|
27
|
+
import { deleteUser, listUsers, updateUserProfile } from "../src/_platform/api/_shared/service/domain/users";
|
|
28
|
+
import { ADMIN, installDb } from "./fixture";
|
|
29
|
+
|
|
30
|
+
let dbo: ReturnType<typeof installDb>;
|
|
31
|
+
const g = globalThis as any;
|
|
32
|
+
const deps: OrgDeps = { now: () => 1_700_000_000 };
|
|
33
|
+
|
|
34
|
+
/** 建一条外部来源(企微)的部门——走适配器写路径,不手写 SQL,顺带钉 upsert 幂等 */
|
|
35
|
+
async function mkDept(name: string, parentId: number | null = null): Promise<number> {
|
|
36
|
+
return createDepartment({ name, parentId }, deps);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deptRow(id: number): { origin: string, name: string | null, parent_id: number | null, deleted: number } {
|
|
40
|
+
const row = dbo.prepare("SELECT origin, name, parent_id, deleted FROM sys_department WHERE id = ?").get(id) as any;
|
|
41
|
+
return { origin: String(row.origin), name: row.name == null ? null : String(row.name), parent_id: row.parent_id == null ? null : Number(row.parent_id), deleted: Number(row.deleted) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mkUser(username: string): number {
|
|
45
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
46
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeAll(() => {
|
|
50
|
+
dbo = installDb();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
g.http = { user: { id: ADMIN } };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
dbo.exec("DELETE FROM sys_dept_member");
|
|
59
|
+
dbo.exec("DELETE FROM sys_department");
|
|
60
|
+
dbo.exec("DELETE FROM sys_oplog");
|
|
61
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
62
|
+
dbo.exec("DELETE FROM sys_refresh_token");
|
|
63
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'u%'");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("部门 CRUD 基线", () => {
|
|
67
|
+
it("建部门:origin 恒 local + origin_id 自生成 + 落审计", async () => {
|
|
68
|
+
const id = await mkDept("总公司");
|
|
69
|
+
const row = deptRow(id);
|
|
70
|
+
expect(row.origin).toBe("local");
|
|
71
|
+
expect(row.name).toBe("总公司");
|
|
72
|
+
expect(row.parent_id).toBeNull();
|
|
73
|
+
const audit = dbo.prepare("SELECT target FROM sys_oplog WHERE action = 'org.department.create'").all() as Array<{ target: string }>;
|
|
74
|
+
expect(audit.map(a => a.target)).toEqual(["总公司"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("父部门不存在 → 404(不建出孤儿)", async () => {
|
|
78
|
+
await expect(mkDept("孤儿", 999)).rejects.toMatchObject({ status: 404 });
|
|
79
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_department").get()).toMatchObject({ c: 0 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("改名 / 改英文名;空名 → 400", async () => {
|
|
83
|
+
const id = await mkDept("旧名");
|
|
84
|
+
await updateDepartment(id, { name: "新名", nameEn: "New" }, deps);
|
|
85
|
+
expect(deptRow(id).name).toBe("新名");
|
|
86
|
+
await expect(updateDepartment(id, { name: " " }, deps)).rejects.toMatchObject({ status: 400 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("删部门:有子部门 → 400(不级联删)", async () => {
|
|
90
|
+
const parent = await mkDept("父");
|
|
91
|
+
await mkDept("子", parent);
|
|
92
|
+
await expect(deleteDepartment(parent)).rejects.toMatchObject({ status: 400 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("删部门:无子部门 → 真删 + 成员关联一并清(逻辑外键无 FK)", async () => {
|
|
96
|
+
const id = await mkDept("待删");
|
|
97
|
+
const uid = mkUser("u_a");
|
|
98
|
+
await addMember(id, uid, {}, deps);
|
|
99
|
+
await deleteDepartment(id);
|
|
100
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_department WHERE id = ?").get(id)).toMatchObject({ c: 0 });
|
|
101
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_dept_member WHERE dept_id = ?").get(id)).toMatchObject({ c: 0 });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("移动:判环(回归)", () => {
|
|
106
|
+
/** 建 1 → 2 → 3 三级链,返回各自 id */
|
|
107
|
+
async function chain(): Promise<[number, number, number]> {
|
|
108
|
+
const a = await mkDept("A");
|
|
109
|
+
const b = await mkDept("B", a);
|
|
110
|
+
const c = await mkDept("C", b);
|
|
111
|
+
return [a, b, c];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it("合法移动:提到根 / 换父", async () => {
|
|
115
|
+
const [a, b, c] = await chain();
|
|
116
|
+
await moveDepartment(c, a, deps); // C 挂到 A 下(A 不是 C 的子孙)
|
|
117
|
+
expect(deptRow(c).parent_id).toBe(a);
|
|
118
|
+
await moveDepartment(a, null, deps); // 提到根
|
|
119
|
+
expect(deptRow(a).parent_id).toBeNull();
|
|
120
|
+
expect(deptRow(b).parent_id).toBe(a);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("自挂 → 400", async () => {
|
|
124
|
+
const [a] = await chain();
|
|
125
|
+
await expect(moveDepartment(a, a, deps)).rejects.toMatchObject({ status: 400 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("挂到自己的子部门 → 400", async () => {
|
|
129
|
+
const [a, b] = await chain();
|
|
130
|
+
await expect(moveDepartment(a, b, deps)).rejects.toMatchObject({ status: 400 });
|
|
131
|
+
expect(deptRow(a).parent_id).toBeNull(); // 未落库
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("挂到自己的孙部门 → 400(判环方向回归:曾漏判,树直接成环)", async () => {
|
|
135
|
+
const [a, , c] = await chain();
|
|
136
|
+
await expect(moveDepartment(a, c, deps)).rejects.toMatchObject({ status: 400 });
|
|
137
|
+
expect(deptRow(a).parent_id).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("判环不受 sync_visible/deleted 过滤影响(隐藏与软删的行也在父链里)", async () => {
|
|
141
|
+
const a = await mkDept("A");
|
|
142
|
+
const b = await mkDept("B", a);
|
|
143
|
+
dbo.prepare("UPDATE sys_department SET sync_visible = 0, deleted = 1 WHERE id = ?").run(b);
|
|
144
|
+
await expect(moveDepartment(a, b, deps)).rejects.toMatchObject({ status: 400 });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("归属权(D13)与保底出口(D14)", () => {
|
|
149
|
+
it("非 local 部门:改名 / 删除 / 移动 一律 400,文案带来源与出路", async () => {
|
|
150
|
+
const id = await upsertDepartment({ origin: "wecom", originId: "wx-1", parentId: null, name: "企微部门" }, deps);
|
|
151
|
+
await expect(updateDepartment(id, { name: "改名" }, deps)).rejects.toMatchObject({ status: 400 });
|
|
152
|
+
await expect(deleteDepartment(id)).rejects.toMatchObject({ status: 400 });
|
|
153
|
+
await expect(moveDepartment(id, null, deps)).rejects.toMatchObject({ status: 400 });
|
|
154
|
+
// 人话提示:点名来源 + 指路「转为本地维护」
|
|
155
|
+
await expect(updateDepartment(id, { name: "改名" }, deps)).rejects.toThrow(/企业微信/);
|
|
156
|
+
await expect(updateDepartment(id, { name: "改名" }, deps)).rejects.toThrow(/转为本地维护/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("转为本地维护后 CRUD 全开;重复 detach 幂等", async () => {
|
|
160
|
+
const id = await upsertDepartment({ origin: "wecom", originId: "wx-2", parentId: null, name: "企微部门" }, deps);
|
|
161
|
+
await detachToLocal(id, deps);
|
|
162
|
+
expect(deptRow(id).origin).toBe("local");
|
|
163
|
+
await updateDepartment(id, { name: "已转本地" }, deps);
|
|
164
|
+
expect(deptRow(id).name).toBe("已转本地");
|
|
165
|
+
await detachToLocal(id, deps); // 已是 local,直接返回
|
|
166
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_oplog WHERE action = 'org.department.detach'").get()).toMatchObject({ c: 1 });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("加人不受部门 origin 限制(「谁在部门」本站始终可调)", async () => {
|
|
170
|
+
const id = await upsertDepartment({ origin: "wecom", originId: "wx-3", parentId: null, name: "企微部门" }, deps);
|
|
171
|
+
const uid = mkUser("u_w");
|
|
172
|
+
await addMember(id, uid, {}, deps);
|
|
173
|
+
expect((await listMembers(id)).map(m => m.userId)).toEqual([uid]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("适配器写路径(读改写,构造器无 upsert)", () => {
|
|
178
|
+
it("同 (origin, origin_id) 重复 upsert 复用同一行,只改结构字段", async () => {
|
|
179
|
+
const a = await upsertDepartment({ origin: "wecom", originId: "wx-9", parentId: null, name: "旧" }, deps);
|
|
180
|
+
const b = await upsertDepartment({ origin: "wecom", originId: "wx-9", parentId: null, name: "新" }, deps);
|
|
181
|
+
expect(b).toBe(a);
|
|
182
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_department").get()).toMatchObject({ c: 1 });
|
|
183
|
+
expect(deptRow(a).name).toBe("新");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("upsert 不覆写 sync_visible / deleted / origin", async () => {
|
|
187
|
+
const id = await upsertDepartment({ origin: "wecom", originId: "wx-10", parentId: null, name: "x" }, deps);
|
|
188
|
+
dbo.prepare("UPDATE sys_department SET sync_visible = 0, deleted = 1 WHERE id = ?").run(id);
|
|
189
|
+
await upsertDepartment({ origin: "wecom", originId: "wx-10", parentId: null, name: "y" }, deps);
|
|
190
|
+
const row = dbo.prepare("SELECT sync_visible, deleted, origin FROM sys_department WHERE id = ?").get(id) as any;
|
|
191
|
+
expect(Number(row.sync_visible)).toBe(0);
|
|
192
|
+
expect(Number(row.deleted)).toBe(1);
|
|
193
|
+
expect(String(row.origin)).toBe("wecom");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("部门成员", () => {
|
|
198
|
+
it("加 / 列 / 改 / 移;重复加 → 400;改不存在的关联 → 404", async () => {
|
|
199
|
+
const id = await mkDept("技术部");
|
|
200
|
+
const uid = mkUser("u_m");
|
|
201
|
+
await addMember(id, uid, { orderNo: 5 }, deps);
|
|
202
|
+
await expect(addMember(id, uid, {}, deps)).rejects.toMatchObject({ status: 400 });
|
|
203
|
+
const [m] = await listMembers(id);
|
|
204
|
+
expect(m).toMatchObject({ userId: uid, deptId: id, orderNo: 5, isLeader: false, isMain: false, username: "u_m" });
|
|
205
|
+
await updateMember(id, uid, { isLeader: true }, deps);
|
|
206
|
+
expect((await listMembers(id))[0]!.isLeader).toBe(true);
|
|
207
|
+
await removeMember(id, uid, deps);
|
|
208
|
+
expect(await listMembers(id)).toEqual([]);
|
|
209
|
+
await expect(updateMember(id, uid, { isLeader: true }, deps)).rejects.toMatchObject({ status: 404 });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("主部门全租户唯一:设新的主部门会把旧的降下来", async () => {
|
|
213
|
+
const d1 = await mkDept("一");
|
|
214
|
+
const d2 = await mkDept("二");
|
|
215
|
+
const uid = mkUser("u_main");
|
|
216
|
+
await addMember(d1, uid, { isMain: true }, deps);
|
|
217
|
+
await addMember(d2, uid, {}, deps);
|
|
218
|
+
await updateMember(d2, uid, { isMain: true }, deps);
|
|
219
|
+
const mains = dbo.prepare("SELECT dept_id FROM sys_dept_member WHERE user_id = ? AND is_main = 1").all(uid) as Array<{ dept_id: number }>;
|
|
220
|
+
expect(mains.map(r => Number(r.dept_id))).toEqual([d2]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("memberCounts 按部门聚合成员数", async () => {
|
|
224
|
+
const d1 = await mkDept("一");
|
|
225
|
+
const d2 = await mkDept("二");
|
|
226
|
+
for (const u of [mkUser("u_c1"), mkUser("u_c2")])
|
|
227
|
+
await addMember(d1, u, {}, deps);
|
|
228
|
+
await addMember(d2, mkUser("u_c3"), {}, deps);
|
|
229
|
+
const counts = await memberCounts();
|
|
230
|
+
expect(counts.get(d1)).toBe(2);
|
|
231
|
+
expect(counts.get(d2)).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("用户侧(改资料 / 删号)", () => {
|
|
236
|
+
it("改资料:只动展示字段,用户名不在可改之列", async () => {
|
|
237
|
+
const uid = mkUser("u_p");
|
|
238
|
+
await updateUserProfile(ADMIN, uid, { position: "cto", displayName: "张三", email: "z@x.com" });
|
|
239
|
+
const row = dbo.prepare("SELECT position, display_name, email, username FROM users WHERE id = ?").get(uid) as any;
|
|
240
|
+
expect(row.position).toBe("cto");
|
|
241
|
+
expect(row.display_name).toBe("张三");
|
|
242
|
+
expect(row.email).toBe("z@x.com");
|
|
243
|
+
expect(row.username).toBe("u_p");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("改资料:用户不存在 → 404;空 patch 不写库", async () => {
|
|
247
|
+
await expect(updateUserProfile(ADMIN, 9999, { position: "x" })).rejects.toMatchObject({ status: 404 });
|
|
248
|
+
const uid = mkUser("u_noop");
|
|
249
|
+
await updateUserProfile(ADMIN, uid, {});
|
|
250
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_oplog WHERE action = 'user.profile.update'").get()).toMatchObject({ c: 0 });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("删号:级联清部门关联 / 角色 / 会话", async () => {
|
|
254
|
+
const uid = mkUser("u_del");
|
|
255
|
+
const dept = await mkDept("待清");
|
|
256
|
+
await addMember(dept, uid, {}, deps);
|
|
257
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'editor')").run(uid);
|
|
258
|
+
dbo.prepare("INSERT INTO sys_refresh_token (user_id, token_hash, expires_at, revoked, created_at) VALUES (?, 'h', 0, 0, ?)").run(uid, 1_700_000_000);
|
|
259
|
+
await deleteUser(ADMIN, uid);
|
|
260
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM users WHERE id = ?").get(uid)).toMatchObject({ c: 0 });
|
|
261
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_dept_member WHERE user_id = ?").get(uid)).toMatchObject({ c: 0 });
|
|
262
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_user_role WHERE user_id = ?").get(uid)).toMatchObject({ c: 0 });
|
|
263
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_refresh_token WHERE user_id = ?").get(uid)).toMatchObject({ c: 0 });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("删号:禁止删自己;最后一个 superadmin 拒删", async () => {
|
|
267
|
+
await expect(deleteUser(ADMIN, ADMIN)).rejects.toMatchObject({ status: 400 });
|
|
268
|
+
const solo = mkUser("u_sup");
|
|
269
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'superadmin')").run(solo);
|
|
270
|
+
await expect(deleteUser(ADMIN, solo)).rejects.toMatchObject({ status: 400 });
|
|
271
|
+
// 有两个 superadmin 时允许删(兜底只保最后一个)
|
|
272
|
+
dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'superadmin')").run(ADMIN);
|
|
273
|
+
await deleteUser(ADMIN, solo);
|
|
274
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM users WHERE id = ?").get(solo)).toMatchObject({ c: 0 });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("listUsers 带 departments(主部门在前 + isMain)与 position", async () => {
|
|
278
|
+
const d1 = await mkDept("主");
|
|
279
|
+
const d2 = await mkDept("副");
|
|
280
|
+
const uid = mkUser("u_list");
|
|
281
|
+
await addMember(d2, uid, {}, deps);
|
|
282
|
+
await addMember(d1, uid, { isMain: true }, deps);
|
|
283
|
+
await updateUserProfile(ADMIN, uid, { position: "tech lead" });
|
|
284
|
+
const row = (await listUsers()).find(u => u.username === "u_list");
|
|
285
|
+
expect(row?.position).toBe("tech lead");
|
|
286
|
+
// 带 id:前端按部门(含子部门)筛选靠它,部门重名也串不了
|
|
287
|
+
expect(row?.departments).toEqual([
|
|
288
|
+
{ id: d1, name: "主", isMain: true },
|
|
289
|
+
{ id: d2, name: "副", isMain: false },
|
|
290
|
+
]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("部门名缺失就下发 null,不在接口里造 `#id` 字符串(展示口径归前端)", async () => {
|
|
294
|
+
const d = await mkDept("待改名");
|
|
295
|
+
const uid = mkUser("u_noname");
|
|
296
|
+
await addMember(d, uid, {}, deps);
|
|
297
|
+
dbo.prepare("UPDATE sys_department SET name = NULL WHERE id = ?").run(d);
|
|
298
|
+
const row = (await listUsers()).find(u => u.username === "u_noname");
|
|
299
|
+
expect(row?.departments).toEqual([{ id: d, name: null, isMain: false }]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("口径:listDepartments 默认过滤隐藏与软删,开关显式打开才全给", async () => {
|
|
303
|
+
const visible = await mkDept("可见");
|
|
304
|
+
const hidden = await mkDept("隐藏");
|
|
305
|
+
dbo.prepare("UPDATE sys_department SET sync_visible = 0 WHERE id = ?").run(hidden);
|
|
306
|
+
const gone = await mkDept("源侧已删");
|
|
307
|
+
dbo.prepare("UPDATE sys_department SET deleted = 1 WHERE id = ?").run(gone);
|
|
308
|
+
expect((await listDepartments()).map(d => d.id)).toEqual([visible]);
|
|
309
|
+
expect((await listDepartments({ includeHidden: true, includeDeleted: true })).map(d => d.id).sort()).toEqual([visible, hidden, gone].sort());
|
|
310
|
+
});
|
|
311
|
+
});
|