Files
koc-loop/tests/task-customer-chat-service.test.mjs
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

220 lines
8.4 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
bindTaskCustomerChat,
listOwnerCustomerChats,
unbindTaskCustomerChat,
} from "../lib/task-customer-chat-service.ts";
import { resolveWecomConfig } from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
function fakeWecom(options = {}) {
const calls = [];
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
return Response.json({
errcode: 0,
errmsg: "ok",
access_token: "token-test",
expires_in: 7200,
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) {
if (options.addErrcode) {
return Response.json({ errcode: options.addErrcode, errmsg: "add failed" });
}
return Response.json({ errcode: 0, errmsg: "ok", config_id: options.configId ?? "cfg-new" });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) {
if (options.updateErrcode) {
return Response.json({ errcode: options.updateErrcode, errmsg: "update failed" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) {
if (options.getErrcode) {
return Response.json({ errcode: options.getErrcode, errmsg: "get failed" });
}
return Response.json({
errcode: 0,
errmsg: "ok",
join_way: { qr_code: options.qrCode ?? "https://p.qpic.cn/qr-live.png" },
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) {
if (options.delErrcode) {
return Response.json({ errcode: options.delErrcode, errmsg: "del failed" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
function captureDb(taskRow) {
const captured = [];
const makeStatement = (sql, values) => ({
async first() {
captured.push({ sql, values });
if (/FROM tasks WHERE id = \?/.test(sql)) return taskRow ?? null;
return null;
},
async run() {
captured.push({ sql, values });
return { changes: 1 };
},
});
const db = {
prepare(sql) {
const unbound = makeStatement(sql, []);
return {
bind(...values) {
return makeStatement(sql, values);
},
first: unbound.first,
run: unbound.run,
};
},
};
return { db, captured };
}
function callsTo(calls, suffix) {
return calls.filter((c) => c.url.pathname.endsWith(suffix));
}
test("listOwnerCustomerChats rejects without app credentials and ignores empty owner", async () => {
const config = resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" });
await assert.rejects(() => listOwnerCustomerChats(config, "zhangsan"), /未配置/);
const { calls, fetchImpl } = fakeWecom();
assert.deepEqual(await listOwnerCustomerChats(resolveWecomConfig(baseBindings), " ", fetchImpl), []);
assert.equal(calls.length, 0, "empty owner short-circuits without API calls");
});
test("bindTaskCustomerChat creates join way when task has no config", async () => {
const { db, captured } = captureDb({
id: "task-1",
name: "美团探店任务",
join_config_id: null,
});
const { calls, fetchImpl } = fakeWecom();
const result = await bindTaskCustomerChat(
db,
{ taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" },
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1);
assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 0);
const addBody = JSON.parse(String(callsTo(calls, "/add_join_way")[0].init.body));
assert.deepEqual(addBody.chat_id_list, ["wr-normal-1"]);
assert.equal(addBody.room_base_name, "美团探店任务");
assert.equal(addBody.state, "task-task-1");
assert.equal(result.joinConfigId, "cfg-new");
assert.equal(result.joinQrCode, "https://p.qpic.cn/qr-live.png");
assert.equal(result.qrError, undefined);
const update = captured.find((c) => /UPDATE tasks/.test(c.sql));
assert.ok(update, "tasks row updated");
assert.match(update.sql, /customer_chat_id = \?/);
assert.deepEqual(update.values, ["wr-normal-1", "KOC 一群", "cfg-new", "https://p.qpic.cn/qr-live.png", "task-1"]);
});
test("bindTaskCustomerChat reuses update_join_way on rebind and falls back to add", async () => {
const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" });
const { calls, fetchImpl } = fakeWecom();
const result = await bindTaskCustomerChat(
db,
{ taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" },
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1);
assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 0);
assert.equal(result.joinConfigId, "cfg-old", "config id preserved so qr stays valid");
const { db: db2 } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" });
const { calls: calls2, fetchImpl: fetch2 } = fakeWecom({
updateErrcode: 40068,
configId: "cfg-rebuilt",
});
const result2 = await bindTaskCustomerChat(
db2,
{ taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" },
resolveWecomConfig(baseBindings),
fetch2,
);
assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1);
assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1);
assert.equal(result2.joinConfigId, "cfg-rebuilt");
});
test("bindTaskCustomerChat tolerates qr fetch failure and records qrError", async () => {
const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null });
const { fetchImpl } = fakeWecom({ getErrcode: 40068 });
const result = await bindTaskCustomerChat(
db,
{ taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" },
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.joinQrCode, "");
assert.match(result.qrError, /活码二维码获取失败/);
});
test("bindTaskCustomerChat validates missing task / chat", async () => {
const { db: missingTask } = captureDb(null);
await assert.rejects(
() =>
bindTaskCustomerChat(
missingTask,
{ taskId: "task-x", chatId: "wr-1", chatName: "群" },
resolveWecomConfig(baseBindings),
fakeWecom().fetchImpl,
),
/任务不存在/,
);
const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null });
await assert.rejects(
() =>
bindTaskCustomerChat(
db,
{ taskId: "task-1", chatId: "", chatName: "" },
resolveWecomConfig(baseBindings),
fakeWecom().fetchImpl,
),
/请选择要绑定的客户群/,
);
});
test("unbindTaskCustomerChat deletes join way (tolerating failure) and clears columns", async () => {
const { db, captured } = captureDb({ id: "task-1", join_config_id: "cfg-old" });
const { calls, fetchImpl } = fakeWecom();
await unbindTaskCustomerChat(db, "task-1", resolveWecomConfig(baseBindings), fetchImpl);
assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/del_join_way").length, 1);
const update = captured.find((c) => /UPDATE tasks/.test(c.sql));
assert.ok(update);
assert.match(update.sql, /customer_chat_id = ''/);
const { db: db2, captured: captured2 } = captureDb({ id: "task-1", join_config_id: "cfg-old" });
const { fetchImpl: fetch2 } = fakeWecom({ delErrcode: 40068 });
await unbindTaskCustomerChat(db2, "task-1", resolveWecomConfig(baseBindings), fetch2);
assert.ok(
captured2.some((c) => /UPDATE tasks/.test(c.sql)),
"local columns still cleared when del_join_way fails",
);
const { db: db3, captured: captured3 } = captureDb({ id: "task-1", join_config_id: "" });
const { calls: calls3, fetchImpl: fetch3 } = fakeWecom();
await unbindTaskCustomerChat(db3, "task-1", resolveWecomConfig(baseBindings), fetch3);
assert.equal(calls3.length, 0, "no wecom calls when nothing bound");
assert.ok(captured3.some((c) => /UPDATE tasks/.test(c.sql)));
});