@sleep2agi/commhub-server 0.9.0-preview.50 → 0.9.0-preview.52
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.52",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// 桌面向导要看得到 daemon 回的失败原因:GET /api/node-create-requests?request_id=…
|
|
2
|
+
// 承重:① 只在调用者的网络作用域里能读到(别的网络 → 404,与不存在同形);② 无用户上下文 401;③ 缺参 400;
|
|
3
|
+
// ④ 回的是 daemon ack 写进去的 status/error 原文。
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-create-request-status-"));
|
|
10
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
11
|
+
|
|
12
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
13
|
+
let base = "";
|
|
14
|
+
let aToken = "", bToken = "", aNet = "", bNet = "";
|
|
15
|
+
const MASTER_TOKEN = "legacy-master-token-for-test";
|
|
16
|
+
|
|
17
|
+
const get = async (path: string, token: string) => {
|
|
18
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
19
|
+
return { status: r.status, body: await r.json() as any };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
|
|
24
|
+
const { db } = await import("./db.js");
|
|
25
|
+
const { register } = await import("./auth.js");
|
|
26
|
+
const stamp = Date.now();
|
|
27
|
+
const admin = register(`cr_admin_${stamp}`, "CrAdmin-Strong-1!");
|
|
28
|
+
expect(admin.ok).toBe(true);
|
|
29
|
+
const a = register(`cr_a_${stamp}`, "CrA-Strong-1!");
|
|
30
|
+
const b = register(`cr_b_${stamp}`, "CrB-Strong-1!");
|
|
31
|
+
expect(a.ok && b.ok).toBe(true);
|
|
32
|
+
aToken = a.token!; bToken = b.token!; aNet = a.network_id!; bNet = b.network_id!;
|
|
33
|
+
const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
|
|
34
|
+
for (const id of [uid(`cr_a_${stamp}`), uid(`cr_b_${stamp}`)]) {
|
|
35
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
|
|
36
|
+
}
|
|
37
|
+
db.run(
|
|
38
|
+
`INSERT INTO node_create_requests
|
|
39
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, acked_at, error)
|
|
40
|
+
VALUES ('cr_a_failed', 'node_daemon_a', 'vansin-go', ?1, 'opencode-cli', NULL, '{}', '[]', 'failed', NULL, ?2, 'tok', ?2,
|
|
41
|
+
'opencode-cli package entrypoint verification currently requires Linux')`,
|
|
42
|
+
[aNet, Date.now()],
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO node_create_requests
|
|
46
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
47
|
+
VALUES ('cr_b_pending', 'node_daemon_b', 'other', ?1, 'codex-app-server', NULL, '{}', '[]', 'pending', NULL, ?2, 'tok')`,
|
|
48
|
+
[bNet, Date.now()],
|
|
49
|
+
);
|
|
50
|
+
const mod = await import("./server.js");
|
|
51
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
52
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { server?.stop(true); } catch {}
|
|
57
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("GET /api/node-create-requests?request_id=", () => {
|
|
61
|
+
test("A reads her own failed request with the daemon's error verbatim", async () => {
|
|
62
|
+
const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", aToken);
|
|
63
|
+
expect(status).toBe(200);
|
|
64
|
+
expect(body.ok).toBe(true);
|
|
65
|
+
expect(body.request).toMatchObject({
|
|
66
|
+
request_id: "cr_a_failed", child_name: "vansin-go", runtime: "opencode-cli", status: "failed",
|
|
67
|
+
error: "opencode-cli package entrypoint verification currently requires Linux",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
test("🔴 B cannot read A's request — 404, same shape as nonexistent", async () => {
|
|
71
|
+
const other = await get("/api/node-create-requests?request_id=cr_a_failed", bToken);
|
|
72
|
+
const missing = await get("/api/node-create-requests?request_id=cr_nope", bToken);
|
|
73
|
+
expect(other.status).toBe(404);
|
|
74
|
+
expect(missing.status).toBe(404);
|
|
75
|
+
expect(other.body).toEqual(missing.body);
|
|
76
|
+
});
|
|
77
|
+
test("pending request reads as pending with no error", async () => {
|
|
78
|
+
const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
|
|
79
|
+
expect(body.request.status).toBe("pending");
|
|
80
|
+
expect(body.request.error).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
test("missing request_id → 400", async () => {
|
|
83
|
+
const { status, body } = await get("/api/node-create-requests", aToken);
|
|
84
|
+
expect(status).toBe(400);
|
|
85
|
+
expect(body.error).toBe("request_id_required");
|
|
86
|
+
});
|
|
87
|
+
test("legacy master token (no user context) → 401", async () => {
|
|
88
|
+
const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", MASTER_TOKEN);
|
|
89
|
+
expect(status).toBe(401);
|
|
90
|
+
expect(body.error).toBe("auth_required");
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -2637,6 +2637,15 @@ return Bun.serve({
|
|
|
2637
2637
|
}
|
|
2638
2638
|
}
|
|
2639
2639
|
|
|
2640
|
+
// #1828 —— 用户名与某个节点 alias 撞名时,inbox 里 session_name=用户名 的行属于那个节点,
|
|
2641
|
+
// 不能当成用户的未读、也不能替它 ack。只看当前 REST scope 内的节点。
|
|
2642
|
+
const userInboxAliasCollides = (username: string, scope: RestNetworkScope): boolean => {
|
|
2643
|
+
const p: any[] = [username];
|
|
2644
|
+
let q = "SELECT 1 AS hit FROM nodes WHERE alias = ?1";
|
|
2645
|
+
q = addNetworkScope(q, p, scope);
|
|
2646
|
+
return !!db.get<{ hit: number }>(q + " LIMIT 1", ...p);
|
|
2647
|
+
};
|
|
2648
|
+
|
|
2640
2649
|
// ── REST: per-user desktop inbox (#1459 ① P3) ──
|
|
2641
2650
|
// 🔴 只在 ?scope=user 时走这条;下面按 alias 寻址的既有分支一行未动
|
|
2642
2651
|
// (dashboard 依赖那条 inbox 查询)。
|
|
@@ -2667,6 +2676,27 @@ return Bun.serve({
|
|
|
2667
2676
|
countSql = addNetworkScope(countSql, countParams, restScope);
|
|
2668
2677
|
const unread = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
2669
2678
|
|
|
2679
|
+
// #1828 —— 按 agent 分的未读:user_inbox(agent 主动发给用户)+ inbox 里发给**这个用户名**的
|
|
2680
|
+
// reply/task/message 行(agent 对用户任务的回复;acked 从来没人置 1,见 app#260)。
|
|
2681
|
+
// `unread`/`pending_count` 口径不动(只算 user_inbox,qa-hub-14 钉着);两表之和另给 `unread_total`。
|
|
2682
|
+
// 🔴 用户名恰好也是某个节点 alias 时,inbox 里那些行是节点的待办,不是用户的 —— 跳过 inbox 半边。
|
|
2683
|
+
const byAgent: Record<string, number> = {};
|
|
2684
|
+
const addByAgent = (agentRows: Array<{ agent: string | null; n: number }>) => {
|
|
2685
|
+
for (const r of agentRows) byAgent[r.agent || "hub"] = (byAgent[r.agent || "hub"] ?? 0) + Number(r.n ?? 0);
|
|
2686
|
+
};
|
|
2687
|
+
const uiAgentParams: any[] = [callerUserId];
|
|
2688
|
+
let uiAgentSql = "SELECT from_session AS agent, COUNT(*) AS n FROM user_inbox WHERE user_id = ?1 AND acked = 0";
|
|
2689
|
+
uiAgentSql = addNetworkScope(uiAgentSql, uiAgentParams, restScope);
|
|
2690
|
+
addByAgent(db.all<{ agent: string | null; n: number }>(uiAgentSql + " GROUP BY from_session", ...uiAgentParams));
|
|
2691
|
+
const callerUsername = restAuth?.username ?? "";
|
|
2692
|
+
if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
|
|
2693
|
+
const ibParams: any[] = [callerUsername];
|
|
2694
|
+
let ibSql = "SELECT from_session AS agent, COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0 AND type IN ('reply', 'task', 'message')";
|
|
2695
|
+
ibSql = addNetworkScope(ibSql, ibParams, restScope);
|
|
2696
|
+
addByAgent(db.all<{ agent: string | null; n: number }>(ibSql + " GROUP BY from_session", ...ibParams));
|
|
2697
|
+
}
|
|
2698
|
+
const unreadTotal = Object.values(byAgent).reduce((a, b) => a + b, 0);
|
|
2699
|
+
|
|
2670
2700
|
// 🔴 redact-at-read:写入方是 agent,不受信任。存储侧已做跨主机路径脱敏,
|
|
2671
2701
|
// 这一层单独防"有人把凭据塞进正文/标题/meta"。
|
|
2672
2702
|
const messages = rows.map(redactMessageRow);
|
|
@@ -2678,6 +2708,8 @@ return Bun.serve({
|
|
|
2678
2708
|
// 同一个数的两个名字:`unread` 给角标读,`pending_count` 与 alias 分支
|
|
2679
2709
|
// 的字段名保持一致。**一处计算**,不是两处实现。
|
|
2680
2710
|
pending_count: unread,
|
|
2711
|
+
unread_by_agent: byAgent,
|
|
2712
|
+
unread_total: unreadTotal,
|
|
2681
2713
|
}));
|
|
2682
2714
|
}
|
|
2683
2715
|
|
|
@@ -2733,7 +2765,19 @@ return Bun.serve({
|
|
|
2733
2765
|
WHERE user_id = ?1 AND message_id IN (${placeholders}) AND acked = 0`;
|
|
2734
2766
|
sql = addNetworkScope(sql, params, restScope);
|
|
2735
2767
|
const res = db.run(sql, params);
|
|
2736
|
-
|
|
2768
|
+
// #1828 —— 同一批 id 也可能是 inbox 里发给这个用户名的 reply/task/message 行(agent 回复);
|
|
2769
|
+
// 用户看过就 ack 掉。只动 session_name = 调用者用户名 的行,别人的 id 匹配不到;
|
|
2770
|
+
// 用户名与节点 alias 撞名时跳过(那是节点的待办)。
|
|
2771
|
+
let inboxAcked = 0;
|
|
2772
|
+
const callerUsername = restAuth?.username ?? "";
|
|
2773
|
+
if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
|
|
2774
|
+
const ibParams: any[] = [callerUsername, ...ids];
|
|
2775
|
+
let ibSql = `UPDATE inbox SET acked = 1
|
|
2776
|
+
WHERE session_name = ?1 AND id IN (${placeholders}) AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
2777
|
+
ibSql = addNetworkScope(ibSql, ibParams, restScope);
|
|
2778
|
+
inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
|
|
2779
|
+
}
|
|
2780
|
+
return withCors(req, Response.json({ ok: true, acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
|
|
2737
2781
|
}
|
|
2738
2782
|
|
|
2739
2783
|
// ── REST: stats summary ──
|
|
@@ -3076,6 +3120,31 @@ return Bun.serve({
|
|
|
3076
3120
|
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
3077
3121
|
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
3078
3122
|
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
3123
|
+
// ── REST: one node_create_request's outcome (app#… 桌面向导) ──
|
|
3124
|
+
// 向导 POST create_node 后只会轮询「子节点注册了没」,daemon 回的失败原因(ack_create_request 写进
|
|
3125
|
+
// node_create_requests.status/error)此前没有任何读接口 —— 用户只看到「24s 没注册」。
|
|
3126
|
+
// 这里按 request_id 读一行;网络作用域与其它 REST 相同(addNetworkScope),无用户上下文 401。
|
|
3127
|
+
if (url.pathname === "/api/node-create-requests" && req.method === "GET") {
|
|
3128
|
+
if (!restAuth) {
|
|
3129
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
3130
|
+
}
|
|
3131
|
+
const requestId = url.searchParams.get("request_id")?.trim() ?? "";
|
|
3132
|
+
if (!requestId) {
|
|
3133
|
+
return withCors(req, Response.json({ ok: false, error: "request_id_required" }, { status: 400 }));
|
|
3134
|
+
}
|
|
3135
|
+
const params: any[] = [requestId];
|
|
3136
|
+
let sql = `SELECT request_id, daemon_node_id, child_name, network_id, runtime, model, status, error,
|
|
3137
|
+
created_at, delivered_at, acked_at
|
|
3138
|
+
FROM node_create_requests WHERE request_id = ?1`;
|
|
3139
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3140
|
+
const row = db.get<Record<string, unknown>>(sql, ...params);
|
|
3141
|
+
if (!row) {
|
|
3142
|
+
// 不存在与不在你作用域内长一样:不泄漏别的网络里有没有这个 id。
|
|
3143
|
+
return withCors(req, Response.json({ ok: false, error: "request_not_found" }, { status: 404 }));
|
|
3144
|
+
}
|
|
3145
|
+
return withCors(req, Response.json({ ok: true, request: row }));
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3079
3148
|
if (url.pathname === "/api/host-supervisors") {
|
|
3080
3149
|
// #380 — utok callers with exactly one accessible network get a
|
|
3081
3150
|
// safe fallback so the create-node wizard doesn't have to bake
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// #1828 —— /api/messages?scope=user 按 agent 分的未读 + 用户 ack 覆盖 inbox 里的回复行。
|
|
2
|
+
//
|
|
3
|
+
// 生产 hub 24h:admin 收到的 70 条 agent 回复全在 `inbox`(session_name='admin', type='reply',
|
|
4
|
+
// acked 永远 0),`user_inbox` 只有 2 行;角标链只读 user_inbox,所以回复从不进未读(app#260)。
|
|
5
|
+
// 这里让 scope=user 同时给 `unread_by_agent`(两表合并)与 `unread_total`,`unread`/`pending_count`
|
|
6
|
+
// 口径不动(qa-hub-14 钉着);POST /api/messages/ack 也能 ack inbox 里发给调用者用户名的回复行。
|
|
7
|
+
//
|
|
8
|
+
// 承重的是两条边界:
|
|
9
|
+
// ① 只算/只 ack **调用者自己用户名** 的 inbox 行 —— B 拿 A 的 id 来 ack 匹配不到;
|
|
10
|
+
// ② 用户名与某节点 alias 撞名时,inbox 那半边整体跳过(那是节点的待办,不是用户的)。
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-by-agent-"));
|
|
17
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
18
|
+
|
|
19
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
20
|
+
let base = "";
|
|
21
|
+
let aToken = "", bToken = "", cToken = "";
|
|
22
|
+
let aUserId = "", bUserId = "", cUserId = "", aNet = "", cNet = "";
|
|
23
|
+
let aName = "", bName = "", cName = "";
|
|
24
|
+
|
|
25
|
+
const get = async (path: string, token: string) => {
|
|
26
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
27
|
+
return { status: r.status, body: await r.json() as any };
|
|
28
|
+
};
|
|
29
|
+
const post = async (path: string, token: string, body: unknown) => {
|
|
30
|
+
const r = await fetch(`${base}${path}`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
return { status: r.status, body: await r.json() as any };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function seedUserInbox(mid: string, userId: string, netId: string, from: string) {
|
|
39
|
+
const { db } = require("./db.js");
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked)
|
|
42
|
+
VALUES (?1, ?2, ?3, ?4, 'agent_message', NULL, 'dm', 'info', NULL, 0)`,
|
|
43
|
+
[mid, netId, userId, from],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function seedInbox(id: string, sessionName: string, netId: string, from: string, type = "reply", acked = 0) {
|
|
47
|
+
const { db } = require("./db.js");
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, 'normal', 'reply body', ?4, ?5, ?6, datetime('now'))`,
|
|
51
|
+
[id, sessionName, type, from, acked, netId],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "legacy-master-token-for-test";
|
|
57
|
+
const { db } = await import("./db.js");
|
|
58
|
+
const { register, addNetworkMember } = await import("./auth.js");
|
|
59
|
+
const stamp = Date.now();
|
|
60
|
+
|
|
61
|
+
// 首个注册用户 = 全局 admin;探针 A/B/C 都不是 admin(admin 走不过滤分支,隔离断言会白过)。
|
|
62
|
+
const admin = register(`uba_admin_${stamp}`, "UbaAdmin-Strong-1!");
|
|
63
|
+
expect(admin.ok).toBe(true);
|
|
64
|
+
aName = `uba_a_${stamp}`; bName = `uba_b_${stamp}`; cName = `uba_c_${stamp}`;
|
|
65
|
+
const a = register(aName, "UbaA-Strong-1!");
|
|
66
|
+
const b = register(bName, "UbaB-Strong-1!");
|
|
67
|
+
const c = register(cName, "UbaC-Strong-1!");
|
|
68
|
+
expect(a.ok && b.ok && c.ok).toBe(true);
|
|
69
|
+
aToken = a.token!; bToken = b.token!; cToken = c.token!;
|
|
70
|
+
aNet = a.network_id!; cNet = c.network_id!;
|
|
71
|
+
const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
|
|
72
|
+
aUserId = uid(aName); bUserId = uid(bName); cUserId = uid(cName);
|
|
73
|
+
for (const id of [aUserId, bUserId, cUserId]) {
|
|
74
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
|
|
75
|
+
}
|
|
76
|
+
// B 与 A 同网:user_id / session_name 过滤是唯一的隔离手段。
|
|
77
|
+
expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
|
|
78
|
+
|
|
79
|
+
// A:user_inbox 两条(node-x),inbox 回复两条(peer-1)+ 一条已 ack + 一条 status 类(不算)。
|
|
80
|
+
seedUserInbox("dm_a1", aUserId, aNet, "node-x");
|
|
81
|
+
seedUserInbox("dm_a2", aUserId, aNet, "node-x");
|
|
82
|
+
seedInbox("ib_a1", aName, aNet, "peer-1");
|
|
83
|
+
seedInbox("ib_a2", aName, aNet, "peer-1");
|
|
84
|
+
seedInbox("ib_a_acked", aName, aNet, "peer-1", "reply", 1);
|
|
85
|
+
seedInbox("ib_a_status", aName, aNet, "peer-1", "status");
|
|
86
|
+
// B 同网:自己的一条回复;不该出现在 A 的数里。
|
|
87
|
+
seedInbox("ib_b1", bName, aNet, "peer-2");
|
|
88
|
+
// C:用户名撞节点 alias —— inbox 里那行是节点的待办。
|
|
89
|
+
db.run(
|
|
90
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
91
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
92
|
+
[`node_${stamp}`, cName, cName, cNet],
|
|
93
|
+
);
|
|
94
|
+
seedInbox("ib_c_node_task", cName, cNet, "leader", "task");
|
|
95
|
+
seedUserInbox("dm_c1", cUserId, cNet, "node-y");
|
|
96
|
+
|
|
97
|
+
const mod = await import("./server.js");
|
|
98
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
99
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
try { server?.stop(true); } catch {}
|
|
104
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("#1828 GET /api/messages?scope=user unread_by_agent", () => {
|
|
108
|
+
test("A:user_inbox + inbox 回复按 agent 合并;unread/pending_count 口径不变", async () => {
|
|
109
|
+
const { status, body } = await get("/api/messages?scope=user", aToken);
|
|
110
|
+
expect(status).toBe(200);
|
|
111
|
+
expect(body.unread).toBe(2); // 只算 user_inbox(qa-hub-14 钉着)
|
|
112
|
+
expect(body.pending_count).toBe(2);
|
|
113
|
+
expect(body.unread_by_agent).toEqual({ "node-x": 2, "peer-1": 2 }); // 已 ack 与 status 类不算
|
|
114
|
+
expect(body.unread_total).toBe(4);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("B(同网):只看到自己的回复,看不到 A 的", async () => {
|
|
118
|
+
const { body } = await get("/api/messages?scope=user", bToken);
|
|
119
|
+
expect(body.unread_by_agent).toEqual({ "peer-2": 1 });
|
|
120
|
+
expect(body.unread_total).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("🔴 C 的用户名撞节点 alias:inbox 半边整体跳过,只剩 user_inbox", async () => {
|
|
124
|
+
const { body } = await get("/api/messages?scope=user", cToken);
|
|
125
|
+
expect(body.unread_by_agent).toEqual({ "node-y": 1 });
|
|
126
|
+
expect(body.unread_total).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("#1828 POST /api/messages/ack covers inbox reply rows", () => {
|
|
131
|
+
test("🔴 B 拿 A 的 inbox id 来 ack:匹配不到,A 的数不动", async () => {
|
|
132
|
+
const { body } = await post("/api/messages/ack", bToken, { message_ids: ["ib_a1"] });
|
|
133
|
+
expect(body).toMatchObject({ ok: true, acked: 0, acked_inbox: 0 });
|
|
134
|
+
const a = await get("/api/messages?scope=user", aToken);
|
|
135
|
+
expect(a.body.unread_by_agent["peer-1"]).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("A ack 一条 inbox 回复 + 一条 user_inbox:两边各减一,响应分开计数", async () => {
|
|
139
|
+
const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1", "dm_a1"] });
|
|
140
|
+
expect(body).toMatchObject({ ok: true, acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
|
|
141
|
+
const a = await get("/api/messages?scope=user", aToken);
|
|
142
|
+
expect(a.body.unread).toBe(1);
|
|
143
|
+
expect(a.body.unread_by_agent).toEqual({ "node-x": 1, "peer-1": 1 });
|
|
144
|
+
expect(a.body.unread_total).toBe(2);
|
|
145
|
+
// 再 ack 同一条:幂等,0 行。
|
|
146
|
+
const again = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1"] });
|
|
147
|
+
expect(again.body.acked).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("status 类 inbox 行不会被用户 ack 掉(不是发给用户的消息)", async () => {
|
|
151
|
+
const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a_status"] });
|
|
152
|
+
expect(body.acked_inbox).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("🔴 C 撞名:节点的待办不能被用户 ack 走", async () => {
|
|
156
|
+
const { body } = await post("/api/messages/ack", cToken, { message_ids: ["ib_c_node_task"] });
|
|
157
|
+
expect(body.acked_inbox).toBe(0);
|
|
158
|
+
const { db } = require("./db.js");
|
|
159
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'ib_c_node_task'")?.acked).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
});
|