@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.75
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-acl-http.test.ts +2 -0
- package/src/auth.ts +16 -7
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/http-gzip.test.ts +21 -0
- package/src/http-gzip.ts +54 -4
- package/src/human-dm.ts +35 -8
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +73 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-http.test.ts +3 -2
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +15 -0
- package/src/requirements-migrate.ts +81 -0
- package/src/requirements-seq-http.test.ts +2 -1
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-migration.test.ts +13 -0
- package/src/requirements.ts +341 -27
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/route-timing.test.ts +40 -0
- package/src/route-timing.ts +70 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/server.ts +95 -12
- package/src/task-access-http.test.ts +375 -0
- package/src/task-access.ts +284 -0
- package/src/tools.ts +52 -9
- package/src/uploads.ts +8 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// GET /api/requirements 省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)—— HTTP 集成测试(真实 Bun.serve,临时库)。
|
|
2
|
+
// view=summary —— 不带 description / checklist,换成 has_description / checklist_count;不带 view = 与原来逐字相同。
|
|
3
|
+
// changes=1 —— updated_since 之后改过的卡(含归档的)+ deleted 墓碑 + server_time;受限成员只收到他看得见的卡的删除。
|
|
4
|
+
// 列表缓存 —— 表没变时复用上一次的正文 / ETag;任何写入(REST、MCP、项目删除)之后都不会拿旧的。
|
|
5
|
+
// /api/stats/routes —— 每个路由的耗时汇总,只给管理员。
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { gunzipSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-requirements-slim-"));
|
|
13
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
14
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
15
|
+
|
|
16
|
+
const PW = "SlimListPassw0rd!x";
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
let NET = "";
|
|
20
|
+
let admin = { token: "", id: "" };
|
|
21
|
+
let member = { token: "", id: "" };
|
|
22
|
+
let scoped = { token: "", id: "" };
|
|
23
|
+
let cache: { size: () => number; clear: () => void; generation: () => number };
|
|
24
|
+
let gzipStats: { hits: number; misses: number };
|
|
25
|
+
|
|
26
|
+
type R = { status: number; body: any; text: string; headers: Headers };
|
|
27
|
+
async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
|
|
28
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
|
|
31
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
let body: any = null;
|
|
35
|
+
try { body = JSON.parse(text); } catch {}
|
|
36
|
+
return { status: res.status, body, text, headers: res.headers };
|
|
37
|
+
}
|
|
38
|
+
const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
|
|
39
|
+
const create = async (t: string, card: Record<string, unknown>) => {
|
|
40
|
+
const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
|
|
41
|
+
expect(r.status).toBe(201);
|
|
42
|
+
return r.body.requirement as { id: string; updatedAt: string };
|
|
43
|
+
};
|
|
44
|
+
const patch = (t: string, id: string, p: Record<string, unknown>) => send(t, "PATCH", `/api/requirements/${id}?network_id=${NET}`, p);
|
|
45
|
+
const del = (t: string, id: string) => send(t, "DELETE", `/api/requirements/${id}?network_id=${NET}`);
|
|
46
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
47
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
50
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
51
|
+
});
|
|
52
|
+
const raw = await res.text();
|
|
53
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
54
|
+
const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
55
|
+
return JSON.parse(payload.result.content[0].text);
|
|
56
|
+
}
|
|
57
|
+
const tick = () => new Promise(r => setTimeout(r, 5));
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
process.env.HOST = "127.0.0.1";
|
|
61
|
+
const { register } = await import("./auth.js");
|
|
62
|
+
const { db } = await import("./db.js");
|
|
63
|
+
const a = register(`slim_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
64
|
+
expect(a.ok).toBe(true);
|
|
65
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
66
|
+
NET = a.network_id!;
|
|
67
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
68
|
+
const mod: any = await import("./server.js");
|
|
69
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
70
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
71
|
+
const mk = async (username: string) => {
|
|
72
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
|
|
73
|
+
expect(r.status).toBe(200);
|
|
74
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
75
|
+
expect(login.status).toBe(200);
|
|
76
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
77
|
+
};
|
|
78
|
+
const stamp = Date.now();
|
|
79
|
+
member = await mk(`slim_member_${stamp}`);
|
|
80
|
+
scoped = await mk(`slim_scoped_${stamp}`);
|
|
81
|
+
// member:看全部任务、全部 Agent(可缓存);scoped:只看相关任务(不缓存,且只收到看得见的卡的删除)。
|
|
82
|
+
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
83
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
|
|
84
|
+
cache = (await import("./requirements.js")).__requirementsListCacheForTest();
|
|
85
|
+
gzipStats = (await import("./http-gzip.js")).gzipCacheStats;
|
|
86
|
+
}, 30_000);
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
try { hub?.stop?.(true); } catch {}
|
|
90
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("view=summary", () => {
|
|
94
|
+
test("drops description and checklist bodies, keeps everything else, adds has_description / checklist_count", async () => {
|
|
95
|
+
const card = await create(admin.token, {
|
|
96
|
+
name: "slim-summary",
|
|
97
|
+
description: "一段很长的描述 ".repeat(200),
|
|
98
|
+
checklist: [{ text: "第一项", done: true }, { text: "第二项", done: false }, { text: "第三项", done: true }],
|
|
99
|
+
});
|
|
100
|
+
await create(admin.token, { name: "slim-empty" });
|
|
101
|
+
const full = await list(admin.token);
|
|
102
|
+
const summary = await list(admin.token, "&view=summary");
|
|
103
|
+
expect(full.status).toBe(200);
|
|
104
|
+
expect(summary.status).toBe(200);
|
|
105
|
+
expect(summary.body.view).toBe("summary");
|
|
106
|
+
expect(full.body.view).toBeUndefined();
|
|
107
|
+
expect(summary.body.capabilities).toContain("list_summary");
|
|
108
|
+
expect(summary.body.capabilities).toContain("changes");
|
|
109
|
+
const f = full.body.requirements.find((r: any) => r.id === card.id);
|
|
110
|
+
const s = summary.body.requirements.find((r: any) => r.id === card.id);
|
|
111
|
+
expect(f.description.length).toBeGreaterThan(1000);
|
|
112
|
+
expect(f.checklist).toHaveLength(3);
|
|
113
|
+
expect("description" in s).toBe(false);
|
|
114
|
+
expect("checklist" in s).toBe(false);
|
|
115
|
+
expect(s.has_description).toBe(true);
|
|
116
|
+
expect(s.checklist_count).toEqual({ total: 3, done: 2 });
|
|
117
|
+
const { description: _d, checklist: _c, ...rest } = f;
|
|
118
|
+
const { has_description: _h, checklist_count: _cc, ...sRest } = s;
|
|
119
|
+
expect(sRest).toEqual(rest);
|
|
120
|
+
const empty = summary.body.requirements.find((r: any) => r.name === "slim-empty");
|
|
121
|
+
expect(empty.has_description).toBe(false);
|
|
122
|
+
expect(empty.checklist_count).toEqual({ total: 0, done: 0 });
|
|
123
|
+
// 同样的卡、同样的顺序;全文在单卡接口上照旧拿得到
|
|
124
|
+
expect(summary.body.requirements.map((r: any) => r.id)).toEqual(full.body.requirements.map((r: any) => r.id));
|
|
125
|
+
const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
|
|
126
|
+
expect(one.body.requirement.description).toBe(f.description);
|
|
127
|
+
expect(summary.text.length).toBeLessThan(full.text.length / 2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("view=full is the plain list; an unknown view is a 400", async () => {
|
|
131
|
+
const plain = await list(admin.token);
|
|
132
|
+
const explicit = await list(admin.token, "&view=full");
|
|
133
|
+
expect(explicit.body.requirements).toEqual(plain.body.requirements);
|
|
134
|
+
expect((await list(admin.token, "&view=tiny")).status).toBe(400);
|
|
135
|
+
expect((await list(admin.token, "&view=tiny")).body.error).toBe("invalid_view");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("changes=1", () => {
|
|
140
|
+
test("requires updated_since", async () => {
|
|
141
|
+
const r = await list(admin.token, "&changes=1");
|
|
142
|
+
expect(r.status).toBe(400);
|
|
143
|
+
expect(r.body.error).toBe("updated_since_required");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("returns only cards changed since, archived ones included, plus deleted ids and a server_time to continue from", async () => {
|
|
147
|
+
const keep = await create(admin.token, { name: "slim-keep" });
|
|
148
|
+
const edit = await create(admin.token, { name: "slim-edit" });
|
|
149
|
+
const arch = await create(admin.token, { name: "slim-arch" });
|
|
150
|
+
const gone = await create(admin.token, { name: "slim-gone" });
|
|
151
|
+
const first = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(Date.now() - 60_000).toISOString())}`);
|
|
152
|
+
expect(first.status).toBe(200);
|
|
153
|
+
const t0 = first.body.server_time as string;
|
|
154
|
+
expect(Date.parse(t0)).toBeGreaterThan(0);
|
|
155
|
+
expect(first.body.deleted).toEqual([]);
|
|
156
|
+
await tick();
|
|
157
|
+
|
|
158
|
+
expect((await patch(admin.token, edit.id, { name: "slim-edit-2" })).status).toBe(200);
|
|
159
|
+
expect((await patch(admin.token, arch.id, { archived: true })).status).toBe(200);
|
|
160
|
+
expect((await del(admin.token, gone.id)).status).toBe(200);
|
|
161
|
+
|
|
162
|
+
const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
|
|
163
|
+
expect(delta.status).toBe(200);
|
|
164
|
+
const ids = delta.body.requirements.map((r: any) => r.id).sort();
|
|
165
|
+
expect(ids).toEqual([edit.id, arch.id].sort());
|
|
166
|
+
expect(delta.body.requirements.find((r: any) => r.id === arch.id).archived).toBe(true);
|
|
167
|
+
expect(delta.body.requirements.find((r: any) => r.id === edit.id).name).toBe("slim-edit-2");
|
|
168
|
+
expect(delta.body.deleted).toEqual([gone.id]);
|
|
169
|
+
expect(ids).not.toContain(keep.id);
|
|
170
|
+
expect(Date.parse(delta.body.tombstones_since)).toBeLessThan(Date.parse(t0));
|
|
171
|
+
|
|
172
|
+
// 接着读:什么都没变就是空的
|
|
173
|
+
const again = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(delta.body.server_time)}`);
|
|
174
|
+
expect(again.body.requirements).toEqual([]);
|
|
175
|
+
expect(again.body.deleted).toEqual([]);
|
|
176
|
+
|
|
177
|
+
// 与 view=summary 组合
|
|
178
|
+
const slim = await list(admin.token, `&changes=1&view=summary&updated_since=${encodeURIComponent(t0)}`);
|
|
179
|
+
expect(slim.body.requirements.every((r: any) => !("description" in r) && "checklist_count" in r)).toBe(true);
|
|
180
|
+
|
|
181
|
+
// 不带 changes 的 updated_since 与原来一样:归档的不回、没有 deleted
|
|
182
|
+
const legacy = await list(admin.token, `&updated_since=${encodeURIComponent(t0)}`);
|
|
183
|
+
expect(legacy.body.requirements.map((r: any) => r.id)).toEqual([edit.id]);
|
|
184
|
+
expect(legacy.body.deleted).toBeUndefined();
|
|
185
|
+
expect(legacy.body.server_time).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("side effects that used to leave updated_at alone now show up: orphaned children, cleared project", async () => {
|
|
189
|
+
const parent = await create(admin.token, { name: "slim-parent" });
|
|
190
|
+
const child = await create(admin.token, { name: "slim-child", parent_id: parent.id });
|
|
191
|
+
const proj = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "slim-proj" });
|
|
192
|
+
expect(proj.status).toBe(201);
|
|
193
|
+
const inProj = await create(admin.token, { name: "slim-in-proj", project_id: proj.body.project.id });
|
|
194
|
+
await tick();
|
|
195
|
+
const t0 = new Date().toISOString();
|
|
196
|
+
await tick();
|
|
197
|
+
expect((await del(admin.token, parent.id)).status).toBe(200);
|
|
198
|
+
expect((await send(admin.token, "DELETE", `/api/requirements/projects/${proj.body.project.id}?network_id=${NET}`)).status).toBe(200);
|
|
199
|
+
const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
|
|
200
|
+
const byId = new Map(delta.body.requirements.map((r: any) => [r.id, r]));
|
|
201
|
+
expect((byId.get(child.id) as any)?.parent_id).toBeNull();
|
|
202
|
+
expect((byId.get(inProj.id) as any)?.project_id).toBeNull();
|
|
203
|
+
expect(delta.body.deleted).toEqual([parent.id]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a scoped member only hears about deletions of cards they could see", async () => {
|
|
207
|
+
const theirs = await create(admin.token, { name: "slim-theirs", owner: { kind: "user", id: scoped.id } });
|
|
208
|
+
const hidden = await create(admin.token, { name: "slim-hidden" });
|
|
209
|
+
const seen = await list(scoped.token);
|
|
210
|
+
expect(seen.body.requirements.map((r: any) => r.id)).toContain(theirs.id);
|
|
211
|
+
expect(seen.body.requirements.map((r: any) => r.id)).not.toContain(hidden.id);
|
|
212
|
+
await tick();
|
|
213
|
+
const t0 = new Date().toISOString();
|
|
214
|
+
await tick();
|
|
215
|
+
expect((await del(admin.token, theirs.id)).status).toBe(200);
|
|
216
|
+
expect((await del(admin.token, hidden.id)).status).toBe(200);
|
|
217
|
+
const mine = await list(scoped.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
|
|
218
|
+
expect(mine.body.deleted).toEqual([theirs.id]);
|
|
219
|
+
const all = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
|
|
220
|
+
expect(all.body.deleted.sort()).toEqual([theirs.id, hidden.id].sort());
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("list cache", () => {
|
|
225
|
+
test("an unchanged table is served from the cache: same ETag, and If-None-Match still 304s", async () => {
|
|
226
|
+
cache.clear();
|
|
227
|
+
const a = await list(admin.token);
|
|
228
|
+
expect(cache.size()).toBe(1);
|
|
229
|
+
const b = await list(admin.token);
|
|
230
|
+
expect(b.headers.get("etag")).toBe(a.headers.get("etag"));
|
|
231
|
+
expect(b.text).toBe(a.text);
|
|
232
|
+
expect((await list(admin.token, "", { "If-None-Match": a.headers.get("etag")! })).status).toBe(304);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("every kind of write invalidates it: REST patch, MCP update, checklist tick, delete", async () => {
|
|
236
|
+
const card = await create(admin.token, { name: "slim-cache", checklist: [{ text: "x", done: false }] });
|
|
237
|
+
const seen = async () => (await list(admin.token)).body.requirements.find((r: any) => r.id === card.id);
|
|
238
|
+
expect((await seen()).name).toBe("slim-cache");
|
|
239
|
+
await patch(admin.token, card.id, { name: "slim-cache-rest" });
|
|
240
|
+
expect((await seen()).name).toBe("slim-cache-rest");
|
|
241
|
+
const viaMcp = await mcp(admin.token, "requirements_update", { id: card.id, network_id: NET, name: "slim-cache-mcp" });
|
|
242
|
+
expect(viaMcp.ok).toBe(true);
|
|
243
|
+
expect((await seen()).name).toBe("slim-cache-mcp");
|
|
244
|
+
const item = (await seen()).checklist[0];
|
|
245
|
+
expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}/checklist/${item.id}?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
246
|
+
expect((await seen()).checklist[0].done).toBe(true);
|
|
247
|
+
await del(admin.token, card.id);
|
|
248
|
+
expect(await seen()).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a failed write still invalidates (the handler may have written before failing)", async () => {
|
|
252
|
+
const g = cache.generation();
|
|
253
|
+
await send(admin.token, "PATCH", `/api/requirements/does-not-exist?network_id=${NET}`, { name: "x" });
|
|
254
|
+
expect(cache.generation()).toBe(g + 1);
|
|
255
|
+
// 不是需求接口的写入不动代数
|
|
256
|
+
await send(admin.token, "POST", "/api/task", { alias: "nobody", task: "x" });
|
|
257
|
+
expect(cache.generation()).toBe(g + 1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("callers are cached separately; a member's access change is seen at once; scoped / agent-restricted members are never cached", async () => {
|
|
261
|
+
cache.clear();
|
|
262
|
+
await list(admin.token);
|
|
263
|
+
await list(member.token);
|
|
264
|
+
expect(cache.size()).toBe(2);
|
|
265
|
+
await list(scoped.token);
|
|
266
|
+
expect(cache.size()).toBe(2);
|
|
267
|
+
const { db } = await import("./db.js");
|
|
268
|
+
const before = await list(member.token);
|
|
269
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
270
|
+
const after = await list(member.token);
|
|
271
|
+
expect(after.body.requirements.length).toBeLessThan(before.body.requirements.length);
|
|
272
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
273
|
+
expect((await list(member.token)).body.requirements.length).toBe(before.body.requirements.length);
|
|
274
|
+
// Agent 受限(看得见哪些 Agent 取决于节点表)→ 不缓存
|
|
275
|
+
cache.clear();
|
|
276
|
+
db.run("UPDATE network_members SET agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
277
|
+
await list(member.token);
|
|
278
|
+
expect(cache.size()).toBe(0);
|
|
279
|
+
db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("changes=1 and search are not cached", async () => {
|
|
283
|
+
cache.clear();
|
|
284
|
+
await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(0).toISOString())}`);
|
|
285
|
+
await list(admin.token, "&q=slim");
|
|
286
|
+
expect(cache.size()).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("gzip of an unchanged list is computed once", async () => {
|
|
290
|
+
const before = { ...gzipStats };
|
|
291
|
+
const a = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
|
|
292
|
+
const b = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
|
|
293
|
+
expect(a.headers.get("content-encoding")).toBe("gzip");
|
|
294
|
+
const ga = new Uint8Array(await a.arrayBuffer());
|
|
295
|
+
const gb = new Uint8Array(await b.arrayBuffer());
|
|
296
|
+
expect(Buffer.from(gunzipSync(gb)).toString()).toBe(Buffer.from(gunzipSync(ga)).toString());
|
|
297
|
+
expect(gzipStats.hits).toBeGreaterThanOrEqual(before.hits + 1);
|
|
298
|
+
expect(JSON.parse(Buffer.from(gunzipSync(gb)).toString()).ok).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("GET /api/stats/routes", () => {
|
|
303
|
+
test("admin sees per-route timings with ids collapsed; a member gets 403", async () => {
|
|
304
|
+
await list(admin.token, "&view=summary");
|
|
305
|
+
const r = await send(admin.token, "GET", "/api/stats/routes?minutes=5");
|
|
306
|
+
expect(r.status).toBe(200);
|
|
307
|
+
const routes = r.body.routes.map((x: any) => x.route);
|
|
308
|
+
expect(routes).toContain("GET /api/requirements?view=summary");
|
|
309
|
+
expect(routes.some((x: string) => x.startsWith("PATCH /api/requirements/:id"))).toBe(true);
|
|
310
|
+
const row = r.body.routes.find((x: any) => x.route === "GET /api/requirements");
|
|
311
|
+
expect(row.count).toBeGreaterThan(0);
|
|
312
|
+
expect(row.max_ms).toBeGreaterThanOrEqual(row.avg_ms);
|
|
313
|
+
expect((await send(member.token, "GET", "/api/stats/routes")).status).toBe(403);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -115,6 +115,21 @@ describe("requirements MCP tools", () => {
|
|
|
115
115
|
expect(page.requirements.length).toBe(1);
|
|
116
116
|
expect(page.has_more).toBe(true);
|
|
117
117
|
expect((await s.call("requirements_list", { limit: 1, include_archived: true, cursor: page.next_cursor })).requirements[0].id).not.toBe(page.requirements[0].id);
|
|
118
|
+
// view / changes 透传(Hub capability list_summary / changes):精简行没有正文;增量带 deleted 与 server_time
|
|
119
|
+
const slim = await s.call("requirements_list", { view: "summary", include_archived: true });
|
|
120
|
+
const slimRow = slim.requirements.find((r: any) => r.id === id);
|
|
121
|
+
expect(slim.view).toBe("summary");
|
|
122
|
+
expect("description" in slimRow).toBe(false);
|
|
123
|
+
expect(typeof slimRow.checklist_count.total).toBe("number");
|
|
124
|
+
expect(typeof slimRow.has_description).toBe("boolean");
|
|
125
|
+
expect((await s.call("requirements_list", { view: "tiny" })).mcpError).toBe(true);
|
|
126
|
+
expect((await s.call("requirements_list", { changes: true })).error).toBe("updated_since_required");
|
|
127
|
+
const since = new Date(Date.now() - 1000).toISOString();
|
|
128
|
+
await s.call("requirements_update", { id: child.requirement.id, name: "子需求改名" });
|
|
129
|
+
const delta = await s.call("requirements_list", { changes: true, updated_since: since, view: "summary" });
|
|
130
|
+
expect(delta.requirements.map((r: any) => r.id)).toContain(child.requirement.id);
|
|
131
|
+
expect(Array.isArray(delta.deleted)).toBe(true);
|
|
132
|
+
expect(typeof delta.server_time).toBe("string");
|
|
118
133
|
} finally { await s.close(); }
|
|
119
134
|
});
|
|
120
135
|
|
|
@@ -43,6 +43,43 @@ export function ensureRequirementProjects(database: DbAdapter): void {
|
|
|
43
43
|
`);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 标签颜色(可选)。标签本身仍只是卡片 tags 数组里的字符串;这里只存「这个网络里这个名字用什么颜色」。
|
|
48
|
+
* 没有行 = 默认色。改名 / 合并 / 删除时跟着挪或删(见 requirements.ts 的标签管理)。IF NOT EXISTS:重复执行无副作用。
|
|
49
|
+
*/
|
|
50
|
+
export function ensureNetworkTags(database: DbAdapter): void {
|
|
51
|
+
database.exec(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS network_tags (
|
|
53
|
+
network_id TEXT NOT NULL,
|
|
54
|
+
name TEXT NOT NULL,
|
|
55
|
+
color TEXT NOT NULL,
|
|
56
|
+
updated_at TEXT NOT NULL,
|
|
57
|
+
PRIMARY KEY (network_id, name)
|
|
58
|
+
);
|
|
59
|
+
`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 删掉的卡留一条墓碑,给 GET /api/requirements?changes=1 报「这些 id 没了」(按 updated_since 增量同步的客户端
|
|
64
|
+
* 否则永远不知道一张卡被删了)。连同判断可见性要用的列一起存:受限成员只收到他本来看得见的卡的删除。
|
|
65
|
+
* 保留 30 天(requirements.ts TOMBSTONE_RETENTION_MS,写入时顺手清)。IF NOT EXISTS:重复执行无副作用。
|
|
66
|
+
*/
|
|
67
|
+
export function ensureRequirementTombstones(database: DbAdapter): void {
|
|
68
|
+
database.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS requirement_tombstones (
|
|
70
|
+
requirement_id TEXT PRIMARY KEY,
|
|
71
|
+
network_id TEXT NOT NULL,
|
|
72
|
+
deleted_at TEXT NOT NULL,
|
|
73
|
+
project_id TEXT,
|
|
74
|
+
owner_json TEXT,
|
|
75
|
+
participants_json TEXT,
|
|
76
|
+
created_by TEXT,
|
|
77
|
+
created_by_json TEXT
|
|
78
|
+
);
|
|
79
|
+
`);
|
|
80
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_tombstones_network ON requirement_tombstones(network_id, deleted_at)");
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
/** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
|
|
47
84
|
export function ensureRequirementIndexes(database: DbAdapter): void {
|
|
48
85
|
database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
|
|
@@ -128,6 +165,50 @@ export function ensureRequirementSeq(database: DbAdapter): { backfilled: number
|
|
|
128
165
|
return { backfilled: pending.length };
|
|
129
166
|
}
|
|
130
167
|
|
|
168
|
+
/**
|
|
169
|
+
* 完成时间(仪表盘「今天 / 本周完成了多少」用)。三列只加不改:
|
|
170
|
+
* - completed_at:进入「完成」列的时刻(ISO,UTC)。移出「完成」列清空;在「完成」列里改别的字段不动它。
|
|
171
|
+
* - completed_by_json:谁把它移进「完成」的({kind,id},同 updated_by)。
|
|
172
|
+
* - completed_at_approx:1 = 这个时刻是补出来的近似值(见下),界面可以标「近似」。
|
|
173
|
+
* 补值:已经在「完成」列、还没有 completed_at 的卡,按 updated_at(没有就 created_at)补,approx = 1,completed_by 留空
|
|
174
|
+
* —— updated_at 是「最后一次改」,不是「完成那一刻」,只能当近似。旧库里 datetime('now') 的「YYYY-MM-DD HH:MM:SS」(UTC)
|
|
175
|
+
* 统一换成 ISO,按时间范围比较时不和 ISO 值错位。
|
|
176
|
+
* 每次启动都跑,重复执行只处理需要处理的行:回滚到旧 Hub 期间移进「完成」的卡补近似值,移出「完成」的卡清掉残留。
|
|
177
|
+
*/
|
|
178
|
+
export function ensureRequirementCompletedAt(database: DbAdapter): { backfilled: number; cleared: number } {
|
|
179
|
+
for (const column of ["completed_at TEXT", "completed_by_json TEXT", "completed_at_approx INTEGER NOT NULL DEFAULT 0"]) {
|
|
180
|
+
try { database.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
181
|
+
}
|
|
182
|
+
const pending = database.all<{ requirement_id: string; updated_at: string | null; created_at: string | null }>(
|
|
183
|
+
"SELECT requirement_id, updated_at, created_at FROM requirements WHERE column_name = 'done' AND completed_at IS NULL",
|
|
184
|
+
);
|
|
185
|
+
const stale = database.all<{ requirement_id: string }>(
|
|
186
|
+
"SELECT requirement_id FROM requirements WHERE column_name <> 'done' AND (completed_at IS NOT NULL OR completed_by_json IS NOT NULL OR completed_at_approx <> 0)",
|
|
187
|
+
);
|
|
188
|
+
if (pending.length || stale.length) {
|
|
189
|
+
database.transaction(() => {
|
|
190
|
+
for (const row of pending) {
|
|
191
|
+
database.run(
|
|
192
|
+
"UPDATE requirements SET completed_at = ?1, completed_by_json = NULL, completed_at_approx = 1 WHERE requirement_id = ?2 AND completed_at IS NULL",
|
|
193
|
+
[isoInstant(row.updated_at) ?? isoInstant(row.created_at) ?? new Date().toISOString(), row.requirement_id],
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
for (const row of stale) {
|
|
197
|
+
database.run("UPDATE requirements SET completed_at = NULL, completed_by_json = NULL, completed_at_approx = 0 WHERE requirement_id = ?1", [row.requirement_id]);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_completed ON requirements(network_id, completed_at) WHERE completed_at IS NOT NULL");
|
|
202
|
+
return { backfilled: pending.length, cleared: stale.length };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 库里的时间 → ISO(UTC)。认 ISO 和旧默认值「YYYY-MM-DD HH:MM:SS」(SQLite datetime('now'),UTC);读不懂 → null。 */
|
|
206
|
+
export function isoInstant(value: string | null | undefined): string | null {
|
|
207
|
+
const s = value ?? "";
|
|
208
|
+
const ms = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(" ", "T")}Z` : s);
|
|
209
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
131
212
|
/**
|
|
132
213
|
* 给新卡领下一个号。调用方把它和 INSERT 放在同一个事务里:插入失败(client_id / external_ref 撞了)号也一起回滚。
|
|
133
214
|
* 一条语句完成「读 + 加一 + 写回」,两个并发的新建拿不到同一个号;(network_id, seq) 唯一索引兜底。
|
|
@@ -122,7 +122,8 @@ describe("requirement short numbers (#N)", () => {
|
|
|
122
122
|
test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
|
|
123
123
|
const { db } = await import("./db.js");
|
|
124
124
|
const { addNetworkMember } = await import("./auth.js");
|
|
125
|
-
|
|
125
|
+
// 看全网的成员(RFC-038 §9 之前的语义):这里测的是跨网络 #N 歧义,不是任务范围。
|
|
126
|
+
addNetworkMember(otherNet, ownerId, "member", otherId, { taskAccess: "all" });
|
|
126
127
|
const both = await api(ownerToken, "/api/requirements/%231");
|
|
127
128
|
expect(both.status).toBe(409);
|
|
128
129
|
expect(both.body.error).toBe("ambiguous_seq");
|