@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -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 +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -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 +933 -36
- 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 +330 -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-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -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 +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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 +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -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 +101 -0
- package/src/node-id-alias.test.ts +190 -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 +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -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 +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -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 +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -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 +349 -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 +259 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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 +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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 +264 -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 +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -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 +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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 +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -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 +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// 参与人可以改任务的状态和检查项,改了通知相关的人(Vincent 2026-10-01)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// 网络 NET(owner = boss)。成员:
|
|
5
|
+
// owen —— 卡的负责人;cara —— 建卡的人(task_access='all');
|
|
6
|
+
// pat —— 参与人,scoped(只看相关任务);pia —— 另一个参与人(all);
|
|
7
|
+
// sam —— scoped,与卡无关(看不见)。
|
|
8
|
+
// 正向:pat 改状态 / 勾检查项 / 改检查项列表 → 200;owen、cara、pia 各收到一条私信(发信人 pat),pat 自己没有;
|
|
9
|
+
// 60 s 内连续改动并成一条(未读时改写正文);读过之后再改 → 新的一条;MCP 与 REST 一致。
|
|
10
|
+
// 反向:pat 改标题 / 描述 / 负责人 / 参与人 / 项目 / 优先级 / 日期 / 标签 → 403 task_read_only + field,卡不变、不通知;
|
|
11
|
+
// 负责人自己改不通知;sam 照旧看不见(与不存在的卡逐字节相同)。
|
|
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-part-notify-"));
|
|
19
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
20
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
21
|
+
process.env.HOST = "127.0.0.1";
|
|
22
|
+
const PW = "PartNotifyPassw0rd!x";
|
|
23
|
+
|
|
24
|
+
type U = { token: string; id: string; username: string };
|
|
25
|
+
let BASE = "";
|
|
26
|
+
let hub: any = null;
|
|
27
|
+
let NET = "";
|
|
28
|
+
let PROJ = "";
|
|
29
|
+
let db: any;
|
|
30
|
+
let notify: typeof import("./requirement-notify.js");
|
|
31
|
+
let boss: U, owen: U, cara: U, pat: U, pia: U, sam: U;
|
|
32
|
+
|
|
33
|
+
type R = { status: number; body: any; text: string };
|
|
34
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
35
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
36
|
+
method,
|
|
37
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
38
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let body: any = null;
|
|
42
|
+
try { body = JSON.parse(text); } catch {}
|
|
43
|
+
return { status: res.status, body, text };
|
|
44
|
+
}
|
|
45
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
46
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
49
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
50
|
+
});
|
|
51
|
+
const raw = await res.text();
|
|
52
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
53
|
+
const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
54
|
+
return JSON.parse(payload.result.content[0].text);
|
|
55
|
+
}
|
|
56
|
+
const userRef = (id: string) => ({ kind: "user", id });
|
|
57
|
+
|
|
58
|
+
/** 某张卡发出的通知私信:按收件人分组(只看这张卡的,meta.task_notice.requirement_id)。 */
|
|
59
|
+
function notices(reqId: string): Record<string, Array<{ message_id: string; content: string; sender_user_id: string; acked: number; from_session: string }>> {
|
|
60
|
+
const rows = db.all(
|
|
61
|
+
"SELECT message_id, user_id, sender_user_id, from_session, content, acked, meta_json FROM user_inbox WHERE network_id = ?1 AND kind = 'human_dm' ORDER BY created_at, message_id",
|
|
62
|
+
NET,
|
|
63
|
+
) as any[];
|
|
64
|
+
const out: Record<string, any[]> = {};
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
let meta: any = null;
|
|
67
|
+
try { meta = JSON.parse(r.meta_json); } catch {}
|
|
68
|
+
if (meta?.task_notice?.requirement_id !== reqId) continue;
|
|
69
|
+
(out[r.user_id] ||= []).push({ message_id: r.message_id, content: r.content, sender_user_id: r.sender_user_id, acked: Number(r.acked), from_session: r.from_session });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function card(name: string, extra: Record<string, unknown> = {}) {
|
|
75
|
+
const r = await send(cara.token, "POST", "/api/requirements", {
|
|
76
|
+
network_id: NET, name, owner: userRef(owen.id), participants: [userRef(pat.id), userRef(pia.id)],
|
|
77
|
+
checklist: [{ id: "i1", text: "写测试", done: false }, { id: "i2", text: "发版", done: false }], ...extra,
|
|
78
|
+
});
|
|
79
|
+
expect(r.status).toBe(201);
|
|
80
|
+
return r.body.requirement.id as string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
({ db } = await import("./db.js"));
|
|
85
|
+
const { addNetworkMember, register } = await import("./auth.js");
|
|
86
|
+
const stamp = Date.now();
|
|
87
|
+
const mk = (name: string, display?: string): U => {
|
|
88
|
+
const r = register(`pn_${name}_${stamp}`, PW, undefined, display);
|
|
89
|
+
expect(r.ok).toBe(true);
|
|
90
|
+
return { token: r.token!, id: r.user!.user_id, username: r.user!.username };
|
|
91
|
+
};
|
|
92
|
+
const b = register(`pn_boss_${stamp}`, PW);
|
|
93
|
+
expect(b.ok).toBe(true);
|
|
94
|
+
boss = { token: b.token!, id: b.user!.user_id, username: b.user!.username };
|
|
95
|
+
NET = b.network_id!;
|
|
96
|
+
owen = mk("owen"); cara = mk("cara"); pat = mk("pat", "参与人甲"); pia = mk("pia"); sam = mk("sam");
|
|
97
|
+
addNetworkMember(NET, owen.id, "member", boss.id, { taskAccess: "all" });
|
|
98
|
+
addNetworkMember(NET, cara.id, "member", boss.id, { taskAccess: "all" });
|
|
99
|
+
addNetworkMember(NET, pat.id, "member", boss.id, { taskAccess: "scoped" });
|
|
100
|
+
addNetworkMember(NET, pia.id, "member", boss.id, { taskAccess: "all" });
|
|
101
|
+
addNetworkMember(NET, sam.id, "member", boss.id, { taskAccess: "scoped" });
|
|
102
|
+
notify = await import("./requirement-notify.js");
|
|
103
|
+
const mod: any = await import("./server.js");
|
|
104
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
105
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
106
|
+
// register() 给每人建了自己的网络:后面的请求一律带 network_id。
|
|
107
|
+
const p = await send(boss.token, "POST", "/api/requirements/projects", { network_id: NET, name: "pn-proj" });
|
|
108
|
+
expect(p.status).toBe(201);
|
|
109
|
+
PROJ = p.body.project.id;
|
|
110
|
+
}, 30_000);
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
try { hub?.stop?.(true); } catch {}
|
|
114
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("参与人能改的", () => {
|
|
118
|
+
test("改状态:200,负责人 / 创建者 / 其他参与人各一条,操作者自己没有", async () => {
|
|
119
|
+
const id = await card("pn-状态");
|
|
120
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" });
|
|
121
|
+
expect(r.status).toBe(200);
|
|
122
|
+
expect(r.body.requirement.column).toBe("doing");
|
|
123
|
+
const n = notices(id);
|
|
124
|
+
expect(Object.keys(n).sort()).toEqual([owen.id, cara.id, pia.id].sort());
|
|
125
|
+
for (const uid of [owen.id, cara.id, pia.id]) {
|
|
126
|
+
expect(n[uid].length).toBe(1);
|
|
127
|
+
expect(n[uid][0].sender_user_id).toBe(pat.id);
|
|
128
|
+
expect(n[uid][0].from_session).toBe(pat.username);
|
|
129
|
+
expect(n[uid][0].content).toBe("参与人甲 把「pn-状态」改为 进行中");
|
|
130
|
+
}
|
|
131
|
+
expect(n[pat.id]).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("勾检查项(单条接口):200 并通知", async () => {
|
|
135
|
+
const id = await card("pn-勾选");
|
|
136
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true });
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(r.body.requirement.checklist.find((x: any) => x.id === "i1").done).toBe(true);
|
|
139
|
+
expect(notices(id)[owen.id].map(x => x.content)).toEqual(["参与人甲 在「pn-勾选」勾选了检查项「写测试」"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("整表改检查项(增 / 删 / 改字)+ 状态:一次 PATCH 一条通知,逐项列出", async () => {
|
|
143
|
+
const id = await card("pn-整表");
|
|
144
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, {
|
|
145
|
+
column: "done", checklist: [{ id: "i1", text: "写更多测试", done: false }, { id: "i3", text: "回归", done: false }],
|
|
146
|
+
});
|
|
147
|
+
expect(r.status).toBe(200);
|
|
148
|
+
const n = notices(id)[cara.id];
|
|
149
|
+
expect(n.length).toBe(1);
|
|
150
|
+
expect(n[0].content).toBe("参与人甲 更新了「pn-整表」:\n· 改为 完成\n· 把检查项「写测试」改为「写更多测试」\n· 添加了检查项「回归」\n· 删除了检查项「发版」");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("60 s 内连续勾选并成一条(未读时改写正文);同一项先勾再取消只留最后一次", async () => {
|
|
154
|
+
const id = await card("pn-合并");
|
|
155
|
+
for (const [item, done] of [["i1", true], ["i2", true], ["i1", false]] as const) {
|
|
156
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/${item}?network_id=${NET}`, { done })).status).toBe(200);
|
|
157
|
+
}
|
|
158
|
+
const n = notices(id);
|
|
159
|
+
for (const uid of [owen.id, cara.id, pia.id]) {
|
|
160
|
+
expect(n[uid].length).toBe(1);
|
|
161
|
+
expect(n[uid][0].content).toBe("参与人甲 更新了「pn-合并」:\n· 勾选了检查项「发版」\n· 取消勾选了检查项「写测试」");
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("收件人读过之后再改 → 另发一条;别人那条仍在合并", async () => {
|
|
166
|
+
const id = await card("pn-已读");
|
|
167
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
168
|
+
db.run("UPDATE user_inbox SET acked = 1 WHERE user_id = ?1 AND message_id = ?2", [owen.id, notices(id)[owen.id][0].message_id]);
|
|
169
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i2?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
170
|
+
const n = notices(id);
|
|
171
|
+
expect(n[owen.id].length).toBe(2);
|
|
172
|
+
expect(n[owen.id][1].content).toBe("参与人甲 在「pn-已读」勾选了检查项「发版」");
|
|
173
|
+
expect(n[cara.id].length).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("过了合并窗口 → 另发一条", async () => {
|
|
177
|
+
const id = await card("pn-窗口");
|
|
178
|
+
const row = (col: string, ck: boolean) => ({
|
|
179
|
+
requirement_id: id, network_id: NET, seq: null, title: "pn-窗口", column_name: col,
|
|
180
|
+
checklist_json: JSON.stringify([{ id: "i1", text: "写测试", done: ck }]),
|
|
181
|
+
owner_json: JSON.stringify(userRef(owen.id)), participants_json: JSON.stringify([userRef(pat.id)]),
|
|
182
|
+
created_by: null, created_by_json: JSON.stringify(userRef(cara.id)),
|
|
183
|
+
});
|
|
184
|
+
const t0 = Date.now();
|
|
185
|
+
expect(notify.notifyParticipantChange({ before: row("pool", false), after: row("pool", true), actorUserId: pat.id, now: t0 }).sort()).toEqual([owen.id, cara.id].sort());
|
|
186
|
+
notify.notifyParticipantChange({ before: row("pool", true), after: row("doing", true), actorUserId: pat.id, now: t0 + notify.COALESCE_WINDOW_MS - 1 });
|
|
187
|
+
expect(notices(id)[owen.id].length).toBe(1);
|
|
188
|
+
notify.notifyParticipantChange({ before: row("doing", true), after: row("done", true), actorUserId: pat.id, now: t0 + notify.COALESCE_WINDOW_MS + 1 });
|
|
189
|
+
expect(notices(id)[owen.id].length).toBe(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("推送:收件人的用户流收到 desktop_message(kind=human_dm,发信人 = 参与人)", async () => {
|
|
193
|
+
const id = await card("pn-推送");
|
|
194
|
+
const ctrl = new AbortController();
|
|
195
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: { Authorization: `Bearer ${owen.token}` }, signal: ctrl.signal });
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
const events: any[] = [];
|
|
198
|
+
const reader = res.body!.getReader();
|
|
199
|
+
const dec = new TextDecoder();
|
|
200
|
+
let buf = "";
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
for (;;) {
|
|
204
|
+
const { done, value } = await reader.read();
|
|
205
|
+
if (done) break;
|
|
206
|
+
buf += dec.decode(value);
|
|
207
|
+
let i: number;
|
|
208
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
209
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
210
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
})();
|
|
215
|
+
const until = async (cond: () => boolean) => { for (let k = 0; k < 150 && !cond(); k++) await new Promise(r => setTimeout(r, 20)); };
|
|
216
|
+
await until(() => events.some(e => e.type === "connected"));
|
|
217
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
218
|
+
await until(() => events.some(e => e.type === "desktop_message"));
|
|
219
|
+
ctrl.abort();
|
|
220
|
+
const ev = events.find(e => e.type === "desktop_message");
|
|
221
|
+
expect(ev).toMatchObject({ kind: "human_dm", from: pat.username, from_user_id: pat.id, title: "任务更新", message: "参与人甲 把「pn-推送」改为 进行中" });
|
|
222
|
+
expect(ev.meta.task_notice.requirement_id).toBe(id);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("MCP 与 REST 一致:requirements_update 改状态能过并通知,改标题被拒", async () => {
|
|
226
|
+
const id = await card("pn-mcp");
|
|
227
|
+
const ok = await mcp(pat.token, "requirements_update", { id, network_id: NET, column: "doing" });
|
|
228
|
+
expect(ok.ok).toBe(true);
|
|
229
|
+
expect(notices(id)[owen.id][0].content).toBe("参与人甲 把「pn-mcp」改为 进行中");
|
|
230
|
+
expect(notices(id)[owen.id][0].from_session).toBe(pat.username);
|
|
231
|
+
const no = await mcp(pat.token, "requirements_update", { id, network_id: NET, name: "改名" });
|
|
232
|
+
expect(no).toMatchObject({ ok: false, error: "task_read_only", field: "name", status: 403 });
|
|
233
|
+
const tog = await mcp(pat.token, "requirements_checklist_toggle", { id, network_id: NET, item_id: "i2", done: true });
|
|
234
|
+
expect(tog.ok).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("viewer_can:参与人带 edit_fields;负责人不带", async () => {
|
|
238
|
+
const id = await card("pn-权限");
|
|
239
|
+
const mine = await send(pat.token, "GET", `/api/requirements/${id}?network_id=${NET}`);
|
|
240
|
+
expect(mine.body.requirement.viewer_can).toEqual({ edit: false, delete: false, edit_fields: ["column", "checklist"] });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("参与人不能改的", () => {
|
|
245
|
+
test("标题 / 描述 / 负责人 / 负责 Agent / 参与人 / 项目 / 优先级 / 日期 / 标签:403 task_read_only + field;卡不变、不通知", async () => {
|
|
246
|
+
const id = await card("pn-拒绝");
|
|
247
|
+
const before = (await send(cara.token, "GET", `/api/requirements/${id}?network_id=${NET}`)).body.requirement;
|
|
248
|
+
const cases: Array<[string, unknown]> = [
|
|
249
|
+
["name", "改名"], ["description", "x"], ["owner", userRef(pat.id)], ["agent_owner", null], ["participants", [userRef(pat.id)]],
|
|
250
|
+
["project_id", PROJ], ["priority", "high"], ["due", "2026-12-31"], ["start", "2026-12-01"], ["tags", ["t"]], ["archived", true], ["parent_id", null],
|
|
251
|
+
];
|
|
252
|
+
for (const [field, value] of cases) {
|
|
253
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { [field]: value });
|
|
254
|
+
expect(r.status).toBe(403);
|
|
255
|
+
expect(r.body.ok).toBe(false);
|
|
256
|
+
expect(r.body.error).toBe("task_read_only");
|
|
257
|
+
expect(r.body.field).toBe(field);
|
|
258
|
+
expect(typeof r.body.message).toBe("string");
|
|
259
|
+
}
|
|
260
|
+
// 混着能改的字段一起发:整条拒绝,状态也不动。
|
|
261
|
+
const mixed = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing", name: "改名" });
|
|
262
|
+
expect(mixed.status).toBe(403);
|
|
263
|
+
expect(mixed.body.field).toBe("name");
|
|
264
|
+
const after = (await send(cara.token, "GET", `/api/requirements/${id}?network_id=${NET}`)).body.requirement;
|
|
265
|
+
expect(after).toEqual(before);
|
|
266
|
+
expect(notices(id)).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("负责人 / 创建者自己改:不通知;删除仍不行", async () => {
|
|
270
|
+
const id = await card("pn-负责人");
|
|
271
|
+
expect((await send(owen.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
272
|
+
expect((await send(cara.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
273
|
+
expect(notices(id)).toEqual({});
|
|
274
|
+
expect((await send(pat.token, "DELETE", `/api/requirements/${id}?network_id=${NET}`)).status).toBe(403);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("负责人同时是参与人:改了也不通知", async () => {
|
|
278
|
+
const id = await card("pn-兼任", { owner: userRef(pat.id), participants: [userRef(pat.id), userRef(pia.id)] });
|
|
279
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
280
|
+
expect(notices(id)).toEqual({});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("只看相关任务的无关成员:看不见、改不了(与不存在的卡逐字节相同),也收不到通知", async () => {
|
|
284
|
+
const id = await card("pn-无关");
|
|
285
|
+
const list = await send(sam.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
286
|
+
expect((list.body.requirements as any[]).some(x => x.id === id)).toBe(false);
|
|
287
|
+
const hidden = await send(sam.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" });
|
|
288
|
+
const ghost = await send(sam.token, "PATCH", `/api/requirements/req_does_not_exist?network_id=${NET}`, { column: "doing" });
|
|
289
|
+
expect(hidden.status).toBe(404);
|
|
290
|
+
expect(hidden.text).toBe(ghost.text);
|
|
291
|
+
const hiddenCk = await send(sam.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true });
|
|
292
|
+
expect(hiddenCk.status).toBe(404);
|
|
293
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
294
|
+
expect(notices(id)[sam.id]).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("收件人(纯函数)", () => {
|
|
299
|
+
test("只要人、去重、去掉操作者;旧卡没有 created_by_json 时用 created_by", () => {
|
|
300
|
+
const base = {
|
|
301
|
+
requirement_id: "r", network_id: NET, seq: 1, title: "t", column_name: "pool", checklist_json: null,
|
|
302
|
+
owner_json: JSON.stringify(userRef("u-owner")),
|
|
303
|
+
participants_json: JSON.stringify([userRef("u-actor"), userRef("u-owner"), { kind: "node", id: "n-1" }, userRef("u-other")]),
|
|
304
|
+
created_by: null, created_by_json: JSON.stringify({ kind: "node", id: "n-2" }),
|
|
305
|
+
};
|
|
306
|
+
expect(notify.noticeRecipients(base, "u-actor").sort()).toEqual(["u-other", "u-owner"]);
|
|
307
|
+
expect(notify.noticeRecipients({ ...base, created_by_json: null, created_by: "u-legacy" }, "u-actor").sort()).toEqual(["u-legacy", "u-other", "u-owner"]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -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
|
+
});
|