/** * 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。 * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义 * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 / * upsert 刷新 / 删除与祖先删除的可见性过滤。 * 运行前提:本地 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 { createNode, softDeleteNode, listChildren, getEffectiveRole, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js"; import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.js"; import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js"; import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js"; const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true }; const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false }; const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false }; function treeDeps(): TreeServiceDeps { return { prisma, groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), versionStore: createInMemoryVersionStore(), organizationId: DEFAULT_ORG_ID, storageRoot: "/tmp/filelib-test", }; } function binDeps(): BinDeps { return { prisma, organizationId: DEFAULT_ORG_ID, groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), }; } function recentDeps(): RecentDeps { return { prisma, organizationId: DEFAULT_ORG_ID, groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), }; } beforeEach(async () => { await resetDb(); 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 } }); } }); describe("binService · 列出与可见性", () => { it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" }); // alice 在 child 上直连 MANAGE。 const own = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "PROJECT", name: "alice 项目", grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], }); await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列 await softDeleteNode(treeDeps(), ADMIN, own.id); const adminBin = await listBin(binDeps(), ADMIN); expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]); const aliceBin = await listBin(binDeps(), ALICE); expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的 const bobBin = await listBin(binDeps(), BOB); expect(bobBin).toEqual([]); void root; }); }); describe("binService · 恢复", () => { it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); await softDeleteNode(treeDeps(), ADMIN, child.id); await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 }); await restoreBinEntry(binDeps(), ADMIN, child.id); const visible = await listChildren(treeDeps(), ADMIN, root.id); expect(visible.map((c) => c.name)).toContain("必修一"); const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore }, }); expect(audits).toHaveLength(1); }); }); describe("binService · 彻底删除", () => { it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141", grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], }); await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶 await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 }); const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id); expect(removed).toBe(2); expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0); expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0); const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } }); expect(audits).toHaveLength(1); }); }); describe("recentService · 最近打开", () => { it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" }); const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" }); // bob 对 secret 无 VIEW → 404 await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 }); // admin 上报:root、proj、proj 内文件 await recordVisit(recentDeps(), ADMIN, root.id, undefined); await recordVisit(recentDeps(), ADMIN, proj.id, undefined); await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md"); let entries = await listRecent(recentDeps(), ADMIN); expect(entries).toHaveLength(3); expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md"); // upsert:重复打开 root 只刷新,不新增 await recordVisit(recentDeps(), ADMIN, root.id, undefined); entries = await listRecent(recentDeps(), ADMIN); expect(entries).toHaveLength(3); expect(entries[0]!.nodeId).toBe(root.id); // 最新在前 // 删祖先 → 整支条目消失 await softDeleteNode(treeDeps(), ADMIN, root.id); entries = await listRecent(recentDeps(), ADMIN); expect(entries).toEqual([]); // 恢复后重新可见 await restoreBinEntry(binDeps(), ADMIN, root.id); entries = await listRecent(recentDeps(), ADMIN); expect(entries).toHaveLength(3); expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE"); }); });