@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.
- 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 +930 -35
- 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 +129 -0
- package/src/rest-identity.ts +125 -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 +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +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 +141 -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-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 +3927 -323
- 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,164 @@
|
|
|
1
|
+
// #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误带上 field / message / hint,`error` 原样。
|
|
2
|
+
// 每条提示一个测试(经 MCP,以 Agent / 人的身份),外加一道取集门:requirements.ts 里返回 / 抛出的每个错误码都在
|
|
3
|
+
// requirements-errors.ts 的登记表里,新加的错误码没登记 ⇒ 红。
|
|
4
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { __documentedErrorCodes, errorBody } from "./requirements-errors.js";
|
|
11
|
+
|
|
12
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-errors-"));
|
|
13
|
+
let BASE = "";
|
|
14
|
+
let hub: any = null;
|
|
15
|
+
const PW = "ReqErrorsPassw0rd!xyz";
|
|
16
|
+
let NET = "", ownerToken = "", ownerId = "", nodeToken = "", memberToken = "", cardId = "";
|
|
17
|
+
|
|
18
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
19
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
let json: any = null;
|
|
22
|
+
try { json = JSON.parse(text); } catch {}
|
|
23
|
+
return { status: res.status, body: json };
|
|
24
|
+
}
|
|
25
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
26
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
29
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
30
|
+
});
|
|
31
|
+
const raw = await res.text();
|
|
32
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
33
|
+
const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
34
|
+
return JSON.parse(out.result.content[0].text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
39
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
40
|
+
process.env.HOST = "127.0.0.1";
|
|
41
|
+
const a = register(`reqerr_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
42
|
+
ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
|
|
43
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [ownerId]);
|
|
44
|
+
nodeToken = createNetworkTokenForNode(ownerId, NET, "示例-甲", "node_reqerr_a").token!;
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
48
|
+
const username = `reqerr_member_${Date.now()}`;
|
|
49
|
+
expect((await rest(ownerToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
|
|
50
|
+
memberToken = (await rest("", "POST", "/api/auth/login", { username, password: PW })).body.token;
|
|
51
|
+
cardId = (await mcp(nodeToken, "requirements_create", { name: "错误提示测试" })).requirement.id;
|
|
52
|
+
}, 30_000);
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
try { hub?.stop?.(true); } catch {}
|
|
56
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("each hint, over MCP", () => {
|
|
60
|
+
test("invalid_due: field due, the accepted formats", async () => {
|
|
61
|
+
for (const due of ["下周五", "2026/10/10"]) {
|
|
62
|
+
const r = await mcp(nodeToken, "requirements_update", { id: cardId, due });
|
|
63
|
+
expect(r.error).toBe("invalid_due");
|
|
64
|
+
expect(r.status).toBe(400);
|
|
65
|
+
expect(r.field).toBe("due");
|
|
66
|
+
expect(r.hint).toContain("YYYY-MM-DD");
|
|
67
|
+
expect(r.hint).toContain("ISO 8601");
|
|
68
|
+
}
|
|
69
|
+
expect((await mcp(nodeToken, "requirements_update", { id: cardId, start: "明天" })).field).toBe("start");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("person_not_in_network: names the field, explains node_id vs user_id and where to find them", async () => {
|
|
73
|
+
const byAlias = await mcp(nodeToken, "requirements_update", { id: cardId, agent_owner: { kind: "node", id: "示例-甲" } });
|
|
74
|
+
expect(byAlias.error).toBe("person_not_in_network");
|
|
75
|
+
expect(byAlias.field).toBe("agent_owner");
|
|
76
|
+
// #473:人员字段也收名字 —— 提示改为指向 requirements_people,并说明 alias / username 的写法。
|
|
77
|
+
expect(byAlias.hint).toContain("requirements_people");
|
|
78
|
+
expect(byAlias.hint).toContain("{kind:'node', alias}");
|
|
79
|
+
expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
|
|
80
|
+
expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("project_not_in_network: a name is recognised as a name; both point to projects_list", async () => {
|
|
84
|
+
const byName = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "示例项目" });
|
|
85
|
+
expect(byName.error).toBe("project_not_in_network");
|
|
86
|
+
expect(byName.field).toBe("project_id");
|
|
87
|
+
expect(byName.message).toContain("looks like a project name");
|
|
88
|
+
expect(byName.hint).toContain("projects_list");
|
|
89
|
+
const byId = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "proj_missing" });
|
|
90
|
+
expect(byId.error).toBe("project_not_in_network");
|
|
91
|
+
expect(byId.message).not.toContain("project name");
|
|
92
|
+
expect(byId.hint).toContain("projects_list");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("empty_patch: lists the writable fields; sending status says to use column", async () => {
|
|
96
|
+
// The MCP tool schema strips keys it does not know, so `status` arrives as nothing; REST sees the raw body.
|
|
97
|
+
const viaRest = await rest(nodeToken, "PATCH", `/api/requirements/${cardId}`, { status: "doing" });
|
|
98
|
+
expect(viaRest.status).toBe(400);
|
|
99
|
+
expect(viaRest.body.error).toBe("empty_patch");
|
|
100
|
+
expect(viaRest.body.hint).toContain("use column");
|
|
101
|
+
expect(viaRest.body.hint).toContain("Writable fields: column");
|
|
102
|
+
expect(viaRest.body.message).toContain("status");
|
|
103
|
+
const viaMcp = await mcp(nodeToken, "requirements_update", { id: cardId });
|
|
104
|
+
expect(viaMcp.error).toBe("empty_patch");
|
|
105
|
+
expect(viaMcp.hint).toContain("Writable fields:");
|
|
106
|
+
expect(viaMcp.hint).not.toContain("use column");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("user_token_required: says projects are managed by people and what to do instead", async () => {
|
|
110
|
+
const r = await mcp(nodeToken, "projects_create", { name: "节点建项目" });
|
|
111
|
+
expect(r.error).toBe("user_token_required");
|
|
112
|
+
expect(r.status).toBe(403);
|
|
113
|
+
expect(r.message).toContain("node token");
|
|
114
|
+
expect(r.hint).toContain("projects_list");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("network_id_required: field network_id and how to fix it", async () => {
|
|
118
|
+
const r = await mcp(memberToken, "projects_create", { name: "成员建项目" });
|
|
119
|
+
expect(r.error).toBe("network_id_required");
|
|
120
|
+
expect(r.field).toBe("network_id");
|
|
121
|
+
expect(r.hint).toContain("network_id");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("swept codes carry hints too: checklist_item_not_found, external_ref_exists (existing_id kept), owner_must_be_human, parent_cycle", async () => {
|
|
125
|
+
const ck = await mcp(nodeToken, "requirements_checklist_toggle", { id: cardId, item_id: "nope", done: true });
|
|
126
|
+
expect(ck.error).toBe("checklist_item_not_found");
|
|
127
|
+
expect(ck.hint).toContain("requirements_get");
|
|
128
|
+
await mcp(nodeToken, "requirements_create", { name: "外部", external_ref: "github:example/repo#472" });
|
|
129
|
+
const dup = await mcp(nodeToken, "requirements_create", { name: "外部又一次", external_ref: "github:example/repo#472" });
|
|
130
|
+
expect(dup.error).toBe("external_ref_exists");
|
|
131
|
+
expect(typeof dup.existing_id).toBe("string");
|
|
132
|
+
expect(dup.hint).toContain("requirements_upsert_by_external_ref");
|
|
133
|
+
const own = await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "node", id: "node_reqerr_a" } });
|
|
134
|
+
expect(own.error).toBe("owner_must_be_human");
|
|
135
|
+
expect(own.hint).toContain("agent_owner");
|
|
136
|
+
const cyc = await mcp(nodeToken, "requirements_update", { id: cardId, parent_id: cardId });
|
|
137
|
+
expect(cyc.error).toBe("parent_cycle");
|
|
138
|
+
expect(cyc.field).toBe("parent_id");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("compatibility", () => {
|
|
143
|
+
test("REST keeps the exact error strings the app matches on; the new keys are additive", async () => {
|
|
144
|
+
const r = await rest(ownerToken, "PATCH", `/api/requirements/${cardId}`, { due: "下周五" });
|
|
145
|
+
expect(r.status).toBe(400);
|
|
146
|
+
expect(r.body.ok).toBe(false);
|
|
147
|
+
expect(r.body.error).toBe("invalid_due");
|
|
148
|
+
expect(Object.keys(r.body).sort()).toEqual(["error", "field", "hint", "message", "ok"]);
|
|
149
|
+
// A code without an entry is exactly the old shape.
|
|
150
|
+
expect(errorBody("some_future_code")).toEqual({ ok: false, error: "some_future_code" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("every error code requirements.ts returns or throws has an entry (a new code must be documented)", () => {
|
|
154
|
+
const src = readFileSync(join(import.meta.dir, "requirements.ts"), "utf8");
|
|
155
|
+
const codes = new Set<string>();
|
|
156
|
+
for (const m of src.matchAll(/jsonError\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
157
|
+
for (const m of src.matchAll(/(?:new (?:Error|RequirementFieldError))\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
158
|
+
for (const m of src.matchAll(/errorBody\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
159
|
+
for (const m of src.matchAll(/return\s+["'](parent_[a-z_]+)["']/g)) codes.add(m[1]);
|
|
160
|
+
expect(codes.size).toBeGreaterThan(40); // the scan really found the codes (not an empty pass)
|
|
161
|
+
const documented = new Set(__documentedErrorCodes());
|
|
162
|
+
expect([...codes].filter(c => !documented.has(c)).sort()).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误不再只有一个代码。
|
|
2
|
+
//
|
|
3
|
+
// 以前回 `{ ok:false, error:"invalid_due" }`:app 按代码自己出文案,能用;但 MCP 上的 Agent 拿到的就只有这个词,
|
|
4
|
+
// 不知道哪个字段错了、正确的格式是什么、下一步该调哪个工具。这里给每个代码配上:
|
|
5
|
+
// field —— 出错的字段(有的话),
|
|
6
|
+
// message —— 一句英文说明(MCP 工具描述都是英文,读的是 LLM;app 不显示它),
|
|
7
|
+
// hint —— 怎么改 / 下一步调什么。
|
|
8
|
+
// 🔴 `error` 字符串原样不变:app、测试、脚本都按它匹配。新增的三个键旧客户端不认识,直接忽略。
|
|
9
|
+
// 只有一个出口:requirements.ts 的 jsonError() 和抛错转换都走 errorBody(),不在调用点各写各的。
|
|
10
|
+
|
|
11
|
+
export type ErrorContext = {
|
|
12
|
+
/** 出错的字段(调用点知道时传;不传就用登记表里的默认字段)。 */
|
|
13
|
+
field?: string;
|
|
14
|
+
/** 请求里实际带了哪些键(empty_patch 用来指出「你传了 status,该用 column」)。 */
|
|
15
|
+
sent?: readonly string[];
|
|
16
|
+
/** 可写字段(empty_patch 列出来)。 */
|
|
17
|
+
writable?: readonly string[];
|
|
18
|
+
/** 出错的那个值(project_not_in_network 用来判断是不是传了项目名)。 */
|
|
19
|
+
value?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Detail = { field?: string; message: string; hint?: string };
|
|
23
|
+
type Entry = Detail | ((ctx: ErrorContext) => Detail);
|
|
24
|
+
|
|
25
|
+
const DUE_FORMATS = 'YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (e.g. 2026-10-10T18:00:00+08:00); "" clears it';
|
|
26
|
+
// #473 —— 人员字段也收名字({kind:'user', username} / {kind:'node', alias}),只在任务所在的网络里解析;查人用 requirements_people。
|
|
27
|
+
const PERSON_IDS = "Look people up with requirements_people (q= filters by name): it returns each member's user_id and username and the node_id and alias of their Agents. Pass {kind:'user', id} or {kind:'user', username}, {kind:'node', id} or {kind:'node', alias}; names resolve only in the task's own network.";
|
|
28
|
+
|
|
29
|
+
const ENTRIES: Record<string, Entry> = {
|
|
30
|
+
// ── 定位 / 范围 ──
|
|
31
|
+
network_id_required: { field: 'network_id', message: 'This token can see more than one network (or is not bound to one), so the network must be named.', hint: 'Pass network_id (the network the task or project belongs to).' },
|
|
32
|
+
ambiguous_seq: { field: 'id', message: 'That #N exists in more than one network you can see.', hint: 'Pass network_id, or use the req_… id.' },
|
|
33
|
+
requirement_not_found: { field: 'id', message: 'No task with that id or #N in this network (or you cannot see it).', hint: 'Find it with requirements_list (q= searches titles; include_archived=true also lists archived tasks).' },
|
|
34
|
+
not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
|
|
35
|
+
project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
|
|
36
|
+
tag_not_found: { field: 'tag', message: 'No task in this network carries that tag.', hint: 'Tags are free text on tasks; list the ones in use from requirements_list (view=summary shows each task’s tags).' },
|
|
37
|
+
checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
|
|
38
|
+
// ── 权限 ──
|
|
39
|
+
user_token_required: { message: 'This operation is for people only: a node token can read projects and read / create / update tasks, but cannot manage projects or delete tasks.', hint: 'Use an existing project (projects_list), or ask a person to create / change the project in the app (任务 → 管理项目).' },
|
|
40
|
+
permission_denied: { message: 'Your role in this network cannot do this (read-only, or task-scoped to other projects).', hint: 'Ask a network admin for write access, or work on tasks you can see.' },
|
|
41
|
+
upsert_not_allowed: { message: 'Task-scoped members cannot upsert by external_ref.', hint: 'Use requirements_create / requirements_update on tasks you can see.' },
|
|
42
|
+
external_ref_not_allowed: { field: 'external_ref', message: 'Task-scoped members cannot set external_ref.', hint: 'Leave external_ref out.' },
|
|
43
|
+
// ── 人员 ──
|
|
44
|
+
invalid_person: (ctx) => ({ field: ctx.field ?? 'owner', message: 'A person must be an object {kind:"user", id | username} or {kind:"node", id | alias}.', hint: PERSON_IDS }),
|
|
45
|
+
person_not_in_network: (ctx) => ({ field: ctx.field ?? 'owner', message: `${ctx.field ?? 'owner'} names nobody in this network (unknown id / username / alias, wrong kind, or a node you cannot see).`, hint: PERSON_IDS }),
|
|
46
|
+
person_ambiguous: (ctx) => ({ field: ctx.field ?? 'agent_owner', message: 'More than one Agent in this network has that alias.', hint: 'Call requirements_people (q=<alias>) and pass the node_id instead.' }),
|
|
47
|
+
owner_must_be_human: { field: 'owner', message: 'owner (负责人) must be a person.', hint: "Use owner: {kind:'user', id}. To assign a node / Agent, use agent_owner: {kind:'node', id}." },
|
|
48
|
+
agent_owner_must_be_agent: { field: 'agent_owner', message: 'agent_owner (负责 Agent) must be a node.', hint: "Use agent_owner: {kind:'node', id}. To assign a person, use owner: {kind:'user', id}." },
|
|
49
|
+
agent_owner_not_granted: { field: 'agent_owner', message: 'The current responsible Agent is one you are not granted, so you cannot replace or clear it.', hint: 'Leave agent_owner out of the patch.' },
|
|
50
|
+
invalid_participants: { field: 'participants', message: 'participants must be an array of up to 100 persons {kind, id}; it replaces the whole list.', hint: 'Send the full list you want (read it first with requirements_get).' },
|
|
51
|
+
department_scope_denied: { field: 'owner', message: 'As a department head you can hand this task only to people in your department (or yourself) and to their Agents.', hint: 'Pick someone from your department, or ask an owner / admin to move it to another department.' },
|
|
52
|
+
department_not_found: { field: 'department_id', message: 'No department with that id in your network(s).', hint: 'Department ids come from GET /api/networks/{id}/departments.' },
|
|
53
|
+
invalid_participants_delta: { field: 'participants_add', message: 'participants_add / participants_remove must each be an array of up to 100 persons ({kind, id}, {kind:"user", username} or {kind:"node", alias}).', hint: PERSON_IDS },
|
|
54
|
+
participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
|
|
55
|
+
participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
|
|
56
|
+
too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
|
|
57
|
+
status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing, done or abandoned).' },
|
|
58
|
+
invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
|
|
59
|
+
// ── 项目 ──
|
|
60
|
+
invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
|
|
61
|
+
project_not_in_network: (ctx) => {
|
|
62
|
+
const looksLikeName = typeof ctx.value === 'string' && !/^proj_/.test(ctx.value);
|
|
63
|
+
return {
|
|
64
|
+
field: ctx.field ?? 'project_id',
|
|
65
|
+
message: looksLikeName ? `"${String(ctx.value).slice(0, 40)}" is not a project id — it looks like a project name.` : 'No project with that id in this network.',
|
|
66
|
+
hint: 'Call projects_list and pass the project’s id (proj_…), not its name.',
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
project_archived: { field: 'project_id', message: 'That project is archived.', hint: 'Pick an active project from projects_list, or ask a person to unarchive it.' },
|
|
70
|
+
project_name_taken: { field: 'name', message: 'A project with that name already exists in this network.', hint: 'Use the existing one (projects_list) or choose another name.' },
|
|
71
|
+
invalid_project_name: { field: 'name', message: 'Project name must be 1–40 characters.' },
|
|
72
|
+
invalid_project_color: { field: 'color', message: 'color must be #RRGGBB.' },
|
|
73
|
+
invalid_project_sort: { field: 'sort', message: 'sort must be an integer.' },
|
|
74
|
+
invalid_project_archived: { field: 'archived', message: 'archived must be true or false.' },
|
|
75
|
+
too_many_projects: { message: 'This network has reached the project limit (200).', hint: 'Reuse an existing project (projects_list).' },
|
|
76
|
+
// ── 任务字段 ──
|
|
77
|
+
empty_patch: (ctx) => {
|
|
78
|
+
const sent = ctx.sent ?? [];
|
|
79
|
+
const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
|
|
80
|
+
const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done / abandoned, use column (status is the name of the list filter). ' : '';
|
|
81
|
+
return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
|
|
82
|
+
},
|
|
83
|
+
invalid_json: { message: 'The request body must be a JSON object.' },
|
|
84
|
+
invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
|
|
85
|
+
invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
|
|
86
|
+
invalid_column: { field: 'column', message: 'column must be pool, doing, done or abandoned.' },
|
|
87
|
+
invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing, done or abandoned.' },
|
|
88
|
+
invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
|
|
89
|
+
invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
90
|
+
invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
91
|
+
invalid_tags: { field: 'tags', message: 'tags must be an array of at most 10 strings, each 1–20 characters.', hint: 'Omit tags to keep them, [] to clear.' },
|
|
92
|
+
invalid_checklist: { field: 'checklist', message: 'checklist must be an array of up to 100 items {text (1–500 chars), done?, id?}; it replaces the whole list.', hint: 'To tick one item use requirements_checklist_toggle (item ids from requirements_get).' },
|
|
93
|
+
invalid_done: { field: 'done', message: 'done must be true or false.' },
|
|
94
|
+
invalid_parent_id: { field: 'parent_id', message: 'parent_id must be a task id in this network (or null to detach).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
|
|
95
|
+
parent_cycle: { field: 'parent_id', message: 'That parent would make the task its own ancestor.', hint: 'Pick a parent that is not this task or one of its children.' },
|
|
96
|
+
parent_too_deep: { field: 'parent_id', message: 'Tasks nest at most 5 levels.', hint: 'Attach it to a higher-level task.' },
|
|
97
|
+
parent_not_found: { field: 'parent_id', message: 'No task with that parent_id in this network (or you cannot see it).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
|
|
98
|
+
invalid_external_ref: { field: 'external_ref', message: 'external_ref must start with a letter or digit and use only letters, digits and _ . : / # @ + - (≤ 200 characters).', hint: 'e.g. github:owner/repo#123' },
|
|
99
|
+
external_ref_required: { field: 'external_ref', message: 'external_ref is required for upsert.' },
|
|
100
|
+
external_ref_exists: { field: 'external_ref', message: 'A task with that external_ref already exists in this network (existing_id).', hint: 'Use requirements_upsert_by_external_ref to sync, or requirements_update on existing_id.' },
|
|
101
|
+
invalid_external_url: { field: 'external_url', message: 'external_url must be an http(s) URL of at most 500 characters, or null.' },
|
|
102
|
+
invalid_issues: { field: 'issues', message: 'issues must be an array of issue references.' },
|
|
103
|
+
invalid_archived: { field: 'archived', message: 'archived must be true or false.' },
|
|
104
|
+
invalid_seq: { field: 'seq', message: 'seq must be a positive integer (the #N shown for a task).' },
|
|
105
|
+
invalid_client_id: { field: 'client_id', message: 'client_id must be 1–80 characters of letters, digits, ., _ or -.' },
|
|
106
|
+
client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
|
|
107
|
+
// ── 列表 / 事件 ──
|
|
108
|
+
invalid_view: { field: 'view', message: 'view must be full or summary.' },
|
|
109
|
+
invalid_last_event: { field: 'last_event', message: 'last_event must be 1 or 0 (REST). On MCP use include_last_event: true / false.' },
|
|
110
|
+
invalid_overdue: { field: 'overdue', message: 'overdue must be true or false (REST: 1 / 0 / true / false).' },
|
|
111
|
+
invalid_due_within_days: { field: 'due_within_days', message: 'due_within_days must be an integer from 0 to 365 (0 = due later today).' },
|
|
112
|
+
invalid_limit: { field: 'limit', message: 'limit is out of range.' },
|
|
113
|
+
invalid_cursor: { field: 'cursor', message: 'cursor is not one this hub issued.', hint: 'Pass next_cursor from the previous page unchanged, or start again without cursor.' },
|
|
114
|
+
invalid_updated_since: { field: 'updated_since', message: 'updated_since must be an ISO 8601 time.', hint: 'Use server_time from the previous changes=true response.' },
|
|
115
|
+
updated_since_required: { field: 'updated_since', message: 'changes=true needs updated_since.', hint: 'Pass updated_since (ISO time; server_time from the last call).' },
|
|
116
|
+
invalid_since: { field: 'since', message: 'since must be an ISO 8601 time.', hint: 'Use server_time from the previous requirements_events response.' },
|
|
117
|
+
invalid_comment: { field: 'text', message: 'A comment needs non-empty text.', hint: 'Pass text (markdown), e.g. a progress note or a conclusion.' },
|
|
118
|
+
comment_too_long: { field: 'text', message: 'A comment can be at most 4000 characters.', hint: 'Split it into several comments, or put the long part in the description.' },
|
|
119
|
+
insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** 错误响应体:`error` 原样,补上 field / message / hint(登记表里没有的代码就只有 error,和以前一样)。 */
|
|
123
|
+
export function errorBody(error: string, ctx: ErrorContext = {}): { ok: false; error: string; field?: string; message?: string; hint?: string } {
|
|
124
|
+
const entry = ENTRIES[error];
|
|
125
|
+
if (!entry) return { ok: false, error };
|
|
126
|
+
const d = typeof entry === 'function' ? entry(ctx) : entry;
|
|
127
|
+
const field = ctx.field ?? d.field;
|
|
128
|
+
return { ok: false, error, ...(field ? { field } : {}), message: d.message, ...(d.hint ? { hint: d.hint } : {}) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 带字段的抛错:assignments / projectRef 里抛,调用点的 catch 用 errorFrom() 转成响应。 */
|
|
132
|
+
export class RequirementFieldError extends Error {
|
|
133
|
+
constructor(code: string, readonly field?: string, readonly value?: unknown) { super(code); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function errorFrom(e: unknown, status = 400): Response {
|
|
137
|
+
const code = (e as Error)?.message ?? 'invalid_json';
|
|
138
|
+
const fe = e instanceof RequirementFieldError ? e : null;
|
|
139
|
+
return Response.json(errorBody(code, { field: fe?.field, value: fe?.value }), { status });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Test-only: every code with an entry. */
|
|
143
|
+
export const __documentedErrorCodes = (): string[] => Object.keys(ENTRIES);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// GET /api/requirements conditional requests: ETag on every list response, 304 with an empty body
|
|
2
|
+
// when If-None-Match matches. The app polls the whole list every 15 s (production: 500 rows,
|
|
3
|
+
// 708 KB / 163 KB gzip) and it is unchanged on almost every poll. Old clients never send
|
|
4
|
+
// If-None-Match and keep getting the full 200.
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-requirements-etag-"));
|
|
11
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
12
|
+
|
|
13
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
14
|
+
let base = "";
|
|
15
|
+
let ownerToken = "", network = "";
|
|
16
|
+
|
|
17
|
+
const list = (token: string, headers: Record<string, string> = {}) =>
|
|
18
|
+
fetch(`${base}/api/requirements?network_id=${network}`, { headers: { Authorization: `Bearer ${token}`, ...headers } });
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
const { register } = await import("./auth.js");
|
|
22
|
+
const owner = register(`etag_owner_${Date.now()}`, "EtagOwnerPassw0rd!", undefined, "seed");
|
|
23
|
+
ownerToken = owner.token!; network = owner.network_id!;
|
|
24
|
+
const mod: any = await import("./server.js");
|
|
25
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
26
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
27
|
+
const created = await fetch(`${base}/api/requirements`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ name: "etag card", network_id: network }),
|
|
31
|
+
});
|
|
32
|
+
expect(created.status).toBe(201);
|
|
33
|
+
}, 30_000);
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("GET /api/requirements conditional requests", () => {
|
|
41
|
+
test("a plain GET is the same 200 JSON as before, plus ETag and private/no-cache", async () => {
|
|
42
|
+
const res = await list(ownerToken);
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
45
|
+
expect(res.headers.get("etag")).toMatch(/^W\/"[A-Za-z0-9_-]{27}"$/);
|
|
46
|
+
expect(res.headers.get("cache-control")).toBe("private, no-cache");
|
|
47
|
+
const body = await res.json() as any;
|
|
48
|
+
expect(body.ok).toBe(true);
|
|
49
|
+
expect(body.requirements.map((r: any) => r.name)).toEqual(["etag card"]);
|
|
50
|
+
expect(Array.isArray(body.capabilities)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("If-None-Match with the current ETag → 304, empty body, same ETag", async () => {
|
|
54
|
+
const etag = (await list(ownerToken)).headers.get("etag")!;
|
|
55
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
56
|
+
expect(res.status).toBe(304);
|
|
57
|
+
expect(res.headers.get("etag")).toBe(etag);
|
|
58
|
+
expect(await res.text()).toBe("");
|
|
59
|
+
// strong/weak spelling and lists are accepted
|
|
60
|
+
expect((await list(ownerToken, { "If-None-Match": etag.replace(/^W\//, "") })).status).toBe(304);
|
|
61
|
+
expect((await list(ownerToken, { "If-None-Match": `W/"stale", ${etag}` })).status).toBe(304);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("304 survives gzip negotiation (not compressed, still empty)", async () => {
|
|
65
|
+
const etag = (await list(ownerToken, { "Accept-Encoding": "gzip" })).headers.get("etag")!;
|
|
66
|
+
const res = await list(ownerToken, { "If-None-Match": etag, "Accept-Encoding": "gzip" });
|
|
67
|
+
expect(res.status).toBe(304);
|
|
68
|
+
expect(res.headers.get("content-encoding")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("any change to the list changes the ETag → full 200 again", async () => {
|
|
72
|
+
const before = await list(ownerToken);
|
|
73
|
+
const etag = before.headers.get("etag")!;
|
|
74
|
+
const id = ((await before.json()) as any).requirements[0].id;
|
|
75
|
+
const patched = await fetch(`${base}/api/requirements/${id}?network_id=${network}`, {
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ name: "etag card renamed" }),
|
|
79
|
+
});
|
|
80
|
+
expect(patched.status).toBe(200);
|
|
81
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(res.headers.get("etag")).not.toBe(etag);
|
|
84
|
+
expect(((await res.json()) as any).requirements[0].name).toBe("etag card renamed");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a stale or foreign ETag never yields 304", async () => {
|
|
88
|
+
expect((await list(ownerToken, { "If-None-Match": 'W/"not-the-current-one"' })).status).toBe(200);
|
|
89
|
+
expect((await list(ownerToken, { "If-None-Match": "" })).status).toBe(200);
|
|
90
|
+
});
|
|
91
|
+
});
|