@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.100
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +965 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +641 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// 标签管理(POST /api/requirements/tags/ops + GET /api/requirements/tags 的 counts / colors / can_manage)
|
|
2
|
+
// —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:Hub 管理员 admin 的网络 NET,另有一个不相干的网络 NET2(同名标签,不能被波及)。成员:
|
|
5
|
+
// alice —— scoped 成员(RFC-038 §9),只授权项目 P1;
|
|
6
|
+
// carol —— task_access='all' 的老成员;
|
|
7
|
+
// vic —— viewer。
|
|
8
|
+
// 正向:改名 / 合并 / 删除改写网络里每张带这个标签的卡(含归档的)、颜色跟着挪、写审计、updated_at 前移;
|
|
9
|
+
// carol 与 owner 能管;GET 带用量与颜色。
|
|
10
|
+
// 反向:scoped / viewer / 节点令牌被挡且一张卡都不改;scoped 的 counts / colors 只含看得见的卡;
|
|
11
|
+
// 不存在的标签 404、坏参数 400;事务中途失败一张都不改;别的网络不受影响。
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-tag-ops-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
let db: any = null;
|
|
22
|
+
const PW = "TagOpsPassw0rd!x";
|
|
23
|
+
let NET = "";
|
|
24
|
+
let NET2 = "";
|
|
25
|
+
let admin = { token: "", id: "" };
|
|
26
|
+
let other = { token: "", id: "" };
|
|
27
|
+
let alice = { token: "", id: "" };
|
|
28
|
+
let carol = { token: "", id: "" };
|
|
29
|
+
let vic = { token: "", id: "" };
|
|
30
|
+
let nodeToken = "";
|
|
31
|
+
let P1 = "";
|
|
32
|
+
const C: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
type R = { status: number; body: any };
|
|
35
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
39
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let body: any = null;
|
|
43
|
+
try { body = JSON.parse(text); } catch {}
|
|
44
|
+
return { status: res.status, body };
|
|
45
|
+
}
|
|
46
|
+
const op = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/ops?network_id=${net}`, payload);
|
|
47
|
+
const tagsOf = (token: string, net = NET) => send(token, "GET", `/api/requirements/tags?network_id=${net}`);
|
|
48
|
+
const cardTags = (name: string): string[] => JSON.parse(db.get("SELECT tags_json FROM requirements WHERE requirement_id = ?1", C[name]).tags_json);
|
|
49
|
+
const snapshot = () => Object.fromEntries(Object.keys(C).map(name => [name, cardTags(name)]));
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
53
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
54
|
+
process.env.HOST = "127.0.0.1";
|
|
55
|
+
({ db } = await import("./db.js"));
|
|
56
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
57
|
+
const stamp = Date.now();
|
|
58
|
+
const a = register(`to_admin_${stamp}`, PW, undefined, "Admin");
|
|
59
|
+
expect(a.ok).toBe(true);
|
|
60
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
61
|
+
NET = a.network_id!;
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
63
|
+
const b = register(`to_other_${stamp}`, PW, undefined, "Other");
|
|
64
|
+
expect(b.ok).toBe(true);
|
|
65
|
+
other = { token: b.token!, id: b.user!.user_id };
|
|
66
|
+
NET2 = b.network_id!;
|
|
67
|
+
const ntok = createNetworkTokenForNode(admin.id, NET, "tag-node");
|
|
68
|
+
expect(ntok.ok).toBe(true);
|
|
69
|
+
nodeToken = ntok.token!;
|
|
70
|
+
|
|
71
|
+
const mod: any = await import("./server.js");
|
|
72
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
73
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
74
|
+
|
|
75
|
+
const mk = async (username: string, role: string) => {
|
|
76
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
|
|
77
|
+
expect(r.status).toBe(200);
|
|
78
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
79
|
+
expect(login.status).toBe(200);
|
|
80
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
81
|
+
};
|
|
82
|
+
alice = await mk(`to_alice_${stamp}`, "member");
|
|
83
|
+
carol = await mk(`to_carol_${stamp}`, "member");
|
|
84
|
+
vic = await mk(`to_vic_${stamp}`, "viewer");
|
|
85
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, alice.id]);
|
|
86
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
87
|
+
|
|
88
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "to-P1" });
|
|
89
|
+
expect(p.status).toBe(201);
|
|
90
|
+
P1 = p.body.project.id;
|
|
91
|
+
const g = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { project_grants: [{ project_id: P1, can_edit: true }] });
|
|
92
|
+
expect(g.status).toBe(200);
|
|
93
|
+
|
|
94
|
+
const card = async (name: string, tags: string[], extra: Record<string, unknown> = {}, token = admin.token, net = NET) => {
|
|
95
|
+
const r = await send(token, "POST", "/api/requirements", { network_id: net, name, tags, ...extra });
|
|
96
|
+
expect(r.status).toBe(201);
|
|
97
|
+
C[name] = r.body.requirement.id;
|
|
98
|
+
};
|
|
99
|
+
await card("visible", ["UI", "交互", "bug"], { project_id: P1 });
|
|
100
|
+
await card("hidden", ["UI", "secret"]);
|
|
101
|
+
await card("archived", ["UI", "旧"], { archived: true });
|
|
102
|
+
await card("both", ["交互", "TMWork", "UI"]);
|
|
103
|
+
await card("plain", []);
|
|
104
|
+
await card("net2", ["UI", "交互"], {}, other.token, NET2);
|
|
105
|
+
}, 30_000);
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
try { hub?.stop?.(true); } catch {}
|
|
109
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("GET /api/requirements/tags", () => {
|
|
113
|
+
test("owner:tags 仍是排序后的字符串数组(旧 App 形状不变),多出 counts / colors / can_manage", async () => {
|
|
114
|
+
const r = await tagsOf(admin.token);
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
expect(r.body.tags).toEqual(["TMWork", "UI", "bug", "secret", "交互", "旧"]);
|
|
117
|
+
expect(r.body.counts).toEqual({ UI: 4, 交互: 2, bug: 1, secret: 1, 旧: 1, TMWork: 1 });
|
|
118
|
+
expect(r.body.colors).toEqual({});
|
|
119
|
+
expect(r.body.can_manage).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("scoped 成员:counts 只数看得见的卡,不能管;viewer 与节点令牌也不能管;老成员能管", async () => {
|
|
123
|
+
const a = await tagsOf(alice.token);
|
|
124
|
+
expect(a.body.tags).toEqual(["UI", "bug", "交互"]);
|
|
125
|
+
expect(a.body.counts).toEqual({ UI: 1, bug: 1, 交互: 1 });
|
|
126
|
+
expect(a.body.can_manage).toBe(false);
|
|
127
|
+
expect((await tagsOf(vic.token)).body.can_manage).toBe(false);
|
|
128
|
+
expect((await send(nodeToken, "GET", "/api/requirements/tags")).body.can_manage).toBe(false);
|
|
129
|
+
expect((await tagsOf(carol.token)).body.can_manage).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("列表响应的 capabilities 带 tag_ops", async () => {
|
|
133
|
+
const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
134
|
+
expect(r.body.capabilities).toContain("tag_ops");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("被拒的调用一张卡都不改", () => {
|
|
139
|
+
test("scoped 成员 403(连他看得见的那张也不改,不做半套改名)", async () => {
|
|
140
|
+
const before = snapshot();
|
|
141
|
+
for (const payload of [{ op: "rename", from: "UI", to: "界面" }, { op: "delete", tag: "bug" }, { op: "merge", from: ["UI", "交互"], to: "x" }, { op: "color", tag: "UI", color: "#123456" }]) {
|
|
142
|
+
const r = await op(alice.token, payload);
|
|
143
|
+
expect(r.status).toBe(403);
|
|
144
|
+
expect(r.body.error).toBe("permission_denied");
|
|
145
|
+
}
|
|
146
|
+
expect(snapshot()).toEqual(before);
|
|
147
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("viewer 403;节点令牌 403 user_token_required;别的网络的 owner 碰不到这个网络", async () => {
|
|
151
|
+
const before = snapshot();
|
|
152
|
+
expect((await op(vic.token, { op: "delete", tag: "UI" })).status).toBe(403);
|
|
153
|
+
const n = await send(nodeToken, "POST", "/api/requirements/tags/ops", { op: "delete", tag: "UI" });
|
|
154
|
+
expect(n.status).toBe(403);
|
|
155
|
+
expect(n.body.error).toBe("user_token_required");
|
|
156
|
+
expect((await op(other.token, { op: "delete", tag: "UI" })).status).toBe(403);
|
|
157
|
+
expect(snapshot()).toEqual(before);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("坏参数 400、不存在的标签 404、GET 到 ops 404", async () => {
|
|
161
|
+
expect((await op(admin.token, { op: "explode" })).body.error).toBe("invalid_tag_op");
|
|
162
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: "" })).body.error).toBe("invalid_tag");
|
|
163
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: "x".repeat(21) })).body.error).toBe("invalid_tag");
|
|
164
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: " UI " })).body.error).toBe("same_tag");
|
|
165
|
+
expect((await op(admin.token, { op: "merge", from: [], to: "UI" })).body.error).toBe("invalid_tag");
|
|
166
|
+
expect((await op(admin.token, { op: "merge", from: ["UI"], to: "UI" })).body.error).toBe("same_tag");
|
|
167
|
+
expect((await op(admin.token, { op: "color", tag: "UI", color: "red" })).body.error).toBe("invalid_tag_color");
|
|
168
|
+
const missing = await op(admin.token, { op: "delete", tag: "没有这个" });
|
|
169
|
+
expect(missing.status).toBe(404);
|
|
170
|
+
expect(missing.body.error).toBe("tag_not_found");
|
|
171
|
+
expect((await send(admin.token, "GET", `/api/requirements/tags/ops?network_id=${NET}`)).status).toBe(404);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("事务:改到一半失败 → 一张都不改,颜色也不挪", async () => {
|
|
175
|
+
expect((await op(admin.token, { op: "color", tag: "UI", color: "#2563EB" })).status).toBe(200);
|
|
176
|
+
// 让其中一张卡的 UPDATE 抛错(SQLite 用 RAISE,PostgreSQL 用 plpgsql 触发器;PG 阶梯会跑这个文件)。
|
|
177
|
+
const pg = db.dialect === "postgres";
|
|
178
|
+
if (pg) {
|
|
179
|
+
db.exec(`CREATE OR REPLACE FUNCTION tag_ops_fail() RETURNS trigger AS $$ BEGIN IF NEW.requirement_id = '${C["both"]}' THEN RAISE EXCEPTION 'boom'; END IF; RETURN NEW; END $$ LANGUAGE plpgsql`);
|
|
180
|
+
db.exec("CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements FOR EACH ROW EXECUTE FUNCTION tag_ops_fail()");
|
|
181
|
+
} else {
|
|
182
|
+
db.exec(`CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements WHEN NEW.requirement_id = '${C["both"]}' BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
|
|
183
|
+
}
|
|
184
|
+
const before = snapshot();
|
|
185
|
+
try {
|
|
186
|
+
const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
|
|
187
|
+
expect(r.status).toBeGreaterThanOrEqual(500);
|
|
188
|
+
} finally {
|
|
189
|
+
db.exec(pg ? "DROP TRIGGER tag_ops_fail ON requirements" : "DROP TRIGGER tag_ops_fail");
|
|
190
|
+
if (pg) db.exec("DROP FUNCTION tag_ops_fail()");
|
|
191
|
+
}
|
|
192
|
+
expect(snapshot()).toEqual(before);
|
|
193
|
+
expect(db.get("SELECT color FROM network_tags WHERE network_id = ?1 AND name = 'UI'", NET).color).toBe("#2563eb");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("改名 / 合并 / 删除 / 颜色", () => {
|
|
198
|
+
test("颜色:设置(存小写)、GET 带出来;scoped 成员看不到他看不见的标签的颜色", async () => {
|
|
199
|
+
expect((await op(admin.token, { op: "color", tag: "secret", color: "#DC2626" })).status).toBe(200);
|
|
200
|
+
const r = await tagsOf(admin.token);
|
|
201
|
+
expect(r.body.colors).toEqual({ UI: "#2563eb", secret: "#dc2626" });
|
|
202
|
+
expect((await tagsOf(alice.token)).body.colors).toEqual({ UI: "#2563eb" });
|
|
203
|
+
expect((await op(admin.token, { op: "color", tag: "secret", color: null })).status).toBe(200);
|
|
204
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ UI: "#2563eb" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("改名:每张卡(含看不见的、归档的)都改,位置不变;颜色跟着走;updated_at 前移;审计;别的网络不动", async () => {
|
|
208
|
+
const t0 = new Date().toISOString();
|
|
209
|
+
const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
|
|
210
|
+
expect(r.status).toBe(200);
|
|
211
|
+
expect(r.body).toEqual({ ok: true, op: "rename", affected: 4 });
|
|
212
|
+
expect(cardTags("visible")).toEqual(["界面", "交互", "bug"]);
|
|
213
|
+
expect(cardTags("hidden")).toEqual(["界面", "secret"]);
|
|
214
|
+
expect(cardTags("archived")).toEqual(["界面", "旧"]);
|
|
215
|
+
expect(cardTags("both")).toEqual(["交互", "TMWork", "界面"]);
|
|
216
|
+
expect(cardTags("net2")).toEqual(["UI", "交互"]);
|
|
217
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
|
|
218
|
+
const row = db.get("SELECT updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", C["hidden"]);
|
|
219
|
+
expect(row.updated_at >= t0).toBe(true);
|
|
220
|
+
expect(JSON.parse(row.updated_by_json)).toEqual({ kind: "user", id: admin.id });
|
|
221
|
+
const audit = db.get("SELECT detail, target_id, network_id FROM audit_log WHERE action = 'requirement_tag_rename' ORDER BY id DESC LIMIT 1");
|
|
222
|
+
expect(audit.target_id).toBe("界面");
|
|
223
|
+
expect(audit.network_id).toBe(NET);
|
|
224
|
+
expect(JSON.parse(audit.detail)).toEqual({ op: "rename", from: "UI", to: "界面", affected: 4 });
|
|
225
|
+
// 这次改动 updated_since 同步得到(包括 GET 列表)。
|
|
226
|
+
const since = await send(admin.token, "GET", `/api/requirements?network_id=${NET}&include_archived=1&updated_since=${encodeURIComponent(t0)}`);
|
|
227
|
+
expect(since.body.requirements.map((x: any) => x.name).sort()).toEqual(["archived", "both", "hidden", "visible"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("改名成已存在的标签 = 合并,同一张卡上不出现两次", async () => {
|
|
231
|
+
const r = await op(carol.token, { op: "rename", from: "交互", to: "界面" });
|
|
232
|
+
expect(r.body.affected).toBe(2);
|
|
233
|
+
expect(cardTags("visible")).toEqual(["界面", "bug"]);
|
|
234
|
+
expect(cardTags("both")).toEqual(["界面", "TMWork"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("合并:多个来源进一个新标签;目标没颜色就继承来源的;来源颜色行删掉", async () => {
|
|
238
|
+
expect((await op(admin.token, { op: "color", tag: "旧", color: "#16a34a" })).status).toBe(200);
|
|
239
|
+
const r = await op(admin.token, { op: "merge", from: ["bug", "旧", "secret", "不存在"], to: "杂项" });
|
|
240
|
+
expect(r.body).toEqual({ ok: true, op: "merge", affected: 3 });
|
|
241
|
+
expect(cardTags("visible")).toEqual(["界面", "杂项"]);
|
|
242
|
+
expect(cardTags("hidden")).toEqual(["界面", "杂项"]);
|
|
243
|
+
expect(cardTags("archived")).toEqual(["界面", "杂项"]);
|
|
244
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb", 杂项: "#16a34a" });
|
|
245
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1 AND name = '旧'", NET).n).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("合并进已有颜色的目标:目标颜色不变", async () => {
|
|
249
|
+
const r = await op(admin.token, { op: "merge", from: ["杂项"], to: "界面" });
|
|
250
|
+
expect(r.body.affected).toBe(3);
|
|
251
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("删除:从每张卡上拿掉(卡保留),颜色行删掉,审计带 affected", async () => {
|
|
255
|
+
const r = await op(admin.token, { op: "delete", tag: "界面" });
|
|
256
|
+
expect(r.body).toEqual({ ok: true, op: "delete", affected: 4 });
|
|
257
|
+
expect(cardTags("visible")).toEqual([]);
|
|
258
|
+
expect(cardTags("both")).toEqual(["TMWork"]);
|
|
259
|
+
expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(5);
|
|
260
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
|
|
261
|
+
const audit = db.get("SELECT detail FROM audit_log WHERE action = 'requirement_tag_delete' ORDER BY id DESC LIMIT 1");
|
|
262
|
+
expect(JSON.parse(audit.detail)).toEqual({ op: "delete", tag: "界面", affected: 4 });
|
|
263
|
+
expect((await tagsOf(admin.token)).body.tags).toEqual(["TMWork"]);
|
|
264
|
+
expect(cardTags("net2")).toEqual(["UI", "交互"]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
|
|
2
|
+
export function normalizeTags(value: unknown): string[] | null {
|
|
3
|
+
if (!Array.isArray(value) || value.length > 10) return null;
|
|
4
|
+
const tags: string[] = [];
|
|
5
|
+
for (const valueTag of value) {
|
|
6
|
+
if (typeof valueTag !== "string") return null;
|
|
7
|
+
const tag = valueTag.trim();
|
|
8
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
|
|
9
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
10
|
+
}
|
|
11
|
+
return tags;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function storedTags(value: string | null): string[] {
|
|
15
|
+
try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
|
|
19
|
+
// 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
|
|
20
|
+
// 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
|
|
21
|
+
export const MAX_MERGE_SOURCES = 50;
|
|
22
|
+
export type TagOp =
|
|
23
|
+
| { op: "rename"; from: string; to: string }
|
|
24
|
+
| { op: "merge"; from: string[]; to: string }
|
|
25
|
+
| { op: "delete"; tag: string }
|
|
26
|
+
| { op: "color"; tag: string; color: string | null };
|
|
27
|
+
|
|
28
|
+
/** 单个标签名,规则同 normalizeTags。不合法 = null。 */
|
|
29
|
+
export function tagName(value: unknown): string | null {
|
|
30
|
+
const tags = normalizeTags([value]);
|
|
31
|
+
return tags && tags.length === 1 ? tags[0] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
35
|
+
/** 请求体 → 操作;不合法返回错误码。 */
|
|
36
|
+
export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
|
|
37
|
+
switch (body.op) {
|
|
38
|
+
case "rename": {
|
|
39
|
+
const from = tagName(body.from), to = tagName(body.to);
|
|
40
|
+
if (!from || !to) return { error: "invalid_tag" };
|
|
41
|
+
if (from === to) return { error: "same_tag" };
|
|
42
|
+
return { op: "rename", from, to };
|
|
43
|
+
}
|
|
44
|
+
case "merge": {
|
|
45
|
+
if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
|
|
46
|
+
const from: string[] = [];
|
|
47
|
+
for (const value of body.from) {
|
|
48
|
+
const tag = tagName(value);
|
|
49
|
+
if (!tag) return { error: "invalid_tag" };
|
|
50
|
+
if (!from.includes(tag)) from.push(tag);
|
|
51
|
+
}
|
|
52
|
+
const to = tagName(body.to);
|
|
53
|
+
if (!to) return { error: "invalid_tag" };
|
|
54
|
+
// 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
|
|
55
|
+
const sources = from.filter(tag => tag !== to);
|
|
56
|
+
if (!sources.length) return { error: "same_tag" };
|
|
57
|
+
return { op: "merge", from: sources, to };
|
|
58
|
+
}
|
|
59
|
+
case "delete": {
|
|
60
|
+
const tag = tagName(body.tag);
|
|
61
|
+
return tag ? { op: "delete", tag } : { error: "invalid_tag" };
|
|
62
|
+
}
|
|
63
|
+
case "color": {
|
|
64
|
+
const tag = tagName(body.tag);
|
|
65
|
+
if (!tag) return { error: "invalid_tag" };
|
|
66
|
+
if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
|
|
67
|
+
return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return { error: "invalid_tag_op" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
|
|
75
|
+
export function applyTagOp(tags: string[], op: TagOp): string[] | null {
|
|
76
|
+
let sources: string[];
|
|
77
|
+
let to: string | null;
|
|
78
|
+
if (op.op === "rename") { sources = [op.from]; to = op.to; }
|
|
79
|
+
else if (op.op === "merge") { sources = op.from; to = op.to; }
|
|
80
|
+
else if (op.op === "delete") { sources = [op.tag]; to = null; }
|
|
81
|
+
else return null;
|
|
82
|
+
if (!tags.some(tag => sources.includes(tag))) return null;
|
|
83
|
+
const next: string[] = [];
|
|
84
|
+
for (const tag of tags) {
|
|
85
|
+
const mapped = sources.includes(tag) ? to : tag;
|
|
86
|
+
if (mapped !== null && !next.includes(mapped)) next.push(mapped);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// #474(MCP 任务生命周期测试报告问题 5)—— 评论 / 进展:只追加,进「动态」(requirement_events kind=comment),
|
|
2
|
+
// 不改任务本身。钉住:
|
|
3
|
+
// - Agent(节点令牌)经 MCP、人经 REST 都能评论,署名是调用者;description / updated_at 一个字节都不动;
|
|
4
|
+
// - requirements_events 里是 kind=comment、new.text 全文,单任务的动态体积可控;
|
|
5
|
+
// - 看不见这张卡的人评论 = 404(和读一样),只读角色 = 403,空 / 超长 = 400 + 提示;
|
|
6
|
+
// - 只追加:没有改 / 删评论的路由。
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { COMMENT_MAX_CHARS } from "./requirements.js";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-comment-"));
|
|
16
|
+
let BASE = "";
|
|
17
|
+
let hub: any = null;
|
|
18
|
+
const PW = "ReqCommentPassw0rd!xyz";
|
|
19
|
+
let NET = "";
|
|
20
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
21
|
+
let card = { id: "", seq: 0 };
|
|
22
|
+
|
|
23
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let json: any = null;
|
|
27
|
+
try { json = JSON.parse(text); } catch {}
|
|
28
|
+
return { status: res.status, body: json, bytes: text.length };
|
|
29
|
+
}
|
|
30
|
+
async function rpc(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; raw: any; bytes: number }> {
|
|
31
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
34
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
35
|
+
});
|
|
36
|
+
const raw = await res.text();
|
|
37
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
38
|
+
const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
39
|
+
const text = out.result?.content?.[0]?.text ?? "";
|
|
40
|
+
let data: any = null;
|
|
41
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
42
|
+
return { data, raw: out, bytes: text.length };
|
|
43
|
+
}
|
|
44
|
+
const mcp = async (token: string, name: string, args: Record<string, unknown>) => (await rpc(token, name, args)).data;
|
|
45
|
+
const cardRow = (id: string) => db.get<{ description: string | null; updated_at: string | null; updated_by_json: string | null }>("SELECT description, updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", id)!;
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
49
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
50
|
+
process.env.HOST = "127.0.0.1";
|
|
51
|
+
const a = register(`cmt_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
52
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
53
|
+
NET = a.network_id!;
|
|
54
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
55
|
+
const mod: any = await import("./server.js");
|
|
56
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
57
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
58
|
+
const mk = async (username: string, role: string) => {
|
|
59
|
+
expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
60
|
+
const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
|
|
61
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
62
|
+
};
|
|
63
|
+
const stamp = Date.now();
|
|
64
|
+
U.member = await mk(`cmt_member_${stamp}`, "member");
|
|
65
|
+
U.viewer = await mk(`cmt_viewer_${stamp}`, "viewer");
|
|
66
|
+
U.scoped = await mk(`cmt_scoped_${stamp}`, "member");
|
|
67
|
+
expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.scoped.id}/task-grants`, { task_access: "scoped", project_grants: [] })).status).toBe(200);
|
|
68
|
+
U.nodeA = { token: createNetworkTokenForNode(U.owner.id, NET, "示例-甲", "node_cmt_a").token!, id: "node_cmt_a" };
|
|
69
|
+
const created = (await mcp(U.nodeA.token, "requirements_create", { name: "示例任务:评论", description: "## 背景\n原文。" })).requirement;
|
|
70
|
+
card = { id: created.id, seq: created.seq };
|
|
71
|
+
}, 30_000);
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
try { hub?.stop?.(true); } catch {}
|
|
75
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("requirements_comment", () => {
|
|
79
|
+
test("an Agent comments over MCP (#N): one comment event, attributed to the node; the task itself is untouched", async () => {
|
|
80
|
+
const before = cardRow(card.id);
|
|
81
|
+
const r = await mcp(U.nodeA.token, "requirements_comment", { id: `#${card.seq}`, text: "进展:示例草稿已写完,等人看。" });
|
|
82
|
+
expect(r.ok).toBe(true);
|
|
83
|
+
expect(r.event.kind).toBe("comment");
|
|
84
|
+
expect(r.event.field).toBeNull();
|
|
85
|
+
expect(r.event.new).toEqual({ text: "进展:示例草稿已写完,等人看。" });
|
|
86
|
+
expect(r.event.actor).toEqual({ kind: "node", id: "node_cmt_a" });
|
|
87
|
+
expect(r.event.requirement_id).toBe(card.id);
|
|
88
|
+
const after = cardRow(card.id);
|
|
89
|
+
expect(after).toEqual(before); // description, updated_at, updated_by all unchanged
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a person comments over REST; both show in requirements_events with the full text, newest first", async () => {
|
|
93
|
+
const r = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " 好的,我下午看。 " });
|
|
94
|
+
expect(r.status).toBe(201);
|
|
95
|
+
expect(r.body.event.new.text).toBe("好的,我下午看。"); // trimmed
|
|
96
|
+
expect(r.body.event.actor).toEqual({ kind: "user", id: U.member.id });
|
|
97
|
+
const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: `#${card.seq}` });
|
|
98
|
+
const comments = ev.data.events.filter((e: any) => e.kind === "comment");
|
|
99
|
+
expect(comments.map((e: any) => e.new.text)).toEqual(["好的,我下午看。", "进展:示例草稿已写完,等人看。"]);
|
|
100
|
+
expect(ev.data.events.at(-1).kind).toBe("created");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("full text up to the cap, and the timeline stays small", async () => {
|
|
104
|
+
const long = "长".repeat(COMMENT_MAX_CHARS);
|
|
105
|
+
const r = await mcp(U.nodeA.token, "requirements_comment", { id: card.id, text: long });
|
|
106
|
+
expect(r.ok).toBe(true);
|
|
107
|
+
expect(r.event.new.text.length).toBe(COMMENT_MAX_CHARS);
|
|
108
|
+
const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: card.id });
|
|
109
|
+
// 1 created + 3 comments (one of them 4000 chars): well under 20 KB.
|
|
110
|
+
expect(ev.bytes).toBeLessThan(20_000);
|
|
111
|
+
expect(ev.data.events.some((e: any) => e.kind === "comment" && e.new.text === long)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("bad input: empty → invalid_comment, over the cap → comment_too_long (REST), MCP schema → -32602; both with hints", async () => {
|
|
115
|
+
const empty = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " " });
|
|
116
|
+
expect(empty.status).toBe(400);
|
|
117
|
+
expect(empty.body.error).toBe("invalid_comment");
|
|
118
|
+
expect(empty.body.field).toBe("text");
|
|
119
|
+
const tooLong = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: "x".repeat(COMMENT_MAX_CHARS + 1) });
|
|
120
|
+
expect(tooLong.status).toBe(400);
|
|
121
|
+
expect(tooLong.body.error).toBe("comment_too_long");
|
|
122
|
+
expect(tooLong.body.hint).toContain("Split");
|
|
123
|
+
const viaMcp = await rpc(U.nodeA.token, "requirements_comment", { id: card.id, text: "x".repeat(COMMENT_MAX_CHARS + 1) });
|
|
124
|
+
expect(String(viaMcp.raw.error?.code ?? viaMcp.raw.result?.content?.[0]?.text)).toMatch(/-32602|too big|Too big/i);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("permissions: a non-reader gets 404 (like reading), a read-only role 403; neither writes anything", async () => {
|
|
128
|
+
const count = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND kind = 'comment'", card.id)!.n;
|
|
129
|
+
const n = count();
|
|
130
|
+
// scoped member with no grant cannot see the task → cannot read it → cannot comment on it.
|
|
131
|
+
expect((await rest(U.scoped.token, "GET", `/api/requirements/${card.id}`)).status).toBe(404);
|
|
132
|
+
const scoped = await rest(U.scoped.token, "POST", `/api/requirements/${card.id}/comments`, { text: "看不见也想评论" });
|
|
133
|
+
expect(scoped.status).toBe(404);
|
|
134
|
+
expect(scoped.body.error).toBe("requirement_not_found");
|
|
135
|
+
const viewer = await rest(U.viewer.token, "POST", `/api/requirements/${card.id}/comments`, { text: "只读角色" });
|
|
136
|
+
expect(viewer.status).toBe(403);
|
|
137
|
+
expect(viewer.body.error).toBe("permission_denied");
|
|
138
|
+
expect(count()).toBe(n);
|
|
139
|
+
// …and the scoped member does not see the comments in the network timeline either.
|
|
140
|
+
const ev = await rest(U.scoped.token, "GET", `/api/requirements/events?network_id=${NET}`);
|
|
141
|
+
expect((ev.body.events ?? []).some((e: any) => e.requirement_id === card.id)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("append-only: no route edits or deletes a comment; unknown task → requirement_not_found with a hint", async () => {
|
|
145
|
+
expect((await rest(U.owner.token, "PATCH", `/api/requirements/${card.id}/comments`, { text: "改" })).status).toBe(404);
|
|
146
|
+
expect((await rest(U.owner.token, "DELETE", `/api/requirements/${card.id}/comments`)).status).toBe(404);
|
|
147
|
+
const missing = await mcp(U.nodeA.token, "requirements_comment", { id: "#9999", text: "没有这张卡" });
|
|
148
|
+
expect(missing.error).toBe("requirement_not_found");
|
|
149
|
+
expect(missing.hint).toContain("requirements_list");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("discoverable: tools/list carries requirements_comment, and requirements_events mentions comments", async () => {
|
|
153
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { Authorization: `Bearer ${U.nodeA.token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
156
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
157
|
+
});
|
|
158
|
+
const raw = await res.text();
|
|
159
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
160
|
+
const tools = (lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw)).result.tools as any[];
|
|
161
|
+
const byName = new Map(tools.map(t => [t.name, t]));
|
|
162
|
+
expect(String(byName.get("requirements_comment")?.description)).toContain("append-only");
|
|
163
|
+
expect(String(byName.get("requirements_events")?.description)).toContain("kind=comment");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
|
|
2
|
+
// 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
|
|
3
|
+
// 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
|
|
4
|
+
import { afterAll, expect, test } from "bun:test";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-completed-at-"));
|
|
11
|
+
const file = join(dir, "hub.db");
|
|
12
|
+
process.env.COMMHUB_DB = file;
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(file);
|
|
15
|
+
legacy.exec(`CREATE TABLE requirements (
|
|
16
|
+
requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
17
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
|
|
18
|
+
client_id TEXT, issues_json TEXT, created_by TEXT,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
|
|
21
|
+
const seed: [string, string, string, string][] = [
|
|
22
|
+
// id, column, created_at, updated_at
|
|
23
|
+
["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
|
|
24
|
+
["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
|
|
25
|
+
["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
|
|
26
|
+
["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
|
|
27
|
+
["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
|
|
28
|
+
];
|
|
29
|
+
for (const [id, column, createdAt, updatedAt] of seed) {
|
|
30
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
|
|
31
|
+
[id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
|
|
32
|
+
}
|
|
33
|
+
legacy.close();
|
|
34
|
+
|
|
35
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
36
|
+
|
|
37
|
+
const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
|
|
38
|
+
|
|
39
|
+
test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
|
|
40
|
+
const { db } = await import("./db.js");
|
|
41
|
+
await import("./requirements.js");
|
|
42
|
+
const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
|
|
43
|
+
const by = read(db);
|
|
44
|
+
expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
|
|
45
|
+
expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
|
|
46
|
+
// updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
|
|
47
|
+
expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
|
|
48
|
+
for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
|
|
49
|
+
expect(by[id].completed_at_approx).toBe(1);
|
|
50
|
+
// 最后改的人不是「完成的人」:近似值不猜完成者
|
|
51
|
+
expect(by[id].completed_by_json).toBeNull();
|
|
52
|
+
// 补值不碰 updated_at
|
|
53
|
+
expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
|
|
54
|
+
}
|
|
55
|
+
for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
|
|
56
|
+
|
|
57
|
+
// 下次启动:什么都不动
|
|
58
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
59
|
+
expect(read(db)).toEqual(by);
|
|
60
|
+
|
|
61
|
+
// 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
|
|
62
|
+
db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
|
|
63
|
+
db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
|
|
64
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
|
|
65
|
+
const after = read(db);
|
|
66
|
+
expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
|
|
67
|
+
expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
|
|
68
|
+
expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
|
|
69
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
70
|
+
});
|