@allbluecn/web-app 0.10.2 → 0.11.0

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 (169) hide show
  1. package/package.json +26 -11
  2. package/public/favicon.ico +0 -0
  3. package/public/logo/allblue-logo-app-icon.svg +7 -0
  4. package/public/logo/allblue-logo-dark-square.svg +4 -0
  5. package/public/logo/allblue-logo-dark.svg +4 -0
  6. package/public/logo/allblue-logo-light-square.svg +4 -0
  7. package/public/logo/allblue-logo-light.svg +4 -0
  8. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  9. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  10. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  11. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  12. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  13. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  14. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  15. package/src/__tests__/setup.ts +9 -0
  16. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  17. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  18. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  19. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  20. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  21. package/src/components/error/global-error-boundary.tsx +2 -2
  22. package/src/components/knowledge/general-groups.ts +15 -0
  23. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  24. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  25. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  26. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  27. package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
  28. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  29. package/src/components/mdx.tsx +9 -0
  30. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  31. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  32. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  33. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  34. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  35. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  36. package/src/components/story/relations/general-groups.ts +1 -15
  37. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  38. package/src/components/story/story-list-view.tsx +7 -1
  39. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  40. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  41. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  42. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  43. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  44. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  45. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  46. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  47. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  48. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  49. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  50. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  51. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  52. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  53. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  54. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  55. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  56. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  57. package/src/lib/__tests__/pgvector.test.ts +2 -2
  58. package/src/lib/changelog/sdk-versions.ts +21 -21
  59. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  60. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  61. package/src/lib/fetch-json.ts +23 -0
  62. package/src/lib/fumadocs-i18n.ts +7 -0
  63. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  64. package/src/lib/layout.shared.tsx +40 -0
  65. package/src/lib/sanitize.ts +14 -1
  66. package/src/lib/session.ts +9 -1
  67. package/src/lib/source.ts +15 -0
  68. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  69. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  70. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  71. package/src/routeTree.gen.ts +65 -0
  72. package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
  73. package/src/routes/$lang/docs/$/route.tsx +52 -0
  74. package/src/routes/-tests/docs-redirect.test.ts +45 -0
  75. package/src/routes/__root.tsx +13 -7
  76. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  77. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  78. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  79. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  80. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  81. package/src/routes/_nav/route.tsx +21 -0
  82. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  83. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  84. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  85. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  86. package/src/routes/api/search.ts +13 -0
  87. package/src/routes/docs.tsx +9 -0
  88. package/src/server/__tests__/agent-crud.test.ts +4 -4
  89. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  90. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  91. package/src/server/__tests__/permissions.test.ts +1 -3
  92. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  93. package/src/server/__tests__/skill-crud.test.ts +3 -3
  94. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  95. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  96. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  97. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  98. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  99. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  100. package/src/server/ai-gateway/index.ts +5 -0
  101. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  102. package/src/server/ai-gateway/llm-fence.ts +82 -0
  103. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  104. package/src/server/ai-gateway/story-prompt.ts +4 -2
  105. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  106. package/src/server/ai-gateway/tools/server.ts +97 -54
  107. package/src/server/builtin-project.ts +21 -0
  108. package/src/server/email/sender.ts +8 -2
  109. package/src/server/fetch-guard.ts +170 -0
  110. package/src/server/middlewares/security-headers.ts +113 -0
  111. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  112. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  115. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  116. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  117. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  118. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  119. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  120. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  121. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  122. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  123. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  124. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  125. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  126. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  127. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  128. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  129. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  130. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  131. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  132. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  133. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  134. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  135. package/src/server/serverFns/kb-doc-search.ts +111 -0
  136. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  137. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  138. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  139. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  140. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  141. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  142. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  143. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  144. package/src/server/serverFns/story/kb-general.ts +30 -17
  145. package/src/server/serverFns/story/story-assoc.ts +13 -103
  146. package/src/server/serverFns/story/story-crud.ts +2 -5
  147. package/src/server/serverFns/story/story-groups.ts +4 -13
  148. package/src/server/serverFns/team.ts +1 -1
  149. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  150. package/src/server/serverFns/workspace-kb.ts +403 -0
  151. package/src/server/serverFns/workspace.ts +4 -1
  152. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  153. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  154. package/src/shared/ai-cli/adapter.ts +2 -2
  155. package/src/shared/ai-cli/cli-detector.ts +18 -9
  156. package/src/start.ts +2 -1
  157. package/src/styles/globals.css +116 -0
  158. package/src/types/jest-dom-vitest.d.ts +8 -7
  159. package/vite.shared.ts +18 -1
  160. package/public/file.svg +0 -1
  161. package/public/globe.svg +0 -1
  162. package/public/window.svg +0 -1
  163. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  164. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  165. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  166. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  167. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  168. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  169. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,230 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ // story-reactions 域覆盖补全:4 个 serverFn(list/add/toggleStory/toggleComment)。
20
+ // 关键断言:未登录拒绝时全部反应查询零触达;toggle 的 up/down 幂等与计数一致性;
21
+ // 断言 Prisma where/select 形状。mock 骨架同 workspace-kb-idor.test.ts 样板。
22
+ import { beforeEach, describe, expect, it, vi } from "vitest";
23
+
24
+ vi.mock("@tanstack/react-start", () => ({
25
+ createServerFn: (_config: unknown) => {
26
+ const api: any = {
27
+ _validator: undefined,
28
+ validator(schema: any) {
29
+ this._validator = schema;
30
+ return this;
31
+ },
32
+ handler(fn: Function) {
33
+ return async (args: any) => {
34
+ let data = args?.data;
35
+ if (this._validator && data !== undefined) {
36
+ data = this._validator.parse(data);
37
+ }
38
+ return fn({ data });
39
+ };
40
+ },
41
+ };
42
+ return api;
43
+ },
44
+ }));
45
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
46
+
47
+ const M = vi.hoisted(() => {
48
+ const f = () => vi.fn();
49
+ return {
50
+ requireAuth: f(),
51
+ storyReaction: { findUnique: f(), findMany: f(), create: f(), delete: f() },
52
+ commentReaction: { findUnique: f(), findMany: f(), create: f(), delete: f() },
53
+ };
54
+ });
55
+
56
+ vi.mock("@allbluecn/database", () => ({
57
+ prisma: {
58
+ storyReaction: M.storyReaction,
59
+ commentReaction: M.commentReaction,
60
+ },
61
+ Prisma: {},
62
+ }));
63
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
64
+
65
+ import {
66
+ addStoryReactionFn,
67
+ listStoryReactionsFn,
68
+ toggleCommentReactionFn,
69
+ toggleStoryReactionFn,
70
+ } from "../story/story-reactions";
71
+
72
+ const USER = "u-current";
73
+ const OTHER = "u-other";
74
+ const STORY_ID = "st-1";
75
+ const COMMENT_ID = "cm-1";
76
+ const EMOJI = "👍";
77
+
78
+ const storyReactionQueries = [
79
+ M.storyReaction.findUnique,
80
+ M.storyReaction.findMany,
81
+ M.storyReaction.create,
82
+ M.storyReaction.delete,
83
+ ];
84
+ const commentReactionQueries = [
85
+ M.commentReaction.findUnique,
86
+ M.commentReaction.findMany,
87
+ M.commentReaction.create,
88
+ M.commentReaction.delete,
89
+ ];
90
+
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ M.requireAuth.mockResolvedValue({ user: { id: USER } });
94
+ M.storyReaction.findMany.mockResolvedValue([]);
95
+ M.commentReaction.findMany.mockResolvedValue([]);
96
+ });
97
+
98
+ describe("listStoryReactionsFn", () => {
99
+ it("按 emoji 聚合并标记当前用户是否已点赞", async () => {
100
+ M.storyReaction.findMany.mockResolvedValue([
101
+ { emoji: "👍", userId: USER },
102
+ { emoji: "👍", userId: OTHER },
103
+ { emoji: "🎉", userId: OTHER },
104
+ ]);
105
+ const res = await listStoryReactionsFn({ data: { storyId: STORY_ID } });
106
+ expect(M.storyReaction.findMany).toHaveBeenCalledWith({
107
+ where: { storyId: STORY_ID },
108
+ select: { emoji: true, userId: true },
109
+ });
110
+ expect(res).toEqual([
111
+ { emoji: "👍", count: 2, userIds: [USER, OTHER], reacted: true },
112
+ { emoji: "🎉", count: 1, userIds: [OTHER], reacted: false },
113
+ ]);
114
+ });
115
+
116
+ it("无点赞数据时返回空数组", async () => {
117
+ const res = await listStoryReactionsFn({ data: { storyId: STORY_ID } });
118
+ expect(res).toEqual([]);
119
+ });
120
+
121
+ it("未登录拒绝且不发起任何点赞查询", async () => {
122
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
123
+ await expect(
124
+ listStoryReactionsFn({ data: { storyId: STORY_ID } }),
125
+ ).rejects.toThrow("UNAUTHORIZED");
126
+ for (const q of storyReactionQueries) expect(q).not.toHaveBeenCalled();
127
+ });
128
+ });
129
+
130
+ describe("addStoryReactionFn", () => {
131
+ it("首次点赞写入 storyReaction 并返回全量聚合", async () => {
132
+ M.storyReaction.findUnique.mockResolvedValue(null);
133
+ M.storyReaction.create.mockResolvedValue({ id: "r1" });
134
+ M.storyReaction.findMany.mockResolvedValue([{ emoji: EMOJI, userId: USER }]);
135
+
136
+ const res = await addStoryReactionFn({ data: { storyId: STORY_ID, emoji: EMOJI } });
137
+
138
+ expect(M.storyReaction.findUnique).toHaveBeenCalledWith({
139
+ where: { storyId_userId_emoji: { storyId: STORY_ID, userId: USER, emoji: EMOJI } },
140
+ });
141
+ expect(M.storyReaction.create).toHaveBeenCalledWith({
142
+ data: { storyId: STORY_ID, userId: USER, emoji: EMOJI },
143
+ });
144
+ expect(res).toEqual([{ emoji: EMOJI, count: 1, userIds: [USER], reacted: true }]);
145
+ });
146
+
147
+ it("重复点赞不重复写入(幂等),仅重算聚合", async () => {
148
+ M.storyReaction.findUnique.mockResolvedValue({ id: "r1" });
149
+ M.storyReaction.findMany.mockResolvedValue([{ emoji: EMOJI, userId: USER }]);
150
+
151
+ const res = await addStoryReactionFn({ data: { storyId: STORY_ID, emoji: EMOJI } });
152
+
153
+ expect(M.storyReaction.create).not.toHaveBeenCalled();
154
+ expect(res).toEqual([{ emoji: EMOJI, count: 1, userIds: [USER], reacted: true }]);
155
+ });
156
+
157
+ it("未登录拒绝且不写库", async () => {
158
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
159
+ await expect(
160
+ addStoryReactionFn({ data: { storyId: STORY_ID, emoji: EMOJI } }),
161
+ ).rejects.toThrow("UNAUTHORIZED");
162
+ expect(M.storyReaction.create).not.toHaveBeenCalled();
163
+ expect(M.storyReaction.findMany).not.toHaveBeenCalled();
164
+ });
165
+ });
166
+
167
+ describe("toggleStoryReactionFn", () => {
168
+ it("已点赞时删除记录(切换为取消)", async () => {
169
+ M.storyReaction.findUnique.mockResolvedValue({ id: "r9" });
170
+ M.storyReaction.delete.mockResolvedValue({ id: "r9" });
171
+ M.storyReaction.findMany.mockResolvedValue([]);
172
+
173
+ const res = await toggleStoryReactionFn({ data: { storyId: STORY_ID, emoji: EMOJI } });
174
+
175
+ expect(M.storyReaction.delete).toHaveBeenCalledWith({ where: { id: "r9" } });
176
+ expect(M.storyReaction.create).not.toHaveBeenCalled();
177
+ expect(res).toEqual([]);
178
+ });
179
+
180
+ it("未点赞时创建记录", async () => {
181
+ M.storyReaction.findUnique.mockResolvedValue(null);
182
+ M.storyReaction.create.mockResolvedValue({ id: "r1" });
183
+ M.storyReaction.findMany.mockResolvedValue([{ emoji: EMOJI, userId: USER }]);
184
+
185
+ const res = await toggleStoryReactionFn({ data: { storyId: STORY_ID, emoji: EMOJI } });
186
+
187
+ expect(M.storyReaction.create).toHaveBeenCalledWith({
188
+ data: { storyId: STORY_ID, userId: USER, emoji: EMOJI },
189
+ });
190
+ expect(M.storyReaction.delete).not.toHaveBeenCalled();
191
+ expect(res[0]?.reacted).toBe(true);
192
+ });
193
+ });
194
+
195
+ describe("toggleCommentReactionFn", () => {
196
+ it("已点赞时删除 commentReaction", async () => {
197
+ M.commentReaction.findUnique.mockResolvedValue({ id: "cr9" });
198
+ M.commentReaction.delete.mockResolvedValue({ id: "cr9" });
199
+
200
+ const res = await toggleCommentReactionFn({ data: { commentId: COMMENT_ID, emoji: EMOJI } });
201
+
202
+ expect(M.commentReaction.findUnique).toHaveBeenCalledWith({
203
+ where: { commentId_userId_emoji: { commentId: COMMENT_ID, userId: USER, emoji: EMOJI } },
204
+ });
205
+ expect(M.commentReaction.delete).toHaveBeenCalledWith({ where: { id: "cr9" } });
206
+ expect(M.commentReaction.create).not.toHaveBeenCalled();
207
+ expect(res).toEqual([]);
208
+ });
209
+
210
+ it("未点赞时创建 commentReaction", async () => {
211
+ M.commentReaction.findUnique.mockResolvedValue(null);
212
+ M.commentReaction.create.mockResolvedValue({ id: "cr1" });
213
+ M.commentReaction.findMany.mockResolvedValue([{ emoji: EMOJI, userId: USER }]);
214
+
215
+ const res = await toggleCommentReactionFn({ data: { commentId: COMMENT_ID, emoji: EMOJI } });
216
+
217
+ expect(M.commentReaction.create).toHaveBeenCalledWith({
218
+ data: { commentId: COMMENT_ID, userId: USER, emoji: EMOJI },
219
+ });
220
+ expect(res).toEqual([{ emoji: EMOJI, count: 1, userIds: [USER], reacted: true }]);
221
+ });
222
+
223
+ it("未登录拒绝且不查询评论点赞", async () => {
224
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
225
+ await expect(
226
+ toggleCommentReactionFn({ data: { commentId: COMMENT_ID, emoji: EMOJI } }),
227
+ ).rejects.toThrow("UNAUTHORIZED");
228
+ for (const q of commentReactionQueries) expect(q).not.toHaveBeenCalled();
229
+ });
230
+ });
@@ -0,0 +1,317 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ // story-tree 域覆盖补全:5 个 serverFn(getStoryTreeFn/addChildFn/moveChildFn/
20
+ // removeChildFn/getChildrenCountFn)。关键断言:循环依赖拒绝且 $transaction 零回滚写入;
21
+ // 子节点排序与父级断链;活动记录写入形状锁死。mock 骨架同 workspace-kb-idor.test.ts 样板。
22
+ import { beforeEach, describe, expect, it, vi } from "vitest";
23
+
24
+ vi.mock("@tanstack/react-start", () => ({
25
+ createServerFn: (_config: unknown) => {
26
+ const api: any = {
27
+ _validator: undefined,
28
+ validator(schema: any) {
29
+ this._validator = schema;
30
+ return this;
31
+ },
32
+ handler(fn: Function) {
33
+ return async (args: any) => {
34
+ let data = args?.data;
35
+ if (this._validator && data !== undefined) {
36
+ data = this._validator.parse(data);
37
+ }
38
+ return fn({ data });
39
+ };
40
+ },
41
+ };
42
+ return api;
43
+ },
44
+ }));
45
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
46
+
47
+ const M = vi.hoisted(() => {
48
+ const f = () => vi.fn();
49
+ return {
50
+ requireAuth: f(),
51
+ story: { findUnique: f(), findMany: f(), update: f(), count: f() },
52
+ storyActivity: { create: f() },
53
+ $transaction: f(),
54
+ };
55
+ });
56
+
57
+ vi.mock("@allbluecn/database", () => ({
58
+ prisma: {
59
+ story: M.story,
60
+ storyActivity: M.storyActivity,
61
+ $transaction: M.$transaction,
62
+ },
63
+ Prisma: {},
64
+ }));
65
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
66
+
67
+ import {
68
+ addChildFn,
69
+ getChildrenCountFn,
70
+ getStoryTreeFn,
71
+ moveChildFn,
72
+ removeChildFn,
73
+ } from "../story/story-tree";
74
+
75
+ const USER = "u-current";
76
+ const PARENT = "st-parent";
77
+ const CHILD = "st-child";
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks();
81
+ M.requireAuth.mockResolvedValue({ user: { id: USER } });
82
+ M.story.findMany.mockResolvedValue([]);
83
+ M.story.count.mockResolvedValue(0);
84
+ M.$transaction.mockImplementation(async (arg: unknown) =>
85
+ Array.isArray(arg) ? Promise.all(arg) : Promise.resolve(undefined),
86
+ );
87
+ });
88
+
89
+ describe("getStoryTreeFn", () => {
90
+ const node = (id: string, parentId: string | null, children: unknown[] = []) => ({
91
+ id,
92
+ name: `需求-${id}`,
93
+ state: "backlog",
94
+ parentId,
95
+ children,
96
+ });
97
+
98
+ it("未传 rootId 时以全部顶层需求为根构建多层树", async () => {
99
+ M.story.findMany.mockResolvedValue([
100
+ node(PARENT, null, [node("st-2", PARENT, [node("st-4", "st-2")]), node("st-3", PARENT)]),
101
+ ]);
102
+ M.story.count.mockImplementation(({ where }: { where: { parentId: string } }) => {
103
+ const n = where.parentId === PARENT ? 2 : where.parentId === "st-2" ? 1 : 0;
104
+ return Promise.resolve(n);
105
+ });
106
+
107
+ const res = await getStoryTreeFn({ data: {} });
108
+
109
+ expect(M.story.findMany).toHaveBeenCalledWith({
110
+ where: { parentId: null },
111
+ include: expect.any(Object),
112
+ });
113
+ expect(res).toHaveLength(1);
114
+ const root = res[0];
115
+ expect(root.id).toBe(PARENT);
116
+ expect(root.title).toBe("需求-st-parent");
117
+ expect(root.depth).toBe(0);
118
+ expect(root.hasChildren).toBe(true);
119
+ expect(root.childrenCount).toBe(2);
120
+ expect(root.children).toBeDefined();
121
+ const children = root.children ?? [];
122
+ expect(children).toHaveLength(2);
123
+ expect(children[0]).toMatchObject({ id: "st-2", depth: 1, childrenCount: 1, hasChildren: true });
124
+ expect(children[0]?.children).toHaveLength(1);
125
+ expect(children[0]?.children?.[0]).toMatchObject({ id: "st-4", depth: 2, childrenCount: 0, hasChildren: false });
126
+ expect(children[1]).toMatchObject({ id: "st-3", depth: 1, childrenCount: 0, hasChildren: false });
127
+ });
128
+
129
+ it("传入 rootId 时仅以该需求为根", async () => {
130
+ M.story.findMany.mockResolvedValue([node("st-9", null)]);
131
+ const res = await getStoryTreeFn({ data: { rootId: "st-9" } });
132
+ expect(M.story.findMany).toHaveBeenCalledWith({
133
+ where: { id: "st-9" },
134
+ include: expect.any(Object),
135
+ });
136
+ expect(res).toEqual([
137
+ expect.objectContaining({ id: "st-9", parentId: null, depth: 0, childrenCount: 0 }),
138
+ ]);
139
+ });
140
+
141
+ it("无根节点时返回空数组", async () => {
142
+ await expect(getStoryTreeFn({ data: {} })).resolves.toEqual([]);
143
+ });
144
+
145
+ it("未登录拒绝且不查询需求", async () => {
146
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
147
+ await expect(getStoryTreeFn({ data: {} })).rejects.toThrow("UNAUTHORIZED");
148
+ expect(M.story.findMany).not.toHaveBeenCalled();
149
+ expect(M.story.count).not.toHaveBeenCalled();
150
+ });
151
+ });
152
+
153
+ describe("addChildFn", () => {
154
+ const standaloneChild = {
155
+ id: CHILD,
156
+ parentId: null,
157
+ identifier: "STORY-2",
158
+ name: "子需求",
159
+ };
160
+
161
+ it("子需求已有父需求时被拒,且不更新不写活动", async () => {
162
+ M.story.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
163
+ where.id === CHILD
164
+ ? Promise.resolve({ ...standaloneChild, parentId: PARENT })
165
+ : Promise.resolve(null),
166
+ );
167
+
168
+ await expect(
169
+ addChildFn({ data: { parentId: PARENT, childId: CHILD } }),
170
+ ).rejects.toThrow("此需求已经是某个需求的子需求");
171
+ expect(M.story.update).not.toHaveBeenCalled();
172
+ expect(M.$transaction).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("目标父需求是该需求的后代时拒绝(防循环依赖)", async () => {
176
+ M.story.findUnique.mockImplementation(({ where }: { where: { id: string } }) => {
177
+ if (where.id === CHILD) return Promise.resolve(standaloneChild);
178
+ // 从 parentId 向上走一步即回到 CHILD → 判定为后代
179
+ return Promise.resolve({ id: PARENT, parentId: CHILD });
180
+ });
181
+
182
+ await expect(
183
+ addChildFn({ data: { parentId: PARENT, childId: CHILD } }),
184
+ ).rejects.toThrow("循环依赖");
185
+ expect(M.story.update).not.toHaveBeenCalled();
186
+ expect(M.$transaction).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it("父需求存在时更新 parentId 并在事务内写入两条活动记录", async () => {
190
+ M.story.findUnique.mockImplementation(({ where }: { where: { id: string } }) => {
191
+ if (where.id === CHILD) return Promise.resolve(standaloneChild);
192
+ return Promise.resolve({ id: PARENT, identifier: "STORY-1", name: "父需求", parentId: null });
193
+ });
194
+ M.storyActivity.create.mockResolvedValue({});
195
+ const updated = { id: CHILD, parentId: PARENT };
196
+ M.story.update.mockResolvedValue(updated);
197
+ const res = await addChildFn({ data: { parentId: PARENT, childId: CHILD } });
198
+
199
+ expect(M.story.update).toHaveBeenCalledWith({
200
+ where: { id: CHILD },
201
+ data: { parentId: PARENT },
202
+ });
203
+ expect(M.$transaction).toHaveBeenCalledWith([expect.any(Promise), expect.any(Promise)]);
204
+ expect(M.storyActivity.create).toHaveBeenCalledTimes(2);
205
+ expect(M.storyActivity.create.mock.calls[0][0].data).toMatchObject({
206
+ storyId: PARENT,
207
+ userId: USER,
208
+ action: "child_added",
209
+ newValue: "STORY-2 子需求",
210
+ });
211
+ expect(M.storyActivity.create.mock.calls[1][0].data).toMatchObject({
212
+ storyId: CHILD,
213
+ action: "parent_changed",
214
+ field: "parentId",
215
+ oldValue: "",
216
+ newValue: "STORY-1 父需求",
217
+ });
218
+ expect(res).toBe(updated);
219
+ });
220
+
221
+ it("父需求不存在时仍更新父子关系但跳过活动记录", async () => {
222
+ M.story.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
223
+ where.id === CHILD ? Promise.resolve(standaloneChild) : Promise.resolve(null),
224
+ );
225
+ const updated = { id: CHILD, parentId: PARENT };
226
+ M.story.update.mockResolvedValue(updated);
227
+
228
+ const res = await addChildFn({ data: { parentId: PARENT, childId: CHILD } });
229
+
230
+ expect(M.story.update).toHaveBeenCalledWith({
231
+ where: { id: CHILD },
232
+ data: { parentId: PARENT },
233
+ });
234
+ expect(M.$transaction).not.toHaveBeenCalled();
235
+ expect(M.storyActivity.create).not.toHaveBeenCalled();
236
+ expect(res).toBe(updated);
237
+ });
238
+
239
+ it("未登录拒绝且不更新父子关系", async () => {
240
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
241
+ await expect(addChildFn({ data: { parentId: PARENT, childId: CHILD } })).rejects.toThrow("UNAUTHORIZED");
242
+ expect(M.story.update).not.toHaveBeenCalled();
243
+ });
244
+ });
245
+
246
+ describe("removeChildFn", () => {
247
+ it("清空 parentId 使需求回到顶层", async () => {
248
+ const updated = { id: CHILD, parentId: null };
249
+ M.story.update.mockResolvedValue(updated);
250
+
251
+ const res = await removeChildFn({ data: { childId: CHILD } });
252
+
253
+ expect(M.story.update).toHaveBeenCalledWith({ where: { id: CHILD }, data: { parentId: null } });
254
+ expect(res).toBe(updated);
255
+ });
256
+
257
+ it("未登录拒绝且不更新", async () => {
258
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
259
+ await expect(removeChildFn({ data: { childId: CHILD } })).rejects.toThrow("UNAUTHORIZED");
260
+ expect(M.story.update).not.toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ describe("moveChildFn", () => {
265
+ it("新父需求是该需求的后代时拒绝(防循环依赖)", async () => {
266
+ M.story.findUnique.mockResolvedValue({ id: "new-parent", parentId: CHILD });
267
+
268
+ await expect(
269
+ moveChildFn({ data: { childId: CHILD, newParentId: "new-parent" } }),
270
+ ).rejects.toThrow("循环依赖");
271
+ expect(M.story.update).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("移动通过时更新为新父需求", async () => {
275
+ M.story.findUnique.mockResolvedValue({ id: "new-parent", parentId: null });
276
+ const updated = { id: CHILD, parentId: "new-parent" };
277
+ M.story.update.mockResolvedValue(updated);
278
+
279
+ const res = await moveChildFn({ data: { childId: CHILD, newParentId: "new-parent" } });
280
+
281
+ expect(M.story.update).toHaveBeenCalledWith({
282
+ where: { id: CHILD },
283
+ data: { parentId: "new-parent" },
284
+ });
285
+ expect(res).toBe(updated);
286
+ });
287
+
288
+ it("未登录拒绝且不更新", async () => {
289
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
290
+ await expect(
291
+ moveChildFn({ data: { childId: CHILD, newParentId: "new-parent" } }),
292
+ ).rejects.toThrow("UNAUTHORIZED");
293
+ expect(M.story.update).not.toHaveBeenCalled();
294
+ });
295
+ });
296
+
297
+ describe("getChildrenCountFn", () => {
298
+ it("返回该父需求下的子需求数量", async () => {
299
+ M.story.count.mockResolvedValue(3);
300
+
301
+ const res = await getChildrenCountFn({ data: { parentId: PARENT } });
302
+
303
+ expect(M.story.count).toHaveBeenCalledWith({ where: { parentId: PARENT } });
304
+ expect(res).toEqual({ count: 3 });
305
+ });
306
+
307
+ it("无子需求时返回 0", async () => {
308
+ M.story.count.mockResolvedValue(0);
309
+ await expect(getChildrenCountFn({ data: { parentId: PARENT } })).resolves.toEqual({ count: 0 });
310
+ });
311
+
312
+ it("未登录拒绝且不查询计数", async () => {
313
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
314
+ await expect(getChildrenCountFn({ data: { parentId: PARENT } })).rejects.toThrow("UNAUTHORIZED");
315
+ expect(M.story.count).not.toHaveBeenCalled();
316
+ });
317
+ });