@sleep2agi/commhub-server 0.9.0-preview.87 → 0.9.0-preview.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-notice.ts +48 -0
- package/src/db.ts +2 -2
- package/src/http-gzip.ts +3 -1
- package/src/model-auth-notify.ts +7 -23
- package/src/node-health-store.ts +16 -1
- package/src/requirements-errors-mcp-http.test.ts +163 -0
- package/src/requirements-errors.ts +129 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-search.ts +9 -2
- package/src/requirements.ts +35 -25
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks.ts +32 -2
- package/src/server.ts +19 -3
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +108 -0
- package/src/tools.ts +39 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.89",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Hub 以某个节点的名义发给一个用户的一条通知(#462 登录失效、#464 定时任务卡住)。
|
|
2
|
+
//
|
|
3
|
+
// 走 user_inbox + /events/users/me 的 desktop_message —— 与 send_desktop_message 同一张表、同一条推送,
|
|
4
|
+
// from_session = 节点别名,所以 App 把它显示在**与该节点的会话**里(主动消息)+ 未读角标 + 通知。不新增渠道。
|
|
5
|
+
// 只发给一个人;他不在本网络了就不发。推送在写库之后:收到推送的 App 立刻回读得到这一行。
|
|
6
|
+
|
|
7
|
+
import { db, uuidv4 } from "./db.js";
|
|
8
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
9
|
+
import { pushUserEvent } from "./push.js";
|
|
10
|
+
|
|
11
|
+
export type AgentNotice = {
|
|
12
|
+
networkId: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
/** 节点别名:通知出现在与它的会话里。 */
|
|
15
|
+
fromAlias: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
title: string;
|
|
18
|
+
text: string;
|
|
19
|
+
severity?: "info" | "warning" | "error";
|
|
20
|
+
meta: Record<string, unknown>;
|
|
21
|
+
/** 消息 id 前缀(`dm_` 开头,与 App 的去重键同一套)。 */
|
|
22
|
+
idPrefix: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** 返回 message_id;没发(收件人不在本网络)→ null。 */
|
|
26
|
+
export function sendAgentNotice(n: AgentNotice): string | null {
|
|
27
|
+
if (!getUserNetworkRole(n.userId, n.networkId)) return null;
|
|
28
|
+
const messageId = `${n.idPrefix}${uuidv4()}`;
|
|
29
|
+
const severity = n.severity ?? "warning";
|
|
30
|
+
db.run(
|
|
31
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
32
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
33
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
34
|
+
[messageId, n.networkId, n.userId, n.fromAlias, n.kind, n.title, n.text, severity, JSON.stringify(n.meta)],
|
|
35
|
+
);
|
|
36
|
+
pushUserEvent(n.networkId, n.userId, {
|
|
37
|
+
type: "desktop_message",
|
|
38
|
+
message_id: messageId,
|
|
39
|
+
kind: n.kind,
|
|
40
|
+
from: n.fromAlias,
|
|
41
|
+
title: n.title,
|
|
42
|
+
message: n.text,
|
|
43
|
+
severity,
|
|
44
|
+
created_at: new Date().toISOString(),
|
|
45
|
+
meta: n.meta,
|
|
46
|
+
});
|
|
47
|
+
return messageId;
|
|
48
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createAdapter, type DbAdapter } from "./db-adapter";
|
|
1
|
+
import { createAdapter, type DbAdapter } from "./db-adapter"; import { withStatementHook } from "./status-read-cache"; // #431 写 sessions 的语句让 /api/status 缓存失效
|
|
2
2
|
|
|
3
|
-
export const db: DbAdapter = createAdapter();
|
|
3
|
+
export const db: DbAdapter = withStatementHook(createAdapter());
|
|
4
4
|
|
|
5
5
|
// Schema
|
|
6
6
|
db.exec(`
|
package/src/http-gzip.ts
CHANGED
|
@@ -56,7 +56,9 @@ function gzipCached(key: string | undefined, raw: () => Promise<Uint8Array>): Pr
|
|
|
56
56
|
}
|
|
57
57
|
return raw().then(bytes => {
|
|
58
58
|
if (bytes.byteLength < GZIP_MIN_BYTES) return { raw: bytes, gz: null };
|
|
59
|
-
|
|
59
|
+
// 可复用的正文(带键)压一次、存起来、反复发:压缩级别拉到 9 —— 多花的那点 CPU 只花一次,
|
|
60
|
+
// 而线上每次都少发 ~3%(#431 实测:旧节点别名解析读 29.6 KB → 28.6 KB)。不带键的照旧默认级别。
|
|
61
|
+
const gz = key ? Bun.gzipSync(bytes, { level: 9 }) : Bun.gzipSync(bytes);
|
|
60
62
|
if (key) gzipCacheStats.misses++;
|
|
61
63
|
if (key && gz.byteLength <= GZIP_CACHE_MAX_BYTES / 4) {
|
|
62
64
|
gzipCache.set(key, gz);
|
package/src/model-auth-notify.ts
CHANGED
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
//
|
|
16
16
|
// 收件人 = 节点主人:上报令牌绑定的 nodes.owner_user_id(RFC-036,建节点时定死),没有绑定(老令牌)就取
|
|
17
17
|
// 铸这个节点令牌的用户(api_tokens.user_id —— 新令牌里两者相同)。只发给这一个人;不在本网络了就不发。
|
|
18
|
-
// 怎么送:user_inbox
|
|
19
|
-
// 所以 App 把它显示在**与该节点的会话**里(主动消息)+ 未读角标 + 通知。不新增渠道。
|
|
18
|
+
// 怎么送:agent-notice.ts(user_inbox + /events/users/me,from_session = 节点别名)。
|
|
20
19
|
|
|
21
|
-
import { db
|
|
20
|
+
import { db } from "./db.js";
|
|
22
21
|
import { getUserNetworkRole } from "./auth.js";
|
|
23
|
-
import {
|
|
22
|
+
import { sendAgentNotice } from "./agent-notice.js";
|
|
24
23
|
import { degradedLayers } from "./node-health-guard.js";
|
|
25
24
|
import type { NodeHealth } from "./node-health-store.js";
|
|
26
25
|
|
|
@@ -104,26 +103,11 @@ export function noteModelAuthHealth(input: { networkId: string; alias: string; t
|
|
|
104
103
|
if (prev === undefined && hasUnreadNotice(owner, networkId, alias)) return [];
|
|
105
104
|
|
|
106
105
|
const { title, text } = modelAuthNoticeText(alias, state);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
111
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'warning', ?8)
|
|
112
|
-
ON CONFLICT(message_id) DO NOTHING`,
|
|
113
|
-
[messageId, networkId, owner, alias, MODEL_AUTH_NOTICE_KIND, title, text, JSON.stringify(meta)],
|
|
114
|
-
);
|
|
115
|
-
// 推送在写库之后:收到推送的 App 立刻回读得到这一行。
|
|
116
|
-
pushUserEvent(networkId, owner, {
|
|
117
|
-
type: "desktop_message",
|
|
118
|
-
message_id: messageId,
|
|
119
|
-
kind: MODEL_AUTH_NOTICE_KIND,
|
|
120
|
-
from: alias,
|
|
121
|
-
title,
|
|
122
|
-
message: text,
|
|
123
|
-
severity: "warning",
|
|
124
|
-
created_at: new Date().toISOString(),
|
|
125
|
-
meta,
|
|
106
|
+
const sent = sendAgentNotice({
|
|
107
|
+
networkId, userId: owner, fromAlias: alias, kind: MODEL_AUTH_NOTICE_KIND, title, text,
|
|
108
|
+
meta: { model_auth_notice: { alias, state } }, idPrefix: "dm_auth_",
|
|
126
109
|
});
|
|
110
|
+
if (!sent) return [];
|
|
127
111
|
return [owner];
|
|
128
112
|
} catch (e: any) {
|
|
129
113
|
console.error(`[model-auth-notify] ${input.alias}: ${e?.message ?? e}`);
|
package/src/node-health-store.ts
CHANGED
|
@@ -34,7 +34,22 @@ export type NodeHealth = NonNullable<z.infer<typeof nodeHealthSchema>>;
|
|
|
34
34
|
const store = new Map<string, { health: NodeHealth; at: number }>();
|
|
35
35
|
const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
|
|
36
36
|
|
|
37
|
+
// #431 —— /api/status 的记忆化(status-read-cache.ts)按这两样判断正文还对不对:任何一次上报 → 版本 +1;
|
|
38
|
+
// 任何一份报告过 TTL 的那一刻(degraded / health 字段会消失)→ 由 nodeHealthNextExpiryAt 给出的时刻失效。
|
|
39
|
+
let version = 0;
|
|
40
|
+
export function nodeHealthVersion(): number { return version; }
|
|
41
|
+
/** 最早一份还没过期的报告的过期时刻(ms);一份都没有 → Infinity。 */
|
|
42
|
+
export function nodeHealthNextExpiryAt(now = Date.now()): number {
|
|
43
|
+
let next = Infinity;
|
|
44
|
+
for (const v of store.values()) {
|
|
45
|
+
const at = v.at + NODE_HEALTH_TTL_MS;
|
|
46
|
+
if (at >= now && at < next) next = at;
|
|
47
|
+
}
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
export function recordNodeHealth(networkId: string | null | undefined, alias: string, health: NodeHealth, now = Date.now()): void {
|
|
52
|
+
version++;
|
|
38
53
|
store.set(keyOf(networkId, alias), { health, at: now });
|
|
39
54
|
// 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
|
|
40
55
|
if (store.size > 256) {
|
|
@@ -54,4 +69,4 @@ export function readNodeHealth(
|
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
/** 测试用。 */
|
|
57
|
-
export function clearNodeHealthStore(): void { store.clear(); }
|
|
72
|
+
export function clearNodeHealthStore(): void { store.clear(); version++; }
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
expect(byAlias.hint).toContain("get_all_status");
|
|
77
|
+
expect(byAlias.hint).toContain("Aliases and usernames are not accepted");
|
|
78
|
+
expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
|
|
79
|
+
expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("project_not_in_network: a name is recognised as a name; both point to projects_list", async () => {
|
|
83
|
+
const byName = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "示例项目" });
|
|
84
|
+
expect(byName.error).toBe("project_not_in_network");
|
|
85
|
+
expect(byName.field).toBe("project_id");
|
|
86
|
+
expect(byName.message).toContain("looks like a project name");
|
|
87
|
+
expect(byName.hint).toContain("projects_list");
|
|
88
|
+
const byId = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "proj_missing" });
|
|
89
|
+
expect(byId.error).toBe("project_not_in_network");
|
|
90
|
+
expect(byId.message).not.toContain("project name");
|
|
91
|
+
expect(byId.hint).toContain("projects_list");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("empty_patch: lists the writable fields; sending status says to use column", async () => {
|
|
95
|
+
// The MCP tool schema strips keys it does not know, so `status` arrives as nothing; REST sees the raw body.
|
|
96
|
+
const viaRest = await rest(nodeToken, "PATCH", `/api/requirements/${cardId}`, { status: "doing" });
|
|
97
|
+
expect(viaRest.status).toBe(400);
|
|
98
|
+
expect(viaRest.body.error).toBe("empty_patch");
|
|
99
|
+
expect(viaRest.body.hint).toContain("use column");
|
|
100
|
+
expect(viaRest.body.hint).toContain("Writable fields: column");
|
|
101
|
+
expect(viaRest.body.message).toContain("status");
|
|
102
|
+
const viaMcp = await mcp(nodeToken, "requirements_update", { id: cardId });
|
|
103
|
+
expect(viaMcp.error).toBe("empty_patch");
|
|
104
|
+
expect(viaMcp.hint).toContain("Writable fields:");
|
|
105
|
+
expect(viaMcp.hint).not.toContain("use column");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("user_token_required: says projects are managed by people and what to do instead", async () => {
|
|
109
|
+
const r = await mcp(nodeToken, "projects_create", { name: "节点建项目" });
|
|
110
|
+
expect(r.error).toBe("user_token_required");
|
|
111
|
+
expect(r.status).toBe(403);
|
|
112
|
+
expect(r.message).toContain("node token");
|
|
113
|
+
expect(r.hint).toContain("projects_list");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("network_id_required: field network_id and how to fix it", async () => {
|
|
117
|
+
const r = await mcp(memberToken, "projects_create", { name: "成员建项目" });
|
|
118
|
+
expect(r.error).toBe("network_id_required");
|
|
119
|
+
expect(r.field).toBe("network_id");
|
|
120
|
+
expect(r.hint).toContain("network_id");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("swept codes carry hints too: checklist_item_not_found, external_ref_exists (existing_id kept), owner_must_be_human, parent_cycle", async () => {
|
|
124
|
+
const ck = await mcp(nodeToken, "requirements_checklist_toggle", { id: cardId, item_id: "nope", done: true });
|
|
125
|
+
expect(ck.error).toBe("checklist_item_not_found");
|
|
126
|
+
expect(ck.hint).toContain("requirements_get");
|
|
127
|
+
await mcp(nodeToken, "requirements_create", { name: "外部", external_ref: "github:example/repo#472" });
|
|
128
|
+
const dup = await mcp(nodeToken, "requirements_create", { name: "外部又一次", external_ref: "github:example/repo#472" });
|
|
129
|
+
expect(dup.error).toBe("external_ref_exists");
|
|
130
|
+
expect(typeof dup.existing_id).toBe("string");
|
|
131
|
+
expect(dup.hint).toContain("requirements_upsert_by_external_ref");
|
|
132
|
+
const own = await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "node", id: "node_reqerr_a" } });
|
|
133
|
+
expect(own.error).toBe("owner_must_be_human");
|
|
134
|
+
expect(own.hint).toContain("agent_owner");
|
|
135
|
+
const cyc = await mcp(nodeToken, "requirements_update", { id: cardId, parent_id: cardId });
|
|
136
|
+
expect(cyc.error).toBe("parent_cycle");
|
|
137
|
+
expect(cyc.field).toBe("parent_id");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("compatibility", () => {
|
|
142
|
+
test("REST keeps the exact error strings the app matches on; the new keys are additive", async () => {
|
|
143
|
+
const r = await rest(ownerToken, "PATCH", `/api/requirements/${cardId}`, { due: "下周五" });
|
|
144
|
+
expect(r.status).toBe(400);
|
|
145
|
+
expect(r.body.ok).toBe(false);
|
|
146
|
+
expect(r.body.error).toBe("invalid_due");
|
|
147
|
+
expect(Object.keys(r.body).sort()).toEqual(["error", "field", "hint", "message", "ok"]);
|
|
148
|
+
// A code without an entry is exactly the old shape.
|
|
149
|
+
expect(errorBody("some_future_code")).toEqual({ ok: false, error: "some_future_code" });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("every error code requirements.ts returns or throws has an entry (a new code must be documented)", () => {
|
|
153
|
+
const src = readFileSync(join(import.meta.dir, "requirements.ts"), "utf8");
|
|
154
|
+
const codes = new Set<string>();
|
|
155
|
+
for (const m of src.matchAll(/jsonError\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
156
|
+
for (const m of src.matchAll(/(?:new (?:Error|RequirementFieldError))\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
157
|
+
for (const m of src.matchAll(/errorBody\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
|
|
158
|
+
for (const m of src.matchAll(/return\s+["'](parent_[a-z_]+)["']/g)) codes.add(m[1]);
|
|
159
|
+
expect(codes.size).toBeGreaterThan(40); // the scan really found the codes (not an empty pass)
|
|
160
|
+
const documented = new Set(__documentedErrorCodes());
|
|
161
|
+
expect([...codes].filter(c => !documented.has(c)).sort()).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
const PERSON_IDS = "node ids (kind:'node') come from get_all_status (node_id) or a task's agent_owner / participants; user ids (kind:'user') from a task's owner / created_by / participants. Aliases and usernames are not accepted.";
|
|
27
|
+
|
|
28
|
+
const ENTRIES: Record<string, Entry> = {
|
|
29
|
+
// ── 定位 / 范围 ──
|
|
30
|
+
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).' },
|
|
31
|
+
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.' },
|
|
32
|
+
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).' },
|
|
33
|
+
not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
|
|
34
|
+
project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
|
|
35
|
+
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).' },
|
|
36
|
+
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.' },
|
|
37
|
+
// ── 权限 ──
|
|
38
|
+
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 (任务 → 管理项目).' },
|
|
39
|
+
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.' },
|
|
40
|
+
upsert_not_allowed: { message: 'Task-scoped members cannot upsert by external_ref.', hint: 'Use requirements_create / requirements_update on tasks you can see.' },
|
|
41
|
+
external_ref_not_allowed: { field: 'external_ref', message: 'Task-scoped members cannot set external_ref.', hint: 'Leave external_ref out.' },
|
|
42
|
+
// ── 人员 ──
|
|
43
|
+
invalid_person: (ctx) => ({ field: ctx.field ?? 'owner', message: 'A person must be an object {kind:"user"|"node", id}.', hint: PERSON_IDS }),
|
|
44
|
+
person_not_in_network: (ctx) => ({ field: ctx.field ?? 'owner', message: `${ctx.field ?? 'owner'} names nobody in this network (unknown id, wrong kind, or a node you cannot see).`, hint: PERSON_IDS }),
|
|
45
|
+
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}." },
|
|
46
|
+
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}." },
|
|
47
|
+
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.' },
|
|
48
|
+
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).' },
|
|
49
|
+
invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
|
|
50
|
+
// ── 项目 ──
|
|
51
|
+
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.' },
|
|
52
|
+
project_not_in_network: (ctx) => {
|
|
53
|
+
const looksLikeName = typeof ctx.value === 'string' && !/^proj_/.test(ctx.value);
|
|
54
|
+
return {
|
|
55
|
+
field: ctx.field ?? 'project_id',
|
|
56
|
+
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.',
|
|
57
|
+
hint: 'Call projects_list and pass the project’s id (proj_…), not its name.',
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
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.' },
|
|
61
|
+
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.' },
|
|
62
|
+
invalid_project_name: { field: 'name', message: 'Project name must be 1–40 characters.' },
|
|
63
|
+
invalid_project_color: { field: 'color', message: 'color must be #RRGGBB.' },
|
|
64
|
+
invalid_project_sort: { field: 'sort', message: 'sort must be an integer.' },
|
|
65
|
+
invalid_project_archived: { field: 'archived', message: 'archived must be true or false.' },
|
|
66
|
+
too_many_projects: { message: 'This network has reached the project limit (200).', hint: 'Reuse an existing project (projects_list).' },
|
|
67
|
+
// ── 任务字段 ──
|
|
68
|
+
empty_patch: (ctx) => {
|
|
69
|
+
const sent = ctx.sent ?? [];
|
|
70
|
+
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';
|
|
71
|
+
const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
|
|
72
|
+
return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
|
|
73
|
+
},
|
|
74
|
+
invalid_json: { message: 'The request body must be a JSON object.' },
|
|
75
|
+
invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
|
|
76
|
+
invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
|
|
77
|
+
invalid_column: { field: 'column', message: 'column must be pool, doing or done.' },
|
|
78
|
+
invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or done.' },
|
|
79
|
+
invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
|
|
80
|
+
invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
81
|
+
invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
|
|
82
|
+
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.' },
|
|
83
|
+
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).' },
|
|
84
|
+
invalid_done: { field: 'done', message: 'done must be true or false.' },
|
|
85
|
+
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.' },
|
|
86
|
+
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.' },
|
|
87
|
+
parent_too_deep: { field: 'parent_id', message: 'Tasks nest at most 5 levels.', hint: 'Attach it to a higher-level task.' },
|
|
88
|
+
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.' },
|
|
89
|
+
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' },
|
|
90
|
+
external_ref_required: { field: 'external_ref', message: 'external_ref is required for upsert.' },
|
|
91
|
+
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.' },
|
|
92
|
+
invalid_external_url: { field: 'external_url', message: 'external_url must be an http(s) URL of at most 500 characters, or null.' },
|
|
93
|
+
invalid_issues: { field: 'issues', message: 'issues must be an array of issue references.' },
|
|
94
|
+
invalid_archived: { field: 'archived', message: 'archived must be true or false.' },
|
|
95
|
+
invalid_seq: { field: 'seq', message: 'seq must be a positive integer (the #N shown for a task).' },
|
|
96
|
+
invalid_client_id: { field: 'client_id', message: 'client_id must be 1–80 characters of letters, digits, ., _ or -.' },
|
|
97
|
+
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.' },
|
|
98
|
+
// ── 列表 / 事件 ──
|
|
99
|
+
invalid_view: { field: 'view', message: 'view must be full or summary.' },
|
|
100
|
+
invalid_limit: { field: 'limit', message: 'limit is out of range.' },
|
|
101
|
+
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.' },
|
|
102
|
+
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.' },
|
|
103
|
+
updated_since_required: { field: 'updated_since', message: 'changes=true needs updated_since.', hint: 'Pass updated_since (ISO time; server_time from the last call).' },
|
|
104
|
+
invalid_since: { field: 'since', message: 'since must be an ISO 8601 time.', hint: 'Use server_time from the previous requirements_events response.' },
|
|
105
|
+
insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** 错误响应体:`error` 原样,补上 field / message / hint(登记表里没有的代码就只有 error,和以前一样)。 */
|
|
109
|
+
export function errorBody(error: string, ctx: ErrorContext = {}): { ok: false; error: string; field?: string; message?: string; hint?: string } {
|
|
110
|
+
const entry = ENTRIES[error];
|
|
111
|
+
if (!entry) return { ok: false, error };
|
|
112
|
+
const d = typeof entry === 'function' ? entry(ctx) : entry;
|
|
113
|
+
const field = ctx.field ?? d.field;
|
|
114
|
+
return { ok: false, error, ...(field ? { field } : {}), message: d.message, ...(d.hint ? { hint: d.hint } : {}) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 带字段的抛错:assignments / projectRef 里抛,调用点的 catch 用 errorFrom() 转成响应。 */
|
|
118
|
+
export class RequirementFieldError extends Error {
|
|
119
|
+
constructor(code: string, readonly field?: string, readonly value?: unknown) { super(code); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function errorFrom(e: unknown, status = 400): Response {
|
|
123
|
+
const code = (e as Error)?.message ?? 'invalid_json';
|
|
124
|
+
const fe = e instanceof RequirementFieldError ? e : null;
|
|
125
|
+
return Response.json(errorBody(code, { field: fe?.field, value: fe?.value }), { status });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Test-only: every code with an entry. */
|
|
129
|
+
export const __documentedErrorCodes = (): string[] => Object.keys(ENTRIES);
|
|
@@ -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
|
+
});
|