forked from EduCraft/curriculum-project-hub
feat(database): 后台成员组(MemberGroup)管理与嵌套解析
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
|
||||
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
|
||||
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
|
||||
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import {
|
||||
addMember,
|
||||
createMemberGroup,
|
||||
deleteMemberGroup,
|
||||
listMemberGroups,
|
||||
listMembers,
|
||||
removeMember,
|
||||
searchMemberGroups,
|
||||
searchUsers,
|
||||
updateMemberGroup,
|
||||
type MemberGroupServiceDeps,
|
||||
} from "../../src/database/filelib/memberGroupService.js";
|
||||
import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js";
|
||||
import {
|
||||
createNode,
|
||||
getEffectiveRole,
|
||||
type FileLibActor,
|
||||
type TreeServiceDeps,
|
||||
} from "../../src/database/filelib/treeService.js";
|
||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||
|
||||
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
|
||||
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
|
||||
|
||||
function svc(): MemberGroupServiceDeps {
|
||||
return { prisma, organizationId: DEFAULT_ORG_ID };
|
||||
}
|
||||
|
||||
/** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */
|
||||
function treeDeps(): TreeServiceDeps {
|
||||
return {
|
||||
prisma,
|
||||
groupResolver: createMemberGroupResolver(prisma),
|
||||
versionStore: createInMemoryVersionStore(),
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
storageRoot: "/tmp/member-groups-test",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedUsers(): Promise<void> {
|
||||
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
|
||||
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
|
||||
}
|
||||
}
|
||||
|
||||
/** 建嵌套链 A>B>C,返回三者 id。 */
|
||||
async function seedChain(): Promise<{ a: string; b: string; c: string }> {
|
||||
const a = await createMemberGroup(svc(), ADMIN, { name: "A" });
|
||||
const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id });
|
||||
const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id });
|
||||
return { a: a.id, b: b.id, c: c.id };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedUsers();
|
||||
});
|
||||
|
||||
describe("memberGroupService · 创建与闭包", () => {
|
||||
it("建根 depth0;建子继承祖先闭包,depth 递增", async () => {
|
||||
const { a, b, c } = await seedChain();
|
||||
|
||||
// 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。
|
||||
const closureC = await prisma.memberGroupClosure.findMany({
|
||||
where: { descendantId: c },
|
||||
orderBy: { depth: "asc" },
|
||||
});
|
||||
expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([
|
||||
[c, 0], [b, 1], [a, 2],
|
||||
]);
|
||||
|
||||
const groups = await listMemberGroups(svc(), ADMIN);
|
||||
const byId = new Map(groups.map((g) => [g.id, g]));
|
||||
expect(byId.get(a)?.depth).toBe(0);
|
||||
expect(byId.get(b)?.depth).toBe(1);
|
||||
expect(byId.get(c)?.depth).toBe(2);
|
||||
expect(byId.get(b)?.parentId).toBe(a);
|
||||
});
|
||||
|
||||
it("父组不存在/已归档 → 404", async () => {
|
||||
await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" }))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupResolver · 解析(直接组 ∪ 活跃祖先)", () => {
|
||||
it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => {
|
||||
const { a, b, c } = await seedChain();
|
||||
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||
const resolver = createMemberGroupResolver(prisma);
|
||||
|
||||
const ids = await resolver.resolveMemberGroupIds("u_alice");
|
||||
expect([...ids].sort()).toEqual([a, b, c].sort());
|
||||
expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 3.2 祖先授权递归传递", () => {
|
||||
it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => {
|
||||
const { a, c } = await seedChain();
|
||||
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||
|
||||
// ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。
|
||||
const folder = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "FOLDER", name: "共享",
|
||||
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
|
||||
});
|
||||
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 改名/改描述(决策6)", () => {
|
||||
it("改名不动闭包:depth/parentId/子树关系全保持", async () => {
|
||||
const { a, b, c } = await seedChain();
|
||||
const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
|
||||
|
||||
const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" });
|
||||
expect(updated.name).toBe("B2");
|
||||
expect(updated.description).toBe("改后");
|
||||
expect(updated.parentId).toBe(a);
|
||||
expect(updated.depth).toBe(1);
|
||||
|
||||
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
|
||||
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
|
||||
expect(after).toEqual(before);
|
||||
// C 仍在 B 之下,depth 不变。
|
||||
const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g]));
|
||||
expect(byId.get(c)?.depth).toBe(2);
|
||||
expect(byId.get(c)?.parentId).toBe(b);
|
||||
});
|
||||
|
||||
it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" });
|
||||
expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull();
|
||||
|
||||
// 只传 name → 描述保持(此时已是 null)。
|
||||
const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
|
||||
expect(renamed.name).toBe("G2");
|
||||
expect(renamed.description).toBeNull();
|
||||
|
||||
await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
await deleteMemberGroup(svc(), ADMIN, g.id);
|
||||
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it("改名写 group.update 审计", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
|
||||
const actions = (await prisma.auditEntry.findMany({
|
||||
where: { organizationId: DEFAULT_ORG_ID },
|
||||
select: { action: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
})).map((e) => e.action);
|
||||
expect(actions).toEqual(["group.create", "group.update"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 级联软删 + 实时失效", () => {
|
||||
it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => {
|
||||
const { a, b, c } = await seedChain();
|
||||
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||
const folder = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "FOLDER", name: "共享",
|
||||
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
|
||||
});
|
||||
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
|
||||
|
||||
const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b);
|
||||
expect(archivedCount).toBe(2); // B + C
|
||||
|
||||
const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id);
|
||||
expect(remaining).toEqual([a]);
|
||||
|
||||
// C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。
|
||||
expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]);
|
||||
await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 成员增删", () => {
|
||||
it("重复添加 → 409;移除后可重新添加", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||
await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" }))
|
||||
.rejects.toMatchObject({ statusCode: 409 });
|
||||
|
||||
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0);
|
||||
|
||||
// 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。
|
||||
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("按飞书 openId 解析成员", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" });
|
||||
expect(m.userId).toBe("u_bob");
|
||||
});
|
||||
|
||||
it("移除不存在成员 → 404", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await expect(removeMember(svc(), ADMIN, g.id, "u_alice"))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 管理门禁(决策2)", () => {
|
||||
it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
// 搜索:非管理员可调(授权选择器)。
|
||||
const results = await searchMemberGroups(svc(), "G");
|
||||
expect(results.map((r) => r.id)).toContain(g.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 成员选择器 searchUsers", () => {
|
||||
it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
|
||||
// 空 q 列出全部(3 个 seed 用户)。
|
||||
expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3);
|
||||
// 按 displayName 命中(seed 的 displayName 即 id)。
|
||||
expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]);
|
||||
// 按 openId 命中。
|
||||
expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]);
|
||||
|
||||
// 已在组的人被排除 —— 避免选中必然 409 的候选。
|
||||
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||
const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId);
|
||||
expect(ids).not.toContain("u_alice");
|
||||
expect(ids).toContain("u_bob");
|
||||
|
||||
// 移除后重新成为候选(revokedAt 软删)。
|
||||
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||
expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice");
|
||||
|
||||
await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 成员表字段", () => {
|
||||
it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||
const [m] = await listMembers(svc(), ADMIN, g.id);
|
||||
expect(m).toMatchObject({
|
||||
userId: "u_alice",
|
||||
displayName: "u_alice",
|
||||
feishuOpenId: "ou_alice",
|
||||
avatarUrl: null,
|
||||
});
|
||||
expect(m?.joinedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 搜索 breadcrumb", () => {
|
||||
it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => {
|
||||
const { c } = await seedChain();
|
||||
const results = await searchMemberGroups(svc(), "C");
|
||||
const hit = results.find((r) => r.id === c);
|
||||
expect(hit?.breadcrumb).toBe("A / B / C");
|
||||
});
|
||||
|
||||
it("归档组不出现在搜索(G3)", async () => {
|
||||
const { a, b } = await seedChain();
|
||||
await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C
|
||||
const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id);
|
||||
expect(ids).toContain(a);
|
||||
expect(ids).not.toContain(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberGroupService · 审计(C3/决策4)", () => {
|
||||
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
|
||||
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||
await deleteMemberGroup(svc(), ADMIN, g.id);
|
||||
const actions = (await prisma.auditEntry.findMany({
|
||||
where: { organizationId: DEFAULT_ORG_ID },
|
||||
select: { action: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
})).map((e) => e.action);
|
||||
expect(actions).toEqual([
|
||||
"group.create", "group.member_add", "group.member_remove", "group.delete",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user