@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,349 @@
1
+ // #506 —— GET /api/requirements 每行带 last_event:这张卡最新的一条动态(requirement_events,评论也算)。
2
+ // HTTP 集成测试(真实 Bun.serve,临时库;PG 梯子上用 COMMHUB_TEST_PG_URL 原样再跑一遍)。钉住:
3
+ // - 形状:{ type, field, actor: { id, kind, display_name } | null, at, summary? };没有流水的卡 = null(字段总在);
4
+ // - 评论(不改卡的任何一列)成为 last_event,summary 是正文摘要;
5
+ // - 节点(节点令牌)改状态 → actor.kind = "node",display_name = 节点名,summary「pool → doing」;
6
+ // - ETag:没变时 304,加一条评论之后同一个 If-None-Match 不再命中(列表缓存也作废);
7
+ // - 可见范围同行:受限成员看不见的节点 actor = null;scoped 成员看不见的卡根本不在列表里;
8
+ // - view=summary 同样带 last_event;REST last_event=0 不带(其他值 400 invalid_last_event);
9
+ // - MCP requirements_list 默认不带(守住 #471 的上下文预算),include_last_event=true 才带,参数严格(非布尔 → 报错)。
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-last-event-"));
16
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
17
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
18
+
19
+ const PW = "LastEventPassw0rd!x";
20
+ let BASE = "";
21
+ let hub: any = null;
22
+ let NET = "";
23
+ let db: any = null;
24
+ let admin = { token: "", id: "" };
25
+ let member = { token: "", id: "" };
26
+ let scoped = { token: "", id: "" };
27
+ let restricted = { token: "", id: "" };
28
+ let nodeToken = "";
29
+ let NODE = "";
30
+
31
+ type R = { status: number; body: any; etag: string | null };
32
+ async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
33
+ const res = await fetch(`${BASE}${path}`, {
34
+ method,
35
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
36
+ body: payload === undefined ? undefined : JSON.stringify(payload),
37
+ });
38
+ const text = await res.text();
39
+ let body: any = null;
40
+ try { body = JSON.parse(text); } catch {}
41
+ return { status: res.status, body, etag: res.headers.get("etag") };
42
+ }
43
+ const create = async (t: string, card: Record<string, unknown>) => {
44
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
45
+ expect(r.status).toBe(201);
46
+ return r.body.requirement as { id: string; seq: number };
47
+ };
48
+ const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
49
+ const rowOf = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id);
50
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; text: string; isError: boolean }> {
51
+ const res = await fetch(`${BASE}/mcp`, {
52
+ method: "POST",
53
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
54
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
55
+ });
56
+ const raw = await res.text();
57
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
58
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
59
+ const text = out.result?.content?.[0]?.text ?? out.error?.message ?? "";
60
+ let data: any = null;
61
+ try { data = JSON.parse(text); } catch {}
62
+ return { data, text, isError: !!out.error || !!out.result?.isError };
63
+ }
64
+ const comment = (t: string, id: string, text: string) => send(t, "POST", `/api/requirements/${id}/comments?network_id=${NET}`, { text });
65
+
66
+ beforeAll(async () => {
67
+ process.env.HOST = "127.0.0.1";
68
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
69
+ db = (await import("./db.js")).db;
70
+ const a = register(`le_admin_${Date.now()}`, PW, undefined, "示例管理员");
71
+ expect(a.ok).toBe(true);
72
+ admin = { token: a.token!, id: a.user!.user_id };
73
+ NET = a.network_id!;
74
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
75
+ const mod: any = await import("./server.js");
76
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
77
+ BASE = `http://127.0.0.1:${hub.port}`;
78
+ const mk = async (username: string) => {
79
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
80
+ expect(r.status).toBe(200);
81
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
82
+ expect(login.status).toBe(200);
83
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
84
+ };
85
+ const stamp = Date.now();
86
+ member = await mk(`le_member_${stamp}`);
87
+ scoped = await mk(`le_scoped_${stamp}`);
88
+ restricted = await mk(`le_restricted_${stamp}`);
89
+ db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["示例成员", member.id]);
90
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
91
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
92
+ // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
93
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
94
+ NODE = `n_le_${stamp}`;
95
+ const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
96
+ expect(ntok.ok).toBe(true);
97
+ nodeToken = ntok.token!;
98
+ // Real legacy-client order, with no owner backfill or administrator claim.
99
+ expect((await mcp(nodeToken, "report_status", {
100
+ resume_id: `le-session-${stamp}`, alias: "le-node", status: "idle", node_id: NODE,
101
+ })).data.ok).toBe(true);
102
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE).owner_user_id).toBeNull();
103
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id).bound_node_id).toBe(NODE);
104
+ }, 30_000);
105
+
106
+ afterAll(() => {
107
+ try { hub?.stop?.(true); } catch {}
108
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
109
+ });
110
+
111
+ describe("#506 GET /api/requirements last_event", () => {
112
+ test("capability `last_event` is advertised; a fresh card's last_event is its `created` event", async () => {
113
+ const card = await create(member.token, { name: "最新动态示例一" });
114
+ const r = await list(admin.token);
115
+ expect(r.status).toBe(200);
116
+ expect(r.body.capabilities).toContain("last_event");
117
+ const row = rowOf(r, card.id);
118
+ expect(row.last_event).toEqual({ type: "created", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, at: row.last_event.at });
119
+ expect(Date.parse(row.last_event.at)).toBeGreaterThan(0);
120
+ });
121
+
122
+ test("rows with no events → last_event null (field present)", async () => {
123
+ const card = await create(admin.token, { name: "没有流水的旧卡" });
124
+ // 动态上线前建的卡 / 过了 180 天保留期的卡:流水里没有它
125
+ db.run("DELETE FROM requirement_events WHERE requirement_id = ?1", [card.id]);
126
+ const row = rowOf(await list(admin.token), card.id);
127
+ expect("last_event" in row).toBe(true);
128
+ expect(row.last_event).toBeNull();
129
+ });
130
+
131
+ test("a comment becomes last_event (type comment, summary = text), though it changes no column", async () => {
132
+ const card = await create(admin.token, { name: "评论示例" });
133
+ const before = rowOf(await list(admin.token), card.id);
134
+ expect((await comment(member.token, card.id, "第一条进展:\n已经复现, 正在修。")).status).toBe(201);
135
+ const row = rowOf(await list(admin.token), card.id);
136
+ expect(row.updatedAt).toBe(before.updatedAt); // 评论不改卡
137
+ expect(row.last_event).toMatchObject({ type: "comment", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, summary: "第一条进展: 已经复现, 正在修。" });
138
+ // 很长的评论:摘要截断到 120 字
139
+ await comment(admin.token, card.id, "长".repeat(500));
140
+ const long = rowOf(await list(admin.token), card.id).last_event;
141
+ expect(long.actor).toEqual({ id: admin.id, kind: "user", display_name: "示例管理员" });
142
+ expect(long.summary.length).toBe(120);
143
+ expect(long.summary.endsWith("…")).toBe(true);
144
+ });
145
+
146
+ test("a status change by a node shows kind=node with the node's name and `pool → doing`", async () => {
147
+ const card = await create(admin.token, { name: "节点改状态示例" });
148
+ const p = await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { column: "doing" });
149
+ expect(p.status).toBe(200);
150
+ const row = rowOf(await list(admin.token), card.id);
151
+ expect(row.last_event).toMatchObject({ type: "changed", field: "column", actor: { id: NODE, kind: "node", display_name: "le-node" }, summary: "pool → doing" });
152
+ // 节点自己读列表(MCP requirements_list 走同一条路,默认 view=summary)也带着
153
+ const viaNode = rowOf(await list(nodeToken, "&view=summary"), card.id);
154
+ expect(viaNode.last_event).toEqual(row.last_event);
155
+ });
156
+
157
+ test("ETag: 304 while nothing changed; adding a comment changes the ETag (cache invalidated)", async () => {
158
+ const card = await create(admin.token, { name: "ETag 示例" });
159
+ const first = await list(admin.token);
160
+ expect(first.etag).toBeTruthy();
161
+ const again = await list(admin.token, "", { "If-None-Match": first.etag! });
162
+ expect(again.status).toBe(304);
163
+ expect((await comment(admin.token, card.id, "加一条评论")).status).toBe(201);
164
+ const after = await list(admin.token, "", { "If-None-Match": first.etag! });
165
+ expect(after.status).toBe(200);
166
+ expect(after.etag).not.toBe(first.etag);
167
+ expect(rowOf(after, card.id).last_event).toMatchObject({ type: "comment", summary: "加一条评论" });
168
+ // summary 视图的缓存键不同,同样作废
169
+ const s1 = await list(admin.token, "&view=summary");
170
+ await comment(admin.token, card.id, "再加一条");
171
+ const s2 = await list(admin.token, "&view=summary", { "If-None-Match": s1.etag! });
172
+ expect(s2.status).toBe(200);
173
+ expect(rowOf(s2, card.id).last_event.summary).toBe("再加一条");
174
+ });
175
+
176
+ test("visibility: a restricted member sees the node actor masked; a scoped member does not get the row at all", async () => {
177
+ const card = await create(admin.token, { name: "可见范围示例" });
178
+ expect((await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
179
+ const seen = rowOf(await list(restricted.token), card.id);
180
+ expect(seen.last_event).toMatchObject({ type: "changed", field: "priority", actor: null, summary: "normal → high" });
181
+ expect(JSON.stringify(seen.last_event)).not.toContain(NODE);
182
+ expect(JSON.stringify(seen.last_event)).not.toContain("le-node");
183
+ // 只看相关任务的成员:这张卡与他无关 → 不在列表里,last_event 也就无从谈起
184
+ const s = await list(scoped.token);
185
+ expect(s.status).toBe(200);
186
+ expect(rowOf(s, card.id)).toBeUndefined();
187
+ expect(JSON.stringify(s.body)).not.toContain(NODE);
188
+ });
189
+
190
+ test("restricted member: an agent_owner change between hidden nodes degrades to a bare `changed` (no field / summary)", async () => {
191
+ const other = `n_le_other_${Date.now()}`;
192
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [other, "le-other", "le-other", NET]);
193
+ const card = await create(admin.token, { name: "隐去示例", agent_owner: { kind: "node", id: NODE } });
194
+ expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { agent_owner: { kind: "node", id: other } })).status).toBe(200);
195
+ expect(rowOf(await list(admin.token), card.id).last_event).toMatchObject({ type: "changed", field: "agent_owner", actor: { id: admin.id, kind: "user" } });
196
+ const seen = rowOf(await list(restricted.token), card.id).last_event;
197
+ expect(seen).toEqual({ type: "changed", field: null, actor: { id: admin.id, kind: "user", display_name: "示例管理员" }, at: seen.at });
198
+ });
199
+
200
+ test("REST last_event=0 drops the field; any other value is 400 invalid_last_event with field", async () => {
201
+ const card = await create(admin.token, { name: "REST 开关示例" });
202
+ const off = await list(admin.token, "&last_event=0");
203
+ expect(off.status).toBe(200);
204
+ expect("last_event" in rowOf(off, card.id)).toBe(false);
205
+ expect(off.body.requirements.some((r: any) => "last_event" in r)).toBe(false);
206
+ expect(rowOf(await list(admin.token, "&last_event=1"), card.id).last_event).toMatchObject({ type: "created" });
207
+ const bad = await list(admin.token, "&last_event=yes");
208
+ expect(bad.status).toBe(400);
209
+ expect(bad.body).toMatchObject({ ok: false, error: "invalid_last_event", field: "last_event" });
210
+ });
211
+
212
+ test("MCP requirements_list: no last_event by default; include_last_event=true adds it; non-boolean rejected", async () => {
213
+ const card = await create(admin.token, { name: "MCP 开关示例" });
214
+ await comment(admin.token, card.id, "MCP 能看到的评论");
215
+ const def = await mcp(nodeToken, "requirements_list", {});
216
+ expect(def.isError).toBe(false);
217
+ expect(def.data.requirements.length).toBeGreaterThan(0);
218
+ expect(def.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
219
+ expect(def.text).not.toContain(`"last_event":`); // (capabilities still lists "last_event" — the Hub supports it)
220
+ const off = await mcp(nodeToken, "requirements_list", { include_last_event: false });
221
+ expect(off.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
222
+ const on = await mcp(nodeToken, "requirements_list", { include_last_event: true });
223
+ expect(on.isError).toBe(false);
224
+ expect(on.data.requirements.every((r: any) => "last_event" in r)).toBe(true);
225
+ expect(on.data.requirements.find((r: any) => r.id === card.id).last_event).toMatchObject({ type: "comment", actor: { id: admin.id, kind: "user" }, summary: "MCP 能看到的评论" });
226
+ const bad = await mcp(nodeToken, "requirements_list", { include_last_event: "yes" });
227
+ expect(bad.isError).toBe(true);
228
+ expect(bad.text).toContain("include_last_event");
229
+ });
230
+
231
+ test("one page of many cards: every row gets its own latest event", async () => {
232
+ const ids: string[] = [];
233
+ for (let i = 0; i < 12; i++) ids.push((await create(admin.token, { name: `批量 ${i}` })).id);
234
+ for (let i = 0; i < ids.length; i += 2) await comment(member.token, ids[i], `评论 ${i}`);
235
+ const r = await list(admin.token, "&limit=50");
236
+ for (let i = 0; i < ids.length; i++) {
237
+ const ev = rowOf(r, ids[i]).last_event;
238
+ if (i % 2 === 0) expect(ev).toMatchObject({ type: "comment", summary: `评论 ${i}` });
239
+ else expect(ev).toMatchObject({ type: "created", actor: { id: admin.id } });
240
+ }
241
+ });
242
+ });
243
+
244
+ // #506 跟进 —— 评论不动 updated_at,所以增量读(changes=1 + updated_since)过去看不见它:「评论了」要等下次整读。
245
+ // 现在 changes=1(且带 last_event)时,游标之后有新流水的卡也回。updated_at 语义不变(评论不改卡)。
246
+ describe("#506 changes=1 returns cards whose newest event is after the cursor", () => {
247
+ const tick = () => new Promise(r => setTimeout(r, 5));
248
+ const changes = (t: string, since: string, extra = "", headers: Record<string, string> = {}) =>
249
+ list(t, `&changes=1&updated_since=${encodeURIComponent(since)}${extra}`, headers);
250
+ const cursorNow = async (t: string) => {
251
+ const base = await changes(t, new Date().toISOString());
252
+ expect(base.status).toBe(200);
253
+ await tick();
254
+ return base.body.server_time as string;
255
+ };
256
+
257
+ test("a comment after the cursor → that row comes back with the new last_event; untouched rows do not", async () => {
258
+ const a = await create(admin.token, { name: "增量评论 A" });
259
+ const b = await create(admin.token, { name: "增量评论 B" });
260
+ const before = rowOf(await list(admin.token), a.id);
261
+ await tick();
262
+ const since = await cursorNow(admin.token);
263
+ expect((await comment(member.token, a.id, "增量里看得到这条")).status).toBe(201);
264
+ const r = await changes(admin.token, since);
265
+ expect(r.status).toBe(200);
266
+ const ids = (r.body.requirements as any[]).map(x => x.id);
267
+ expect(ids).toEqual([a.id]);
268
+ expect(ids).not.toContain(b.id);
269
+ const row = rowOf(r, a.id);
270
+ expect(row.updatedAt).toBe(before.updatedAt); // updated_at 语义不变:评论不改卡
271
+ expect(row.last_event).toMatchObject({ type: "comment", actor: { id: member.id, kind: "user" }, summary: "增量里看得到这条" });
272
+ expect(r.body.deleted).toEqual([]);
273
+ // summary 视图同样回
274
+ const s = await changes(admin.token, since, "&view=summary");
275
+ expect((s.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
276
+ expect(rowOf(s, a.id).last_event.summary).toBe("增量里看得到这条");
277
+ });
278
+
279
+ test("no event and no update after the cursor → nothing returned; a comment then changes the result and its ETag", async () => {
280
+ const a = await create(admin.token, { name: "增量空" });
281
+ await tick();
282
+ const since = await cursorNow(admin.token);
283
+ const empty = await changes(admin.token, since);
284
+ expect(empty.status).toBe(200);
285
+ expect(empty.body.requirements).toEqual([]);
286
+ expect(empty.body.deleted).toEqual([]);
287
+ expect(empty.etag).toBeTruthy();
288
+ expect((await comment(admin.token, a.id, "空之后的评论")).status).toBe(201);
289
+ const after = await changes(admin.token, since, "", { "If-None-Match": empty.etag! });
290
+ expect(after.status).toBe(200);
291
+ expect(after.etag).not.toBe(empty.etag);
292
+ expect((after.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
293
+ });
294
+
295
+ test("full-list ETag changes on a comment (list cache invalidated by the write)", async () => {
296
+ const a = await create(admin.token, { name: "整表 ETag" });
297
+ const first = await list(admin.token);
298
+ expect((await list(admin.token, "", { "If-None-Match": first.etag! })).status).toBe(304);
299
+ expect((await comment(admin.token, a.id, "整表也变")).status).toBe(201);
300
+ const after = await list(admin.token, "", { "If-None-Match": first.etag! });
301
+ expect(after.status).toBe(200);
302
+ expect(after.etag).not.toBe(first.etag);
303
+ });
304
+
305
+ test("unchanged contracts: last_event=0 (MCP default) and plain updated_since (no changes=1) still filter by updated_at only", async () => {
306
+ const a = await create(admin.token, { name: "旧语义" });
307
+ await tick();
308
+ const since = await cursorNow(admin.token);
309
+ expect((await comment(admin.token, a.id, "只是一条评论")).status).toBe(201);
310
+ expect((await changes(admin.token, since, "&last_event=0")).body.requirements).toEqual([]);
311
+ const plain = await list(admin.token, `&updated_since=${encodeURIComponent(since)}`);
312
+ expect(plain.status).toBe(200);
313
+ expect(rowOf(plain, a.id)).toBeUndefined();
314
+ // MCP changes=true 默认不带 last_event → 同样不回;include_last_event=true → 回
315
+ const mDef = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since });
316
+ expect(mDef.isError).toBe(false);
317
+ expect(mDef.data.requirements.map((r: any) => r.id)).not.toContain(a.id);
318
+ const mOn = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since, include_last_event: true });
319
+ expect(mOn.isError).toBe(false);
320
+ expect(mOn.data.requirements.find((r: any) => r.id === a.id)?.last_event).toMatchObject({ type: "comment", summary: "只是一条评论" });
321
+ });
322
+
323
+ test("a real update after the cursor still comes back (updated_at path); a delete is reported in `deleted`, not as a row", async () => {
324
+ const a = await create(admin.token, { name: "增量改动" });
325
+ const d = await create(admin.token, { name: "增量删除" });
326
+ await comment(admin.token, d.id, "删之前的评论");
327
+ await tick();
328
+ const since = await cursorNow(admin.token);
329
+ expect((await send(admin.token, "PATCH", `/api/requirements/${a.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
330
+ expect((await send(admin.token, "DELETE", `/api/requirements/${d.id}?network_id=${NET}`)).status).toBe(200);
331
+ const r = await changes(admin.token, since);
332
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
333
+ expect(rowOf(r, a.id).last_event).toMatchObject({ type: "changed", field: "priority" });
334
+ expect(r.body.deleted).toEqual([d.id]);
335
+ });
336
+
337
+ test("visibility: a scoped member does not get a card it cannot see, even when it was commented on", async () => {
338
+ const hidden = await create(admin.token, { name: "scoped 看不见" });
339
+ const mine = await create(admin.token, { name: "scoped 看得见", owner: { kind: "user", id: scoped.id } });
340
+ await tick();
341
+ const since = await cursorNow(scoped.token);
342
+ expect((await comment(admin.token, hidden.id, "别人的卡上的评论")).status).toBe(201);
343
+ expect((await comment(admin.token, mine.id, "他的卡上的评论")).status).toBe(201);
344
+ const r = await changes(scoped.token, since);
345
+ expect(r.status).toBe(200);
346
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([mine.id]);
347
+ expect(JSON.stringify(r.body)).not.toContain(hidden.id);
348
+ });
349
+ });
@@ -0,0 +1,155 @@
1
+ // #471(#469 问题 3)—— MCP requirements_list 默认省流 + 严格参数。
2
+ //
3
+ // 钉住:
4
+ // 1. MCP 默认 = summary 视图 + 50 张一页:120 张有正文 / 检查项 / 标签 / 参与人的卡,第一页 50 张、has_more、
5
+ // next_cursor 接着翻,两页不重叠;一页的线上字节有上限(见 SUMMARY_PAGE_MAX_BYTES)。
6
+ // 2. view='full' 照旧给全文(description / checklist);limit 可显式放大。
7
+ // 3. 不认识的参数(以前的 tag 写错成 tags / 任何拼错)→ -32602,错误里列出能用的参数;不会回整张表。
8
+ // 4. tag= 精确筛(区分大小写),和 limit / cursor 一起翻页。
9
+ // 5. REST GET /api/requirements 不变:不带参数仍是 full + 最多 500 张(App 靠它)。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/requirements-list-mcp-slim-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { register } from "./auth.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
21
+ if (!activeDbPath) throw new Error("requirements-list-mcp-slim requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
22
+
23
+ const TASKS = 120;
24
+ /**
25
+ * 一页 summary(50 张;每张中文标题、3 个标签、负责人 + 参与人、8 条检查项、1.5 KB 正文)的字节上限。
26
+ * 这批数据实测 39.7 KB(≈ 795 B / 张,中文 3 字节 / 字);生产 122 张 summary 81 KB ≈ 670 B / 张 → 50 张约 33 KB。
27
+ * 上限 45 KB。同一批数据走旧默认(full、500 张)是整张表 120 张全文。
28
+ */
29
+ const SUMMARY_PAGE_MAX_BYTES = 45_000;
30
+
31
+ let server: any;
32
+ let base = "";
33
+ let token = "", userId = "", netId = "";
34
+ let call: (args: Record<string, unknown>) => Promise<{ isError: boolean; text: string }>;
35
+ let closeMcp: () => Promise<void>;
36
+
37
+ const DESCRIPTION = "背景:".padEnd(1500, "这是一段很长的任务描述,用来模拟真实看板上的正文。");
38
+ const checklist = Array.from({ length: 8 }, (_, i) => ({ text: `检查项 ${i + 1}:确认这一步已经完成并且有记录` }));
39
+
40
+ beforeAll(async () => {
41
+ const stamp = Date.now();
42
+ const u = register(`slim_${stamp}`, "SlimList123!", undefined, "seed");
43
+ expect(u.ok).toBe(true);
44
+ token = u.token!; userId = u.user!.user_id; netId = u.network_id!;
45
+ const mod: any = await import("./server.js");
46
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ base = `http://127.0.0.1:${server.port}`;
48
+
49
+ const s = new McpServer({ name: "slim", version: "1" });
50
+ registerTools(s, undefined, null, userId, u.user!.username, false, "tok_user");
51
+ const client = new Client({ name: "slim-client", version: "1" });
52
+ const [ct, st] = InMemoryTransport.createLinkedPair();
53
+ await s.connect(st);
54
+ await client.connect(ct);
55
+ const raw = async (name: string, args: Record<string, unknown>) => {
56
+ const r: any = await client.callTool({ name, arguments: args });
57
+ return { isError: r.isError === true, text: String(r.content?.[0]?.text ?? "") };
58
+ };
59
+ call = (args) => raw("requirements_list", { network_id: netId, ...args });
60
+ closeMcp = async () => { await client.close(); await s.close(); };
61
+
62
+ for (let i = 0; i < TASKS; i++) {
63
+ const tags = ["bug", "后端", `批次${i % 4}`];
64
+ if (i % 3 === 0) tags[0] = "Bug"; // 大小写不同的另一个标签
65
+ const r = await raw("requirements_create", {
66
+ network_id: netId, name: `任务 ${i}:修复列表接口返回过大的问题`, description: DESCRIPTION, checklist, tags,
67
+ owner: { kind: "user", id: userId }, participants: [{ kind: "user", id: userId }],
68
+ priority: "normal", column: i % 2 ? "doing" : "pool",
69
+ });
70
+ expect(r.isError).toBe(false);
71
+ }
72
+ }, 120_000);
73
+
74
+ afterAll(async () => { await closeMcp?.(); server?.stop?.(true); });
75
+
76
+ describe("#471 MCP requirements_list defaults", () => {
77
+ test("default = summary, 50 per page, under the byte bound; cursor pages without overlap", async () => {
78
+ const first = await call({});
79
+ expect(first.isError).toBe(false);
80
+ const bytes = Buffer.byteLength(first.text, "utf8");
81
+ const body = JSON.parse(first.text);
82
+ expect(body.view).toBe("summary");
83
+ expect(body.requirements.length).toBe(50);
84
+ expect(body.has_more).toBe(true);
85
+ expect(typeof body.next_cursor).toBe("string");
86
+ for (const r of body.requirements) {
87
+ expect(r.description).toBeUndefined();
88
+ expect(r.checklist).toBeUndefined();
89
+ expect(r.has_description).toBe(true);
90
+ expect(r.checklist_count).toEqual({ total: 8, done: 0 });
91
+ }
92
+ console.log(`summary page of 50: ${bytes} B`);
93
+ expect(bytes).toBeLessThan(SUMMARY_PAGE_MAX_BYTES);
94
+
95
+ const second = JSON.parse((await call({ cursor: body.next_cursor })).text);
96
+ expect(second.requirements.length).toBe(50);
97
+ const third = JSON.parse((await call({ cursor: second.next_cursor })).text);
98
+ expect(third.requirements.length).toBe(TASKS - 100);
99
+ expect(third.has_more).toBe(false);
100
+ const ids = [...body.requirements, ...second.requirements, ...third.requirements].map((r: any) => r.id);
101
+ expect(new Set(ids).size).toBe(TASKS);
102
+ });
103
+
104
+ test("view='full' still returns description and checklist; limit is honoured", async () => {
105
+ const r = JSON.parse((await call({ view: "full", limit: 3 })).text);
106
+ expect(r.view).toBeUndefined();
107
+ expect(r.requirements.length).toBe(3);
108
+ expect(r.requirements[0].description).toBe(DESCRIPTION);
109
+ expect(r.requirements[0].checklist.length).toBe(8);
110
+ });
111
+
112
+ test("unknown parameters → -32602 listing the valid ones; never the whole table", async () => {
113
+ for (const bad of [{ tags: "bug" }, { lable: "x" }, { status: "doing", foo: 1 }]) {
114
+ const r = await call(bad);
115
+ expect(r.isError).toBe(true);
116
+ expect(r.text).toContain("-32602");
117
+ expect(r.text).toContain(`unknown parameter(s): ${Object.keys(bad).filter(k => k !== "status").join(", ")}`);
118
+ expect(r.text).toContain("valid parameters: network_id, seq, status, project_id, owner, agent_owner, tag,");
119
+ expect(r.text).not.toContain('"requirements":[');
120
+ }
121
+ });
122
+
123
+ test("tag = exact label match (case-sensitive), pages with limit/cursor", async () => {
124
+ const lower = JSON.parse((await call({ tag: "bug", limit: 100 })).text);
125
+ expect(lower.requirements.length).toBe(TASKS - TASKS / 3);
126
+ expect(lower.requirements.every((r: any) => r.tags.includes("bug"))).toBe(true);
127
+ const upper = JSON.parse((await call({ tag: "Bug", limit: 100 })).text);
128
+ expect(upper.requirements.length).toBe(TASKS / 3);
129
+ expect(upper.requirements.every((r: any) => r.tags.includes("Bug") && !r.tags.includes("bug"))).toBe(true);
130
+ const p1 = JSON.parse((await call({ tag: "批次1", limit: 20 })).text);
131
+ expect(p1.requirements.length).toBe(20);
132
+ expect(p1.has_more).toBe(true);
133
+ const p2 = JSON.parse((await call({ tag: "批次1", limit: 20, cursor: p1.next_cursor })).text);
134
+ expect(p2.requirements.length).toBe(TASKS / 4 - 20);
135
+ expect(p2.has_more).toBe(false);
136
+ expect((await call({ tag: "nope" })).text).toContain('"requirements":[]');
137
+ // tag + q 组合:两个条件都要满足
138
+ const both = JSON.parse((await call({ tag: "批次2", q: "任务 10", limit: 100 })).text);
139
+ expect(both.requirements.map((r: any) => r.name)).toContain("任务 10:修复列表接口返回过大的问题");
140
+ expect(both.requirements.every((r: any) => r.tags.includes("批次2") && /任务 \d*10/.test(r.name))).toBe(true);
141
+ });
142
+
143
+ test("REST GET /api/requirements is unchanged: no params = full view, up to 500", async () => {
144
+ const res = await fetch(`${base}/api/requirements?network_id=${netId}`, { headers: { Authorization: `Bearer ${token}` } });
145
+ expect(res.status).toBe(200);
146
+ const body = await res.json() as any;
147
+ expect(body.view).toBeUndefined();
148
+ expect(body.requirements.length).toBe(TASKS);
149
+ expect(body.requirements[0].description).toBe(DESCRIPTION);
150
+ // REST 的 tag= 是新增的可选参数:不合法 → 400,与其它筛选参数同一形状
151
+ const bad = await fetch(`${base}/api/requirements?network_id=${netId}&tag=${"x".repeat(21)}`, { headers: { Authorization: `Bearer ${token}` } });
152
+ expect(bad.status).toBe(400);
153
+ expect(((await bad.json()) as any).error).toBe("invalid_tag");
154
+ });
155
+ });