@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.93
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 +2 -2
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +358 -0
- package/src/departments.ts +30 -9
- 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-permissions-http.test.ts +18 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/server.ts +166 -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 +8 -1
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// RFC-042(看板 #457)部门群,第三个 PR:群消息、未读、实时推送。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
|
|
3
|
+
// 钉住:只有当前群成员能发 / 读 / 标已读(管理者不是成员也 404,Agent 403 humans_only);按 client_request_id 去重;
|
|
4
|
+
// 翻页游标;未读 = 别人发的、入群后的、超过已读位置的;已读只前进;推 group_message 给发送那一刻的每个成员、不给非成员;
|
|
5
|
+
// 被同步移出的人立刻读不到历史、收不到推送、打不开群里的附件;群附件对群成员可见、不能借群解锁别人的私信文件。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-group-msgs-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "GroupMsgsPassw0rd!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
|
+
|
|
23
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let json: any = null;
|
|
27
|
+
try { json = JSON.parse(text); } catch {}
|
|
28
|
+
return { status: res.status, body: json };
|
|
29
|
+
}
|
|
30
|
+
const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
|
|
31
|
+
const msgs = (g: string) => CG(`/${GRP[g]}/messages`);
|
|
32
|
+
const post = (who: string, g: string, body: Record<string, unknown>) => send(U[who].token, "POST", msgs(g), body);
|
|
33
|
+
const unread = async (who: string, g: string) => {
|
|
34
|
+
const r = await send(U[who].token, "GET", CG());
|
|
35
|
+
expect(r.status).toBe(200);
|
|
36
|
+
return r.body.groups.find((x: any) => x.id === GRP[g])?.unread as number;
|
|
37
|
+
};
|
|
38
|
+
const place = async (who: string, dept: string | null) =>
|
|
39
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
|
|
40
|
+
|
|
41
|
+
async function upload(token: string, body: string, query = "") {
|
|
42
|
+
const form = new FormData();
|
|
43
|
+
form.append("file", new Blob([new TextEncoder().encode(body)], { type: "image/png" }), "shot.png");
|
|
44
|
+
const res = await fetch(`${BASE}/api/upload?network_id=${NET}${query}`, { method: "POST", body: form, headers: { Authorization: `Bearer ${token}` } });
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
return ((await res.json()) as any).file_id as string;
|
|
47
|
+
}
|
|
48
|
+
const fileStatus = async (token: string, fileId: string) => (await fetch(`${BASE}/api/files/${fileId}`, { headers: { Authorization: `Bearer ${token}` } })).status;
|
|
49
|
+
const att = (fileId: string) => [{ type: "file", file_id: fileId, name: "shot.png", mime: "image/png" }];
|
|
50
|
+
|
|
51
|
+
/** 订阅 /events/users/me,收集事件。 */
|
|
52
|
+
async function subscribe(who: string) {
|
|
53
|
+
const ctrl = new AbortController();
|
|
54
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: { Authorization: `Bearer ${U[who].token}` }, signal: ctrl.signal });
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
const events: any[] = [];
|
|
57
|
+
const reader = res.body!.getReader();
|
|
58
|
+
const dec = new TextDecoder();
|
|
59
|
+
let buf = "";
|
|
60
|
+
void (async () => {
|
|
61
|
+
try {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
buf += dec.decode(value);
|
|
66
|
+
let i: number;
|
|
67
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
68
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
69
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
})();
|
|
74
|
+
await until(() => events.some((e) => e.type === "connected"));
|
|
75
|
+
return { events, close: () => ctrl.abort() };
|
|
76
|
+
}
|
|
77
|
+
const until = async (cond: () => boolean, tries = 150) => { for (let k = 0; k < tries && !cond(); k++) await new Promise((r) => setTimeout(r, 20)); };
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
81
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
82
|
+
process.env.HOST = "127.0.0.1";
|
|
83
|
+
const stamp = Date.now();
|
|
84
|
+
const a = register(`gm_owner_${stamp}`, PW, undefined, "Owner");
|
|
85
|
+
expect(a.ok).toBe(true);
|
|
86
|
+
U.owner = { token: a.token!, id: a.user!.user_id, username: a.user!.username };
|
|
87
|
+
NET = a.network_id!;
|
|
88
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
89
|
+
const mod: any = await import("./server.js");
|
|
90
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
91
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
92
|
+
const add = async (username: string, role: string) => {
|
|
93
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
94
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
95
|
+
expect(login.status).toBe(200);
|
|
96
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string, username };
|
|
97
|
+
};
|
|
98
|
+
U.head = await add(`gm_head_${stamp}`, "member"); // 研发部负责人 + 成员
|
|
99
|
+
U.alice = await add(`gm_alice_${stamp}`, "member"); // 研发部
|
|
100
|
+
U.bob = await add(`gm_bob_${stamp}`, "member"); // 后端组(研发部子部门)
|
|
101
|
+
U.carol = await add(`gm_carol_${stamp}`, "member"); // 研发部,之后被调走(同步移出)
|
|
102
|
+
U.dave = await add(`gm_dave_${stamp}`, "member"); // 市场部(不在研发部群)
|
|
103
|
+
U.viewer = await add(`gm_viewer_${stamp}`, "viewer"); // 研发部,只读角色也能聊天
|
|
104
|
+
U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "gm-node", "node_gm").token!, id: "node_gm", username: "gm-node" };
|
|
105
|
+
const o = register(`gm_outsider_${stamp}`, PW);
|
|
106
|
+
U.outsider = { token: o.token!, id: o.user!.user_id, username: o.user!.username };
|
|
107
|
+
|
|
108
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
109
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments`, { name, ...extra });
|
|
110
|
+
expect(r.status).toBe(201);
|
|
111
|
+
return r.body.department.id as string;
|
|
112
|
+
};
|
|
113
|
+
DEPT.rd = await mk("研发部", { leader_user_id: U.head.id });
|
|
114
|
+
DEPT.backend = await mk("后端组", { parent_id: DEPT.rd });
|
|
115
|
+
DEPT.market = await mk("市场部");
|
|
116
|
+
for (const w of ["head", "alice", "carol", "viewer"]) await place(w, DEPT.rd);
|
|
117
|
+
await place("bob", DEPT.backend);
|
|
118
|
+
await place("dave", DEPT.market);
|
|
119
|
+
for (const key of ["rd", "backend", "market"]) {
|
|
120
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments/${DEPT[key]}/group`, {});
|
|
121
|
+
expect(r.status).toBe(201);
|
|
122
|
+
GRP[key] = r.body.group.id;
|
|
123
|
+
}
|
|
124
|
+
// 入群时间是秒级:等过这一秒,下面「入群后发的才算未读」的口径就不受同一秒的影响。
|
|
125
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
126
|
+
}, 30_000);
|
|
127
|
+
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
try { hub?.stop?.(true); } catch {}
|
|
130
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("send + history", () => {
|
|
134
|
+
test("a member sends: 200, DM-shaped row with seq / kind / direction, one audit row", async () => {
|
|
135
|
+
const r = await post("alice", "rd", { message: "大家好" });
|
|
136
|
+
expect(r.status).toBe(200);
|
|
137
|
+
expect(r.body.duplicate).toBe(false);
|
|
138
|
+
expect(r.body.message).toMatchObject({ group_id: GRP.rd, network_id: NET, sender_user_id: U.alice.id, from_session: U.alice.username, content: "大家好", kind: "group_message", direction: "out", meta_json: null });
|
|
139
|
+
expect(typeof r.body.message.seq).toBe("number");
|
|
140
|
+
expect(r.body.message.created_at).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}$/);
|
|
141
|
+
const audits = db.all<{ target_id: string }>("SELECT target_id FROM audit_log WHERE action = 'chat_group_message_sent' AND user_id = ?1", U.alice.id);
|
|
142
|
+
expect(audits.map((x) => x.target_id)).toEqual([GRP.rd]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("bob (sub-department member) and the viewer are in the group and can send", async () => {
|
|
146
|
+
expect((await post("bob", "rd", { message: "后端在" })).status).toBe(200);
|
|
147
|
+
expect((await post("viewer", "rd", { message: "只读角色也能聊天" })).status).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("history: newest first, direction per reader, cursor pagination by seq", async () => {
|
|
151
|
+
const all = await send(U.bob.token, "GET", msgs("rd"));
|
|
152
|
+
expect(all.status).toBe(200);
|
|
153
|
+
expect(all.body.messages.map((m: any) => m.content)).toEqual(["只读角色也能聊天", "后端在", "大家好"]);
|
|
154
|
+
expect(all.body.messages.map((m: any) => m.direction)).toEqual(["in", "out", "in"]);
|
|
155
|
+
expect(all.body.next_before).toBeNull();
|
|
156
|
+
const p1 = await send(U.bob.token, "GET", `${msgs("rd")}?limit=2`);
|
|
157
|
+
expect(p1.body.messages.map((m: any) => m.content)).toEqual(["只读角色也能聊天", "后端在"]);
|
|
158
|
+
expect(p1.body.next_before).toBe(p1.body.messages[1].seq);
|
|
159
|
+
const p2 = await send(U.bob.token, "GET", `${msgs("rd")}?limit=2&before=${p1.body.next_before}`);
|
|
160
|
+
expect(p2.body.messages.map((m: any) => m.content)).toEqual(["大家好"]);
|
|
161
|
+
expect(p2.body.next_before).toBeNull();
|
|
162
|
+
expect((await send(U.bob.token, "GET", `${msgs("rd")}?before=abc`)).status).toBe(400);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("validation mirrors DM: empty → 400 message_required, > 10000 chars → 400 message_too_long, bad attachments → 400", async () => {
|
|
166
|
+
expect((await post("alice", "rd", { message: " " })).body.error).toBe("message_required");
|
|
167
|
+
expect((await post("alice", "rd", { message: "x".repeat(10_001) })).body.error).toBe("message_too_long");
|
|
168
|
+
expect((await post("alice", "rd", { message: "x".repeat(10_000) })).status).toBe(200);
|
|
169
|
+
const bad = await post("alice", "rd", { message: "a", attachments: [{ type: "file", file_id: "../etc" }] });
|
|
170
|
+
expect(bad.status).toBe(400);
|
|
171
|
+
expect(bad.body.error).toBe("bad_attachments");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("dedup by client_request_id (not by content)", () => {
|
|
176
|
+
test("a retried bubble returns the same message and stores one row; same text without an id stores two", async () => {
|
|
177
|
+
const a = await post("alice", "rd", { message: "重试", client_request_id: "cr-1" });
|
|
178
|
+
const b = await post("alice", "rd", { message: "重试", client_request_id: "cr-1" });
|
|
179
|
+
expect(a.status).toBe(200);
|
|
180
|
+
expect(b.status).toBe(200);
|
|
181
|
+
expect(b.body.duplicate).toBe(true);
|
|
182
|
+
expect(b.body.message.message_id).toBe(a.body.message.message_id);
|
|
183
|
+
expect(b.body.message.seq).toBe(a.body.message.seq);
|
|
184
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_messages WHERE message_id = ?1", a.body.message.message_id)!.n)).toBe(1);
|
|
185
|
+
const c = await post("alice", "rd", { message: "重试" });
|
|
186
|
+
const d = await post("alice", "rd", { message: "重试" });
|
|
187
|
+
expect(c.body.message.message_id).not.toBe(d.body.message.message_id);
|
|
188
|
+
});
|
|
189
|
+
test("the same client_request_id from another sender or in another group is a different message", async () => {
|
|
190
|
+
const mine = await post("alice", "rd", { message: "x", client_request_id: "cr-2" });
|
|
191
|
+
const his = await post("bob", "rd", { message: "x", client_request_id: "cr-2" });
|
|
192
|
+
const other = await post("bob", "backend", { message: "x", client_request_id: "cr-2" });
|
|
193
|
+
expect(new Set([mine.body.message.message_id, his.body.message.message_id, other.body.message.message_id]).size).toBe(3);
|
|
194
|
+
expect(his.body.duplicate).toBe(false);
|
|
195
|
+
expect(other.body.duplicate).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("unread + read", () => {
|
|
200
|
+
test("unread counts others' messages after my read cursor; own messages never count", async () => {
|
|
201
|
+
// 先都读到最新,再从干净的 0 开始数。
|
|
202
|
+
for (const w of ["alice", "bob", "head"]) expect((await send(U[w].token, "POST", CG(`/${GRP.rd}/read`), {})).body.unread).toBe(0);
|
|
203
|
+
expect(await unread("bob", "rd")).toBe(0);
|
|
204
|
+
for (const t of ["一", "二", "三"]) expect((await post("alice", "rd", { message: t })).status).toBe(200);
|
|
205
|
+
expect(await unread("bob", "rd")).toBe(3);
|
|
206
|
+
expect(await unread("head", "rd")).toBe(3);
|
|
207
|
+
expect(await unread("alice", "rd")).toBe(0);
|
|
208
|
+
// GET messages 也带上 unread(读历史不等于标已读)
|
|
209
|
+
expect((await send(U.bob.token, "GET", msgs("rd"))).body.unread).toBe(3);
|
|
210
|
+
// /api/dm/threads 只增字段 group_threads
|
|
211
|
+
const th = await send(U.bob.token, "GET", `/api/dm/threads?network_id=${NET}`);
|
|
212
|
+
expect(th.status).toBe(200);
|
|
213
|
+
expect(Array.isArray(th.body.threads)).toBe(true);
|
|
214
|
+
const rd = th.body.group_threads.find((g: any) => g.group_id === GRP.rd);
|
|
215
|
+
expect(rd).toMatchObject({ unread: 3, name: "研发部", department_id: DEPT.rd });
|
|
216
|
+
expect(th.body.group_threads.map((g: any) => g.group_id).sort()).toEqual([GRP.backend, GRP.rd].sort());
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("read up to a seq, never backwards, clamps past the latest; bad seq → 400", async () => {
|
|
220
|
+
const page = await send(U.bob.token, "GET", msgs("rd"));
|
|
221
|
+
const [newest, middle] = page.body.messages;
|
|
222
|
+
const r1 = await send(U.bob.token, "POST", CG(`/${GRP.rd}/read`), { seq: middle.seq });
|
|
223
|
+
expect(r1.status).toBe(200);
|
|
224
|
+
expect(r1.body).toMatchObject({ last_read_seq: middle.seq, unread: 1 });
|
|
225
|
+
const back = await send(U.bob.token, "POST", CG(`/${GRP.rd}/read`), { seq: 1 });
|
|
226
|
+
expect(back.body).toMatchObject({ last_read_seq: middle.seq, unread: 1 });
|
|
227
|
+
const far = await send(U.bob.token, "POST", CG(`/${GRP.rd}/read`), { seq: newest.seq + 1000 });
|
|
228
|
+
expect(far.body).toMatchObject({ last_read_seq: newest.seq, unread: 0 });
|
|
229
|
+
expect((await send(U.bob.token, "POST", CG(`/${GRP.rd}/read`), { seq: -1 })).body.error).toBe("invalid_seq");
|
|
230
|
+
expect((await send(U.bob.token, "POST", CG(`/${GRP.rd}/read`), { seq: "5" })).status).toBe(400);
|
|
231
|
+
expect(await unread("bob", "rd")).toBe(0);
|
|
232
|
+
expect(await unread("head", "rd")).toBe(3);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("live push", () => {
|
|
237
|
+
test("group_message goes to every current member (sender included, each with own unread), not to non-members", async () => {
|
|
238
|
+
const subs = { head: await subscribe("head"), alice: await subscribe("alice"), dave: await subscribe("dave"), owner: await subscribe("owner") };
|
|
239
|
+
const r = await post("alice", "rd", { message: "推送测试", client_request_id: "push-1" });
|
|
240
|
+
expect(r.body.delivered_to).toBe(2); // head + alice 在线;dave / owner 不是成员
|
|
241
|
+
await until(() => subs.head.events.some((e) => e.type === "group_message") && subs.alice.events.some((e) => e.type === "group_message"));
|
|
242
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
243
|
+
const ev = subs.head.events.find((e) => e.type === "group_message");
|
|
244
|
+
expect(ev).toMatchObject({ message_id: r.body.message.message_id, seq: r.body.message.seq, group_id: GRP.rd, group_name: "研发部", kind: "group_message", from: U.alice.username, from_user_id: U.alice.id, message: "推送测试", unread: 4, network_id: NET, user_id: U.head.id });
|
|
245
|
+
expect(subs.alice.events.find((e) => e.type === "group_message")).toMatchObject({ message_id: r.body.message.message_id, unread: 0 });
|
|
246
|
+
expect(subs.dave.events.some((e) => e.type === "group_message")).toBe(false);
|
|
247
|
+
expect(subs.owner.events.some((e) => e.type === "group_message")).toBe(false);
|
|
248
|
+
// 重投不推第二次
|
|
249
|
+
expect((await post("alice", "rd", { message: "推送测试", client_request_id: "push-1" })).body.delivered_to).toBe(0);
|
|
250
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
251
|
+
expect(subs.head.events.filter((e) => e.type === "group_message").length).toBe(1);
|
|
252
|
+
// 标已读推 group_read 给自己(多端同步角标)
|
|
253
|
+
await send(U.head.token, "POST", CG(`/${GRP.rd}/read`), {});
|
|
254
|
+
await until(() => subs.head.events.some((e) => e.type === "group_read"));
|
|
255
|
+
expect(subs.head.events.find((e) => e.type === "group_read")).toMatchObject({ group_id: GRP.rd, unread: 0, last_read_seq: r.body.message.seq });
|
|
256
|
+
for (const s of Object.values(subs)) s.close();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("who may use the message endpoints", () => {
|
|
261
|
+
test("non-member (incl. a network owner/admin who is not in the group) → 404 on send / history / read", async () => {
|
|
262
|
+
for (const w of ["dave", "owner"]) {
|
|
263
|
+
expect((await post(w, "rd", { message: "x" })).body.error).toBe("group_not_found");
|
|
264
|
+
expect((await send(U[w].token, "GET", msgs("rd"))).status).toBe(404);
|
|
265
|
+
expect((await send(U[w].token, "POST", CG(`/${GRP.rd}/read`), {})).status).toBe(404);
|
|
266
|
+
}
|
|
267
|
+
// 管理者照样看得到群资料(第 1 个 PR 的口径不变),只是读不到消息。
|
|
268
|
+
expect((await send(U.owner.token, "GET", CG(`/${GRP.rd}`))).status).toBe(200);
|
|
269
|
+
expect((await post("alice", "rd", { message: "x" })).status).toBe(200);
|
|
270
|
+
expect((await send(U.alice.token, "GET", CG("/grp_doesnotexist0000000/messages"))).status).toBe(404);
|
|
271
|
+
});
|
|
272
|
+
test("node token → 403 humans_only; outside the network → 403; no token → 401", async () => {
|
|
273
|
+
const n = await post("node", "rd", { message: "x" });
|
|
274
|
+
expect(n.status).toBe(403);
|
|
275
|
+
expect(n.body.error).toBe("humans_only");
|
|
276
|
+
expect((await send(U.node.token, "GET", msgs("rd"))).body.error).toBe("humans_only");
|
|
277
|
+
expect((await send(U.node.token, "POST", CG(`/${GRP.rd}/read`), {})).status).toBe(403);
|
|
278
|
+
expect((await post("outsider", "rd", { message: "x" })).status).toBe(403);
|
|
279
|
+
expect((await send("", "GET", msgs("rd"))).status).toBe(401);
|
|
280
|
+
});
|
|
281
|
+
test("wrong method → 405; messages/<x> → 404", async () => {
|
|
282
|
+
expect((await send(U.alice.token, "DELETE", msgs("rd"))).status).toBe(405);
|
|
283
|
+
expect((await send(U.alice.token, "GET", CG(`/${GRP.rd}/read`))).status).toBe(405);
|
|
284
|
+
expect((await send(U.alice.token, "GET", `${msgs("rd")}/abc`)).status).toBe(404);
|
|
285
|
+
});
|
|
286
|
+
test("chat-groups list carries unread / last_message_at only for groups I'm in", async () => {
|
|
287
|
+
const r = await send(U.owner.token, "GET", CG());
|
|
288
|
+
const rd = r.body.groups.find((g: any) => g.id === GRP.rd);
|
|
289
|
+
expect(rd).toMatchObject({ is_member: false, unread: 0, last_message_at: null });
|
|
290
|
+
const mine = (await send(U.alice.token, "GET", CG())).body.groups.find((g: any) => g.id === GRP.rd);
|
|
291
|
+
expect(mine.is_member).toBe(true);
|
|
292
|
+
expect(typeof mine.last_message_at).toBe("string");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("attachments", () => {
|
|
297
|
+
let fid = "";
|
|
298
|
+
test("a DM-scoped upload sent to the group is readable by current members only", async () => {
|
|
299
|
+
fid = await upload(U.alice.token, "group-image", "&purpose=dm");
|
|
300
|
+
expect(await fileStatus(U.bob.token, fid)).toBe(404); // 还没发:只有上传者
|
|
301
|
+
const r = await post("alice", "rd", { message: "看图", attachments: att(fid) });
|
|
302
|
+
expect(r.status).toBe(200);
|
|
303
|
+
expect(JSON.parse(r.body.message.meta_json).attachments[0].file_id).toBe(fid);
|
|
304
|
+
for (const w of ["alice", "bob", "head", "carol", "viewer"]) expect(await fileStatus(U[w].token, fid)).toBe(200);
|
|
305
|
+
expect(await fileStatus(U.dave.token, fid)).toBe(404);
|
|
306
|
+
expect(await fileStatus(U.node.token, fid)).toBe(404);
|
|
307
|
+
expect(await fileStatus(U.outsider.token, fid)).toBe(404);
|
|
308
|
+
});
|
|
309
|
+
test("a non-member can't unlock it by putting the id into their own group; a member who sees it may forward", async () => {
|
|
310
|
+
const r = await post("dave", "market", { message: "偷", attachments: att(fid) });
|
|
311
|
+
expect(r.status).toBe(403);
|
|
312
|
+
expect(r.body.error).toBe("attachment_not_accessible");
|
|
313
|
+
expect(await fileStatus(U.dave.token, fid)).toBe(404);
|
|
314
|
+
expect((await post("bob", "backend", { message: "转发", attachments: att(fid) })).status).toBe(200);
|
|
315
|
+
});
|
|
316
|
+
test("restricted members (agent_access=granted): a plain upload in a group is readable by restricted members of that group only, and they may forward it", async () => {
|
|
317
|
+
const { isAgentRestricted } = await import("./agent-access.js");
|
|
318
|
+
db.run("UPDATE network_members SET agent_access = 'granted' WHERE network_id = ?1 AND user_id IN (?2, ?3)", [NET, U.head.id, U.dave.id]);
|
|
319
|
+
expect(isAgentRestricted(U.head.id, NET)).toBe(true);
|
|
320
|
+
expect(isAgentRestricted(U.dave.id, NET)).toBe(true);
|
|
321
|
+
const plain = await upload(U.alice.token, "plain-network-file"); // 不带 purpose:网络文件
|
|
322
|
+
expect(await fileStatus(U.head.token, plain)).toBe(404); // 受限成员原本读不到
|
|
323
|
+
expect((await post("alice", "rd", { message: "网络文件", attachments: att(plain) })).status).toBe(200);
|
|
324
|
+
expect(await fileStatus(U.head.token, plain)).toBe(200); // 进了他所在的群 → 能读
|
|
325
|
+
expect(await fileStatus(U.dave.token, plain)).toBe(404); // 不在这个群的受限成员仍读不到
|
|
326
|
+
expect((await post("dave", "market", { message: "x", attachments: att(plain) })).body.error).toBe("attachment_not_accessible");
|
|
327
|
+
// head 在群里看得见它,可以转发(他是受限成员,走 restrictedMemberCanUseFile || groupMemberSeesFile)
|
|
328
|
+
expect((await post("head", "rd", { message: "转一下", attachments: att(plain) })).status).toBe(200);
|
|
329
|
+
db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id IN (?2, ?3)", [NET, U.head.id, U.dave.id]);
|
|
330
|
+
});
|
|
331
|
+
test("someone else's DM file can't be pushed into a group by a member who never saw it", async () => {
|
|
332
|
+
const secret = await upload(U.dave.token, "dave-secret", "&purpose=dm");
|
|
333
|
+
const r = await post("alice", "rd", { message: "x", attachments: att(secret) });
|
|
334
|
+
expect(r.status).toBe(403);
|
|
335
|
+
expect(r.body.error).toBe("attachment_not_accessible");
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("removed by sync: no history, no push, no attachments", () => {
|
|
340
|
+
test("carol is moved out of 研发部 → 404 on the group, file gone, no push", async () => {
|
|
341
|
+
const before = await send(U.carol.token, "GET", msgs("rd"));
|
|
342
|
+
expect(before.status).toBe(200);
|
|
343
|
+
const fid = JSON.parse(before.body.messages.find((m: any) => m.meta_json)!.meta_json).attachments[0].file_id;
|
|
344
|
+
expect(await fileStatus(U.carol.token, fid)).toBe(200);
|
|
345
|
+
await place("carol", DEPT.market);
|
|
346
|
+
expect((await send(U.carol.token, "GET", msgs("rd"))).status).toBe(404);
|
|
347
|
+
expect((await post("carol", "rd", { message: "x" })).status).toBe(404);
|
|
348
|
+
expect((await send(U.carol.token, "POST", CG(`/${GRP.rd}/read`), {})).status).toBe(404);
|
|
349
|
+
expect(await fileStatus(U.carol.token, fid)).toBe(404);
|
|
350
|
+
expect((await send(U.carol.token, "GET", CG())).body.groups.map((g: any) => g.id)).toEqual([GRP.market]);
|
|
351
|
+
const sub = await subscribe("carol");
|
|
352
|
+
const r = await post("alice", "rd", { message: "carol 走了" });
|
|
353
|
+
expect(r.status).toBe(200);
|
|
354
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
355
|
+
expect(sub.events.some((e) => e.type === "group_message")).toBe(false);
|
|
356
|
+
// 进了市场部群:那边的消息照样推给她
|
|
357
|
+
await post("dave", "market", { message: "欢迎" });
|
|
358
|
+
await until(() => sub.events.some((e) => e.type === "group_message"));
|
|
359
|
+
expect(sub.events.find((e) => e.type === "group_message")).toMatchObject({ group_id: GRP.market, from_user_id: U.dave.id });
|
|
360
|
+
sub.close();
|
|
361
|
+
});
|
|
362
|
+
test("re-joining restores history; messages sent while away are not counted as unread", async () => {
|
|
363
|
+
await new Promise((res) => setTimeout(res, 1100)); // 入群时间是秒级
|
|
364
|
+
await place("carol", DEPT.rd);
|
|
365
|
+
const r = await send(U.carol.token, "GET", msgs("rd"));
|
|
366
|
+
expect(r.status).toBe(200);
|
|
367
|
+
expect(r.body.messages.some((m: any) => m.content === "carol 走了")).toBe(true);
|
|
368
|
+
expect(r.body.unread).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("app API polish: last_message / member names / viewer_can / health capability", () => {
|
|
373
|
+
// 状态:研发部群 = head(负责人)/ alice / bob / carol / viewer;owner 不是成员但能管(网络 owner + Hub 管理员)。
|
|
374
|
+
const LONG = "第一行\n 第二行 " + "字".repeat(100);
|
|
375
|
+
test("group_threads and chat-groups rows carry last_message: 80-char preview, attachments-only → empty text + count", async () => {
|
|
376
|
+
db.run("UPDATE users SET display_name = ?2 WHERE user_id = ?1", [U.alice.id, "爱丽丝"]);
|
|
377
|
+
const r1 = await post("alice", "rd", { message: LONG });
|
|
378
|
+
expect(r1.status).toBe(200);
|
|
379
|
+
const preview = ("第一行 第二行 " + "字".repeat(100)).slice(0, 80);
|
|
380
|
+
const expected = { text: preview, attachment_count: 0, sender_user_id: U.alice.id, sender_name: "爱丽丝", at: r1.body.message.created_at };
|
|
381
|
+
const th = await send(U.head.token, "GET", `/api/dm/threads?network_id=${NET}`);
|
|
382
|
+
expect(th.status).toBe(200);
|
|
383
|
+
const row = th.body.group_threads.find((g: any) => g.group_id === GRP.rd);
|
|
384
|
+
expect(row.last_message).toEqual(expected);
|
|
385
|
+
expect([...row.last_message.text].length).toBe(80);
|
|
386
|
+
expect(row.last_at).toBe(expected.at);
|
|
387
|
+
const list = await send(U.head.token, "GET", CG());
|
|
388
|
+
expect(list.body.groups.find((g: any) => g.id === GRP.rd).last_message).toEqual(expected);
|
|
389
|
+
|
|
390
|
+
const fid = await upload(U.bob.token, "png-bytes-preview");
|
|
391
|
+
const r2 = await post("bob", "rd", { attachments: [...att(fid), ...att(fid)] });
|
|
392
|
+
expect(r2.status).toBe(200);
|
|
393
|
+
const th2 = await send(U.alice.token, "GET", `/api/dm/threads?network_id=${NET}`);
|
|
394
|
+
// bob 没设 display_name → sender_name 回落到 username
|
|
395
|
+
expect(th2.body.group_threads.find((g: any) => g.group_id === GRP.rd).last_message).toEqual({ text: "", attachment_count: 2, sender_user_id: U.bob.id, sender_name: U.bob.username, at: r2.body.message.created_at });
|
|
396
|
+
});
|
|
397
|
+
test("last_message is null for a group with no messages and for a group I can see but am not in", async () => {
|
|
398
|
+
const d = await send(U.owner.token, "POST", `/api/networks/${NET}/departments`, { name: "空群部" });
|
|
399
|
+
expect(d.status).toBe(201);
|
|
400
|
+
await place("dave", d.body.department.id);
|
|
401
|
+
const g = await send(U.owner.token, "POST", `/api/networks/${NET}/departments/${d.body.department.id}/group`, {});
|
|
402
|
+
expect(g.status).toBe(201);
|
|
403
|
+
GRP.empty = g.body.group.id;
|
|
404
|
+
const mine = await send(U.dave.token, "GET", CG());
|
|
405
|
+
expect(mine.body.groups.find((x: any) => x.id === GRP.empty).last_message).toBeNull();
|
|
406
|
+
// owner 看得到研发部群(管理身份)但不是成员:不给预览
|
|
407
|
+
const owner = await send(U.owner.token, "GET", CG());
|
|
408
|
+
const rd = owner.body.groups.find((x: any) => x.id === GRP.rd);
|
|
409
|
+
expect(rd.is_member).toBe(false);
|
|
410
|
+
expect(rd.last_message).toBeNull();
|
|
411
|
+
await place("dave", DEPT.market);
|
|
412
|
+
});
|
|
413
|
+
test("members carry username + display_name (empty when unset or equal to username)", async () => {
|
|
414
|
+
db.run("UPDATE users SET display_name = ?2 WHERE user_id = ?1", [U.bob.id, U.bob.username]);
|
|
415
|
+
const r = await send(U.alice.token, "GET", CG(`/${GRP.rd}`));
|
|
416
|
+
expect(r.status).toBe(200);
|
|
417
|
+
const by = (id: string) => r.body.members.find((m: any) => m.user_id === id);
|
|
418
|
+
expect(by(U.alice.id)).toMatchObject({ username: U.alice.username, display_name: "爱丽丝", source: "department" });
|
|
419
|
+
expect(by(U.bob.id)).toMatchObject({ username: U.bob.username, display_name: "" }); // 等于 username
|
|
420
|
+
expect(by(U.carol.id)).toMatchObject({ username: U.carol.username, display_name: "" }); // 没设
|
|
421
|
+
for (const m of r.body.members) expect(typeof m.username === "string" && m.username.length > 0).toBe(true);
|
|
422
|
+
const d = await send(U.alice.token, "GET", `/api/networks/${NET}/departments/${DEPT.rd}/group`);
|
|
423
|
+
expect(d.status).toBe(200);
|
|
424
|
+
expect(d.body.members.find((m: any) => m.user_id === U.alice.id)).toMatchObject({ username: U.alice.username, display_name: "爱丽丝" });
|
|
425
|
+
// 手动拉人的返回行也带名字
|
|
426
|
+
const added = await send(U.head.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.dave.id });
|
|
427
|
+
expect(added.status).toBe(201);
|
|
428
|
+
expect(added.body.member).toMatchObject({ user_id: U.dave.id, username: U.dave.username, display_name: "", source: "manual" });
|
|
429
|
+
expect((await send(U.head.token, "DELETE", CG(`/${GRP.rd}/members/${U.dave.id}`))).status).toBe(200);
|
|
430
|
+
});
|
|
431
|
+
test("viewer_can matches what the write endpoints actually allow", async () => {
|
|
432
|
+
const can = async (who: string, gid: string) => (await send(U[who].token, "GET", CG(`/${gid}`))).body.group?.viewer_can;
|
|
433
|
+
expect(await can("alice", GRP.rd)).toEqual({ manage: false, post: true }); // 普通成员
|
|
434
|
+
expect(await can("head", GRP.rd)).toEqual({ manage: true, post: true }); // 部门负责人 + 成员
|
|
435
|
+
expect(await can("head", GRP.backend)).toEqual({ manage: true, post: false }); // 上级负责人,不在子部门群
|
|
436
|
+
expect(await can("owner", GRP.rd)).toEqual({ manage: true, post: false }); // 网络 owner,不是成员
|
|
437
|
+
// 与写接口对照:manage=false → 改名 403;post=false → 发消息 404
|
|
438
|
+
expect((await send(U.alice.token, "PATCH", CG(`/${GRP.rd}`), { name: "x" })).status).toBe(403);
|
|
439
|
+
expect((await send(U.owner.token, "POST", msgs("rd"), { message: "x" })).status).toBe(404);
|
|
440
|
+
const renamed = await send(U.head.token, "PATCH", CG(`/${GRP.rd}`), { name: "研发部" });
|
|
441
|
+
expect(renamed.status).toBe(200);
|
|
442
|
+
expect(renamed.body.group.viewer_can).toEqual({ manage: true, post: true });
|
|
443
|
+
// 列表行同口径
|
|
444
|
+
const rows = (await send(U.alice.token, "GET", CG())).body.groups;
|
|
445
|
+
expect(rows.find((g: any) => g.id === GRP.rd).viewer_can).toEqual({ manage: false, post: true });
|
|
446
|
+
const headRows = (await send(U.head.token, "GET", CG())).body.groups;
|
|
447
|
+
expect(headRows.find((g: any) => g.id === GRP.rd).viewer_can).toEqual({ manage: true, post: true });
|
|
448
|
+
// 部门群接口:GET / POST
|
|
449
|
+
const dg = await send(U.alice.token, "GET", `/api/networks/${NET}/departments/${DEPT.rd}/group`);
|
|
450
|
+
expect(dg.body.group.viewer_can).toEqual({ manage: false, post: true });
|
|
451
|
+
const dh = await send(U.head.token, "GET", `/api/networks/${NET}/departments/${DEPT.rd}/group`);
|
|
452
|
+
expect(dh.body.group.viewer_can).toEqual({ manage: true, post: true });
|
|
453
|
+
const nd = await send(U.owner.token, "POST", `/api/networks/${NET}/departments`, { name: "新建部", parent_id: DEPT.rd });
|
|
454
|
+
expect(nd.status).toBe(201);
|
|
455
|
+
const created = await send(U.head.token, "POST", `/api/networks/${NET}/departments/${nd.body.department.id}/group`, {});
|
|
456
|
+
expect(created.status).toBe(201);
|
|
457
|
+
expect(created.body.group.viewer_can).toEqual({ manage: true, post: false }); // 上级负责人建的,自己不在这个子部门
|
|
458
|
+
expect(created.body.members).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
test("/health advertises chat_groups", async () => {
|
|
461
|
+
const health = await fetch(`${BASE}/health`).then((r) => r.json()) as any;
|
|
462
|
+
expect(health.capabilities).toContain("chat_groups");
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe("delete network", () => {
|
|
467
|
+
test("messages and read cursors go with the network", async () => {
|
|
468
|
+
const tmp = register(`gm_tmp_${Date.now()}`, PW);
|
|
469
|
+
const net = tmp.network_id!;
|
|
470
|
+
const d = await send(tmp.token!, "POST", `/api/networks/${net}/departments`, { name: "临时部" });
|
|
471
|
+
expect(d.status).toBe(201);
|
|
472
|
+
expect((await send(tmp.token!, "PUT", `/api/networks/${net}/members/${tmp.user!.user_id}/department`, { department_id: d.body.department.id })).status).toBe(200);
|
|
473
|
+
const g = await send(tmp.token!, "POST", `/api/networks/${net}/departments/${d.body.department.id}/group`, {});
|
|
474
|
+
const gid = g.body.group.id;
|
|
475
|
+
expect((await send(tmp.token!, "POST", `/api/networks/${net}/chat-groups/${gid}/messages`, { message: "hi" })).status).toBe(200);
|
|
476
|
+
expect((await send(tmp.token!, "POST", `/api/networks/${net}/chat-groups/${gid}/read`, {})).status).toBe(200);
|
|
477
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_messages WHERE network_id = ?1", net)!.n)).toBe(1);
|
|
478
|
+
(await import("./departments.js")).deleteDepartmentsForNetwork(net); // 删网络时调的那一个
|
|
479
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_messages WHERE network_id = ?1", net)!.n)).toBe(0);
|
|
480
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_reads WHERE network_id = ?1", net)!.n)).toBe(0);
|
|
481
|
+
});
|
|
482
|
+
});
|