@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.72

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.
@@ -0,0 +1,192 @@
1
+ // 任务短号 seq:新建领号(并发不撞)、按网络各自编号、按 #N 查 / 改、删除和归档不回收号、MCP 工具也收 #N。
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-"));
8
+ process.env.COMMHUB_DB = join(dir, "hub.db");
9
+
10
+ let server: { port: number; stop?: (force?: boolean) => void };
11
+ let base = "";
12
+ let ownerToken = "", otherToken = "", nodeToken = "";
13
+ let ownerNet = "", otherNet = "", ownerId = "", otherId = "";
14
+
15
+ async function api(token: string, path: string, init?: RequestInit) {
16
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
17
+ return { status: res.status, body: await res.json() as any };
18
+ }
19
+ const create = (token: string, body: Record<string, unknown>, query = "") => api(token, `/api/requirements${query}`, { method: "POST", body: JSON.stringify(body) });
20
+
21
+ beforeAll(async () => {
22
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
23
+ // 第一个注册的用户是 Hub 管理员、作用域是全部网络;让它先占掉,下面两个是普通用户(各自一个网络)。
24
+ register(`seq_admin_${Date.now()}`, "SeqAdmin123!x", undefined, "seed");
25
+ const o = register(`seq_owner_${Date.now()}`, "SeqOwner123!x", undefined, "seed");
26
+ const x = register(`seq_other_${Date.now()}`, "SeqOther123!x", undefined, "seed");
27
+ ownerToken = o.token!; ownerNet = o.network_id!; ownerId = o.user!.user_id;
28
+ otherToken = x.token!; otherNet = x.network_id!; otherId = x.user!.user_id;
29
+ nodeToken = createNetworkTokenForNode(ownerId, ownerNet, "seq-node").token!;
30
+ const mod: any = await import("./server.js");
31
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
32
+ base = `http://127.0.0.1:${server.port}`;
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("requirement short numbers (#N)", () => {
41
+ test("capability is advertised and create returns seq", async () => {
42
+ const list = await api(ownerToken, "/api/requirements");
43
+ expect(list.body.capabilities).toContain("requirement_seq");
44
+ const first = await create(ownerToken, { name: "第一张" });
45
+ expect(first.status).toBe(201);
46
+ expect(first.body.requirement.seq).toBe(1);
47
+ expect(first.body.requirement.id).toMatch(/^req_/);
48
+ const got = await api(ownerToken, `/api/requirements/${first.body.requirement.id}`);
49
+ expect(got.body.requirement.seq).toBe(1);
50
+ expect((await api(ownerToken, "/api/requirements")).body.requirements[0].seq).toBe(1);
51
+ });
52
+
53
+ test("concurrent creates get distinct, gap-free seqs", async () => {
54
+ const before = (await create(ownerToken, { name: "before" })).body.requirement.seq as number;
55
+ const results = await Promise.all(Array.from({ length: 40 }, (_, i) => create(i % 2 ? nodeToken : ownerToken, { name: `并发 ${i}` })));
56
+ expect(results.every(r => r.status === 201)).toBe(true);
57
+ const seqs = results.map(r => r.body.requirement.seq as number).sort((a, b) => a - b);
58
+ expect(new Set(seqs).size).toBe(40);
59
+ expect(seqs).toEqual(Array.from({ length: 40 }, (_, i) => before + 1 + i));
60
+ });
61
+
62
+ test("seq is per network", async () => {
63
+ const a = await create(otherToken, { name: "别的网络第一张" });
64
+ expect(a.body.requirement.seq).toBe(1);
65
+ const b = await create(otherToken, { name: "别的网络第二张" });
66
+ expect(b.body.requirement.seq).toBe(2);
67
+ // 同一个 #1 在两个网络各指各的卡
68
+ expect((await api(ownerToken, "/api/requirements/%231")).body.requirement.name).toBe("第一张");
69
+ expect((await api(otherToken, "/api/requirements/%231")).body.requirement.name).toBe("别的网络第一张");
70
+ // 别的网络的号查不到对方的卡
71
+ expect((await api(otherToken, "/api/requirements/%2310")).status).toBe(404);
72
+ });
73
+
74
+ test("lookup and patch by #N; list filter ?seq=; malformed forms rejected", async () => {
75
+ const c = (await create(ownerToken, { name: "按号查", checklist: [{ id: "a", text: "一" }] })).body.requirement;
76
+ const ref = `%23${c.seq}`;
77
+ const got = await api(ownerToken, `/api/requirements/${ref}`);
78
+ expect(got.status).toBe(200);
79
+ expect(got.body.requirement.id).toBe(c.id);
80
+ const patched = await api(nodeToken, `/api/requirements/${ref}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
81
+ expect(patched.status).toBe(200);
82
+ expect(patched.body.requirement.column).toBe("doing");
83
+ expect(patched.body.requirement.seq).toBe(c.seq); // 改卡不改号
84
+ const ticked = await api(ownerToken, `/api/requirements/${ref}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
85
+ expect(ticked.body.requirement.checklist[0].done).toBe(true);
86
+ const listed = await api(ownerToken, `/api/requirements?seq=${c.seq}`);
87
+ expect(listed.body.requirements.map((r: any) => r.id)).toEqual([c.id]);
88
+ expect((await api(ownerToken, "/api/requirements?seq=abc")).status).toBe(400);
89
+ expect((await api(ownerToken, "/api/requirements?seq=0")).status).toBe(400);
90
+ // 旧 id 照常;裸数字、「#」「#0」「#-1」都不是短号,按 id 查 → 404
91
+ expect((await api(ownerToken, `/api/requirements/${c.id}`)).body.requirement.seq).toBe(c.seq);
92
+ for (const bad of [String(c.seq), "%23", "%230", "%23-1", `%23${c.seq}x`]) expect((await api(ownerToken, `/api/requirements/${bad}`)).status).toBe(404);
93
+ });
94
+
95
+ test("deleting or archiving never frees a number", async () => {
96
+ const top = (await create(ownerToken, { name: "最大号" })).body.requirement;
97
+ expect((await api(ownerToken, `/api/requirements/%23${top.seq}`, { method: "DELETE" })).status).toBe(200);
98
+ expect((await api(ownerToken, `/api/requirements/%23${top.seq}`)).status).toBe(404);
99
+ const next = (await create(ownerToken, { name: "删之后" })).body.requirement;
100
+ expect(next.seq).toBe(top.seq + 1);
101
+ await api(ownerToken, `/api/requirements/${next.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
102
+ expect((await create(ownerToken, { name: "归档之后" })).body.requirement.seq).toBe(next.seq + 1);
103
+ // 归档的卡按号照样能查到
104
+ expect((await api(ownerToken, `/api/requirements/%23${next.seq}`)).body.requirement.archived).toBe(true);
105
+ });
106
+
107
+ test("a create that loses on client_id / external_ref does not burn a number", async () => {
108
+ const a = (await create(ownerToken, { name: "幂等", client_id: "seq-client-1" })).body.requirement;
109
+ const again = await create(ownerToken, { name: "幂等", client_id: "seq-client-1" });
110
+ expect(again.body.requirement.seq).toBe(a.seq);
111
+ await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" });
112
+ expect((await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" })).status).toBe(409);
113
+ const after = (await create(ownerToken, { name: "之后" })).body.requirement;
114
+ expect(after.seq).toBe(a.seq + 2);
115
+ // upsert 新建也领号,改的时候号不变
116
+ const up = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步", external_ref: "github:acme/seq#2" }) });
117
+ expect(up.body.requirement.seq).toBe(after.seq + 1);
118
+ const up2 = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步改名", external_ref: "github:acme/seq#2" }) });
119
+ expect(up2.body.requirement.seq).toBe(after.seq + 1);
120
+ });
121
+
122
+ test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
123
+ const { db } = await import("./db.js");
124
+ const { addNetworkMember } = await import("./auth.js");
125
+ addNetworkMember(otherNet, ownerId, "member", otherId);
126
+ const both = await api(ownerToken, "/api/requirements/%231");
127
+ expect(both.status).toBe(409);
128
+ expect(both.body.error).toBe("ambiguous_seq");
129
+ expect(both.body.networks.sort()).toEqual([ownerNet, otherNet].sort());
130
+ expect(both.body.message).toContain("pass network_id");
131
+ // MCP 上用户令牌(跨两个网络)同样:清楚的错误 + 带 network_id 就能查到
132
+ {
133
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
134
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
135
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
136
+ const { registerTools } = await import("./tools.js");
137
+ const mcp = new McpServer({ name: "seq-mcp-user", version: "1" });
138
+ registerTools(mcp, undefined, null, ownerId, "seq-owner", false, "tok_user");
139
+ const client = new Client({ name: "seq-mcp-user-client", version: "1" });
140
+ const [ct, st] = InMemoryTransport.createLinkedPair();
141
+ await mcp.connect(st); await client.connect(ct);
142
+ const call = async (name: string, args: Record<string, unknown>) => JSON.parse(((await client.callTool({ name, arguments: args })) as any).content[0].text);
143
+ try {
144
+ const amb = await call("requirements_get", { id: "#1" });
145
+ expect(amb.ok).toBe(false);
146
+ expect(amb.error).toBe("ambiguous_seq");
147
+ expect(amb.status).toBe(409);
148
+ expect(amb.message).toContain("pass network_id");
149
+ expect((await call("requirements_update", { id: "#1", priority: "low" })).error).toBe("ambiguous_seq");
150
+ expect((await call("requirements_get", { id: "#1", network_id: otherNet })).requirement.name).toBe("别的网络第一张");
151
+ expect((await call("requirements_get", { id: "#1", network_id: ownerNet })).requirement.name).toBe("第一张");
152
+ } finally { await client.close(); await mcp.close(); }
153
+ }
154
+ // 旧 id 不受影响
155
+ const firstId = (await api(ownerToken, `/api/requirements/%231?network_id=${ownerNet}`)).body.requirement.id;
156
+ expect((await api(ownerToken, `/api/requirements/${firstId}`)).status).toBe(200);
157
+ const scoped = await api(ownerToken, `/api/requirements/%231?network_id=${otherNet}`);
158
+ expect(scoped.status).toBe(200);
159
+ expect(scoped.body.requirement.name).toBe("别的网络第一张");
160
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [otherNet, ownerId]);
161
+ });
162
+
163
+ test("MCP tools accept #N and expose seq", async () => {
164
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
165
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
166
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
167
+ const { registerTools } = await import("./tools.js");
168
+ const { db } = await import("./db.js");
169
+ const tokenId = db.get<{ token_id: string }>("SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%' LIMIT 1", ownerNet)?.token_id ?? null;
170
+ const mcp = new McpServer({ name: "seq-mcp", version: "1" });
171
+ registerTools(mcp, undefined, ownerNet, ownerId, "seq-node", true, tokenId);
172
+ const client = new Client({ name: "seq-mcp-client", version: "1" });
173
+ const [ct, st] = InMemoryTransport.createLinkedPair();
174
+ await mcp.connect(st); await client.connect(ct);
175
+ const call = async (name: string, args: Record<string, unknown>) => {
176
+ const r: any = await client.callTool({ name, arguments: args });
177
+ return r.isError ? { mcpError: true } : JSON.parse(r.content[0].text);
178
+ };
179
+ try {
180
+ const made = await call("requirements_create", { name: "MCP 建的", checklist: [{ id: "k", text: "一" }] });
181
+ expect(Number.isInteger(made.requirement.seq)).toBe(true);
182
+ const n = `#${made.requirement.seq}`;
183
+ expect((await call("requirements_get", { id: n })).requirement.id).toBe(made.requirement.id);
184
+ expect((await call("requirements_update", { id: n, priority: "high" })).requirement.priority).toBe("high");
185
+ expect((await call("requirements_checklist_toggle", { id: n, item_id: "k", done: true })).requirement.checklist[0].done).toBe(true);
186
+ const listed = await call("requirements_list", { seq: made.requirement.seq });
187
+ expect(listed.requirements.map((r: any) => r.id)).toEqual([made.requirement.id]);
188
+ expect((await call("requirements_list", { seq: 0 })).mcpError).toBe(true);
189
+ expect((await call("requirements_get", { id: "#1" })).requirement.name).toBe("第一张"); // 节点令牌只看自己网络的 #1
190
+ } finally { await client.close(); await mcp.close(); }
191
+ });
192
+ });
@@ -0,0 +1,63 @@
1
+ // 任务短号 seq 的启动迁移:用没有 seq 列的旧表建库、塞行,再走真实启动路径(import db.ts + requirements.ts)。
2
+ // 补号按网络各自从 1 开始,按 created_at(新旧两种时间格式混在一起)排,同一时刻按 requirement_id。
3
+ import { afterAll, expect, test } from "bun:test";
4
+ import { Database } from "bun:sqlite";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-migrate-"));
10
+ const file = join(dir, "hub.db");
11
+ process.env.COMMHUB_DB = file;
12
+
13
+ const legacy = new Database(file);
14
+ legacy.exec(`CREATE TABLE requirements (
15
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
16
+ column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
17
+ client_id TEXT, issues_json TEXT, created_by TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
19
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
20
+ // 插入顺序故意打乱;期望的号写在第三列。
21
+ const rows: [string, string, string, number][] = [
22
+ ["req_c", "net_a", "2026-09-02T00:00:00.000Z", 5],
23
+ ["req_a", "net_a", "2026-09-01 08:00:00", 1], // 旧默认格式(UTC,空格分隔)
24
+ ["req_z", "net_b", "2026-08-01T00:00:00.000Z", 1],
25
+ ["req_b2", "net_a", "2026-09-01T09:00:00.000Z", 3], // 与 req_b1 同一时刻 → 按 id
26
+ ["req_b1", "net_a", "2026-09-01T09:00:00.000Z", 2],
27
+ ["req_y", "net_b", "2026-08-02 00:00:00", 2],
28
+ ["req_d", "net_a", "2026-09-01 23:59:59", 4], // 旧格式,晚于 09:00 的 ISO、早于 09-02
29
+ ];
30
+ for (const [id, net, at] of rows) legacy.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", [id, net, `卡 ${id}`, at]);
31
+ legacy.close();
32
+
33
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
34
+
35
+ test("startup backfills seq per network in created_at order, then id; counters and unique index follow", async () => {
36
+ const { db } = await import("./db.js");
37
+ await import("./requirements.js"); // 真实启动路径
38
+ const got = Object.fromEntries((db.all("SELECT requirement_id, network_id, seq FROM requirements") as any[]).map(r => [r.requirement_id, r.seq]));
39
+ // net_a 按时间:req_a 09-01 08:00 → req_b1 / req_b2 09-01 09:00(按 id)→ req_d 09-01 23:59:59 → req_c 09-02
40
+ expect(got).toEqual({ req_a: 1, req_b1: 2, req_b2: 3, req_d: 4, req_c: 5, req_z: 1, req_y: 2 });
41
+ // 按字符串排会把「2026-09-01 23:59:59」排到所有 ISO 的 09-01 前面 —— 证明排序真的按时刻,不按字面
42
+ expect("2026-09-01 23:59:59" < "2026-09-01T09:00:00.000Z").toBe(true);
43
+ const counters = Object.fromEntries((db.all("SELECT network_id, last_seq FROM requirement_seq_counters") as any[]).map(r => [r.network_id, r.last_seq]));
44
+ expect(counters).toEqual({ net_a: 5, net_b: 2 });
45
+ const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_network_seq");
46
+ expect(idx?.unique).toBe(1);
47
+ expect(() => db.run("UPDATE requirements SET seq = 1 WHERE requirement_id = 'req_c'")).toThrow();
48
+
49
+ // 重复执行:已有号的行一个不动;回滚到旧 Hub 期间建的卡(seq 为空)接在后面补号
50
+ const { ensureRequirementSeq, nextRequirementSeq } = await import("./requirements-migrate.js");
51
+ expect(ensureRequirementSeq(db).backfilled).toBe(0);
52
+ db.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES ('req_old_hub', 'net_a', 'x', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')");
53
+ expect(ensureRequirementSeq(db).backfilled).toBe(1);
54
+ expect((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_old_hub'") as any).seq).toBe(6);
55
+ expect(got.req_a).toBe((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_a'") as any).seq);
56
+ // 删掉最大号的卡,下一个号也不回收
57
+ db.run("DELETE FROM requirements WHERE requirement_id = 'req_old_hub'");
58
+ ensureRequirementSeq(db);
59
+ expect(nextRequirementSeq(db, "net_a")).toBe(7);
60
+ // 从没建过卡的网络从 1 开始
61
+ expect(nextRequirementSeq(db, "net_new")).toBe(1);
62
+ expect(nextRequirementSeq(db, "net_new")).toBe(2);
63
+ });
@@ -0,0 +1,125 @@
1
+ // 需求的可选开始字段(start,存 start_on):App 甘特图用。
2
+ // 先用「没有 start_on 列」的旧库形状建库、塞行,再走真实启动路径(db.ts + server.ts),看:
3
+ // 列被加上、存量行读出来 start = "" 且别的列原样;POST / PATCH / upsert 都能写;只 PATCH due 不动 start 和描述;
4
+ // 旧客户端(不带 start)的 PATCH 不清掉已有的 start;坏值 400 invalid_start;capabilities 带 start_date。
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { Database } from "bun:sqlite";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-start-"));
12
+ const file = join(dir, "hub.db");
13
+ process.env.COMMHUB_DB = file;
14
+
15
+ const legacy = new Database(file);
16
+ legacy.exec(`
17
+ CREATE TABLE IF NOT EXISTS requirements (
18
+ requirement_id TEXT PRIMARY KEY,
19
+ network_id TEXT NOT NULL,
20
+ title TEXT NOT NULL,
21
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
22
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
23
+ due_on TEXT,
24
+ assignee TEXT,
25
+ client_id TEXT,
26
+ issues_json TEXT,
27
+ created_by TEXT,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
29
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
30
+ );
31
+ `);
32
+ legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
33
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, description) VALUES ('r_old', 'net_legacy', '旧卡', '2026-10-01', '旧描述')");
34
+ expect(legacy.query("SELECT name FROM pragma_table_info('requirements') WHERE name = 'start_on'").all()).toEqual([]);
35
+ legacy.close();
36
+
37
+ let base = "";
38
+ let token = "";
39
+ let server: { port: number; stop?: (force?: boolean) => void };
40
+ let db: any;
41
+
42
+ async function api(path: string, init?: RequestInit) {
43
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
44
+ return { status: res.status, body: await res.json() as any };
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ ({ db } = await import("./db.js"));
49
+ const { register } = await import("./auth.js");
50
+ const owner = register(`start_owner_${Date.now()}`, "StartOwner123!", undefined, "seed");
51
+ token = owner.token!;
52
+ const mod: any = await import("./server.js");
53
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
54
+ base = `http://127.0.0.1:${server.port}`;
55
+ }, 30_000);
56
+
57
+ afterAll(() => {
58
+ try { server?.stop?.(true); } catch {}
59
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
60
+ });
61
+
62
+ describe("optional requirement start date", () => {
63
+ test("startup adds start_on to an old table; existing rows keep every value and read start = ''", () => {
64
+ const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
65
+ expect(cols).toContain("start_on");
66
+ const row = db.get("SELECT title, due_on, description, start_on FROM requirements WHERE requirement_id = 'r_old'") as any;
67
+ expect(row).toEqual({ title: "旧卡", due_on: "2026-10-01", description: "旧描述", start_on: null });
68
+ });
69
+
70
+ test("create / PATCH / upsert write start; due-only PATCH and start-less PATCH keep it", async () => {
71
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "有开始的", start: "2026-09-20", due: "2026-10-01", description: "别动我" }) });
72
+ expect(created.status).toBe(201);
73
+ expect(created.body.requirement.start).toBe("2026-09-20");
74
+ const id = created.body.requirement.id;
75
+
76
+ // 甘特图拖动只发 due:start 和描述原样
77
+ const dueOnly = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-05" }) });
78
+ expect(dueOnly.status).toBe(200);
79
+ expect(dueOnly.body.requirement.due).toBe("2026-10-05");
80
+ expect(dueOnly.body.requirement.start).toBe("2026-09-20");
81
+ expect(dueOnly.body.requirement.description).toBe("别动我");
82
+
83
+ // 旧客户端不认识 start:它的 PATCH 不带这个字段,不能清掉
84
+ const old = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "改名了" }) });
85
+ expect(old.body.requirement.start).toBe("2026-09-20");
86
+
87
+ const instant = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-21T09:30:00+08:00" }) });
88
+ expect(instant.body.requirement.start).toBe("2026-09-21T01:30:00Z");
89
+ const cleared = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "" }) });
90
+ expect(cleared.status).toBe(200);
91
+ expect(cleared.body.requirement.start).toBe("");
92
+ // 只带 start 的 PATCH 不是 empty_patch
93
+ const onlyStart = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-22" }) });
94
+ expect(onlyStart.status).toBe(200);
95
+
96
+ const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#9", start: "2026-09-01" }) });
97
+ expect(up.status).toBe(201);
98
+ expect(up.body.requirement.start).toBe("2026-09-01");
99
+ const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#9", start: "2026-09-02" }) });
100
+ expect(upAgain.body.requirement.start).toBe("2026-09-02");
101
+
102
+ const noStart = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "没开始字段" }) });
103
+ expect(noStart.body.requirement.start).toBe("");
104
+ });
105
+
106
+ test("bad start values are 400 invalid_start and change nothing", async () => {
107
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏值", start: "2026-09-20" }) });
108
+ const id = created.body.requirement.id;
109
+ for (const bad of ["2026-02-30", "tomorrow", "2026-09-20T10:00:00", 20260920]) {
110
+ const post = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "x", start: bad }) });
111
+ const patch = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: bad }) });
112
+ expect(patch.status).toBe(400);
113
+ expect(patch.body.error).toBe("invalid_start");
114
+ if (typeof bad === "string") {
115
+ expect(post.status).toBe(400);
116
+ expect(post.body.error).toBe("invalid_start");
117
+ }
118
+ }
119
+ const listed = await api("/api/requirements");
120
+ expect(listed.body.requirements.find((r: any) => r.id === id).start).toBe("2026-09-20");
121
+ expect(listed.body.capabilities).toContain("start_date");
122
+ // 旧卡在列表里 start = ""(不是缺字段:新 App 按 capabilities 判断支持与否)
123
+ expect(listed.body.requirements.every((r: any) => typeof r.start === "string")).toBe(true);
124
+ });
125
+ });