@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,266 @@
|
|
|
1
|
+
// 标签管理(POST /api/requirements/tags/ops + GET /api/requirements/tags 的 counts / colors / can_manage)
|
|
2
|
+
// —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:Hub 管理员 admin 的网络 NET,另有一个不相干的网络 NET2(同名标签,不能被波及)。成员:
|
|
5
|
+
// alice —— scoped 成员(RFC-038 §9),只授权项目 P1;
|
|
6
|
+
// carol —— task_access='all' 的老成员;
|
|
7
|
+
// vic —— viewer。
|
|
8
|
+
// 正向:改名 / 合并 / 删除改写网络里每张带这个标签的卡(含归档的)、颜色跟着挪、写审计、updated_at 前移;
|
|
9
|
+
// carol 与 owner 能管;GET 带用量与颜色。
|
|
10
|
+
// 反向:scoped / viewer / 节点令牌被挡且一张卡都不改;scoped 的 counts / colors 只含看得见的卡;
|
|
11
|
+
// 不存在的标签 404、坏参数 400;事务中途失败一张都不改;别的网络不受影响。
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-tag-ops-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
let db: any = null;
|
|
22
|
+
const PW = "TagOpsPassw0rd!x";
|
|
23
|
+
let NET = "";
|
|
24
|
+
let NET2 = "";
|
|
25
|
+
let admin = { token: "", id: "" };
|
|
26
|
+
let other = { token: "", id: "" };
|
|
27
|
+
let alice = { token: "", id: "" };
|
|
28
|
+
let carol = { token: "", id: "" };
|
|
29
|
+
let vic = { token: "", id: "" };
|
|
30
|
+
let nodeToken = "";
|
|
31
|
+
let P1 = "";
|
|
32
|
+
const C: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
type R = { status: number; body: any };
|
|
35
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
39
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let body: any = null;
|
|
43
|
+
try { body = JSON.parse(text); } catch {}
|
|
44
|
+
return { status: res.status, body };
|
|
45
|
+
}
|
|
46
|
+
const op = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/ops?network_id=${net}`, payload);
|
|
47
|
+
const tagsOf = (token: string, net = NET) => send(token, "GET", `/api/requirements/tags?network_id=${net}`);
|
|
48
|
+
const cardTags = (name: string): string[] => JSON.parse(db.get("SELECT tags_json FROM requirements WHERE requirement_id = ?1", C[name]).tags_json);
|
|
49
|
+
const snapshot = () => Object.fromEntries(Object.keys(C).map(name => [name, cardTags(name)]));
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
53
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
54
|
+
process.env.HOST = "127.0.0.1";
|
|
55
|
+
({ db } = await import("./db.js"));
|
|
56
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
57
|
+
const stamp = Date.now();
|
|
58
|
+
const a = register(`to_admin_${stamp}`, PW, undefined, "Admin");
|
|
59
|
+
expect(a.ok).toBe(true);
|
|
60
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
61
|
+
NET = a.network_id!;
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
63
|
+
const b = register(`to_other_${stamp}`, PW, undefined, "Other");
|
|
64
|
+
expect(b.ok).toBe(true);
|
|
65
|
+
other = { token: b.token!, id: b.user!.user_id };
|
|
66
|
+
NET2 = b.network_id!;
|
|
67
|
+
const ntok = createNetworkTokenForNode(admin.id, NET, "tag-node");
|
|
68
|
+
expect(ntok.ok).toBe(true);
|
|
69
|
+
nodeToken = ntok.token!;
|
|
70
|
+
|
|
71
|
+
const mod: any = await import("./server.js");
|
|
72
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
73
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
74
|
+
|
|
75
|
+
const mk = async (username: string, role: string) => {
|
|
76
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
|
|
77
|
+
expect(r.status).toBe(200);
|
|
78
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
79
|
+
expect(login.status).toBe(200);
|
|
80
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
81
|
+
};
|
|
82
|
+
alice = await mk(`to_alice_${stamp}`, "member");
|
|
83
|
+
carol = await mk(`to_carol_${stamp}`, "member");
|
|
84
|
+
vic = await mk(`to_vic_${stamp}`, "viewer");
|
|
85
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, alice.id]);
|
|
86
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
87
|
+
|
|
88
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "to-P1" });
|
|
89
|
+
expect(p.status).toBe(201);
|
|
90
|
+
P1 = p.body.project.id;
|
|
91
|
+
const g = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { project_grants: [{ project_id: P1, can_edit: true }] });
|
|
92
|
+
expect(g.status).toBe(200);
|
|
93
|
+
|
|
94
|
+
const card = async (name: string, tags: string[], extra: Record<string, unknown> = {}, token = admin.token, net = NET) => {
|
|
95
|
+
const r = await send(token, "POST", "/api/requirements", { network_id: net, name, tags, ...extra });
|
|
96
|
+
expect(r.status).toBe(201);
|
|
97
|
+
C[name] = r.body.requirement.id;
|
|
98
|
+
};
|
|
99
|
+
await card("visible", ["UI", "交互", "bug"], { project_id: P1 });
|
|
100
|
+
await card("hidden", ["UI", "secret"]);
|
|
101
|
+
await card("archived", ["UI", "旧"], { archived: true });
|
|
102
|
+
await card("both", ["交互", "TMWork", "UI"]);
|
|
103
|
+
await card("plain", []);
|
|
104
|
+
await card("net2", ["UI", "交互"], {}, other.token, NET2);
|
|
105
|
+
}, 30_000);
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
try { hub?.stop?.(true); } catch {}
|
|
109
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("GET /api/requirements/tags", () => {
|
|
113
|
+
test("owner:tags 仍是排序后的字符串数组(旧 App 形状不变),多出 counts / colors / can_manage", async () => {
|
|
114
|
+
const r = await tagsOf(admin.token);
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
expect(r.body.tags).toEqual(["TMWork", "UI", "bug", "secret", "交互", "旧"]);
|
|
117
|
+
expect(r.body.counts).toEqual({ UI: 4, 交互: 2, bug: 1, secret: 1, 旧: 1, TMWork: 1 });
|
|
118
|
+
expect(r.body.colors).toEqual({});
|
|
119
|
+
expect(r.body.can_manage).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("scoped 成员:counts 只数看得见的卡,不能管;viewer 与节点令牌也不能管;老成员能管", async () => {
|
|
123
|
+
const a = await tagsOf(alice.token);
|
|
124
|
+
expect(a.body.tags).toEqual(["UI", "bug", "交互"]);
|
|
125
|
+
expect(a.body.counts).toEqual({ UI: 1, bug: 1, 交互: 1 });
|
|
126
|
+
expect(a.body.can_manage).toBe(false);
|
|
127
|
+
expect((await tagsOf(vic.token)).body.can_manage).toBe(false);
|
|
128
|
+
expect((await send(nodeToken, "GET", "/api/requirements/tags")).body.can_manage).toBe(false);
|
|
129
|
+
expect((await tagsOf(carol.token)).body.can_manage).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("列表响应的 capabilities 带 tag_ops", async () => {
|
|
133
|
+
const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
134
|
+
expect(r.body.capabilities).toContain("tag_ops");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("被拒的调用一张卡都不改", () => {
|
|
139
|
+
test("scoped 成员 403(连他看得见的那张也不改,不做半套改名)", async () => {
|
|
140
|
+
const before = snapshot();
|
|
141
|
+
for (const payload of [{ op: "rename", from: "UI", to: "界面" }, { op: "delete", tag: "bug" }, { op: "merge", from: ["UI", "交互"], to: "x" }, { op: "color", tag: "UI", color: "#123456" }]) {
|
|
142
|
+
const r = await op(alice.token, payload);
|
|
143
|
+
expect(r.status).toBe(403);
|
|
144
|
+
expect(r.body.error).toBe("permission_denied");
|
|
145
|
+
}
|
|
146
|
+
expect(snapshot()).toEqual(before);
|
|
147
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("viewer 403;节点令牌 403 user_token_required;别的网络的 owner 碰不到这个网络", async () => {
|
|
151
|
+
const before = snapshot();
|
|
152
|
+
expect((await op(vic.token, { op: "delete", tag: "UI" })).status).toBe(403);
|
|
153
|
+
const n = await send(nodeToken, "POST", "/api/requirements/tags/ops", { op: "delete", tag: "UI" });
|
|
154
|
+
expect(n.status).toBe(403);
|
|
155
|
+
expect(n.body.error).toBe("user_token_required");
|
|
156
|
+
expect((await op(other.token, { op: "delete", tag: "UI" })).status).toBe(403);
|
|
157
|
+
expect(snapshot()).toEqual(before);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("坏参数 400、不存在的标签 404、GET 到 ops 404", async () => {
|
|
161
|
+
expect((await op(admin.token, { op: "explode" })).body.error).toBe("invalid_tag_op");
|
|
162
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: "" })).body.error).toBe("invalid_tag");
|
|
163
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: "x".repeat(21) })).body.error).toBe("invalid_tag");
|
|
164
|
+
expect((await op(admin.token, { op: "rename", from: "UI", to: " UI " })).body.error).toBe("same_tag");
|
|
165
|
+
expect((await op(admin.token, { op: "merge", from: [], to: "UI" })).body.error).toBe("invalid_tag");
|
|
166
|
+
expect((await op(admin.token, { op: "merge", from: ["UI"], to: "UI" })).body.error).toBe("same_tag");
|
|
167
|
+
expect((await op(admin.token, { op: "color", tag: "UI", color: "red" })).body.error).toBe("invalid_tag_color");
|
|
168
|
+
const missing = await op(admin.token, { op: "delete", tag: "没有这个" });
|
|
169
|
+
expect(missing.status).toBe(404);
|
|
170
|
+
expect(missing.body.error).toBe("tag_not_found");
|
|
171
|
+
expect((await send(admin.token, "GET", `/api/requirements/tags/ops?network_id=${NET}`)).status).toBe(404);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("事务:改到一半失败 → 一张都不改,颜色也不挪", async () => {
|
|
175
|
+
expect((await op(admin.token, { op: "color", tag: "UI", color: "#2563EB" })).status).toBe(200);
|
|
176
|
+
// 让其中一张卡的 UPDATE 抛错(SQLite 用 RAISE,PostgreSQL 用 plpgsql 触发器;PG 阶梯会跑这个文件)。
|
|
177
|
+
const pg = db.dialect === "postgres";
|
|
178
|
+
if (pg) {
|
|
179
|
+
db.exec(`CREATE OR REPLACE FUNCTION tag_ops_fail() RETURNS trigger AS $$ BEGIN IF NEW.requirement_id = '${C["both"]}' THEN RAISE EXCEPTION 'boom'; END IF; RETURN NEW; END $$ LANGUAGE plpgsql`);
|
|
180
|
+
db.exec("CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements FOR EACH ROW EXECUTE FUNCTION tag_ops_fail()");
|
|
181
|
+
} else {
|
|
182
|
+
db.exec(`CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements WHEN NEW.requirement_id = '${C["both"]}' BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
|
|
183
|
+
}
|
|
184
|
+
const before = snapshot();
|
|
185
|
+
try {
|
|
186
|
+
const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
|
|
187
|
+
expect(r.status).toBeGreaterThanOrEqual(500);
|
|
188
|
+
} finally {
|
|
189
|
+
db.exec(pg ? "DROP TRIGGER tag_ops_fail ON requirements" : "DROP TRIGGER tag_ops_fail");
|
|
190
|
+
if (pg) db.exec("DROP FUNCTION tag_ops_fail()");
|
|
191
|
+
}
|
|
192
|
+
expect(snapshot()).toEqual(before);
|
|
193
|
+
expect(db.get("SELECT color FROM network_tags WHERE network_id = ?1 AND name = 'UI'", NET).color).toBe("#2563eb");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("改名 / 合并 / 删除 / 颜色", () => {
|
|
198
|
+
test("颜色:设置(存小写)、GET 带出来;scoped 成员看不到他看不见的标签的颜色", async () => {
|
|
199
|
+
expect((await op(admin.token, { op: "color", tag: "secret", color: "#DC2626" })).status).toBe(200);
|
|
200
|
+
const r = await tagsOf(admin.token);
|
|
201
|
+
expect(r.body.colors).toEqual({ UI: "#2563eb", secret: "#dc2626" });
|
|
202
|
+
expect((await tagsOf(alice.token)).body.colors).toEqual({ UI: "#2563eb" });
|
|
203
|
+
expect((await op(admin.token, { op: "color", tag: "secret", color: null })).status).toBe(200);
|
|
204
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ UI: "#2563eb" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("改名:每张卡(含看不见的、归档的)都改,位置不变;颜色跟着走;updated_at 前移;审计;别的网络不动", async () => {
|
|
208
|
+
const t0 = new Date().toISOString();
|
|
209
|
+
const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
|
|
210
|
+
expect(r.status).toBe(200);
|
|
211
|
+
expect(r.body).toEqual({ ok: true, op: "rename", affected: 4 });
|
|
212
|
+
expect(cardTags("visible")).toEqual(["界面", "交互", "bug"]);
|
|
213
|
+
expect(cardTags("hidden")).toEqual(["界面", "secret"]);
|
|
214
|
+
expect(cardTags("archived")).toEqual(["界面", "旧"]);
|
|
215
|
+
expect(cardTags("both")).toEqual(["交互", "TMWork", "界面"]);
|
|
216
|
+
expect(cardTags("net2")).toEqual(["UI", "交互"]);
|
|
217
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
|
|
218
|
+
const row = db.get("SELECT updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", C["hidden"]);
|
|
219
|
+
expect(row.updated_at >= t0).toBe(true);
|
|
220
|
+
expect(JSON.parse(row.updated_by_json)).toEqual({ kind: "user", id: admin.id });
|
|
221
|
+
const audit = db.get("SELECT detail, target_id, network_id FROM audit_log WHERE action = 'requirement_tag_rename' ORDER BY id DESC LIMIT 1");
|
|
222
|
+
expect(audit.target_id).toBe("界面");
|
|
223
|
+
expect(audit.network_id).toBe(NET);
|
|
224
|
+
expect(JSON.parse(audit.detail)).toEqual({ op: "rename", from: "UI", to: "界面", affected: 4 });
|
|
225
|
+
// 这次改动 updated_since 同步得到(包括 GET 列表)。
|
|
226
|
+
const since = await send(admin.token, "GET", `/api/requirements?network_id=${NET}&include_archived=1&updated_since=${encodeURIComponent(t0)}`);
|
|
227
|
+
expect(since.body.requirements.map((x: any) => x.name).sort()).toEqual(["archived", "both", "hidden", "visible"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("改名成已存在的标签 = 合并,同一张卡上不出现两次", async () => {
|
|
231
|
+
const r = await op(carol.token, { op: "rename", from: "交互", to: "界面" });
|
|
232
|
+
expect(r.body.affected).toBe(2);
|
|
233
|
+
expect(cardTags("visible")).toEqual(["界面", "bug"]);
|
|
234
|
+
expect(cardTags("both")).toEqual(["界面", "TMWork"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("合并:多个来源进一个新标签;目标没颜色就继承来源的;来源颜色行删掉", async () => {
|
|
238
|
+
expect((await op(admin.token, { op: "color", tag: "旧", color: "#16a34a" })).status).toBe(200);
|
|
239
|
+
const r = await op(admin.token, { op: "merge", from: ["bug", "旧", "secret", "不存在"], to: "杂项" });
|
|
240
|
+
expect(r.body).toEqual({ ok: true, op: "merge", affected: 3 });
|
|
241
|
+
expect(cardTags("visible")).toEqual(["界面", "杂项"]);
|
|
242
|
+
expect(cardTags("hidden")).toEqual(["界面", "杂项"]);
|
|
243
|
+
expect(cardTags("archived")).toEqual(["界面", "杂项"]);
|
|
244
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb", 杂项: "#16a34a" });
|
|
245
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1 AND name = '旧'", NET).n).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("合并进已有颜色的目标:目标颜色不变", async () => {
|
|
249
|
+
const r = await op(admin.token, { op: "merge", from: ["杂项"], to: "界面" });
|
|
250
|
+
expect(r.body.affected).toBe(3);
|
|
251
|
+
expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("删除:从每张卡上拿掉(卡保留),颜色行删掉,审计带 affected", async () => {
|
|
255
|
+
const r = await op(admin.token, { op: "delete", tag: "界面" });
|
|
256
|
+
expect(r.body).toEqual({ ok: true, op: "delete", affected: 4 });
|
|
257
|
+
expect(cardTags("visible")).toEqual([]);
|
|
258
|
+
expect(cardTags("both")).toEqual(["TMWork"]);
|
|
259
|
+
expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(5);
|
|
260
|
+
expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
|
|
261
|
+
const audit = db.get("SELECT detail FROM audit_log WHERE action = 'requirement_tag_delete' ORDER BY id DESC LIMIT 1");
|
|
262
|
+
expect(JSON.parse(audit.detail)).toEqual({ op: "delete", tag: "界面", affected: 4 });
|
|
263
|
+
expect((await tagsOf(admin.token)).body.tags).toEqual(["TMWork"]);
|
|
264
|
+
expect(cardTags("net2")).toEqual(["UI", "交互"]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
|
|
19
|
+
// 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
|
|
20
|
+
// 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
|
|
21
|
+
export const MAX_MERGE_SOURCES = 50;
|
|
22
|
+
export type TagOp =
|
|
23
|
+
| { op: "rename"; from: string; to: string }
|
|
24
|
+
| { op: "merge"; from: string[]; to: string }
|
|
25
|
+
| { op: "delete"; tag: string }
|
|
26
|
+
| { op: "color"; tag: string; color: string | null };
|
|
27
|
+
|
|
28
|
+
/** 单个标签名,规则同 normalizeTags。不合法 = null。 */
|
|
29
|
+
export function tagName(value: unknown): string | null {
|
|
30
|
+
const tags = normalizeTags([value]);
|
|
31
|
+
return tags && tags.length === 1 ? tags[0] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
35
|
+
/** 请求体 → 操作;不合法返回错误码。 */
|
|
36
|
+
export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
|
|
37
|
+
switch (body.op) {
|
|
38
|
+
case "rename": {
|
|
39
|
+
const from = tagName(body.from), to = tagName(body.to);
|
|
40
|
+
if (!from || !to) return { error: "invalid_tag" };
|
|
41
|
+
if (from === to) return { error: "same_tag" };
|
|
42
|
+
return { op: "rename", from, to };
|
|
43
|
+
}
|
|
44
|
+
case "merge": {
|
|
45
|
+
if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
|
|
46
|
+
const from: string[] = [];
|
|
47
|
+
for (const value of body.from) {
|
|
48
|
+
const tag = tagName(value);
|
|
49
|
+
if (!tag) return { error: "invalid_tag" };
|
|
50
|
+
if (!from.includes(tag)) from.push(tag);
|
|
51
|
+
}
|
|
52
|
+
const to = tagName(body.to);
|
|
53
|
+
if (!to) return { error: "invalid_tag" };
|
|
54
|
+
// 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
|
|
55
|
+
const sources = from.filter(tag => tag !== to);
|
|
56
|
+
if (!sources.length) return { error: "same_tag" };
|
|
57
|
+
return { op: "merge", from: sources, to };
|
|
58
|
+
}
|
|
59
|
+
case "delete": {
|
|
60
|
+
const tag = tagName(body.tag);
|
|
61
|
+
return tag ? { op: "delete", tag } : { error: "invalid_tag" };
|
|
62
|
+
}
|
|
63
|
+
case "color": {
|
|
64
|
+
const tag = tagName(body.tag);
|
|
65
|
+
if (!tag) return { error: "invalid_tag" };
|
|
66
|
+
if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
|
|
67
|
+
return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return { error: "invalid_tag_op" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
|
|
75
|
+
export function applyTagOp(tags: string[], op: TagOp): string[] | null {
|
|
76
|
+
let sources: string[];
|
|
77
|
+
let to: string | null;
|
|
78
|
+
if (op.op === "rename") { sources = [op.from]; to = op.to; }
|
|
79
|
+
else if (op.op === "merge") { sources = op.from; to = op.to; }
|
|
80
|
+
else if (op.op === "delete") { sources = [op.tag]; to = null; }
|
|
81
|
+
else return null;
|
|
82
|
+
if (!tags.some(tag => sources.includes(tag))) return null;
|
|
83
|
+
const next: string[] = [];
|
|
84
|
+
for (const tag of tags) {
|
|
85
|
+
const mapped = sources.includes(tag) ? to : tag;
|
|
86
|
+
if (mapped !== null && !next.includes(mapped)) next.push(mapped);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
|
|
2
|
+
// 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
|
|
3
|
+
// 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
|
|
4
|
+
import { afterAll, 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-completed-at-"));
|
|
11
|
+
const file = join(dir, "hub.db");
|
|
12
|
+
process.env.COMMHUB_DB = file;
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(file);
|
|
15
|
+
legacy.exec(`CREATE TABLE requirements (
|
|
16
|
+
requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
17
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
|
|
18
|
+
client_id TEXT, issues_json TEXT, created_by TEXT,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
|
|
21
|
+
const seed: [string, string, string, string][] = [
|
|
22
|
+
// id, column, created_at, updated_at
|
|
23
|
+
["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
|
|
24
|
+
["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
|
|
25
|
+
["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
|
|
26
|
+
["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
|
|
27
|
+
["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
|
|
28
|
+
];
|
|
29
|
+
for (const [id, column, createdAt, updatedAt] of seed) {
|
|
30
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
|
|
31
|
+
[id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
|
|
32
|
+
}
|
|
33
|
+
legacy.close();
|
|
34
|
+
|
|
35
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
36
|
+
|
|
37
|
+
const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
|
|
38
|
+
|
|
39
|
+
test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
|
|
40
|
+
const { db } = await import("./db.js");
|
|
41
|
+
await import("./requirements.js");
|
|
42
|
+
const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
|
|
43
|
+
const by = read(db);
|
|
44
|
+
expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
|
|
45
|
+
expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
|
|
46
|
+
// updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
|
|
47
|
+
expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
|
|
48
|
+
for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
|
|
49
|
+
expect(by[id].completed_at_approx).toBe(1);
|
|
50
|
+
// 最后改的人不是「完成的人」:近似值不猜完成者
|
|
51
|
+
expect(by[id].completed_by_json).toBeNull();
|
|
52
|
+
// 补值不碰 updated_at
|
|
53
|
+
expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
|
|
54
|
+
}
|
|
55
|
+
for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
|
|
56
|
+
|
|
57
|
+
// 下次启动:什么都不动
|
|
58
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
59
|
+
expect(read(db)).toEqual(by);
|
|
60
|
+
|
|
61
|
+
// 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
|
|
62
|
+
db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
|
|
63
|
+
db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
|
|
64
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
|
|
65
|
+
const after = read(db);
|
|
66
|
+
expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
|
|
67
|
+
expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
|
|
68
|
+
expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
|
|
69
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
70
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// GET /api/requirements conditional requests: ETag on every list response, 304 with an empty body
|
|
2
|
+
// when If-None-Match matches. The app polls the whole list every 15 s (production: 500 rows,
|
|
3
|
+
// 708 KB / 163 KB gzip) and it is unchanged on almost every poll. Old clients never send
|
|
4
|
+
// If-None-Match and keep getting the full 200.
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
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-requirements-etag-"));
|
|
11
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
12
|
+
|
|
13
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
14
|
+
let base = "";
|
|
15
|
+
let ownerToken = "", network = "";
|
|
16
|
+
|
|
17
|
+
const list = (token: string, headers: Record<string, string> = {}) =>
|
|
18
|
+
fetch(`${base}/api/requirements?network_id=${network}`, { headers: { Authorization: `Bearer ${token}`, ...headers } });
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
const { register } = await import("./auth.js");
|
|
22
|
+
const owner = register(`etag_owner_${Date.now()}`, "EtagOwnerPassw0rd!", undefined, "seed");
|
|
23
|
+
ownerToken = owner.token!; network = owner.network_id!;
|
|
24
|
+
const mod: any = await import("./server.js");
|
|
25
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
26
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
27
|
+
const created = await fetch(`${base}/api/requirements`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ name: "etag card", network_id: network }),
|
|
31
|
+
});
|
|
32
|
+
expect(created.status).toBe(201);
|
|
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("GET /api/requirements conditional requests", () => {
|
|
41
|
+
test("a plain GET is the same 200 JSON as before, plus ETag and private/no-cache", async () => {
|
|
42
|
+
const res = await list(ownerToken);
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
45
|
+
expect(res.headers.get("etag")).toMatch(/^W\/"[A-Za-z0-9_-]{27}"$/);
|
|
46
|
+
expect(res.headers.get("cache-control")).toBe("private, no-cache");
|
|
47
|
+
const body = await res.json() as any;
|
|
48
|
+
expect(body.ok).toBe(true);
|
|
49
|
+
expect(body.requirements.map((r: any) => r.name)).toEqual(["etag card"]);
|
|
50
|
+
expect(Array.isArray(body.capabilities)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("If-None-Match with the current ETag → 304, empty body, same ETag", async () => {
|
|
54
|
+
const etag = (await list(ownerToken)).headers.get("etag")!;
|
|
55
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
56
|
+
expect(res.status).toBe(304);
|
|
57
|
+
expect(res.headers.get("etag")).toBe(etag);
|
|
58
|
+
expect(await res.text()).toBe("");
|
|
59
|
+
// strong/weak spelling and lists are accepted
|
|
60
|
+
expect((await list(ownerToken, { "If-None-Match": etag.replace(/^W\//, "") })).status).toBe(304);
|
|
61
|
+
expect((await list(ownerToken, { "If-None-Match": `W/"stale", ${etag}` })).status).toBe(304);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("304 survives gzip negotiation (not compressed, still empty)", async () => {
|
|
65
|
+
const etag = (await list(ownerToken, { "Accept-Encoding": "gzip" })).headers.get("etag")!;
|
|
66
|
+
const res = await list(ownerToken, { "If-None-Match": etag, "Accept-Encoding": "gzip" });
|
|
67
|
+
expect(res.status).toBe(304);
|
|
68
|
+
expect(res.headers.get("content-encoding")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("any change to the list changes the ETag → full 200 again", async () => {
|
|
72
|
+
const before = await list(ownerToken);
|
|
73
|
+
const etag = before.headers.get("etag")!;
|
|
74
|
+
const id = ((await before.json()) as any).requirements[0].id;
|
|
75
|
+
const patched = await fetch(`${base}/api/requirements/${id}?network_id=${network}`, {
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ name: "etag card renamed" }),
|
|
79
|
+
});
|
|
80
|
+
expect(patched.status).toBe(200);
|
|
81
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(res.headers.get("etag")).not.toBe(etag);
|
|
84
|
+
expect(((await res.json()) as any).requirements[0].name).toBe("etag card renamed");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a stale or foreign ETag never yields 304", async () => {
|
|
88
|
+
expect((await list(ownerToken, { "If-None-Match": 'W/"not-the-current-one"' })).status).toBe(200);
|
|
89
|
+
expect((await list(ownerToken, { "If-None-Match": "" })).status).toBe(200);
|
|
90
|
+
});
|
|
91
|
+
});
|