@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// 需求卡的启动迁移。requirements.ts 载入时调用一次(列由 db.ts 的加列循环加上)。
|
|
2
|
+
import type { DbAdapter } from "./db-adapter";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 负责人 = 人类(owner, kind user);负责 Agent = 节点(agent_owner, kind node)。
|
|
6
|
+
* 之前 owner 可以是节点:这些卡把节点挪到 agent_owner、owner 置空。只动 agent_owner 还空着的行,
|
|
7
|
+
* 所以重复执行不会再改任何东西;不删行,不碰别的列。在 JS 里判 kind(不依赖 SQLite 的 json_extract)。
|
|
8
|
+
*/
|
|
9
|
+
export function migrateRequirementAgentOwners(database: DbAdapter): { moved: number } {
|
|
10
|
+
const rows = database.all<{ requirement_id: string; owner_json: string | null }>(
|
|
11
|
+
"SELECT requirement_id, owner_json FROM requirements WHERE owner_json IS NOT NULL AND agent_owner_json IS NULL",
|
|
12
|
+
);
|
|
13
|
+
let moved = 0;
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
let owner: { kind?: unknown; id?: unknown } | null = null;
|
|
16
|
+
try { owner = JSON.parse(row.owner_json || "null"); } catch { continue; }
|
|
17
|
+
if (!owner || owner.kind !== "node" || typeof owner.id !== "string" || !owner.id) continue;
|
|
18
|
+
database.run(
|
|
19
|
+
"UPDATE requirements SET agent_owner_json = ?1, owner_json = NULL WHERE requirement_id = ?2 AND agent_owner_json IS NULL",
|
|
20
|
+
[JSON.stringify({ kind: "node", id: owner.id }), row.requirement_id],
|
|
21
|
+
);
|
|
22
|
+
moved += 1;
|
|
23
|
+
}
|
|
24
|
+
return { moved };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 项目表(需求卡的「项目」)。只建表、建索引,不塞任何项目 —— 军团项目 / TMAI 由 owner 在界面里建。
|
|
29
|
+
* CREATE ... IF NOT EXISTS:重复执行无副作用。放在这里而不是 db.ts,db.ts 的行号被文档钉着。
|
|
30
|
+
*/
|
|
31
|
+
export function ensureRequirementProjects(database: DbAdapter): void {
|
|
32
|
+
database.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS requirement_projects (
|
|
34
|
+
project_id TEXT PRIMARY KEY,
|
|
35
|
+
network_id TEXT NOT NULL,
|
|
36
|
+
name TEXT NOT NULL,
|
|
37
|
+
color TEXT NOT NULL,
|
|
38
|
+
sort INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
created_at TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
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
|
+
|
|
83
|
+
/** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
|
|
84
|
+
export function ensureRequirementIndexes(database: DbAdapter): void {
|
|
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");
|
|
86
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_updated ON requirements(network_id, updated_at)");
|
|
87
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_parent ON requirements(parent_id) WHERE parent_id IS NOT NULL");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 优先级加 lowest(P3 极低)。旧库建表时带 CHECK(priority IN ('high', 'normal', 'low')),SQLite 不能就地改
|
|
92
|
+
* CHECK,只能重建一次:拿 sqlite_master 里的原建表语句(含历次加的列)只换掉那个 CHECK,整表拷过去,
|
|
93
|
+
* 再按原样重建这张表的索引。已经放开的库什么都不做。存量值不改。
|
|
94
|
+
*/
|
|
95
|
+
const OLD_PRIORITY_CHECK = /CHECK\s*\(\s*priority\s+IN\s*\(\s*'high'\s*,\s*'normal'\s*,\s*'low'\s*\)\s*\)/i;
|
|
96
|
+
export const PRIORITY_CHECK = "CHECK(priority IN ('high', 'normal', 'low', 'lowest'))";
|
|
97
|
+
export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt: boolean } {
|
|
98
|
+
if (database.dialect === "postgres") {
|
|
99
|
+
try {
|
|
100
|
+
database.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_priority_check");
|
|
101
|
+
database.exec(`ALTER TABLE requirements ADD CONSTRAINT requirements_priority_check ${PRIORITY_CHECK}`);
|
|
102
|
+
} catch {}
|
|
103
|
+
return { rebuilt: false };
|
|
104
|
+
}
|
|
105
|
+
const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
|
|
106
|
+
if (!table?.sql || !OLD_PRIORITY_CHECK.test(table.sql)) return { rebuilt: false };
|
|
107
|
+
const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
|
|
108
|
+
const createSql = table.sql
|
|
109
|
+
.replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
|
|
110
|
+
.replace(OLD_PRIORITY_CHECK, PRIORITY_CHECK);
|
|
111
|
+
database.transaction(() => {
|
|
112
|
+
database.exec("DROP TABLE IF EXISTS requirements_migrated");
|
|
113
|
+
database.exec(createSql);
|
|
114
|
+
database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
|
|
115
|
+
database.exec("DROP TABLE requirements");
|
|
116
|
+
database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
|
|
117
|
+
for (const index of indexes) database.exec(index.sql);
|
|
118
|
+
});
|
|
119
|
+
return { rebuilt: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 任务短号 seq:每个网络自己的 #1、#2…,界面显示和按 #N 查都用它。主键 requirement_id 不变。
|
|
124
|
+
* - 加可空列 seq;没有号的旧行按 created_at(再按 requirement_id)补号,接在该网络已有的最大号后面。
|
|
125
|
+
* - requirement_seq_counters 记每个网络发到过的最大号:删掉 / 归档的卡,号也不回收。
|
|
126
|
+
* - (network_id, seq) 唯一。重复执行只补还没号的行(回滚到旧 Hub 期间新建的卡)。
|
|
127
|
+
*/
|
|
128
|
+
export function ensureRequirementSeq(database: DbAdapter): { backfilled: number } {
|
|
129
|
+
try { database.exec("ALTER TABLE requirements ADD COLUMN seq INTEGER"); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
130
|
+
database.exec("CREATE TABLE IF NOT EXISTS requirement_seq_counters (network_id TEXT PRIMARY KEY, last_seq INTEGER NOT NULL DEFAULT 0)");
|
|
131
|
+
const pending = database.all<{ requirement_id: string; network_id: string; created_at: string | null }>(
|
|
132
|
+
"SELECT requirement_id, network_id, created_at FROM requirements WHERE seq IS NULL",
|
|
133
|
+
);
|
|
134
|
+
if (pending.length) {
|
|
135
|
+
// datetime('now') 的旧默认值「YYYY-MM-DD HH:MM:SS」是 UTC,和 ISO 混在一起时按字符串排会错位,换成毫秒再排。
|
|
136
|
+
const at = (v: string | null) => {
|
|
137
|
+
const s = v ?? "";
|
|
138
|
+
const ms = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(" ", "T")}Z` : s);
|
|
139
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
140
|
+
};
|
|
141
|
+
pending.sort((a, b) => a.network_id.localeCompare(b.network_id) || at(a.created_at) - at(b.created_at) || (a.requirement_id < b.requirement_id ? -1 : a.requirement_id > b.requirement_id ? 1 : 0));
|
|
142
|
+
database.transaction(() => {
|
|
143
|
+
const next = new Map<string, number>();
|
|
144
|
+
for (const row of pending) {
|
|
145
|
+
if (!next.has(row.network_id)) {
|
|
146
|
+
const max = database.get<{ m: number | null }>("SELECT MAX(seq) AS m FROM requirements WHERE network_id = ?1", row.network_id)?.m ?? 0;
|
|
147
|
+
const counter = database.get<{ last_seq: number }>("SELECT last_seq FROM requirement_seq_counters WHERE network_id = ?1", row.network_id)?.last_seq ?? 0;
|
|
148
|
+
next.set(row.network_id, Math.max(Number(max), Number(counter)));
|
|
149
|
+
}
|
|
150
|
+
const seq = next.get(row.network_id)! + 1;
|
|
151
|
+
next.set(row.network_id, seq);
|
|
152
|
+
database.run("UPDATE requirements SET seq = ?1 WHERE requirement_id = ?2 AND seq IS NULL", [seq, row.requirement_id]);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// 计数器不低于库里的最大号(补号之后、或计数器表是新建的)。
|
|
157
|
+
for (const row of database.all<{ network_id: string; m: number }>("SELECT network_id, MAX(seq) AS m FROM requirements WHERE seq IS NOT NULL GROUP BY network_id")) {
|
|
158
|
+
database.run(
|
|
159
|
+
`INSERT INTO requirement_seq_counters (network_id, last_seq) VALUES (?1, ?2)
|
|
160
|
+
ON CONFLICT(network_id) DO UPDATE SET last_seq = CASE WHEN excluded.last_seq > requirement_seq_counters.last_seq THEN excluded.last_seq ELSE requirement_seq_counters.last_seq END`,
|
|
161
|
+
[row.network_id, Number(row.m)],
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_network_seq ON requirements(network_id, seq) WHERE seq IS NOT NULL");
|
|
165
|
+
return { backfilled: pending.length };
|
|
166
|
+
}
|
|
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
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 给新卡领下一个号。调用方把它和 INSERT 放在同一个事务里:插入失败(client_id / external_ref 撞了)号也一起回滚。
|
|
214
|
+
* 一条语句完成「读 + 加一 + 写回」,两个并发的新建拿不到同一个号;(network_id, seq) 唯一索引兜底。
|
|
215
|
+
*/
|
|
216
|
+
export function nextRequirementSeq(database: DbAdapter, networkId: string): number {
|
|
217
|
+
const row = database.get<{ last_seq: number }>(
|
|
218
|
+
`INSERT INTO requirement_seq_counters (network_id, last_seq)
|
|
219
|
+
VALUES (?1, COALESCE((SELECT MAX(seq) FROM requirements WHERE network_id = ?1), 0) + 1)
|
|
220
|
+
ON CONFLICT(network_id) DO UPDATE SET last_seq = requirement_seq_counters.last_seq + 1
|
|
221
|
+
RETURNING last_seq`,
|
|
222
|
+
networkId,
|
|
223
|
+
);
|
|
224
|
+
return Number(row!.last_seq);
|
|
225
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// GET /api/requirements/people 的 display_name 字段 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// name 保持原样:成员 = display_name,没设回落到用户名;节点 = display_name → alias → node_name(旧 app 只读它)。
|
|
5
|
+
// display_name 另给一份,没设就是 "" —— 分享图等对外场景据此不把 admin 之类的账号名印出去。
|
|
6
|
+
// 成员的 display_name 等于用户名也返回 "":register() 没给显示名时存的就是 username。
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-people-"));
|
|
14
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
15
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
16
|
+
process.env.HOST = "127.0.0.1";
|
|
17
|
+
const PW = "ReqPeoplePassw0rd!x";
|
|
18
|
+
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
let NET = "";
|
|
22
|
+
let owner = { token: "", id: "", username: "" };
|
|
23
|
+
let named = { id: "" };
|
|
24
|
+
let plain = { id: "", username: "" };
|
|
25
|
+
let same = { id: "", username: "" };
|
|
26
|
+
|
|
27
|
+
async function people(token: string) {
|
|
28
|
+
const res = await fetch(`${BASE}/api/requirements/people?network_id=${NET}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
29
|
+
return { status: res.status, body: await res.json() as any };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const { db } = await import("./db.js");
|
|
34
|
+
const { addNetworkMember, register } = await import("./auth.js");
|
|
35
|
+
const stamp = Date.now();
|
|
36
|
+
const o = register(`rp_owner_${stamp}`, PW, undefined, "");
|
|
37
|
+
expect(o.ok).toBe(true);
|
|
38
|
+
owner = { token: o.token!, id: o.user!.user_id, username: o.user!.username };
|
|
39
|
+
NET = o.network_id!;
|
|
40
|
+
const m = register(`rp_member_${stamp}`, PW, undefined, "");
|
|
41
|
+
expect(m.ok).toBe(true);
|
|
42
|
+
named = { id: m.user!.user_id };
|
|
43
|
+
addNetworkMember(NET, named.id, "member", owner.id, { taskAccess: "all" });
|
|
44
|
+
// owner:没有显示名(= 只有用户名);member:有显示名。
|
|
45
|
+
db.run("UPDATE users SET display_name = NULL WHERE user_id = ?1", [owner.id]);
|
|
46
|
+
db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["Placeholder Person", named.id]);
|
|
47
|
+
// plain:注册时没给显示名 —— register() 存的是 display_name = username,不改。
|
|
48
|
+
const pl = register(`rp_plain_${stamp}`, PW);
|
|
49
|
+
expect(pl.ok).toBe(true);
|
|
50
|
+
plain = { id: pl.user!.user_id, username: pl.user!.username };
|
|
51
|
+
addNetworkMember(NET, plain.id, "member", owner.id, { taskAccess: "all" });
|
|
52
|
+
// same:显示名被显式设成和用户名一模一样。
|
|
53
|
+
const sm = register(`rp_same_${stamp}`, PW, undefined, "Placeholder Other");
|
|
54
|
+
expect(sm.ok).toBe(true);
|
|
55
|
+
same = { id: sm.user!.user_id, username: sm.user!.username };
|
|
56
|
+
addNetworkMember(NET, same.id, "member", owner.id, { taskAccess: "all" });
|
|
57
|
+
db.run("UPDATE users SET display_name = username WHERE user_id = ?1", [same.id]);
|
|
58
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?3,?4,?5)", ["rp-node-alias", "rp-node-name", "placeholder-alias", "", NET]);
|
|
59
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?3,?4,?5)", ["rp-node-named", "rp-node-name-2", "placeholder-alias-2", "Placeholder Agent", NET]);
|
|
60
|
+
db.run("INSERT INTO nodes(node_id,node_name,network_id) VALUES (?1,?2,?3)", ["rp-node-bare", "rp-node-bare-name", NET]);
|
|
61
|
+
const mod: any = await import("./server.js");
|
|
62
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
63
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
try { hub?.stop?.(true); } catch {}
|
|
68
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("GET /api/requirements/people display_name", () => {
|
|
72
|
+
test("display_name is its own field, empty when unset; name keeps the username / alias fallback", async () => {
|
|
73
|
+
const r = await people(owner.token);
|
|
74
|
+
expect(r.status).toBe(200);
|
|
75
|
+
const find = (kind: string, id: string) => r.body.people.find((p: any) => p.kind === kind && p.id === id);
|
|
76
|
+
expect(find("user", owner.id)).toMatchObject({ name: owner.username, display_name: "", networkId: NET });
|
|
77
|
+
expect(find("user", named.id)).toMatchObject({ name: "Placeholder Person", display_name: "Placeholder Person" });
|
|
78
|
+
expect(find("node", "rp-node-alias")).toMatchObject({ name: "placeholder-alias", display_name: "" });
|
|
79
|
+
expect(find("node", "rp-node-named")).toMatchObject({ name: "Placeholder Agent", display_name: "Placeholder Agent" });
|
|
80
|
+
expect(find("node", "rp-node-bare")).toMatchObject({ name: "rp-node-bare-name", display_name: "" });
|
|
81
|
+
expect(r.body.people.every((p: any) => typeof p.display_name === "string")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a member whose display_name is just the username gets display_name \"\" (name still the username)", async () => {
|
|
85
|
+
const { db } = await import("./db.js");
|
|
86
|
+
// 前提是真的:register() 没给显示名时存的就是用户名。
|
|
87
|
+
expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", plain.id)!.display_name).toBe(plain.username);
|
|
88
|
+
const r = await people(owner.token);
|
|
89
|
+
const find = (id: string) => r.body.people.find((p: any) => p.kind === "user" && p.id === id);
|
|
90
|
+
expect(find(plain.id)).toMatchObject({ name: plain.username, display_name: "" });
|
|
91
|
+
expect(find(same.id)).toMatchObject({ name: same.username, display_name: "" });
|
|
92
|
+
// 显示名只是「包含」用户名、不等于它:照常返回。
|
|
93
|
+
db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", [`${same.username} (placeholder)`, same.id]);
|
|
94
|
+
const r2 = await people(owner.token);
|
|
95
|
+
expect(r2.body.people.find((p: any) => p.kind === "user" && p.id === same.id)).toMatchObject({ display_name: `${same.username} (placeholder)` });
|
|
96
|
+
db.run("UPDATE users SET display_name = username WHERE user_id = ?1", [same.id]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("sort order is still by name, then id", async () => {
|
|
100
|
+
const r = await people(owner.token);
|
|
101
|
+
const users = r.body.people.filter((p: any) => p.kind === "user");
|
|
102
|
+
const sorted = [...users].sort((a: any, b: any) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.id < b.id ? -1 : 1));
|
|
103
|
+
expect(users.map((p: any) => p.id)).toEqual(sorted.map((p: any) => p.id));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the existing fields keep their shape for old apps", async () => {
|
|
107
|
+
const r = await people(owner.token);
|
|
108
|
+
for (const p of r.body.people) {
|
|
109
|
+
expect(Object.keys(p).sort()).toEqual(["display_name", "id", "kind", "name", "networkId"]);
|
|
110
|
+
expect(typeof p.name).toBe("string");
|
|
111
|
+
expect(p.name.length).toBeGreaterThan(0);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// GET /api/requirements 的 q= 服务端搜索与 limit / cursor 分页(requirements-search.ts)。一次性 hub:临时 DB、端口 0。
|
|
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-search-"));
|
|
8
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
9
|
+
|
|
10
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
11
|
+
let base = "";
|
|
12
|
+
let token = "";
|
|
13
|
+
let otherToken = "";
|
|
14
|
+
let net = "";
|
|
15
|
+
let db: any;
|
|
16
|
+
|
|
17
|
+
async function api(tok: string, path: string, init?: RequestInit) {
|
|
18
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
|
|
19
|
+
return { status: res.status, body: await res.json() as any };
|
|
20
|
+
}
|
|
21
|
+
const list = (query: string, tok = token) => api(tok, `/api/requirements?network_id=${net}${query ? `&${query}` : ""}`);
|
|
22
|
+
const ids = async (query: string, tok = token) => {
|
|
23
|
+
const r = await list(query, tok);
|
|
24
|
+
expect(r.status).toBe(200);
|
|
25
|
+
return (r.body.requirements as any[]).map(x => x.name);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
({ db } = await import("./db.js"));
|
|
30
|
+
const { register } = await import("./auth.js");
|
|
31
|
+
const owner = register(`search_owner_${Date.now()}`, "SearchOwner123!", undefined, "seed");
|
|
32
|
+
token = owner.token!;
|
|
33
|
+
net = owner.network_id!;
|
|
34
|
+
const other = register(`search_other_${Date.now()}`, "SearchOther123!", undefined, "seed");
|
|
35
|
+
otherToken = other.token!;
|
|
36
|
+
const mod: any = await import("./server.js");
|
|
37
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
38
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
39
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?2,?3,?4)", ["n_search_a", "portal-cow", "示例-门户牛", net]);
|
|
40
|
+
const project = await api(token, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "示例门户项目", network_id: net }) });
|
|
41
|
+
const pid = project.body.project.id;
|
|
42
|
+
const mk = (name: string, extra: Record<string, unknown> = {}) => api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: net, ...extra }) });
|
|
43
|
+
await mk("企业组织树权限设置");
|
|
44
|
+
await mk("Portal Token 管理 | 估算成本卡布局", { agent_owner: { kind: "node", id: "n_search_a" }, tags: ["前端"] });
|
|
45
|
+
await mk("Space 网络策略 | 历史 receipt 误报", { description: "看  和 [链接文字](https://example.com/x),重启导致 binding 丢失" });
|
|
46
|
+
await mk("子项目的卡", { project_id: pid });
|
|
47
|
+
const arch = await mk("已归档的组织树旧方案");
|
|
48
|
+
await api(token, `/api/requirements/${arch.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
49
|
+
await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的组织树" }) });
|
|
50
|
+
}, 30_000);
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
try { server?.stop?.(true); } catch {}
|
|
54
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("q= server-side search (same semantics as the app's task search)", () => {
|
|
58
|
+
test("title substring, CJK, case-insensitive, full-width input", async () => {
|
|
59
|
+
expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
|
|
60
|
+
expect(await ids("q=portal")).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
61
|
+
expect(await ids("q=" + encodeURIComponent("PORTAL"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
62
|
+
});
|
|
63
|
+
test("agent owner display name, tags, project name, description text (not URLs)", async () => {
|
|
64
|
+
expect(await ids("q=" + encodeURIComponent("门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
65
|
+
expect(await ids("q=" + encodeURIComponent("前端"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
66
|
+
expect(await ids("q=" + encodeURIComponent("示例门户项目"))).toEqual(["子项目的卡"]);
|
|
67
|
+
expect(await ids("q=binding")).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
68
|
+
expect(await ids("q=" + encodeURIComponent("链接文字"))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
69
|
+
expect(await ids("q=abc123")).toEqual([]);
|
|
70
|
+
expect(await ids("q=example.com")).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
test("space-separated terms are ANDed across fields", async () => {
|
|
73
|
+
expect(await ids("q=" + encodeURIComponent("成本 门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
74
|
+
expect(await ids("q=" + encodeURIComponent("成本 receipt"))).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
test("archived only with archived=true / include_archived=1; other networks never", async () => {
|
|
77
|
+
expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
|
|
78
|
+
expect(await ids("archived=true&q=" + encodeURIComponent("组织树"))).toEqual(["已归档的组织树旧方案"]);
|
|
79
|
+
expect((await ids("include_archived=1&q=" + encodeURIComponent("组织树"))).sort()).toEqual(["企业组织树权限设置", "已归档的组织树旧方案"].sort());
|
|
80
|
+
});
|
|
81
|
+
test("combines with the other filters", async () => {
|
|
82
|
+
expect(await ids("status=done&q=portal")).toEqual([]);
|
|
83
|
+
expect(await ids("agent_owner=node:n_search_a&q=" + encodeURIComponent("成本"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
|
|
84
|
+
});
|
|
85
|
+
test("empty / whitespace q = no search; too long q = 400", async () => {
|
|
86
|
+
expect((await ids("q=")).length).toBe(4);
|
|
87
|
+
expect((await ids("q=%20%20")).length).toBe(4);
|
|
88
|
+
const r = await list("q=" + "x".repeat(201));
|
|
89
|
+
expect(r.status).toBe(400);
|
|
90
|
+
expect(r.body.error).toBe("invalid_q");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("q= also matches task ids: #N / N on seq (#2139), full id and 8+ char prefix", () => {
|
|
95
|
+
const idOf = async (name: string) => (await list("limit=1000&include_archived=1")).body.requirements.find((r: any) => r.name === name).id as string;
|
|
96
|
+
test("full id and id prefix (with or without req_), not shorter prefixes", async () => {
|
|
97
|
+
const id = await idOf("企业组织树权限设置");
|
|
98
|
+
const bare = id.replace(/^req_/, "");
|
|
99
|
+
expect(await ids("q=" + encodeURIComponent(id))).toEqual(["企业组织树权限设置"]);
|
|
100
|
+
expect(await ids("q=" + encodeURIComponent(id.toUpperCase()))).toEqual(["企业组织树权限设置"]);
|
|
101
|
+
expect(await ids("q=" + encodeURIComponent(bare.slice(0, 10)))).toEqual(["企业组织树权限设置"]);
|
|
102
|
+
expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 9)}`))).toEqual(["企业组织树权限设置"]);
|
|
103
|
+
// 7 位以下的前缀不算 ID(否则输入几个字母就搜出一堆)
|
|
104
|
+
expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 5)}`))).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
test("#N / full-width #N / padded #N match the task's seq (#2139); #N plus text is plain text; filters still apply", async () => {
|
|
107
|
+
const target = await idOf("Space 网络策略 | 历史 receipt 误报");
|
|
108
|
+
const n = db.get("SELECT seq FROM requirements WHERE requirement_id = ?1", target).seq as number;
|
|
109
|
+
expect(typeof n).toBe("number");
|
|
110
|
+
expect(await ids(`q=%23${n}`)).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
111
|
+
const fullWidth = `#${String(n).replace(/\d/g, d => String.fromCharCode(0xff10 + Number(d)))}`;
|
|
112
|
+
expect(await ids("q=" + encodeURIComponent(fullWidth))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
113
|
+
expect(await ids("q=" + encodeURIComponent(` #${n} `))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
|
|
114
|
+
// 「#N receipt」整句不是 ID,只按文字「且」:没有卡同时含「#N」和「receipt」
|
|
115
|
+
expect(await ids("q=" + encodeURIComponent(`#${n} receipt`))).toEqual([]);
|
|
116
|
+
// ID 命中也受筛选管
|
|
117
|
+
expect(await ids(`status=done&q=%23${n}`)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("paging: limit / cursor / has_more / next_cursor", () => {
|
|
122
|
+
test("no params: legacy shape plus has_more=false, next_cursor=null, capabilities advertise search + paging", async () => {
|
|
123
|
+
const r = await list("");
|
|
124
|
+
expect(r.body.requirements.length).toBe(4);
|
|
125
|
+
expect(r.body.has_more).toBe(false);
|
|
126
|
+
expect(r.body.next_cursor).toBeNull();
|
|
127
|
+
expect(r.body.capabilities).toContain("search");
|
|
128
|
+
expect(r.body.capabilities).toContain("paging");
|
|
129
|
+
});
|
|
130
|
+
test("walking pages returns every row exactly once, newest first, including same-timestamp ties", async () => {
|
|
131
|
+
// 25 张,其中 10 张同一个 created_at(翻页边界落在并列上也不能漏 / 重)
|
|
132
|
+
const same = new Date(Date.now() + 60_000).toISOString();
|
|
133
|
+
for (let i = 0; i < 25; i++) {
|
|
134
|
+
const c = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: `页-${String(i).padStart(2, "0")}`, network_id: net }) });
|
|
135
|
+
if (i >= 10 && i < 20) db.run("UPDATE requirements SET created_at = ?1 WHERE requirement_id = ?2", [same, c.body.requirement.id]);
|
|
136
|
+
}
|
|
137
|
+
const full = await ids("limit=1000");
|
|
138
|
+
expect(full.length).toBe(29);
|
|
139
|
+
const seen: string[] = [];
|
|
140
|
+
let cursor = "";
|
|
141
|
+
let pages = 0;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const r = await list(`limit=7${cursor ? `&cursor=${cursor}` : ""}`);
|
|
144
|
+
expect(r.status).toBe(200);
|
|
145
|
+
seen.push(...r.body.requirements.map((x: any) => x.name));
|
|
146
|
+
pages++;
|
|
147
|
+
if (!r.body.has_more) { expect(r.body.next_cursor).toBeNull(); break; }
|
|
148
|
+
expect(typeof r.body.next_cursor).toBe("string");
|
|
149
|
+
cursor = r.body.next_cursor;
|
|
150
|
+
expect(pages).toBeLessThan(10);
|
|
151
|
+
}
|
|
152
|
+
expect(pages).toBe(5);
|
|
153
|
+
expect(seen).toEqual(full);
|
|
154
|
+
expect(new Set(seen).size).toBe(29);
|
|
155
|
+
});
|
|
156
|
+
test("q= pages too", async () => {
|
|
157
|
+
const first = await list("limit=4&q=" + encodeURIComponent("页-"));
|
|
158
|
+
expect(first.body.requirements.length).toBe(4);
|
|
159
|
+
expect(first.body.has_more).toBe(true);
|
|
160
|
+
const rest = await list(`limit=100&q=${encodeURIComponent("页-")}&cursor=${first.body.next_cursor}`);
|
|
161
|
+
expect(rest.body.requirements.length).toBe(21);
|
|
162
|
+
expect(rest.body.has_more).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
test("default limit stays 500 (legacy clients see the same cap)", async () => {
|
|
165
|
+
const { DEFAULT_LIST_LIMIT } = await import("./requirements-search.js");
|
|
166
|
+
expect(DEFAULT_LIST_LIMIT).toBe(500);
|
|
167
|
+
});
|
|
168
|
+
test("bad limit / cursor → 400", async () => {
|
|
169
|
+
for (const q of ["limit=0", "limit=1001", "limit=abc", "limit=-1", "cursor=%%%", "cursor=bm9wZQ"]) {
|
|
170
|
+
const r = await list(q);
|
|
171
|
+
expect(r.status).toBe(400);
|
|
172
|
+
expect(["invalid_limit", "invalid_cursor"]).toContain(r.body.error);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
test("ETag still works with the new fields", async () => {
|
|
176
|
+
const first = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
|
|
177
|
+
const etag = first.headers.get("etag")!;
|
|
178
|
+
await first.text();
|
|
179
|
+
const again = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag } });
|
|
180
|
+
expect(again.status).toBe(304);
|
|
181
|
+
});
|
|
182
|
+
});
|