@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.94
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/auth.ts +3 -3
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/departments.ts +31 -10
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/human-dm.ts +2 -2
- package/src/mcp-context-cost-http.test.ts +4 -2
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-permissions-http.test.ts +18 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +16 -1
- package/src/server.ts +171 -10
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +2 -1
- package/src/tools.ts +36 -6
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// RFC-042 §9.3 补口(看板 #457):入群 / 退群实时事件 group_membership_changed。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
|
|
3
|
+
// 钉住:手动拉人 / 移人、调进 / 调出部门、换负责人、移出网络 → 被加 / 被移的人和提交后的当前群成员各收到一条;
|
|
4
|
+
// 不相干的人收不到;事件只在提交之后推 —— 整个事务回滚一条不推,内层 savepoint 回滚只丢内层那几笔。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { register } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
import { groupTx, syncDepartmentGroups, isGroupMember } from "./department-groups.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-group-membership-ev-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "GroupMembershipEvPassw0rd!x";
|
|
18
|
+
let NET = "";
|
|
19
|
+
const U: Record<string, { token: string; id: string; username: string }> = {};
|
|
20
|
+
const DEPT: Record<string, string> = {};
|
|
21
|
+
const GRP: Record<string, string> = {};
|
|
22
|
+
const S: Record<string, { events: any[]; close: () => void }> = {};
|
|
23
|
+
|
|
24
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
25
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let json: any = null;
|
|
28
|
+
try { json = JSON.parse(text); } catch {}
|
|
29
|
+
return { status: res.status, body: json };
|
|
30
|
+
}
|
|
31
|
+
const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
|
|
32
|
+
const place = async (who: string, dept: string | null) =>
|
|
33
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
|
|
34
|
+
|
|
35
|
+
async function subscribe(who: string) {
|
|
36
|
+
const ctrl = new AbortController();
|
|
37
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: { Authorization: `Bearer ${U[who].token}` }, signal: ctrl.signal });
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
const events: any[] = [];
|
|
40
|
+
const reader = res.body!.getReader();
|
|
41
|
+
const dec = new TextDecoder();
|
|
42
|
+
let buf = "";
|
|
43
|
+
void (async () => {
|
|
44
|
+
try {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
buf += dec.decode(value);
|
|
49
|
+
let i: number;
|
|
50
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
51
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
52
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
})();
|
|
57
|
+
await until(() => events.some((e) => e.type === "connected"));
|
|
58
|
+
return { events, close: () => ctrl.abort() };
|
|
59
|
+
}
|
|
60
|
+
const until = async (cond: () => boolean, tries = 150) => { for (let k = 0; k < tries && !cond(); k++) await new Promise((r) => setTimeout(r, 20)); };
|
|
61
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
62
|
+
|
|
63
|
+
/** 只看成员事件;清空所有订阅者已收到的,便于每个用例从零数。 */
|
|
64
|
+
const mev = (who: string) => S[who].events.filter((e) => e.type === "group_membership_changed");
|
|
65
|
+
// 先等上一个用例里没被等的收件人(比如 hank)的事件落地,再清空 —— 否则它会串到下一个用例里。
|
|
66
|
+
const reset = async () => { await sleep(150); for (const k of Object.keys(S)) S[k].events.length = 0; };
|
|
67
|
+
const match = (who: string, group: string, member: string, change: string, source: string) =>
|
|
68
|
+
mev(who).filter((e) => e.group_id === GRP[group] && e.member_user_id === U[member].id && e.change === change && e.source === source);
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
72
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
73
|
+
process.env.HOST = "127.0.0.1";
|
|
74
|
+
const stamp = Date.now();
|
|
75
|
+
const a = register(`gme_owner_${stamp}`, PW, undefined, "Owner");
|
|
76
|
+
expect(a.ok).toBe(true);
|
|
77
|
+
U.owner = { token: a.token!, id: a.user!.user_id, username: a.user!.username };
|
|
78
|
+
NET = a.network_id!;
|
|
79
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
80
|
+
const mod: any = await import("./server.js");
|
|
81
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
82
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
83
|
+
const add = async (username: string, role: string) => {
|
|
84
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
85
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
86
|
+
expect(login.status).toBe(200);
|
|
87
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string, username };
|
|
88
|
+
};
|
|
89
|
+
U.alice = await add(`gme_alice_${stamp}`, "member"); // 研发部(旁观的当前成员)
|
|
90
|
+
U.carol = await add(`gme_carol_${stamp}`, "member"); // 研发部,之后被调去市场部
|
|
91
|
+
U.dave = await add(`gme_dave_${stamp}`, "member"); // 市场部,被手动拉进 / 移出研发部群
|
|
92
|
+
U.erin = await add(`gme_erin_${stamp}`, "member"); // 未分配,之后调进研发部
|
|
93
|
+
U.frank = await add(`gme_frank_${stamp}`, "member"); // 未分配:回滚用例的对象
|
|
94
|
+
U.gina = await add(`gme_gina_${stamp}`, "member"); // 未分配:不相干的人 / 负责人用例
|
|
95
|
+
U.hank = await add(`gme_hank_${stamp}`, "member"); // 研发部,之后被移出网络
|
|
96
|
+
|
|
97
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
98
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments`, { name, ...extra });
|
|
99
|
+
expect(r.status).toBe(201);
|
|
100
|
+
return r.body.department.id as string;
|
|
101
|
+
};
|
|
102
|
+
DEPT.rd = await mk("研发部");
|
|
103
|
+
DEPT.market = await mk("市场部");
|
|
104
|
+
for (const w of ["alice", "carol", "hank"]) await place(w, DEPT.rd);
|
|
105
|
+
await place("dave", DEPT.market);
|
|
106
|
+
for (const key of ["rd", "market"]) {
|
|
107
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments/${DEPT[key]}/group`, {});
|
|
108
|
+
expect(r.status).toBe(201);
|
|
109
|
+
GRP[key] = r.body.group.id;
|
|
110
|
+
}
|
|
111
|
+
for (const w of ["owner", "alice", "carol", "dave", "erin", "frank", "gina", "hank"]) S[w] = await subscribe(w);
|
|
112
|
+
}, 30_000);
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
for (const s of Object.values(S)) try { s.close(); } catch {}
|
|
116
|
+
try { hub?.stop?.(true); } catch {}
|
|
117
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("manual add / remove", () => {
|
|
121
|
+
test("manual add: the added user and current members get one `added` event; outsiders get nothing", async () => {
|
|
122
|
+
await reset();
|
|
123
|
+
const r = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.dave.id });
|
|
124
|
+
expect(r.status).toBe(201);
|
|
125
|
+
await until(() => ["dave", "alice", "carol"].every((w) => match(w, "rd", "dave", "added", "manual").length > 0));
|
|
126
|
+
expect(match("dave", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
127
|
+
expect(match("alice", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
128
|
+
expect(match("carol", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
129
|
+
const ev = match("dave", "rd", "dave", "added", "manual")[0];
|
|
130
|
+
expect(ev).toMatchObject({ type: "group_membership_changed", network_id: NET, group_id: GRP.rd, member_user_id: U.dave.id, change: "added", source: "manual", scope: "user" });
|
|
131
|
+
expect(ev.user_id).toBe(U.dave.id); // 信封 user_id = 收件人
|
|
132
|
+
expect(match("alice", "rd", "dave", "added", "manual")[0].user_id).toBe(U.alice.id);
|
|
133
|
+
expect(new Date(ev.at).toISOString()).toBe(ev.at);
|
|
134
|
+
await sleep(100);
|
|
135
|
+
expect(mev("gina")).toHaveLength(0);
|
|
136
|
+
expect(mev("erin")).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("manual remove: the removed user and the remaining members get one `removed` event", async () => {
|
|
140
|
+
await reset();
|
|
141
|
+
const r = await send(U.owner.token, "DELETE", CG(`/${GRP.rd}/members/${U.dave.id}`));
|
|
142
|
+
expect(r.status).toBe(200);
|
|
143
|
+
await until(() => ["dave", "alice"].every((w) => match(w, "rd", "dave", "removed", "manual").length > 0));
|
|
144
|
+
expect(match("dave", "rd", "dave", "removed", "manual")).toHaveLength(1);
|
|
145
|
+
expect(match("alice", "rd", "dave", "removed", "manual")).toHaveLength(1);
|
|
146
|
+
await sleep(100);
|
|
147
|
+
expect(mev("gina")).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a refused write (409 already_group_member) emits nothing", async () => {
|
|
151
|
+
await reset();
|
|
152
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.alice.id })).status).toBe(409);
|
|
153
|
+
await sleep(150);
|
|
154
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("department sync", () => {
|
|
159
|
+
test("move-in: erin into 研发部 → `added`/department to erin and the 研发部 group's members", async () => {
|
|
160
|
+
await reset();
|
|
161
|
+
await place("erin", DEPT.rd);
|
|
162
|
+
await until(() => ["erin", "alice"].every((w) => match(w, "rd", "erin", "added", "department").length > 0));
|
|
163
|
+
expect(match("erin", "rd", "erin", "added", "department")).toHaveLength(1);
|
|
164
|
+
expect(match("alice", "rd", "erin", "added", "department")).toHaveLength(1);
|
|
165
|
+
await sleep(100);
|
|
166
|
+
expect(mev("dave")).toHaveLength(0); // 市场部群没变
|
|
167
|
+
expect(isGroupMember(GRP.rd, U.erin.id)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("move-out: carol 研发部 → 市场部 → removed from 研发部 group, added to 市场部 group", async () => {
|
|
171
|
+
await reset();
|
|
172
|
+
await place("carol", DEPT.market);
|
|
173
|
+
await until(() => ["carol", "alice"].every((w) => match(w, "rd", "carol", "removed", "department").length > 0) && ["carol", "dave"].every((w) => match(w, "market", "carol", "added", "department").length > 0));
|
|
174
|
+
expect(match("carol", "rd", "carol", "removed", "department")).toHaveLength(1);
|
|
175
|
+
expect(match("alice", "rd", "carol", "removed", "department")).toHaveLength(1);
|
|
176
|
+
expect(match("carol", "market", "carol", "added", "department")).toHaveLength(1);
|
|
177
|
+
expect(match("dave", "market", "carol", "added", "department")).toHaveLength(1);
|
|
178
|
+
// alice 不在市场部群:不该收到市场部群的变化
|
|
179
|
+
await sleep(100);
|
|
180
|
+
expect(mev("alice").filter((e) => e.group_id === GRP.market)).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("head change: gina made 研发部 leader joins the group; unsetting removes her", async () => {
|
|
184
|
+
await reset();
|
|
185
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { leader_user_id: U.gina.id })).status).toBe(200);
|
|
186
|
+
await until(() => ["gina", "alice"].every((w) => match(w, "rd", "gina", "added", "department").length > 0));
|
|
187
|
+
expect(match("gina", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
188
|
+
expect(match("alice", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
189
|
+
// 部门写会踢掉负责人的用户流(RFC-040,权限变了要重连重新鉴权)—— App 会重连;这里同样重订阅一次。
|
|
190
|
+
S.gina.close();
|
|
191
|
+
S.gina = await subscribe("gina");
|
|
192
|
+
await reset();
|
|
193
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { leader_user_id: null })).status).toBe(200);
|
|
194
|
+
await until(() => ["gina", "alice"].every((w) => match(w, "rd", "gina", "removed", "department").length > 0));
|
|
195
|
+
expect(match("gina", "rd", "gina", "removed", "department")).toHaveLength(1);
|
|
196
|
+
expect(match("alice", "rd", "gina", "removed", "department")).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("rename-only PATCH (no roster change) emits nothing", async () => {
|
|
200
|
+
await reset();
|
|
201
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { name: "研发中心" })).status).toBe(200);
|
|
202
|
+
await sleep(150);
|
|
203
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("post-commit only", () => {
|
|
208
|
+
test("a rolled-back write emits nothing and changes nothing", async () => {
|
|
209
|
+
await reset();
|
|
210
|
+
expect(() => groupTx(() => {
|
|
211
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.frank.id, DEPT.rd]);
|
|
212
|
+
syncDepartmentGroups(NET);
|
|
213
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(true); // 事务里确实对过账
|
|
214
|
+
throw new Error("boom");
|
|
215
|
+
})).toThrow("boom");
|
|
216
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(false);
|
|
217
|
+
await sleep(200);
|
|
218
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
219
|
+
// 下一次提交的写也不会把回滚掉的那笔捎带出去
|
|
220
|
+
await place("erin", DEPT.rd); // 无变化的调人(erin 已在研发部)
|
|
221
|
+
await sleep(150);
|
|
222
|
+
for (const w of Object.keys(S)) expect(mev(w).filter((e) => e.member_user_id === U.frank.id)).toHaveLength(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("an inner savepoint rollback drops only its own changes; the outer commit still emits", async () => {
|
|
226
|
+
await reset();
|
|
227
|
+
groupTx(() => {
|
|
228
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.gina.id, DEPT.rd]);
|
|
229
|
+
syncDepartmentGroups(NET);
|
|
230
|
+
try {
|
|
231
|
+
groupTx(() => {
|
|
232
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.frank.id, DEPT.rd]);
|
|
233
|
+
syncDepartmentGroups(NET);
|
|
234
|
+
throw new Error("inner");
|
|
235
|
+
});
|
|
236
|
+
} catch {}
|
|
237
|
+
// 事务还没提交:这时一条都不该推出去
|
|
238
|
+
expect(mev("alice")).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
expect(isGroupMember(GRP.rd, U.gina.id)).toBe(true);
|
|
241
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(false);
|
|
242
|
+
await until(() => match("alice", "rd", "gina", "added", "department").length > 0);
|
|
243
|
+
expect(match("alice", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
244
|
+
await sleep(100);
|
|
245
|
+
for (const w of Object.keys(S)) expect(mev(w).filter((e) => e.member_user_id === U.frank.id)).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("network member removal", () => {
|
|
250
|
+
test("removing hank from the network → remaining 研发部 members get `removed`/network_removal", async () => {
|
|
251
|
+
// 调部门 / 换负责人会踢掉当事人的用户流(RFC-040,App 会重连):erin、gina 先重订阅。
|
|
252
|
+
for (const w of ["erin", "gina"]) { S[w].close(); S[w] = await subscribe(w); }
|
|
253
|
+
await reset();
|
|
254
|
+
const r = await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${U.hank.id}`);
|
|
255
|
+
expect(r.status).toBe(200);
|
|
256
|
+
await until(() => ["alice", "erin", "gina"].every((w) => match(w, "rd", "hank", "removed", "network_removal").length > 0));
|
|
257
|
+
expect(match("alice", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
258
|
+
expect(match("erin", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
259
|
+
expect(match("gina", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
260
|
+
// 不能再有一条 department 来源的重复移出(移出网络已经删了所有行,对账没东西可删)
|
|
261
|
+
expect(match("alice", "rd", "hank", "removed", "department")).toHaveLength(0);
|
|
262
|
+
expect(isGroupMember(GRP.rd, U.hank.id)).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|