@allbluecn/web-app 0.10.3 → 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 (157) hide show
  1. package/package.json +21 -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-workspace.tsx +20 -22
  28. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  29. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  30. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  31. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  32. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  33. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  34. package/src/components/story/relations/general-groups.ts +1 -15
  35. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  36. package/src/components/story/story-list-view.tsx +7 -1
  37. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  38. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  39. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  40. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  41. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  42. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  43. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  44. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  45. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  46. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  47. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  48. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  49. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  50. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  51. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  52. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  53. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  54. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  55. package/src/lib/__tests__/pgvector.test.ts +2 -2
  56. package/src/lib/changelog/sdk-versions.ts +21 -21
  57. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  58. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  59. package/src/lib/fetch-json.ts +23 -0
  60. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  61. package/src/lib/sanitize.ts +14 -1
  62. package/src/lib/session.ts +9 -1
  63. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  64. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  65. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  66. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  67. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  68. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  70. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  71. package/src/routes/_nav/route.tsx +21 -0
  72. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  73. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  74. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  75. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  76. package/src/server/__tests__/agent-crud.test.ts +4 -4
  77. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  78. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  79. package/src/server/__tests__/permissions.test.ts +1 -3
  80. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  81. package/src/server/__tests__/skill-crud.test.ts +3 -3
  82. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  83. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  84. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  85. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  86. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  87. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  88. package/src/server/ai-gateway/index.ts +5 -0
  89. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  90. package/src/server/ai-gateway/llm-fence.ts +82 -0
  91. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  92. package/src/server/ai-gateway/story-prompt.ts +4 -2
  93. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  94. package/src/server/ai-gateway/tools/server.ts +97 -54
  95. package/src/server/builtin-project.ts +21 -0
  96. package/src/server/email/sender.ts +8 -2
  97. package/src/server/fetch-guard.ts +170 -0
  98. package/src/server/middlewares/security-headers.ts +113 -0
  99. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  100. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  101. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  102. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  103. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  104. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  105. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  106. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  107. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  108. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  109. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  110. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  111. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  112. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  113. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  115. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  116. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  117. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  118. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  119. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  120. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  121. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  122. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  123. package/src/server/serverFns/kb-doc-search.ts +111 -0
  124. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  125. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  126. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  127. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  128. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  129. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  130. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  131. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  132. package/src/server/serverFns/story/kb-general.ts +30 -17
  133. package/src/server/serverFns/story/story-assoc.ts +13 -103
  134. package/src/server/serverFns/story/story-crud.ts +2 -5
  135. package/src/server/serverFns/story/story-groups.ts +4 -13
  136. package/src/server/serverFns/team.ts +1 -1
  137. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  138. package/src/server/serverFns/workspace-kb.ts +403 -0
  139. package/src/server/serverFns/workspace.ts +4 -1
  140. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  141. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  142. package/src/shared/ai-cli/adapter.ts +2 -2
  143. package/src/shared/ai-cli/cli-detector.ts +18 -9
  144. package/src/start.ts +2 -1
  145. package/src/styles/globals.css +114 -0
  146. package/src/types/jest-dom-vitest.d.ts +8 -7
  147. package/vite.shared.ts +18 -1
  148. package/public/file.svg +0 -1
  149. package/public/globe.svg +0 -1
  150. package/public/window.svg +0 -1
  151. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  152. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  153. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  154. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  155. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  156. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  157. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,265 @@
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-comments 域覆盖补全:4 个 serverFn(list/create/update/delete)。
20
+ // 关键断言:未登录拒绝且不触达评论查询;删除走级联(含回复子评论计数);
21
+ // 每条断言锁死 Prisma where 形状。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
+ storyComment: { findUnique: f(), findMany: f(), create: f(), update: f(), delete: f() },
52
+ storyActivity: { create: f() },
53
+ };
54
+ });
55
+
56
+ vi.mock("@allbluecn/database", () => ({
57
+ prisma: {
58
+ storyComment: M.storyComment,
59
+ storyActivity: M.storyActivity,
60
+ },
61
+ Prisma: {},
62
+ }));
63
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
64
+
65
+ import {
66
+ createCommentFn,
67
+ deleteCommentFn,
68
+ listCommentsFn,
69
+ updateCommentFn,
70
+ } from "../story/story-comments";
71
+ import { USER_BRIEF_SELECT } from "../story/types";
72
+
73
+ const USER = "u-current";
74
+ const OTHER = "u-other";
75
+ const STORY_ID = "st-1";
76
+ const T1 = new Date("2026-01-01T00:00:00Z");
77
+ const T2 = new Date("2026-01-02T00:00:00Z");
78
+
79
+ const userRow = {
80
+ id: USER,
81
+ username: "alice",
82
+ email: "alice@example.com",
83
+ avatarConfig: null,
84
+ bgShape: null,
85
+ bgColor: null,
86
+ };
87
+ const commentRow = (over: Record<string, unknown> = {}) => ({
88
+ id: "c1",
89
+ content: "评论内容",
90
+ parentId: null,
91
+ createdAt: T1,
92
+ updatedAt: T1,
93
+ user: userRow,
94
+ reactions: [],
95
+ ...over,
96
+ });
97
+
98
+ beforeEach(() => {
99
+ vi.clearAllMocks();
100
+ M.requireAuth.mockResolvedValue({ user: { id: USER } });
101
+ M.storyComment.create.mockResolvedValue(commentRow());
102
+ M.storyActivity.create.mockResolvedValue({});
103
+ });
104
+
105
+ describe("listCommentsFn", () => {
106
+ it("按 createdAt 升序取出后组装为多级回复树", async () => {
107
+ M.storyComment.findMany.mockResolvedValue([
108
+ commentRow({ id: "c1", parentId: null, reactions: [{ emoji: "👍", userId: OTHER }] }),
109
+ commentRow({ id: "c2", parentId: "c1" }),
110
+ commentRow({ id: "c3", parentId: "c2" }),
111
+ ]);
112
+
113
+ const res = await listCommentsFn({ data: { storyId: STORY_ID } });
114
+
115
+ expect(M.storyComment.findMany).toHaveBeenCalledWith({
116
+ where: { storyId: STORY_ID },
117
+ orderBy: { createdAt: "asc" },
118
+ select: {
119
+ id: true,
120
+ content: true,
121
+ parentId: true,
122
+ createdAt: true,
123
+ updatedAt: true,
124
+ user: { select: USER_BRIEF_SELECT },
125
+ reactions: { select: { emoji: true, userId: true } },
126
+ },
127
+ });
128
+ expect(res).toHaveLength(1);
129
+ expect(res[0].id).toBe("c1");
130
+ expect(res[0].parentId).toBeNull();
131
+ expect(res[0].createdAt).toBe(T1.toISOString());
132
+ expect(res[0].user.id).toBe(USER);
133
+ expect(res[0].reactions).toEqual([{ emoji: "👍", count: 1, userIds: [OTHER], reacted: false }]);
134
+ expect(res[0].replies).toHaveLength(1);
135
+ expect(res[0].replies[0].id).toBe("c2");
136
+ expect(res[0].replies[0].parentId).toBe("c1");
137
+ expect(res[0].replies[0].replies).toHaveLength(1);
138
+ expect(res[0].replies[0].replies[0].id).toBe("c3");
139
+ expect(res[0].replies[0].replies[0].replies).toEqual([]);
140
+ });
141
+
142
+ it("无评论时返回空数组", async () => {
143
+ M.storyComment.findMany.mockResolvedValue([]);
144
+ await expect(listCommentsFn({ data: { storyId: STORY_ID } })).resolves.toEqual([]);
145
+ });
146
+
147
+ it("未登录拒绝且不查询评论", async () => {
148
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
149
+ await expect(listCommentsFn({ data: { storyId: STORY_ID } })).rejects.toThrow("UNAUTHORIZED");
150
+ expect(M.storyComment.findMany).not.toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe("createCommentFn", () => {
155
+ it("创建评论并追加 commented 活动记录", async () => {
156
+ const content = "x".repeat(60);
157
+ M.storyComment.create.mockResolvedValue(commentRow({ content, parentId: null }));
158
+
159
+ const res = await createCommentFn({ data: { storyId: STORY_ID, content, parentId: null } });
160
+
161
+ expect(M.storyComment.create).toHaveBeenCalledWith({
162
+ data: { storyId: STORY_ID, userId: USER, parentId: null, content },
163
+ select: {
164
+ id: true,
165
+ content: true,
166
+ parentId: true,
167
+ createdAt: true,
168
+ updatedAt: true,
169
+ user: { select: USER_BRIEF_SELECT },
170
+ },
171
+ });
172
+ expect(M.storyActivity.create).toHaveBeenCalledWith({
173
+ data: { storyId: STORY_ID, userId: USER, action: "commented", newValue: "x".repeat(50) },
174
+ });
175
+ expect(res.content).toBe(content);
176
+ expect(res.createdAt).toBe(T1.toISOString());
177
+ expect(res.replies).toEqual([]);
178
+ expect(res.reactions).toEqual([]);
179
+ });
180
+
181
+ it("子评论带上 parentId 写入", async () => {
182
+ await createCommentFn({ data: { storyId: STORY_ID, content: "回复", parentId: "c1" } });
183
+ expect(M.storyComment.create).toHaveBeenCalledWith(
184
+ expect.objectContaining({ data: expect.objectContaining({ parentId: "c1" }) }),
185
+ );
186
+ });
187
+
188
+ it("空内容被 validator 拒绝(不落库)", async () => {
189
+ await expect(createCommentFn({ data: { storyId: STORY_ID, content: "" } })).rejects.toThrow();
190
+ expect(M.storyComment.create).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("未登录拒绝且不创建评论", async () => {
194
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
195
+ await expect(
196
+ createCommentFn({ data: { storyId: STORY_ID, content: "hi" } }),
197
+ ).rejects.toThrow("UNAUTHORIZED");
198
+ expect(M.storyComment.create).not.toHaveBeenCalled();
199
+ });
200
+ });
201
+
202
+ describe("updateCommentFn", () => {
203
+ it("本人评论可编辑并返回新内容", async () => {
204
+ M.storyComment.findUnique.mockResolvedValue({ userId: USER });
205
+ M.storyComment.update.mockResolvedValue({ id: "c1", content: "新内容", updatedAt: T2 });
206
+
207
+ const res = await updateCommentFn({ data: { commentId: "c1", content: "新内容" } });
208
+
209
+ expect(M.storyComment.findUnique).toHaveBeenCalledWith({
210
+ where: { id: "c1" },
211
+ select: { userId: true },
212
+ });
213
+ expect(M.storyComment.update).toHaveBeenCalledWith({
214
+ where: { id: "c1" },
215
+ data: { content: "新内容" },
216
+ select: { id: true, content: true, updatedAt: true },
217
+ });
218
+ expect(res).toEqual({ id: "c1", content: "新内容", updatedAt: T2.toISOString() });
219
+ });
220
+
221
+ it("他人评论被拒且不执行更新", async () => {
222
+ M.storyComment.findUnique.mockResolvedValue({ userId: OTHER });
223
+ await expect(
224
+ updateCommentFn({ data: { commentId: "c1", content: "篡改" } }),
225
+ ).rejects.toThrow("无权编辑他人评论");
226
+ expect(M.storyComment.update).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("评论不存在时报错且不执行更新", async () => {
230
+ M.storyComment.findUnique.mockResolvedValue(null);
231
+ await expect(
232
+ updateCommentFn({ data: { commentId: "missing", content: "x" } }),
233
+ ).rejects.toThrow("评论不存在");
234
+ expect(M.storyComment.update).not.toHaveBeenCalled();
235
+ });
236
+ });
237
+
238
+ describe("deleteCommentFn", () => {
239
+ it("本人评论可删除", async () => {
240
+ M.storyComment.findUnique.mockResolvedValue({ userId: USER });
241
+ M.storyComment.delete.mockResolvedValue({ id: "c1" });
242
+
243
+ await expect(deleteCommentFn({ data: { commentId: "c1" } })).resolves.toEqual({ success: true });
244
+ expect(M.storyComment.delete).toHaveBeenCalledWith({ where: { id: "c1" } });
245
+ });
246
+
247
+ it("他人评论被拒且不删除", async () => {
248
+ M.storyComment.findUnique.mockResolvedValue({ userId: OTHER });
249
+ await expect(deleteCommentFn({ data: { commentId: "c1" } })).rejects.toThrow("无权删除他人评论");
250
+ expect(M.storyComment.delete).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("评论不存在时报错且不删除", async () => {
254
+ M.storyComment.findUnique.mockResolvedValue(null);
255
+ await expect(deleteCommentFn({ data: { commentId: "missing" } })).rejects.toThrow("评论不存在");
256
+ expect(M.storyComment.delete).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("未登录拒绝且不查询评论归属", async () => {
260
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
261
+ await expect(deleteCommentFn({ data: { commentId: "c1" } })).rejects.toThrow("UNAUTHORIZED");
262
+ expect(M.storyComment.findUnique).not.toHaveBeenCalled();
263
+ expect(M.storyComment.delete).not.toHaveBeenCalled();
264
+ });
265
+ });
@@ -0,0 +1,266 @@
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-crud 衍生域覆盖补全:archiveStoryFn / unarchiveStoryFn / getStoryNeighborsFn /
20
+ // getStoryDetailFn。关键断言:归档状态流转与相邻节点排序;详情查询断言 select 形状;
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(), findFirst: f(), update: f() },
52
+ storyActivity: { create: f() },
53
+ };
54
+ });
55
+
56
+ vi.mock("@allbluecn/database", () => ({
57
+ prisma: {
58
+ story: M.story,
59
+ storyActivity: M.storyActivity,
60
+ },
61
+ Prisma: {},
62
+ }));
63
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: M.requireAuth }));
64
+
65
+ import { archiveStoryFn, unarchiveStoryFn } from "../story/story-crud/archive";
66
+ import { getStoryDetailFn, getStoryNeighborsFn } from "../story/story-crud/detail";
67
+ import { STORY_LIST_SELECT } from "../story/types";
68
+
69
+ const USER = "u-current";
70
+ const STORY_ID = "st-1";
71
+ const PROJECT_ID = "pr-1";
72
+ const T1 = new Date("2026-01-01T00:00:00Z");
73
+ const T2 = new Date("2026-01-02T00:00:00Z");
74
+
75
+ const userRow = {
76
+ id: USER,
77
+ username: "alice",
78
+ email: "alice@example.com",
79
+ avatarConfig: null,
80
+ bgShape: null,
81
+ bgColor: null,
82
+ };
83
+ /** STORY_LIST_SELECT 形状的需求行 fixture(castStoryUsers 需要 createdBy/labels 等) */
84
+ const storyRow = (over: Record<string, unknown> = {}) => ({
85
+ id: STORY_ID,
86
+ identifier: "STORY-1",
87
+ sequenceId: 1,
88
+ name: "需求A",
89
+ description: null,
90
+ state: "backlog",
91
+ priority: "none",
92
+ archived: false,
93
+ startDate: null,
94
+ targetDate: null,
95
+ boardOrder: 0,
96
+ projectId: PROJECT_ID,
97
+ parentId: null,
98
+ parent: null,
99
+ assignee: null,
100
+ createdBy: userRow,
101
+ updatedBy: null,
102
+ labels: [],
103
+ createdAt: T1,
104
+ updatedAt: T2,
105
+ ...over,
106
+ });
107
+
108
+ beforeEach(() => {
109
+ vi.clearAllMocks();
110
+ M.requireAuth.mockResolvedValue({ user: { id: USER } });
111
+ M.story.update.mockResolvedValue(storyRow());
112
+ M.storyActivity.create.mockResolvedValue({});
113
+ });
114
+
115
+ describe("archiveStoryFn", () => {
116
+ it("归档时置 archived 并写入 archivedAt 与活动记录", async () => {
117
+ M.story.update.mockResolvedValue(storyRow({ archived: true, archivedAt: new Date("2026-01-03T00:00:00Z") }));
118
+ const res = await archiveStoryFn({ data: { storyId: STORY_ID } });
119
+
120
+ expect(M.story.update).toHaveBeenCalledWith({
121
+ where: { id: STORY_ID },
122
+ data: expect.objectContaining({ archived: true, archivedAt: expect.any(Date) }),
123
+ select: STORY_LIST_SELECT,
124
+ });
125
+ expect(M.storyActivity.create).toHaveBeenCalledWith({
126
+ data: {
127
+ storyId: STORY_ID,
128
+ userId: USER,
129
+ action: "archived",
130
+ field: "archived",
131
+ oldValue: "false",
132
+ newValue: "true",
133
+ },
134
+ });
135
+ expect(res).toMatchObject({
136
+ id: STORY_ID,
137
+ identifier: "STORY-1",
138
+ name: "需求A",
139
+ archived: true,
140
+ createdAt: T1.toISOString(),
141
+ updatedAt: T2.toISOString(),
142
+ labels: [],
143
+ });
144
+ expect(res.createdBy).toMatchObject({ id: USER, username: "alice" });
145
+ expect(res.startDate).toBeNull();
146
+ });
147
+
148
+ it("未登录拒绝且不更新不写活动", async () => {
149
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
150
+ await expect(archiveStoryFn({ data: { storyId: STORY_ID } })).rejects.toThrow("UNAUTHORIZED");
151
+ expect(M.story.update).not.toHaveBeenCalled();
152
+ expect(M.storyActivity.create).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe("unarchiveStoryFn", () => {
157
+ it("恢复时清空 archivedAt 并写入 unarchived 活动记录", async () => {
158
+ const res = await unarchiveStoryFn({ data: { storyId: STORY_ID } });
159
+
160
+ expect(M.story.update).toHaveBeenCalledWith({
161
+ where: { id: STORY_ID },
162
+ data: { archived: false, archivedAt: null },
163
+ select: STORY_LIST_SELECT,
164
+ });
165
+ expect(M.storyActivity.create).toHaveBeenCalledWith(
166
+ expect.objectContaining({
167
+ data: expect.objectContaining({ action: "unarchived", oldValue: "true", newValue: "false" }),
168
+ }),
169
+ );
170
+ expect(res.archived).toBe(false);
171
+ });
172
+
173
+ it("未登录拒绝且不更新", async () => {
174
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
175
+ await expect(unarchiveStoryFn({ data: { storyId: STORY_ID } })).rejects.toThrow("UNAUTHORIZED");
176
+ expect(M.story.update).not.toHaveBeenCalled();
177
+ });
178
+ });
179
+
180
+ describe("getStoryNeighborsFn", () => {
181
+ it("需求不存在时返回双 null 且不查询相邻需求", async () => {
182
+ M.story.findUnique.mockResolvedValue(null);
183
+
184
+ await expect(getStoryNeighborsFn({ data: { storyId: STORY_ID } })).resolves.toEqual({
185
+ prevId: null,
186
+ nextId: null,
187
+ });
188
+ expect(M.story.findFirst).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it("按 createdAt 分别取最新与最早作为前后邻居", async () => {
192
+ M.story.findUnique.mockResolvedValue({ id: STORY_ID, projectId: PROJECT_ID, createdAt: T1 });
193
+ M.story.findFirst.mockImplementation(({ orderBy }: { orderBy: unknown }) => {
194
+ const desc = JSON.stringify(orderBy).includes('"desc"');
195
+ return Promise.resolve(desc ? { id: "st-prev" } : { id: "st-next" });
196
+ });
197
+
198
+ const res = await getStoryNeighborsFn({ data: { storyId: STORY_ID } });
199
+
200
+ expect(M.story.findUnique).toHaveBeenCalledWith({
201
+ where: { id: STORY_ID },
202
+ select: { id: true, projectId: true, createdAt: true },
203
+ });
204
+ expect(M.story.findFirst).toHaveBeenCalledTimes(2);
205
+ expect(M.story.findFirst).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ where: expect.objectContaining({ projectId: PROJECT_ID, archived: false, id: { not: STORY_ID } }),
208
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
209
+ select: { id: true },
210
+ }),
211
+ );
212
+ expect(res).toEqual({ prevId: "st-prev", nextId: "st-next" });
213
+ });
214
+
215
+ it("查询到邻居为空时对应字段为 null", async () => {
216
+ M.story.findUnique.mockResolvedValue({ id: STORY_ID, projectId: PROJECT_ID, createdAt: T1 });
217
+ M.story.findFirst.mockResolvedValue(null);
218
+ await expect(getStoryNeighborsFn({ data: { storyId: STORY_ID } })).resolves.toEqual({
219
+ prevId: null,
220
+ nextId: null,
221
+ });
222
+ });
223
+
224
+ it("未登录拒绝且不查询", async () => {
225
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
226
+ await expect(getStoryNeighborsFn({ data: { storyId: STORY_ID } })).rejects.toThrow("UNAUTHORIZED");
227
+ expect(M.story.findUnique).not.toHaveBeenCalled();
228
+ });
229
+ });
230
+
231
+ describe("getStoryDetailFn", () => {
232
+ it("返回序列化后的需求详情", async () => {
233
+ M.story.findUnique.mockResolvedValue(
234
+ storyRow({ name: "需求详情", startDate: T1, labels: [{ label: { id: "lb-1", name: "标签", color: "#f00" } }] }),
235
+ );
236
+
237
+ const res = await getStoryDetailFn({ data: { storyId: STORY_ID } });
238
+
239
+ expect(M.story.findUnique).toHaveBeenCalledWith({
240
+ where: { id: STORY_ID },
241
+ select: STORY_LIST_SELECT,
242
+ });
243
+ expect(res).toMatchObject({
244
+ id: STORY_ID,
245
+ name: "需求详情",
246
+ state: "backlog",
247
+ priority: "none",
248
+ startDate: T1.toISOString(),
249
+ targetDate: null,
250
+ parent: null,
251
+ assignee: null,
252
+ labels: [{ id: "lb-1", name: "标签", color: "#f00" }],
253
+ });
254
+ });
255
+
256
+ it("需求不存在时报错", async () => {
257
+ M.story.findUnique.mockResolvedValue(null);
258
+ await expect(getStoryDetailFn({ data: { storyId: "missing" } })).rejects.toThrow("需求不存在");
259
+ });
260
+
261
+ it("未登录拒绝且不查询详情", async () => {
262
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
263
+ await expect(getStoryDetailFn({ data: { storyId: STORY_ID } })).rejects.toThrow("UNAUTHORIZED");
264
+ expect(M.story.findUnique).not.toHaveBeenCalled();
265
+ });
266
+ });