@goplusvn/core 0.1.74 → 0.1.76

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/ui/auth/auth-layout.tsx +106 -82
  17. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  18. package/src/user/user-service.ts +64 -10
  19. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  20. package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
  21. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  22. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  23. package/src/workspace/components/scope-level-select.tsx +91 -0
  24. package/src/workspace/components/workspace-switcher.tsx +139 -0
  25. package/src/workspace/components/workspace-tree-view.tsx +260 -0
  26. package/src/workspace/context.ts +78 -0
  27. package/src/workspace/delegation.ts +400 -0
  28. package/src/workspace/guard.ts +138 -0
  29. package/src/workspace/index.ts +157 -0
  30. package/src/workspace/pages/workspace-list-page.tsx +430 -0
  31. package/src/workspace/route-handlers.ts +274 -0
  32. package/src/workspace/scope.ts +396 -0
  33. package/src/workspace/service.ts +301 -0
  34. package/src/workspace/tree.ts +193 -0
  35. package/src/workspace/types.ts +182 -0
@@ -0,0 +1,592 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ getBranchScopeContext,
5
+ resolveAmbientBranchScope,
6
+ runWithBranchScope,
7
+ runWithoutBranchScope,
8
+ } from "../../branch-scope/context";
9
+ import { canAccessBranch, scopedBranchWhere } from "../../branch-scope/scope";
10
+ import {
11
+ getScopeContext,
12
+ resolveAmbientScope,
13
+ runWithScope,
14
+ runWithoutScope,
15
+ } from "../context";
16
+ import { createScopeGuardExtension } from "../guard";
17
+ import {
18
+ canAccessWorkspace,
19
+ clampScopeFilter,
20
+ clampToAdminScope,
21
+ configureWorkspaces,
22
+ createWorkspaceScope,
23
+ getWorkspaceScope,
24
+ hasAdminScope,
25
+ isWorkspaceAdmin,
26
+ memberScopeWhere,
27
+ resetWorkspaceConfig,
28
+ scopeLevelAtLeast,
29
+ scopeLevelWhere,
30
+ scopedWhere,
31
+ } from "../scope";
32
+ import {
33
+ assertDepthLimit,
34
+ assertNoCycle,
35
+ buildPath,
36
+ isInSubtree,
37
+ pathDepth,
38
+ repathSubtree,
39
+ selfAndDescendants,
40
+ WorkspaceTreeError,
41
+ } from "../tree";
42
+ import { NO_WORKSPACE_ACCESS } from "../types";
43
+ import type { WorkspaceNode } from "../types";
44
+
45
+ /**
46
+ * Cây dùng chung: Tấn Lộc (vận hành) và Spartronics (khách hàng) là hai đơn vị
47
+ * ngang hàng; Spartronics có hai phòng ban con.
48
+ */
49
+ const NODES: WorkspaceNode[] = [
50
+ { id: "tanloc", parentId: null, path: "/tanloc/", kind: "unit" },
51
+ { id: "spa", parentId: null, path: "/spa/", kind: "unit" },
52
+ { id: "spa-qc", parentId: "spa", path: "/spa/spa-qc/", kind: "department" },
53
+ { id: "spa-kho", parentId: "spa", path: "/spa/spa-kho/", kind: "department" },
54
+ ];
55
+
56
+ interface FakeSession {
57
+ id?: string;
58
+ admin?: boolean;
59
+ }
60
+
61
+ beforeEach(() => {
62
+ resetWorkspaceConfig();
63
+ });
64
+
65
+ describe("cây — hàm thuần", () => {
66
+ it("buildPath luôn có / hai đầu (tránh bắt nhầm id trùng tiền tố)", () => {
67
+ expect(buildPath(null, "a")).toBe("/a/");
68
+ expect(buildPath("/a/", "b")).toBe("/a/b/");
69
+ expect(pathDepth("/a/b/c/")).toBe(3);
70
+ });
71
+
72
+ it("isInSubtree tính cả chính nó — 'được gán nhánh X' luôn gồm X", () => {
73
+ expect(isInSubtree("/spa/", "/spa/")).toBe(true);
74
+ expect(isInSubtree("/spa/", "/spa/", { includeSelf: false })).toBe(false);
75
+ expect(isInSubtree("/spa/spa-qc/", "/spa/")).toBe(true);
76
+ expect(isInSubtree("/tanloc/", "/spa/")).toBe(false);
77
+ });
78
+
79
+ it("bung con cháu: gán nút cha ⇒ thấy nút con", () => {
80
+ expect(selfAndDescendants(["spa"], NODES).sort()).toEqual(
81
+ ["spa", "spa-kho", "spa-qc"].sort(),
82
+ );
83
+ });
84
+
85
+ it("gán nút con KHÔNG kéo theo nút cha (quyền chảy xuống, không chảy lên)", () => {
86
+ expect(selfAndDescendants(["spa-qc"], NODES)).toEqual(["spa-qc"]);
87
+ });
88
+
89
+ it("id không có trong cây vẫn được giữ — app mức 0 truyền cây rỗng", () => {
90
+ expect(selfAndDescendants(["b1", "b2"], [])).toEqual(["b1", "b2"]);
91
+ });
92
+
93
+ it("đổi cha ⇒ ghi lại path cho CẢ nhánh con", () => {
94
+ const updates = repathSubtree("spa", "/tanloc/", NODES);
95
+ expect(updates).toEqual(
96
+ expect.arrayContaining([
97
+ { id: "spa", path: "/tanloc/spa/", depth: 2 },
98
+ { id: "spa-qc", path: "/tanloc/spa/spa-qc/", depth: 3 },
99
+ { id: "spa-kho", path: "/tanloc/spa/spa-kho/", depth: 3 },
100
+ ]),
101
+ );
102
+ expect(updates).toHaveLength(3);
103
+ });
104
+
105
+ it("chống chu trình: không nhận chính mình hay con cháu làm cha", () => {
106
+ expect(() => assertNoCycle("spa", "spa", NODES)).toThrow(
107
+ WorkspaceTreeError,
108
+ );
109
+ expect(() => assertNoCycle("spa", "spa-qc", NODES)).toThrow(
110
+ WorkspaceTreeError,
111
+ );
112
+ expect(() => assertNoCycle("spa", "tanloc", NODES)).not.toThrow();
113
+ });
114
+
115
+ it("trần độ sâu 4 tầng", () => {
116
+ expect(() => assertDepthLimit("/a/b/c/d/")).not.toThrow();
117
+ expect(() => assertDepthLimit("/a/b/c/d/e/")).toThrow(WorkspaceTreeError);
118
+ });
119
+ });
120
+
121
+ describe("getWorkspaceScope", () => {
122
+ it("xem-tất không tốn truy vấn membership", async () => {
123
+ const getMemberships = vi.fn(async () => []);
124
+ configureWorkspaces<FakeSession>({
125
+ getMemberships,
126
+ getUserId: (s) => s.id,
127
+ canViewAll: (s) => Boolean(s.admin),
128
+ });
129
+ const scope = await getWorkspaceScope({ id: "u1", admin: true });
130
+ expect(scope.canViewAll).toBe(true);
131
+ expect(scope.allowedIds).toBeUndefined();
132
+ expect(getMemberships).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("chưa gán không gian nào → sentinel = thấy 0 dòng, KHÔNG phải thấy tất", async () => {
136
+ configureWorkspaces<FakeSession>({
137
+ getMemberships: async () => [],
138
+ getUserId: (s) => s.id,
139
+ canViewAll: () => false,
140
+ });
141
+ const scope = await getWorkspaceScope({ id: "u1" });
142
+ expect(scope.allowedIds).toEqual([NO_WORKSPACE_ACCESS]);
143
+ expect(scope.adminIds).toEqual([]);
144
+ });
145
+
146
+ it("MỨC 0 — không có db, không có expander ⇒ cây phẳng, hành vi như branch-scope", async () => {
147
+ configureWorkspaces<FakeSession>({
148
+ getMemberships: async () => ["b1", "b2"],
149
+ getUserId: (s) => s.id,
150
+ canViewAll: () => false,
151
+ });
152
+ const scope = await getWorkspaceScope({ id: "u1" });
153
+ expect(scope.rootIds).toEqual(["b1", "b2"]);
154
+ expect(scope.allowedIds).toEqual(["b1", "b2"]);
155
+ });
156
+
157
+ it("bung con cháu qua expander; isAdmin cũng được bung", async () => {
158
+ configureWorkspaces<FakeSession>({
159
+ getMemberships: async () => [{ workspaceId: "spa", isAdmin: true }],
160
+ expandDescendants: (roots) => selfAndDescendants(roots, NODES),
161
+ getUserId: (s) => s.id,
162
+ canViewAll: () => false,
163
+ });
164
+ const scope = await getWorkspaceScope({ id: "u1" });
165
+ expect(scope.rootIds).toEqual(["spa"]);
166
+ expect(scope.allowedIds?.sort()).toEqual(["spa", "spa-kho", "spa-qc"]);
167
+ // Admin nút cha PHẢI quản trị được phòng ban con của chính mình.
168
+ expect(scope.adminIds.sort()).toEqual(["spa", "spa-kho", "spa-qc"]);
169
+ });
170
+
171
+ it("thành viên thường KHÔNG có adminIds — xem được ≠ cấp tài khoản được", async () => {
172
+ configureWorkspaces<FakeSession>({
173
+ getMemberships: async () => [{ workspaceId: "spa" }],
174
+ expandDescendants: (roots) => selfAndDescendants(roots, NODES),
175
+ getUserId: (s) => s.id,
176
+ canViewAll: () => false,
177
+ });
178
+ const scope = await getWorkspaceScope({ id: "u1" });
179
+ expect(scope.adminIds).toEqual([]);
180
+ expect(hasAdminScope(scope)).toBe(false);
181
+ });
182
+
183
+ it("defaultId lấy từ isDefault, không có thì lấy nút đầu", async () => {
184
+ configureWorkspaces<FakeSession>({
185
+ getMemberships: async () => [
186
+ { workspaceId: "a" },
187
+ { workspaceId: "b", isDefault: true },
188
+ ],
189
+ getUserId: (s) => s.id,
190
+ canViewAll: () => false,
191
+ });
192
+ expect((await getWorkspaceScope({ id: "u1" })).defaultId).toBe("b");
193
+ });
194
+
195
+ it("session không có userId → sentinel, không tra DB bằng undefined", async () => {
196
+ const findMany = vi.fn(async () => [{ workspaceId: "spa" }]);
197
+ configureWorkspaces<FakeSession>({
198
+ db: {
199
+ userWorkspace: { findMany },
200
+ workspace: { findMany: vi.fn(async () => []) },
201
+ },
202
+ getUserId: (s) => s.id,
203
+ canViewAll: () => false,
204
+ });
205
+ const scope = await getWorkspaceScope({});
206
+ expect(scope.allowedIds).toEqual([NO_WORKSPACE_ACCESS]);
207
+ expect(findMany).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("bản mặc định đi DB: 2 truy vấn, dùng startsWith chứ không contains", async () => {
211
+ const workspaceFindMany = vi
212
+ .fn()
213
+ .mockResolvedValueOnce([{ id: "spa", path: "/spa/" }])
214
+ .mockResolvedValueOnce([
215
+ { id: "spa", path: "/spa/" },
216
+ { id: "spa-qc", path: "/spa/spa-qc/" },
217
+ ]);
218
+ configureWorkspaces<FakeSession>({
219
+ db: {
220
+ userWorkspace: {
221
+ findMany: vi.fn(async () => [{ workspaceId: "spa" }]),
222
+ },
223
+ workspace: { findMany: workspaceFindMany },
224
+ },
225
+ getUserId: (s) => s.id,
226
+ canViewAll: () => false,
227
+ });
228
+ const scope = await getWorkspaceScope({ id: "u1" });
229
+ expect(scope.allowedIds?.sort()).toEqual(["spa", "spa-qc"]);
230
+ expect(workspaceFindMany.mock.calls[1][0].where).toEqual({
231
+ OR: [{ path: { startsWith: "/spa/" } }],
232
+ });
233
+ });
234
+
235
+ it("chưa configureWorkspaces thì NÉM, không im lặng trả về rỗng", async () => {
236
+ await expect(getWorkspaceScope({})).rejects.toThrow(/configureWorkspaces/);
237
+ });
238
+ });
239
+
240
+ describe("scopedWhere / canAccessWorkspace", () => {
241
+ const scope = createWorkspaceScope({
242
+ canViewAll: false,
243
+ rootIds: ["spa"],
244
+ allowedIds: ["spa", "spa-qc"],
245
+ });
246
+
247
+ it("xem tất → không thêm điều kiện", () => {
248
+ expect(scopedWhere(createWorkspaceScope({ canViewAll: true }))).toEqual({});
249
+ });
250
+
251
+ it("cột phạm vi mặc định là branchId (tương thích ngược)", () => {
252
+ expect(scopedWhere(scope)).toEqual({
253
+ OR: [{ branchId: { in: ["spa", "spa-qc"] } }, { branchId: null }],
254
+ });
255
+ });
256
+
257
+ it("đổi cột + đi qua quan hệ", () => {
258
+ expect(scopedWhere(scope, { field: "workspaceId" })).toEqual({
259
+ OR: [{ workspaceId: { in: ["spa", "spa-qc"] } }, { workspaceId: null }],
260
+ });
261
+ expect(scopedWhere(scope, { relation: "warehouse" })).toEqual({
262
+ OR: [
263
+ { warehouse: { branchId: { in: ["spa", "spa-qc"] } } },
264
+ { warehouse: { branchId: null } },
265
+ ],
266
+ });
267
+ });
268
+
269
+ it("bản ghi không gắn không gian = dùng chung", () => {
270
+ expect(canAccessWorkspace(scope, null)).toBe(true);
271
+ expect(canAccessWorkspace(scope, "spa-qc")).toBe(true);
272
+ expect(canAccessWorkspace(scope, "tanloc")).toBe(false);
273
+ });
274
+ });
275
+
276
+ describe("thang 5 nấc (Δ1)", () => {
277
+ const scope = createWorkspaceScope({
278
+ canViewAll: false,
279
+ rootIds: ["spa"],
280
+ allowedIds: ["spa", "spa-qc", "spa-kho"],
281
+ });
282
+
283
+ it("all → không điều kiện; none → 0 dòng", () => {
284
+ expect(scopeLevelWhere("all", scope)).toEqual({});
285
+ expect(scopeLevelWhere("none", scope)).toEqual({
286
+ branchId: { in: [NO_WORKSPACE_ACCESS] },
287
+ });
288
+ });
289
+
290
+ it("own → chỉ bản ghi của user", () => {
291
+ expect(
292
+ scopeLevelWhere("own", scope, { ownerField: "createdBy", userId: "u1" }),
293
+ ).toEqual({ createdBy: "u1" });
294
+ });
295
+
296
+ it("own KHÔNG biết user là ai → đóng lại, không mở ra", () => {
297
+ expect(scopeLevelWhere("own", scope, { userId: null })).toEqual({
298
+ createdBy: NO_WORKSPACE_ACCESS,
299
+ });
300
+ });
301
+
302
+ it("workspace = đúng nút được gán, KHÔNG bung con cháu", () => {
303
+ expect(scopeLevelWhere("workspace", scope)).toEqual({
304
+ OR: [{ branchId: { in: ["spa"] } }, { branchId: null }],
305
+ });
306
+ });
307
+
308
+ it("subtree = nút được gán + con cháu", () => {
309
+ expect(scopeLevelWhere("subtree", scope)).toEqual({
310
+ OR: [
311
+ { branchId: { in: ["spa", "spa-qc", "spa-kho"] } },
312
+ { branchId: null },
313
+ ],
314
+ });
315
+ });
316
+
317
+ it("thứ tự nấc: all > subtree > workspace > own > none", () => {
318
+ expect(scopeLevelAtLeast("all", "subtree")).toBe(true);
319
+ expect(scopeLevelAtLeast("workspace", "subtree")).toBe(false);
320
+ expect(scopeLevelAtLeast("own", "own")).toBe(true);
321
+ });
322
+ });
323
+
324
+ describe("kẹp bộ lọc", () => {
325
+ const scope = createWorkspaceScope({
326
+ canViewAll: false,
327
+ rootIds: ["spa"],
328
+ allowedIds: ["spa", "spa-qc"],
329
+ adminIds: ["spa", "spa-qc"],
330
+ });
331
+
332
+ it("clampScopeFilter: toàn bộ ngoài phạm vi → sentinel, không rơi về không-lọc", () => {
333
+ expect(clampScopeFilter(["spa", "tanloc"], scope)).toEqual(["spa"]);
334
+ expect(clampScopeFilter(["tanloc"], scope)).toEqual([NO_WORKSPACE_ACCESS]);
335
+ expect(clampScopeFilter(undefined, scope)).toBeUndefined();
336
+ });
337
+
338
+ it("D1 — clampToAdminScope TỪ CHỐI khi có id ngoài nhánh, không lặng lẽ bỏ bớt", () => {
339
+ expect(clampToAdminScope(["spa", "spa-qc"], scope)).toEqual([
340
+ "spa",
341
+ "spa-qc",
342
+ ]);
343
+ expect(clampToAdminScope(["spa", "tanloc"], scope)).toBeNull();
344
+ });
345
+
346
+ it("D1 — người không được uỷ quyền thì không gán được gì", () => {
347
+ const plain = createWorkspaceScope({
348
+ canViewAll: false,
349
+ allowedIds: ["spa"],
350
+ });
351
+ expect(isWorkspaceAdmin(plain, "spa")).toBe(false);
352
+ expect(clampToAdminScope(["spa"], plain)).toBeNull();
353
+ });
354
+ });
355
+
356
+ describe("tương thích ngược với branch-scope", () => {
357
+ it("WorkspaceScope cắm thẳng vào hàm branch-scope cũ", () => {
358
+ const scope = createWorkspaceScope({
359
+ canViewAll: false,
360
+ allowedIds: ["b1", "b2"],
361
+ });
362
+ // Cùng một mảng, không phải bản sao — lệch nhau là bug im lặng.
363
+ expect(scope.allowedBranchIds).toBe(scope.allowedIds);
364
+ expect(scopedBranchWhere(scope)).toEqual({
365
+ OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }],
366
+ });
367
+ expect(canAccessBranch(scope, "b1")).toBe(true);
368
+ expect(canAccessBranch(scope, "x")).toBe(false);
369
+ });
370
+
371
+ /**
372
+ * BẪY SỐ MỘT của việc tách module: hai AsyncLocalStorage riêng thì
373
+ * `runWithoutBranchScope` chỉ thoát được một store, store kia vẫn lọc ⇒
374
+ * `MAX(số phiếu)` bị cắt theo phạm vi ⇒ TRÙNG SỐ PHIẾU (RS-260727-0003).
375
+ */
376
+ it("branch-scope và workspace dùng CHUNG một store", async () => {
377
+ const scope = createWorkspaceScope({ canViewAll: true });
378
+ await runWithScope({ resolve: async () => scope }, async () => {
379
+ expect(getBranchScopeContext()).toBeDefined();
380
+ expect(await resolveAmbientBranchScope()).toBe(scope);
381
+ });
382
+ await runWithBranchScope({ resolve: async () => scope }, async () => {
383
+ expect(getScopeContext()).toBeDefined();
384
+ expect(await resolveAmbientScope()).toBe(scope);
385
+ });
386
+ });
387
+
388
+ it("runWithoutScope thoát được context mở bằng runWithBranchScope (và ngược lại)", async () => {
389
+ const scope = createWorkspaceScope({ canViewAll: true });
390
+ await runWithBranchScope({ resolve: async () => scope }, async () => {
391
+ runWithoutScope(() => {
392
+ expect(getBranchScopeContext()).toBeUndefined();
393
+ expect(getScopeContext()).toBeUndefined();
394
+ });
395
+ expect(getScopeContext()).toBeDefined();
396
+ });
397
+ await runWithScope({ resolve: async () => scope }, async () => {
398
+ runWithoutBranchScope(() => {
399
+ expect(getScopeContext()).toBeUndefined();
400
+ });
401
+ expect(getBranchScopeContext()).toBeDefined();
402
+ });
403
+ });
404
+ });
405
+
406
+ /** Chữ ký thô của `$allOperations` — Prisma không xuất type này ra ngoài. */
407
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
408
+ type GuardRun = (a: any) => Promise<any>;
409
+
410
+ describe("createScopeGuardExtension", () => {
411
+ const scope = createWorkspaceScope({
412
+ canViewAll: false,
413
+ allowedIds: ["spa"],
414
+ });
415
+ const ext = createScopeGuardExtension({
416
+ models: {
417
+ Receipt: {},
418
+ GoodsReceipt: { relation: "warehouse" },
419
+ SalesOrder: { crossScopeRead: true },
420
+ },
421
+ });
422
+ const run = ext.query.$allModels.$allOperations as GuardRun;
423
+
424
+ let seen: unknown;
425
+ const query = vi.fn(async (args: unknown) => {
426
+ seen = args;
427
+ return "ok";
428
+ });
429
+
430
+ beforeEach(() => {
431
+ seen = undefined;
432
+ query.mockClear();
433
+ configureWorkspaces<FakeSession>({
434
+ getMemberships: async () => [],
435
+ getUserId: (s) => s.id,
436
+ canViewAll: () => false,
437
+ });
438
+ });
439
+
440
+ const call = (model: string, operation: string, args: unknown) =>
441
+ run({ model, operation, args, query });
442
+
443
+ it("model ngoài danh sách → đi thẳng", async () => {
444
+ await runWithScope({ resolve: async () => scope }, () =>
445
+ call("Customer", "findMany", { where: { name: "a" } }),
446
+ );
447
+ expect(seen).toEqual({ where: { name: "a" } });
448
+ });
449
+
450
+ it("ngoài context (cron/script) → không cắt dữ liệu", async () => {
451
+ await call("Receipt", "findMany", { where: { a: 1 } });
452
+ expect(seen).toEqual({ where: { a: 1 } });
453
+ });
454
+
455
+ it("AND thêm điều kiện, GIỮ nguyên where cũ", async () => {
456
+ await runWithScope({ resolve: async () => scope }, () =>
457
+ call("Receipt", "findMany", { where: { status: "paid" } }),
458
+ );
459
+ expect(seen).toEqual({
460
+ where: {
461
+ AND: [
462
+ { status: "paid" },
463
+ { OR: [{ branchId: { in: ["spa"] } }, { branchId: null }] },
464
+ ],
465
+ },
466
+ });
467
+ });
468
+
469
+ it("findUnique và ghi một dòng → không đụng vào (where chỉ nhận unique field)", async () => {
470
+ await runWithScope({ resolve: async () => scope }, async () => {
471
+ await call("Receipt", "findUnique", { where: { id: "r1" } });
472
+ expect(seen).toEqual({ where: { id: "r1" } });
473
+ await call("Receipt", "update", { where: { id: "r1" }, data: {} });
474
+ expect(seen).toEqual({ where: { id: "r1" }, data: {} });
475
+ });
476
+ });
477
+
478
+ /** Δ2 — NetSuite `Allow Cross-Subsidiary Record Viewing`: xem được, sửa không được. */
479
+ it("crossScopeRead: ĐỌC đi thẳng…", async () => {
480
+ await runWithScope({ resolve: async () => scope }, () =>
481
+ call("SalesOrder", "findMany", { where: { status: "new" } }),
482
+ );
483
+ expect(seen).toEqual({ where: { status: "new" } });
484
+ });
485
+
486
+ it("…nhưng GHI hàng loạt vẫn bị kẹp", async () => {
487
+ await runWithScope({ resolve: async () => scope }, () =>
488
+ call("SalesOrder", "updateMany", { where: { status: "new" }, data: {} }),
489
+ );
490
+ expect(seen).toEqual({
491
+ where: {
492
+ AND: [
493
+ { status: "new" },
494
+ { OR: [{ branchId: { in: ["spa"] } }, { branchId: null }] },
495
+ ],
496
+ },
497
+ data: {},
498
+ });
499
+ });
500
+
501
+ it("model thường: deleteMany cũng bị kẹp", async () => {
502
+ await runWithScope({ resolve: async () => scope }, () =>
503
+ call("Receipt", "deleteMany", {}),
504
+ );
505
+ expect(seen).toEqual({
506
+ where: { OR: [{ branchId: { in: ["spa"] } }, { branchId: null }] },
507
+ });
508
+ });
509
+
510
+ it("khai bằng mảng tên model vẫn chạy (dạng gọn)", async () => {
511
+ const simple = createScopeGuardExtension({ models: ["Receipt"] });
512
+
513
+ const runSimple = simple.query.$allModels.$allOperations as GuardRun;
514
+ await runWithScope({ resolve: async () => scope }, () =>
515
+ runSimple({ model: "Receipt", operation: "count", args: {}, query }),
516
+ );
517
+ expect(seen).toEqual({
518
+ where: { OR: [{ branchId: { in: ["spa"] } }, { branchId: null }] },
519
+ });
520
+ });
521
+ });
522
+
523
+ describe("memberScopeWhere", () => {
524
+ const scope = createWorkspaceScope({
525
+ canViewAll: false,
526
+ rootIds: ["spa"],
527
+ allowedIds: ["spa", "spa-qc", "spa-kho"],
528
+ adminIds: ["spa-qc"],
529
+ });
530
+
531
+ it("lọc qua bảng nối, KHÔNG có nhánh null", () => {
532
+ configureWorkspaces({
533
+ getUserId: () => undefined,
534
+ canViewAll: () => false,
535
+ });
536
+ expect(memberScopeWhere(scope)).toEqual({
537
+ userWorkspaces: {
538
+ some: { workspaceId: { in: ["spa", "spa-qc", "spa-kho"] } },
539
+ },
540
+ });
541
+ });
542
+
543
+ it("mức 0 khai lại tên quan hệ / cột", () => {
544
+ configureWorkspaces({
545
+ getUserId: () => undefined,
546
+ canViewAll: () => false,
547
+ membershipRelation: "userBranches",
548
+ membershipField: "branchId",
549
+ });
550
+ expect(memberScopeWhere(scope)).toEqual({
551
+ userBranches: {
552
+ some: { branchId: { in: ["spa", "spa-qc", "spa-kho"] } },
553
+ },
554
+ });
555
+ });
556
+
557
+ it("adminOnly thu về đúng nhánh được uỷ quyền", () => {
558
+ configureWorkspaces({
559
+ getUserId: () => undefined,
560
+ canViewAll: () => false,
561
+ });
562
+ expect(memberScopeWhere(scope, { adminOnly: true })).toEqual({
563
+ userWorkspaces: { some: { workspaceId: { in: ["spa-qc"] } } },
564
+ });
565
+ });
566
+
567
+ it("không được uỷ quyền nhánh nào + adminOnly ⇒ sentinel, 0 dòng", () => {
568
+ configureWorkspaces({
569
+ getUserId: () => undefined,
570
+ canViewAll: () => false,
571
+ });
572
+ const nobody = createWorkspaceScope({
573
+ canViewAll: false,
574
+ rootIds: ["spa"],
575
+ allowedIds: ["spa"],
576
+ adminIds: [],
577
+ });
578
+ expect(memberScopeWhere(nobody, { adminOnly: true })).toEqual({
579
+ userWorkspaces: { some: { workspaceId: { in: [NO_WORKSPACE_ACCESS] } } },
580
+ });
581
+ });
582
+
583
+ it("xem được tất ⇒ không thêm điều kiện", () => {
584
+ configureWorkspaces({
585
+ getUserId: () => undefined,
586
+ canViewAll: () => false,
587
+ });
588
+ expect(
589
+ memberScopeWhere(createWorkspaceScope({ canViewAll: true })),
590
+ ).toEqual({});
591
+ });
592
+ });