@allbluecn/web-app 0.4.22 → 0.5.1
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.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +14 -3
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -573,3 +573,46 @@ export const ensureBuiltinProjectFn = createServerFn({ method: "GET" }).handler(
|
|
|
573
573
|
const projectId = await ensureBuiltinIndependentProject(userId);
|
|
574
574
|
return { id: projectId, name: BUILTIN_INDEPENDENT_NAME, displayId: BUILTIN_INDEPENDENT_DISPLAY_ID, icon: BUILTIN_INDEPENDENT_ICON };
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
export const listProjectKbsFn = createServerFn({ method: "GET" })
|
|
578
|
+
.validator(z.object({ projectId: z.string() }))
|
|
579
|
+
.handler(async ({ data }) => {
|
|
580
|
+
const request = getRequest();
|
|
581
|
+
await requireAuth(request);
|
|
582
|
+
const rows = await prisma.projectKnowledgeBase.findMany({
|
|
583
|
+
where: { projectId: data.projectId },
|
|
584
|
+
select: {
|
|
585
|
+
kb: { select: { id: true, name: true, icon: true, visibility: true } },
|
|
586
|
+
},
|
|
587
|
+
orderBy: { createdAt: "asc" },
|
|
588
|
+
});
|
|
589
|
+
return rows.map((r) => r.kb);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
export const setProjectKbsFn = createServerFn({ method: "POST" })
|
|
593
|
+
.validator(z.object({ projectId: z.string(), kbIds: z.array(z.string()) }))
|
|
594
|
+
.handler(async ({ data }) => {
|
|
595
|
+
const request = getRequest();
|
|
596
|
+
await requireAuth(request);
|
|
597
|
+
if (data.kbIds.length > 0) {
|
|
598
|
+
const valid = await prisma.knowledgeBase.findMany({
|
|
599
|
+
where: { id: { in: data.kbIds }, visibility: "team", isDeleted: false },
|
|
600
|
+
select: { id: true },
|
|
601
|
+
});
|
|
602
|
+
if (valid.length !== data.kbIds.length) {
|
|
603
|
+
throw new Error("仅允许团队可见的知识库关联项目");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await prisma.$transaction(async (tx) => {
|
|
607
|
+
await tx.projectKnowledgeBase.deleteMany({
|
|
608
|
+
where: { projectId: data.projectId, kbId: { notIn: data.kbIds } },
|
|
609
|
+
});
|
|
610
|
+
if (data.kbIds.length > 0) {
|
|
611
|
+
await tx.projectKnowledgeBase.createMany({
|
|
612
|
+
data: data.kbIds.map((kbId) => ({ projectId: data.projectId, kbId })),
|
|
613
|
+
skipDuplicates: true,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
return { ok: true };
|
|
618
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import { isIndependentProject, buildKbScopeWhere } from "../kb-scope";
|
|
20
|
+
|
|
21
|
+
describe("kb-scope 可用范围判定", () => {
|
|
22
|
+
it("独立需求项目(displayId=STORY)识别", () => {
|
|
23
|
+
expect(isIndependentProject("STORY")).toBe(true);
|
|
24
|
+
expect(isIndependentProject("story")).toBe(false);
|
|
25
|
+
expect(isIndependentProject(null)).toBe(false);
|
|
26
|
+
expect(isIndependentProject("PROJ")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("普通项目:general ∪ 项目关联(不含他人 private/team)", () => {
|
|
30
|
+
const where = buildKbScopeWhere({
|
|
31
|
+
projectId: "p1",
|
|
32
|
+
projectDisplayId: "PROJ",
|
|
33
|
+
userId: "u1",
|
|
34
|
+
});
|
|
35
|
+
expect(where).toEqual({
|
|
36
|
+
OR: [
|
|
37
|
+
{ visibility: "general" },
|
|
38
|
+
{ projectLinks: { some: { projectId: "p1" } } },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("独立需求项目:自己的全部 ∪ 他人 team/general", () => {
|
|
44
|
+
const where = buildKbScopeWhere({
|
|
45
|
+
projectId: "p2",
|
|
46
|
+
projectDisplayId: "STORY",
|
|
47
|
+
userId: "u1",
|
|
48
|
+
});
|
|
49
|
+
expect(where).toEqual({
|
|
50
|
+
OR: [
|
|
51
|
+
{ userId: "u1" },
|
|
52
|
+
{ visibility: { in: ["team", "general"] } },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
storyFindUnique: vi.fn(),
|
|
49
|
+
kbFindMany: vi.fn(),
|
|
50
|
+
kbFindFirst: vi.fn(),
|
|
51
|
+
docFindMany: vi.fn(),
|
|
52
|
+
linkFindMany: vi.fn(),
|
|
53
|
+
linkCreateMany: vi.fn(),
|
|
54
|
+
extLinkFindMany: vi.fn(),
|
|
55
|
+
extLinkCreateMany: vi.fn(),
|
|
56
|
+
extLinkDeleteMany: vi.fn(),
|
|
57
|
+
activityCreate: vi.fn(),
|
|
58
|
+
currentSession: vi.fn(),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock("@allbluecn/database", () => {
|
|
62
|
+
const prisma = {
|
|
63
|
+
story: { findUnique: mocks.storyFindUnique },
|
|
64
|
+
knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
|
|
65
|
+
knowledgeDocument: { findMany: mocks.docFindMany },
|
|
66
|
+
storyDocLink: { findMany: mocks.linkFindMany, createMany: mocks.linkCreateMany },
|
|
67
|
+
storyExternalLink: {
|
|
68
|
+
findMany: mocks.extLinkFindMany,
|
|
69
|
+
createMany: mocks.extLinkCreateMany,
|
|
70
|
+
deleteMany: mocks.extLinkDeleteMany,
|
|
71
|
+
},
|
|
72
|
+
storyActivity: { create: mocks.activityCreate },
|
|
73
|
+
};
|
|
74
|
+
return { prisma, Prisma: {} };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
78
|
+
requireAuth: mocks.currentSession,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
listStoryAvailableKbsFn,
|
|
83
|
+
searchKbDocsFn,
|
|
84
|
+
linkDocsFn,
|
|
85
|
+
getLinkedDocsFn,
|
|
86
|
+
} from "../story-assoc";
|
|
87
|
+
|
|
88
|
+
const SESSION = { user: { id: "u1" } };
|
|
89
|
+
const STORY_WITH_PROJECT = {
|
|
90
|
+
project: { id: "p1", displayId: "PROJ" },
|
|
91
|
+
};
|
|
92
|
+
const STORY_INDEPENDENT = {
|
|
93
|
+
project: { id: "p2", displayId: "STORY" },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
mocks.currentSession.mockResolvedValue(SESSION);
|
|
99
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
100
|
+
mocks.extLinkFindMany.mockResolvedValue([]);
|
|
101
|
+
mocks.extLinkCreateMany.mockResolvedValue({ count: 0 });
|
|
102
|
+
mocks.extLinkDeleteMany.mockResolvedValue({ count: 0 });
|
|
103
|
+
mocks.activityCreate.mockResolvedValue({
|
|
104
|
+
id: "act1",
|
|
105
|
+
action: "kb_linked",
|
|
106
|
+
field: null,
|
|
107
|
+
oldValue: null,
|
|
108
|
+
newValue: "1",
|
|
109
|
+
isAI: false,
|
|
110
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
111
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("listStoryAvailableKbsFn", () => {
|
|
116
|
+
it("普通项目:按 scope 查询返回 KB 列表", async () => {
|
|
117
|
+
mocks.kbFindMany.mockResolvedValue([
|
|
118
|
+
{ id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
|
|
119
|
+
{ id: "kb2", name: "通用库", icon: "book", visibility: "general" },
|
|
120
|
+
]);
|
|
121
|
+
const result = await listStoryAvailableKbsFn({ data: { storyId: "s1" } });
|
|
122
|
+
expect(result).toHaveLength(2);
|
|
123
|
+
expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
|
|
124
|
+
const where = mocks.kbFindMany.mock.calls[0][0].where;
|
|
125
|
+
expect(where.AND).toBeDefined(); // AND: [scope, { isDeleted: false }]
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("需求不存在抛错", async () => {
|
|
129
|
+
mocks.storyFindUnique.mockResolvedValue(null);
|
|
130
|
+
await expect(listStoryAvailableKbsFn({ data: { storyId: "nope" } })).rejects.toMatchObject({
|
|
131
|
+
message: expect.stringContaining("需求不存在"),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("searchKbDocsFn 两态", () => {
|
|
137
|
+
it("指定 kbId:全量树查询(take 500,含 parentId/type/kb 元数据)", async () => {
|
|
138
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
139
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kb1" }); // 范围内
|
|
140
|
+
mocks.docFindMany.mockResolvedValue([
|
|
141
|
+
{ id: "d1", title: "页1", parentId: null, kb: { id: "kb1", name: "团队库", icon: "notebook" }, icon: "doc" },
|
|
142
|
+
]);
|
|
143
|
+
const result = await searchKbDocsFn({ data: { storyId: "s1", kbId: "kb1", query: "" } });
|
|
144
|
+
expect(result.items[0]).toMatchObject({ id: "d1", kbName: "团队库" });
|
|
145
|
+
expect(result.nextCursor).toBeNull();
|
|
146
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
147
|
+
expect(args.take).toBe(500);
|
|
148
|
+
expect(args.where.kbId).toBe("kb1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("指定范围外 kbId:抛越界错误", async () => {
|
|
152
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
153
|
+
mocks.kbFindFirst.mockResolvedValue(null); // 不在 scope 内
|
|
154
|
+
await expect(
|
|
155
|
+
searchKbDocsFn({ data: { storyId: "s1", kbId: "kbX", query: "" } }),
|
|
156
|
+
).rejects.toMatchObject({ message: expect.stringContaining("不在可用范围") });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("全部模式:平铺分页(take 21 判 hasMore)", async () => {
|
|
160
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
161
|
+
mocks.docFindMany.mockResolvedValue([]);
|
|
162
|
+
await searchKbDocsFn({ data: { storyId: "s1", query: "关键词" } });
|
|
163
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
164
|
+
expect(args.take).toBe(21);
|
|
165
|
+
expect(args.where.kb).toBeDefined(); // scope 下推到 kb 关系
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("linkDocsFn 批量关联", () => {
|
|
170
|
+
it("合法项批量建链、已存在跳过、越界归 failed", async () => {
|
|
171
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
172
|
+
mocks.docFindMany.mockResolvedValue([
|
|
173
|
+
{ id: "d1", kbId: "kb1" }, // 合法
|
|
174
|
+
{ id: "d2", kbId: "kb9" }, // 越界(kb9 不在 scope)
|
|
175
|
+
]);
|
|
176
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]); // scope 内 KB 集合
|
|
177
|
+
mocks.linkFindMany.mockResolvedValue([{ docId: "d1" }]); // d1 已关联
|
|
178
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 0 });
|
|
179
|
+
|
|
180
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d1", "d2"] } });
|
|
181
|
+
expect(result.linked).toEqual([]);
|
|
182
|
+
expect(result.skipped).toEqual(["d1"]);
|
|
183
|
+
expect(result.failed).toEqual([{ docId: "d2", reason: "outOfScope" }]);
|
|
184
|
+
expect(mocks.linkCreateMany).not.toHaveBeenCalled(); // 无新增
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("新增项走 createMany skipDuplicates", async () => {
|
|
188
|
+
mocks.docFindMany.mockResolvedValue([{ id: "d3", kbId: "kb1" }]);
|
|
189
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]);
|
|
190
|
+
mocks.linkFindMany.mockResolvedValue([]);
|
|
191
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 1 });
|
|
192
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d3"] } });
|
|
193
|
+
expect(result.linked).toEqual(["d3"]);
|
|
194
|
+
expect(mocks.linkCreateMany.mock.calls[0][0].data).toEqual([{ storyId: "s1", docId: "d3" }]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("getLinkedDocsFn 扩展返回 KB 元数据", () => {
|
|
199
|
+
it("返回对齐 DocBrief 的字段", async () => {
|
|
200
|
+
mocks.linkFindMany.mockResolvedValue([
|
|
201
|
+
{
|
|
202
|
+
doc: {
|
|
203
|
+
id: "d1",
|
|
204
|
+
title: "页1",
|
|
205
|
+
kb: { id: "kb1", name: "团队库", icon: "notebook" },
|
|
206
|
+
icon: "doc",
|
|
207
|
+
parentId: null,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
const result = await getLinkedDocsFn({ data: { storyId: "s1" } });
|
|
212
|
+
expect(result[0]).toEqual({
|
|
213
|
+
id: "d1",
|
|
214
|
+
title: "页1",
|
|
215
|
+
kbId: "kb1",
|
|
216
|
+
kbName: "团队库",
|
|
217
|
+
kbIcon: "notebook",
|
|
218
|
+
docIcon: "doc",
|
|
219
|
+
parentId: null,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -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
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
depFindMany: vi.fn(),
|
|
49
|
+
depFindFirst: vi.fn(),
|
|
50
|
+
depCreate: vi.fn(),
|
|
51
|
+
depDelete: vi.fn(),
|
|
52
|
+
storyFindMany: vi.fn(),
|
|
53
|
+
activityCreate: vi.fn(),
|
|
54
|
+
currentSession: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("@allbluecn/database", () => {
|
|
58
|
+
const prisma = {
|
|
59
|
+
storyDependency: {
|
|
60
|
+
findMany: mocks.depFindMany,
|
|
61
|
+
findFirst: mocks.depFindFirst,
|
|
62
|
+
create: mocks.depCreate,
|
|
63
|
+
delete: mocks.depDelete,
|
|
64
|
+
},
|
|
65
|
+
story: { findMany: mocks.storyFindMany },
|
|
66
|
+
storyActivity: { create: mocks.activityCreate },
|
|
67
|
+
};
|
|
68
|
+
return { prisma, Prisma: {} };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
72
|
+
requireAuth: mocks.currentSession,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import {
|
|
76
|
+
addDependenciesFn,
|
|
77
|
+
removeDependencyFn,
|
|
78
|
+
getDependenciesFn,
|
|
79
|
+
} from "../story-dependencies";
|
|
80
|
+
|
|
81
|
+
const STORY_ROW = (id: string) => ({
|
|
82
|
+
id,
|
|
83
|
+
identifier: `PROJ-${id}`,
|
|
84
|
+
sequenceId: 1,
|
|
85
|
+
name: `story ${id}`,
|
|
86
|
+
description: null,
|
|
87
|
+
state: "backlog",
|
|
88
|
+
priority: "medium",
|
|
89
|
+
archived: false,
|
|
90
|
+
startDate: null,
|
|
91
|
+
targetDate: null,
|
|
92
|
+
boardOrder: 0,
|
|
93
|
+
projectId: "p1",
|
|
94
|
+
parentId: null,
|
|
95
|
+
parent: null,
|
|
96
|
+
assignee: null,
|
|
97
|
+
createdBy: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
98
|
+
updatedBy: null,
|
|
99
|
+
labels: [],
|
|
100
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
101
|
+
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.clearAllMocks();
|
|
106
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
107
|
+
// 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
|
|
108
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
109
|
+
// 活动写入段:端点 label 查询 + create 返回(含 select 字段)
|
|
110
|
+
mocks.storyFindMany.mockResolvedValue([
|
|
111
|
+
{ id: "a", identifier: "A-1", name: "story a" },
|
|
112
|
+
{ id: "b", identifier: "B-1", name: "story b" },
|
|
113
|
+
]);
|
|
114
|
+
mocks.activityCreate.mockImplementation(({ data }: any) =>
|
|
115
|
+
Promise.resolve({
|
|
116
|
+
id: `act-${data.storyId}`,
|
|
117
|
+
action: "dependency_added",
|
|
118
|
+
field: null,
|
|
119
|
+
oldValue: null,
|
|
120
|
+
newValue: data.newValue,
|
|
121
|
+
isAI: false,
|
|
122
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
123
|
+
storyId: data.storyId,
|
|
124
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("addDependenciesFn", () => {
|
|
130
|
+
it("自依赖拒绝并计入 failed", async () => {
|
|
131
|
+
const res = await addDependenciesFn({
|
|
132
|
+
data: { items: [{ sourceId: "a", targetId: "a", type: "BLOCKS" }] },
|
|
133
|
+
});
|
|
134
|
+
expect(res.created).toHaveLength(0);
|
|
135
|
+
expect(res.failed[0].targetId).toBe("a");
|
|
136
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("pair 级判重:同 pair 任意方向任意类型已存在则拒绝", async () => {
|
|
140
|
+
mocks.depFindFirst.mockResolvedValue({ id: "d0" }); // 命中已有边
|
|
141
|
+
const res = await addDependenciesFn({
|
|
142
|
+
data: {
|
|
143
|
+
items: [
|
|
144
|
+
{ sourceId: "a", targetId: "b", type: "STARTS_BEFORE" }, // 正向查 (a,b,*)
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
expect(res.failed[0].targetId).toBe("b");
|
|
149
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
150
|
+
// 守住双向查询:实现退化为单方向(漏掉反向 OR)时本断言必须失败
|
|
151
|
+
expect(mocks.depFindFirst).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
where: expect.objectContaining({
|
|
154
|
+
OR: expect.arrayContaining([
|
|
155
|
+
{ sourceId: "a", targetId: "b" },
|
|
156
|
+
{ sourceId: "b", targetId: "a" },
|
|
157
|
+
]),
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("循环依赖拒绝:target 可达 source(跨类型混合环)", async () => {
|
|
164
|
+
mocks.depFindFirst.mockResolvedValue(null); // 无重复
|
|
165
|
+
// 图:b -> a 已存在(b BLOCKS a);插入 a -> b 成环
|
|
166
|
+
// handler 开头一次性取全边,返回全图唯一一条边
|
|
167
|
+
mocks.depFindMany.mockResolvedValue([{ sourceId: "b", targetId: "a" }]);
|
|
168
|
+
const res = await addDependenciesFn({
|
|
169
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
|
|
170
|
+
});
|
|
171
|
+
expect(res.failed[0].targetId).toBe("b");
|
|
172
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("批内互指环:第二条被逐条拦截", async () => {
|
|
176
|
+
mocks.depFindFirst.mockResolvedValue(null);
|
|
177
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
178
|
+
mocks.depCreate.mockImplementation(({ data }: any) =>
|
|
179
|
+
Promise.resolve({ id: `d-${data.sourceId}-${data.targetId}`, ...data }),
|
|
180
|
+
);
|
|
181
|
+
// 第一条插入 a->b 后,第二条 b->a 的环检测需看到它:
|
|
182
|
+
// 实现必须把本批已插入边并入检测图(depFindMany 之后的内存集合)
|
|
183
|
+
// 这里 mock depFindMany 每次返回空(DB 无边),实现需自维护本批边
|
|
184
|
+
const res = await addDependenciesFn({
|
|
185
|
+
data: {
|
|
186
|
+
items: [
|
|
187
|
+
{ sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
188
|
+
{ sourceId: "b", targetId: "a", type: "STARTS_BEFORE" },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(res.created).toHaveLength(1);
|
|
193
|
+
expect(res.failed[0].targetId).toBe("a");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("合法边创建成功", async () => {
|
|
197
|
+
mocks.depFindFirst.mockResolvedValue(null);
|
|
198
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
199
|
+
mocks.depCreate.mockResolvedValue({ id: "d1" });
|
|
200
|
+
const res = await addDependenciesFn({
|
|
201
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
|
|
202
|
+
});
|
|
203
|
+
expect(res.created).toHaveLength(1);
|
|
204
|
+
expect(mocks.depCreate).toHaveBeenCalledOnce();
|
|
205
|
+
// 性能契约:环检测的图只查一次(取全边),且不带任何 where 过滤
|
|
206
|
+
expect(mocks.depFindMany).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(mocks.depFindMany).toHaveBeenCalledWith({
|
|
208
|
+
select: { sourceId: true, targetId: true },
|
|
209
|
+
});
|
|
210
|
+
// 活动契约:依赖两端各记一条「添加依赖关系」,newValue 为对方描述
|
|
211
|
+
expect(res.activities).toHaveLength(2);
|
|
212
|
+
expect(res.activities.map((x) => [x.storyId, x.newValue]).sort()).toEqual([
|
|
213
|
+
["a", "B-1 story b"],
|
|
214
|
+
["b", "A-1 story a"],
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
|
|
219
|
+
await expect(
|
|
220
|
+
addDependenciesFn({
|
|
221
|
+
data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKED_BY" as any }] },
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toThrow();
|
|
224
|
+
expect(mocks.depCreate).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("removeDependencyFn", () => {
|
|
229
|
+
it("按 depId 删除", async () => {
|
|
230
|
+
mocks.depDelete.mockResolvedValue({ id: "d1" });
|
|
231
|
+
const res = await removeDependencyFn({ data: { depId: "d1" } });
|
|
232
|
+
expect(res.id).toBe("d1");
|
|
233
|
+
expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("getDependenciesFn", () => {
|
|
238
|
+
it("双视角分组:dependsOn 取 source,dependents 取 target", async () => {
|
|
239
|
+
mocks.depFindMany.mockResolvedValue([
|
|
240
|
+
{ id: "d1", sourceId: "s1", targetId: "me", type: "BLOCKS" },
|
|
241
|
+
{ id: "d2", sourceId: "me", targetId: "t1", type: "STARTS_BEFORE" },
|
|
242
|
+
]);
|
|
243
|
+
mocks.storyFindMany.mockImplementation(({ where }: any) => {
|
|
244
|
+
const rows = [STORY_ROW("s1"), STORY_ROW("t1"), STORY_ROW("me")];
|
|
245
|
+
return Promise.resolve(rows.filter((r) => where.id.in.includes(r.id)));
|
|
246
|
+
});
|
|
247
|
+
const res = await getDependenciesFn({ data: { storyId: "me" } });
|
|
248
|
+
expect(res.dependsOn).toHaveLength(1);
|
|
249
|
+
expect(res.dependsOn[0].id).toBe("s1");
|
|
250
|
+
expect(res.dependsOn[0].depId).toBe("d1");
|
|
251
|
+
expect(res.dependsOn[0].depType).toBe("BLOCKS");
|
|
252
|
+
expect(res.dependents).toHaveLength(1);
|
|
253
|
+
expect(res.dependents[0].id).toBe("t1");
|
|
254
|
+
expect(res.dependents[0].depType).toBe("STARTS_BEFORE");
|
|
255
|
+
// 日期已 ISO 化(castStoryUsers)
|
|
256
|
+
expect(res.dependsOn[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("无边时返回空分组且不查需求表", async () => {
|
|
260
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
261
|
+
const res = await getDependenciesFn({ data: { storyId: "me" } });
|
|
262
|
+
expect(res).toEqual({ dependsOn: [], dependents: [] });
|
|
263
|
+
expect(mocks.storyFindMany).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -84,7 +84,7 @@ describe("listStoriesInGroupFn", () => {
|
|
|
84
84
|
const arg = mocks.findMany.mock.calls[0][0] as any;
|
|
85
85
|
expect(arg.where.AND).toContainEqual({ state: "todo" });
|
|
86
86
|
expect(arg.where.archived).toBe(false);
|
|
87
|
-
expect(arg.orderBy).toEqual({ createdAt: "desc" });
|
|
87
|
+
expect(arg.orderBy).toEqual([{ createdAt: "desc" }, { id: "asc" }]);
|
|
88
88
|
expect(mocks.count).not.toHaveBeenCalled();
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -222,6 +222,24 @@ describe("listStoryGroupsFn", () => {
|
|
|
222
222
|
expect(out.groups.find((g) => g.key === "backlog")?.total).toBe(0);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
it("state/priority 组间顺序按枚举序排列(与筛选 pop 一致),不受 DB 返回顺序影响", async () => {
|
|
226
|
+
mocks.groupBy.mockResolvedValue([
|
|
227
|
+
{ state: "done", _count: { _all: 91 } },
|
|
228
|
+
{ state: "in_progress", _count: { _all: 2 } },
|
|
229
|
+
{ state: "todo", _count: { _all: 1 } },
|
|
230
|
+
]);
|
|
231
|
+
const stateOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "state" } });
|
|
232
|
+
expect(stateOut.groups.map((g) => g.key)).toEqual(["todo", "in_progress", "done"]);
|
|
233
|
+
|
|
234
|
+
mocks.groupBy.mockResolvedValue([
|
|
235
|
+
{ priority: "low", _count: { _all: 4 } },
|
|
236
|
+
{ priority: "urgent", _count: { _all: 2 } },
|
|
237
|
+
{ priority: "medium", _count: { _all: 1 } },
|
|
238
|
+
]);
|
|
239
|
+
const priorityOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "priority" } });
|
|
240
|
+
expect(priorityOut.groups.map((g) => g.key)).toEqual(["urgent", "medium", "low"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
225
243
|
it("labels 分组按 storyLabel.groupBy 聚合并计入 no-labels", async () => {
|
|
226
244
|
mocks.storyLabelGroupBy.mockResolvedValue([
|
|
227
245
|
{ labelId: "l1", _count: { _all: 2 } },
|
|
@@ -81,10 +81,9 @@ export {
|
|
|
81
81
|
} from "./story-children";
|
|
82
82
|
|
|
83
83
|
export {
|
|
84
|
-
|
|
84
|
+
addDependenciesFn,
|
|
85
85
|
removeDependencyFn,
|
|
86
86
|
getDependenciesFn,
|
|
87
|
-
validateDependencyFn,
|
|
88
87
|
} from "./story-dependencies";
|
|
89
88
|
|
|
90
89
|
export {
|
|
@@ -94,11 +93,11 @@ export {
|
|
|
94
93
|
getLinksFn,
|
|
95
94
|
} from "./story-links";
|
|
96
95
|
|
|
96
|
+
export { checkLinkEmbeddableFn } from "./link-preview";
|
|
97
|
+
|
|
97
98
|
export {
|
|
98
|
-
uploadAttachmentFn,
|
|
99
99
|
removeAttachmentFn,
|
|
100
100
|
getAttachmentsFn,
|
|
101
|
-
mockCloudStorageFn,
|
|
102
101
|
} from "./story-attachments";
|
|
103
102
|
|
|
104
103
|
export {
|
|
@@ -106,10 +105,12 @@ export {
|
|
|
106
105
|
unlinkPrdFn,
|
|
107
106
|
getLinkedPrdsFn,
|
|
108
107
|
searchPrdsFn,
|
|
109
|
-
|
|
108
|
+
listStoryAvailableKbsFn,
|
|
109
|
+
searchKbDocsFn,
|
|
110
|
+
linkDocsFn,
|
|
110
111
|
unlinkDocFn,
|
|
111
112
|
getLinkedDocsFn,
|
|
112
|
-
|
|
113
|
+
type SearchDocRow,
|
|
113
114
|
} from "./story-assoc";
|
|
114
115
|
|
|
115
116
|
export {
|