@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.69
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-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +31 -1
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +79 -1
- package/src/requirements-mcp.test.ts +10 -0
- package/src/requirements-migrate.ts +32 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements.ts +107 -35
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-tasks-http.test.ts +2 -1
- package/src/scheduled-tasks.ts +12 -0
- package/src/server.ts +345 -41
- package/src/tools.ts +99 -32
package/src/rename.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
|
+
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
18
19
|
import { pushEvent } from "./push";
|
|
19
20
|
import { eventBus } from "./event_bus";
|
|
20
21
|
|
|
@@ -35,7 +36,8 @@ export interface CanonicalAlias {
|
|
|
35
36
|
|
|
36
37
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
37
38
|
const role = getUserNetworkRole(userId, networkId);
|
|
38
|
-
|
|
39
|
+
// 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
|
|
40
|
+
return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// Resolve committed alias renames (old -> new), following short chains such
|
|
@@ -214,8 +216,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
214
216
|
// without this it would never receive node.renamed (#84 SSE channel fix:
|
|
215
217
|
// N站马 confirmed the dashboard listens on the user channel). All members get
|
|
216
218
|
// it, not just the owner, since any member's dashboard should reflect the rename.
|
|
219
|
+
// 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
|
|
217
220
|
for (const member of getNetworkMembers(txn.network_id)) {
|
|
218
|
-
if (member.username)
|
|
221
|
+
if (!member.username) continue;
|
|
222
|
+
if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
|
|
223
|
+
pushEvent(member.username, renamedEvent, txn.network_id);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
return { ok: true, txn_id: txnId };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
|
|
2
|
+
export function normalizeTags(value: unknown): string[] | null {
|
|
3
|
+
if (!Array.isArray(value) || value.length > 10) return null;
|
|
4
|
+
const tags: string[] = [];
|
|
5
|
+
for (const valueTag of value) {
|
|
6
|
+
if (typeof valueTag !== "string") return null;
|
|
7
|
+
const tag = valueTag.trim();
|
|
8
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
|
|
9
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
10
|
+
}
|
|
11
|
+
return tags;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function storedTags(value: string | null): string[] {
|
|
15
|
+
try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
|
|
16
|
+
}
|
|
@@ -55,6 +55,84 @@ afterAll(() => {
|
|
|
55
55
|
describe("requirements stay on the hub", () => {
|
|
56
56
|
let id = "";
|
|
57
57
|
|
|
58
|
+
test("name limit is consistent across create, PATCH and both upsert paths", async () => {
|
|
59
|
+
const send = (path: string, method: string, body: unknown) => api(ownerToken, path, { method, body: JSON.stringify(body) });
|
|
60
|
+
const ref = 'github:test/name-limit#1';
|
|
61
|
+
const created = await send('/api/requirements/upsert', 'POST', { name: '字'.repeat(80), external_ref: ref });
|
|
62
|
+
expect(created.status).toBe(201);
|
|
63
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
64
|
+
for (const name of ['a'.repeat(150), '字'.repeat(81), ' ', null, 12]) {
|
|
65
|
+
for (const [url, method, extra] of [
|
|
66
|
+
['/api/requirements', 'POST', {}],
|
|
67
|
+
[path, 'PATCH', {}],
|
|
68
|
+
['/api/requirements/upsert', 'POST', { external_ref: ref }],
|
|
69
|
+
['/api/requirements/upsert', 'POST', { external_ref: 'github:test/name-limit#2' }],
|
|
70
|
+
] as const) {
|
|
71
|
+
const result = await send(url, method, { ...extra, name });
|
|
72
|
+
expect(result.status).toBe(400);
|
|
73
|
+
expect(result.body.error).toBe('invalid_name');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect((await api(ownerToken, path)).body.requirement.name).toBe('字'.repeat(80));
|
|
77
|
+
const boundary = await send(path, 'PATCH', { name: ` ${'界'.repeat(80)} ` });
|
|
78
|
+
expect(boundary.status).toBe(200);
|
|
79
|
+
expect(boundary.body.requirement.name).toBe('界'.repeat(80));
|
|
80
|
+
expect((await send(path, 'PATCH', { priority: 'high' })).body.requirement.name).toBe('界'.repeat(80));
|
|
81
|
+
await api(ownerToken, path, { method: 'DELETE' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("archived=true returns only archived tasks and default excludes them", async () => {
|
|
85
|
+
const create = async (token: string, name: string) => api(token, '/api/requirements', { method: 'POST', body: JSON.stringify({ name }) });
|
|
86
|
+
const active = (await create(ownerToken, 'archive-filter-active')).body.requirement.id;
|
|
87
|
+
const archived = (await create(ownerToken, 'archive-filter-archived')).body.requirement.id;
|
|
88
|
+
const foreign = (await create(otherToken, 'archive-filter-foreign')).body.requirement.id;
|
|
89
|
+
for (const [token, taskId] of [[ownerToken, archived], [otherToken, foreign]]) {
|
|
90
|
+
expect((await api(token, `/api/requirements/${taskId}`, { method: 'PATCH', body: JSON.stringify({ archived: true }) })).status).toBe(200);
|
|
91
|
+
}
|
|
92
|
+
const ids = async (query: string) => {
|
|
93
|
+
const result = await api(ownerToken, `/api/requirements${query}${query ? '&' : '?'}network_id=${ownerNetwork}`);
|
|
94
|
+
expect(result.status).toBe(200);
|
|
95
|
+
return result.body.requirements.map((task: any) => task.id);
|
|
96
|
+
};
|
|
97
|
+
expect(await ids('?archived=true')).toEqual([archived]);
|
|
98
|
+
expect(await ids('?archived=true&include_archived=1')).toEqual([archived]);
|
|
99
|
+
const defaults = await ids('');
|
|
100
|
+
expect(defaults).toContain(active);
|
|
101
|
+
expect(defaults).not.toContain(archived);
|
|
102
|
+
expect(defaults).not.toContain(foreign);
|
|
103
|
+
expect(await ids('?archived=false')).toEqual(defaults);
|
|
104
|
+
const all = await ids('?include_archived=1');
|
|
105
|
+
expect(all).toContain(active);
|
|
106
|
+
expect(all).toContain(archived);
|
|
107
|
+
expect(all).not.toContain(foreign);
|
|
108
|
+
for (const [token, taskId] of [[ownerToken, active], [ownerToken, archived], [otherToken, foreign]]) {
|
|
109
|
+
await api(token, `/api/requirements/${taskId}`, { method: 'DELETE' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("tags validate, aggregate within network, and survive old PATCH shapes", async () => {
|
|
114
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'tagged task', tags: [' UI ', 'UI', '交付'] }) });
|
|
115
|
+
expect(created.status).toBe(201);
|
|
116
|
+
expect(created.body.requirement.tags).toEqual(['UI', '交付']);
|
|
117
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
118
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ column: 'doing', name: 'old app edit' }) });
|
|
119
|
+
expect(old.status).toBe(200);
|
|
120
|
+
expect(old.body.requirement.tags).toEqual(['UI', '交付']);
|
|
121
|
+
for (const tags of [null, 'UI', [12], [''], ['a'.repeat(21)], Array.from({ length: 11 }, (_, i) => `t${i}`)]) {
|
|
122
|
+
const invalid = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags }) });
|
|
123
|
+
expect(invalid.status).toBe(400);
|
|
124
|
+
expect(invalid.body.error).toBe('invalid_tags');
|
|
125
|
+
}
|
|
126
|
+
const own = await api(ownerToken, '/api/requirements/tags');
|
|
127
|
+
expect(own.body.tags).toEqual(['UI', '交付']);
|
|
128
|
+
expect((await api(otherToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
129
|
+
expect((await api(nodeToken, '/api/requirements/tags')).body.tags).toEqual(['UI', '交付']);
|
|
130
|
+
expect((await api(viewerToken, `${path}?network_id=${ownerNetwork}`, { method: 'PATCH', body: JSON.stringify({ tags: [] }) })).status).toBe(403);
|
|
131
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags: [] }) });
|
|
132
|
+
expect(cleared.body.requirement.tags).toEqual([]);
|
|
133
|
+
expect((await api(ownerToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
58
136
|
test("typed assignments validate network membership, preserve kinds and clear explicitly", async () => {
|
|
59
137
|
const { db } = await import('./db.js');
|
|
60
138
|
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
@@ -294,7 +372,7 @@ describe("requirements stay on the hub", () => {
|
|
|
294
372
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
295
373
|
const listed = await api(ownerToken, "/api/requirements");
|
|
296
374
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
297
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"]);
|
|
375
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest"]);
|
|
298
376
|
});
|
|
299
377
|
|
|
300
378
|
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
@@ -60,6 +60,13 @@ describe("requirements MCP tools", () => {
|
|
|
60
60
|
expect((await s.call("requirements_create", { name: "x", owner: { kind: "robot", id: "a" } })).mcpError).toBe(true);
|
|
61
61
|
expect((await s.call("requirements_checklist_toggle", { id: "r", item_id: "has space", done: true })).mcpError).toBe(true);
|
|
62
62
|
expect((await s.call("requirements_create", { name: "" })).mcpError).toBe(true);
|
|
63
|
+
// P0–P3:lowest(P3 极低)在建 / 改 / upsert 都能写;不认识的值在协议边界被拒
|
|
64
|
+
expect((await s.call("requirements_create", { name: "x", priority: "urgent" })).mcpError).toBe(true);
|
|
65
|
+
const low = await s.call("requirements_create", { name: "极低", priority: "lowest" });
|
|
66
|
+
expect(low.requirement.priority).toBe("lowest");
|
|
67
|
+
expect((await s.call("requirements_update", { id: low.requirement.id, priority: "high" })).requirement.priority).toBe("high");
|
|
68
|
+
expect((await s.call("requirements_update", { id: low.requirement.id, priority: "lowest" })).requirement.priority).toBe("lowest");
|
|
69
|
+
expect((await s.call("requirements_upsert_by_external_ref", { external_ref: "github:acme/p3#1", name: "同步", priority: "lowest" })).requirement.priority).toBe("lowest");
|
|
63
70
|
} finally { await s.close(); }
|
|
64
71
|
});
|
|
65
72
|
|
|
@@ -70,9 +77,12 @@ describe("requirements MCP tools", () => {
|
|
|
70
77
|
const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
|
|
71
78
|
expect(first.ok).toBe(true);
|
|
72
79
|
expect(first.created).toBe(true);
|
|
80
|
+
const tagged = await s.call("requirements_update", { id: first.requirement.id, tags: ["release"] });
|
|
81
|
+
expect(tagged.requirement.tags).toEqual(["release"]);
|
|
73
82
|
expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
|
|
74
83
|
const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
|
|
75
84
|
expect(again.created).toBe(false);
|
|
85
|
+
expect(again.requirement.tags).toEqual(["release"]);
|
|
76
86
|
expect(again.requirement.id).toBe(first.requirement.id);
|
|
77
87
|
const listed = await s.call("requirements_list", { external_ref: ref });
|
|
78
88
|
expect(listed.requirements.length).toBe(1);
|
|
@@ -49,3 +49,35 @@ export function ensureRequirementIndexes(database: DbAdapter): void {
|
|
|
49
49
|
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_updated ON requirements(network_id, updated_at)");
|
|
50
50
|
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_parent ON requirements(parent_id) WHERE parent_id IS NOT NULL");
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 优先级加 lowest(P3 极低)。旧库建表时带 CHECK(priority IN ('high', 'normal', 'low')),SQLite 不能就地改
|
|
55
|
+
* CHECK,只能重建一次:拿 sqlite_master 里的原建表语句(含历次加的列)只换掉那个 CHECK,整表拷过去,
|
|
56
|
+
* 再按原样重建这张表的索引。已经放开的库什么都不做。存量值不改。
|
|
57
|
+
*/
|
|
58
|
+
const OLD_PRIORITY_CHECK = /CHECK\s*\(\s*priority\s+IN\s*\(\s*'high'\s*,\s*'normal'\s*,\s*'low'\s*\)\s*\)/i;
|
|
59
|
+
export const PRIORITY_CHECK = "CHECK(priority IN ('high', 'normal', 'low', 'lowest'))";
|
|
60
|
+
export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt: boolean } {
|
|
61
|
+
if (database.dialect === "postgres") {
|
|
62
|
+
try {
|
|
63
|
+
database.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_priority_check");
|
|
64
|
+
database.exec(`ALTER TABLE requirements ADD CONSTRAINT requirements_priority_check ${PRIORITY_CHECK}`);
|
|
65
|
+
} catch {}
|
|
66
|
+
return { rebuilt: false };
|
|
67
|
+
}
|
|
68
|
+
const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
|
|
69
|
+
if (!table?.sql || !OLD_PRIORITY_CHECK.test(table.sql)) return { rebuilt: false };
|
|
70
|
+
const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
|
|
71
|
+
const createSql = table.sql
|
|
72
|
+
.replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
|
|
73
|
+
.replace(OLD_PRIORITY_CHECK, PRIORITY_CHECK);
|
|
74
|
+
database.transaction(() => {
|
|
75
|
+
database.exec("DROP TABLE IF EXISTS requirements_migrated");
|
|
76
|
+
database.exec(createSql);
|
|
77
|
+
database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
|
|
78
|
+
database.exec("DROP TABLE requirements");
|
|
79
|
+
database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
|
|
80
|
+
for (const index of indexes) database.exec(index.sql);
|
|
81
|
+
});
|
|
82
|
+
return { rebuilt: true };
|
|
83
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// P0–P3 优先级:lowest(P3 极低)。生产库的 requirements 表建于旧 db.ts,带 CHECK(priority IN ('high', 'normal', 'low')),
|
|
2
|
+
// 光放开校验集合写进去也会被 SQLite 拒掉。这里先用旧建表语句建库、塞行,再走真实启动路径(db.ts + server.ts),
|
|
3
|
+
// 看:表重建一次、存量行 / 索引原样、lowest 在 POST / PATCH / upsert 都能写、坏值仍被拒、再启动不再重建。
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-priority-"));
|
|
11
|
+
const file = join(dir, "hub.db");
|
|
12
|
+
process.env.COMMHUB_DB = file;
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(file);
|
|
15
|
+
legacy.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS requirements (
|
|
17
|
+
requirement_id TEXT PRIMARY KEY,
|
|
18
|
+
network_id TEXT NOT NULL,
|
|
19
|
+
title TEXT NOT NULL,
|
|
20
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
21
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
22
|
+
due_on TEXT,
|
|
23
|
+
assignee TEXT,
|
|
24
|
+
client_id TEXT,
|
|
25
|
+
issues_json TEXT,
|
|
26
|
+
created_by TEXT,
|
|
27
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_requirements_network ON requirements(network_id, created_at DESC);
|
|
31
|
+
`);
|
|
32
|
+
legacy.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
|
|
33
|
+
legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
|
|
34
|
+
const seeded = [["r_high", "high"], ["r_normal", "normal"], ["r_low", "low"]] as const;
|
|
35
|
+
for (const [id, p] of seeded) {
|
|
36
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority, client_id, description, tags_json) VALUES (?1, 'net_legacy', ?2, ?3, ?4, ?5, '[\"旧\"]')", [id, `卡 ${id}`, p, `c_${id}`, `desc ${id}`]);
|
|
37
|
+
}
|
|
38
|
+
// 旧库确实拒 lowest:这就是光改 PRIORITIES 不够的原因
|
|
39
|
+
expect(() => legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('x', 'n', 't', 'lowest')")).toThrow(/CHECK/);
|
|
40
|
+
legacy.close();
|
|
41
|
+
|
|
42
|
+
let base = "";
|
|
43
|
+
let token = "";
|
|
44
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
45
|
+
let db: any;
|
|
46
|
+
|
|
47
|
+
async function api(path: string, init?: RequestInit) {
|
|
48
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
49
|
+
return { status: res.status, body: await res.json() as any };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
({ db } = await import("./db.js"));
|
|
54
|
+
const { register } = await import("./auth.js");
|
|
55
|
+
const owner = register(`prio_owner_${Date.now()}`, "PriorityOwner123!", undefined, "seed");
|
|
56
|
+
token = owner.token!;
|
|
57
|
+
const mod: any = await import("./server.js");
|
|
58
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
59
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
try { server?.stop?.(true); } catch {}
|
|
64
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("P3 lowest priority", () => {
|
|
68
|
+
test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
|
|
69
|
+
const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
|
|
70
|
+
expect(sql).toContain("'lowest'");
|
|
71
|
+
expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done'))");
|
|
72
|
+
const rows = db.all("SELECT requirement_id, network_id, title, priority, client_id, description, tags_json FROM requirements WHERE network_id = 'net_legacy' ORDER BY requirement_id") as any[];
|
|
73
|
+
expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
expect(r.title).toBe(`卡 ${r.requirement_id}`);
|
|
76
|
+
expect(r.client_id).toBe(`c_${r.requirement_id}`);
|
|
77
|
+
expect(r.description).toBe(`desc ${r.requirement_id}`);
|
|
78
|
+
expect(r.tags_json).toBe('["旧"]');
|
|
79
|
+
}
|
|
80
|
+
const indexes = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).map(i => i.name);
|
|
81
|
+
for (const name of ["idx_requirements_network", "idx_requirements_client", "idx_requirements_external_ref", "idx_requirements_network_updated", "idx_requirements_parent"]) expect(indexes).toContain(name);
|
|
82
|
+
expect(db.get("SELECT name FROM sqlite_master WHERE name = 'requirements_migrated'")).toBeNull();
|
|
83
|
+
// 坏值仍被数据库拒
|
|
84
|
+
expect(() => db.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('bad', 'n', 't', 'urgent')")).toThrow(/CHECK/);
|
|
85
|
+
// 下次启动:已经放开,不再重建
|
|
86
|
+
const { migrateRequirementPriorityCheck } = await import("./requirements-migrate.js");
|
|
87
|
+
expect(migrateRequirementPriorityCheck(db).rebuilt).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("lowest is accepted on create, PATCH and upsert; unknown values stay 400", async () => {
|
|
91
|
+
const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "极低的活", priority: "lowest" }) });
|
|
92
|
+
expect(created.status).toBe(201);
|
|
93
|
+
expect(created.body.requirement.priority).toBe("lowest");
|
|
94
|
+
const id = created.body.requirement.id;
|
|
95
|
+
const patched = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "low" }) });
|
|
96
|
+
expect(patched.body.requirement.priority).toBe("low");
|
|
97
|
+
const back = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "lowest" }) });
|
|
98
|
+
expect(back.status).toBe(200);
|
|
99
|
+
expect(back.body.requirement.priority).toBe("lowest");
|
|
100
|
+
const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#1", priority: "lowest" }) });
|
|
101
|
+
expect(up.status).toBe(201);
|
|
102
|
+
expect(up.body.requirement.priority).toBe("lowest");
|
|
103
|
+
const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#1", priority: "high" }) });
|
|
104
|
+
expect(upAgain.body.requirement.priority).toBe("high");
|
|
105
|
+
for (const [path, method] of [["/api/requirements", "POST"], [`/api/requirements/${id}`, "PATCH"]] as const) {
|
|
106
|
+
const bad = await api(path, { method, body: JSON.stringify({ name: "x", priority: "urgent" }) });
|
|
107
|
+
expect(bad.status).toBe(400);
|
|
108
|
+
expect(bad.body.error).toBe("invalid_priority");
|
|
109
|
+
}
|
|
110
|
+
const listed = await api("/api/requirements");
|
|
111
|
+
expect(listed.body.capabilities).toContain("priority_lowest");
|
|
112
|
+
expect(listed.body.requirements.find((r: any) => r.id === id).priority).toBe("lowest");
|
|
113
|
+
});
|
|
114
|
+
});
|