- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫 - 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式 - 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底 - 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id - 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
211 lines
7.9 KiB
JavaScript
211 lines
7.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
bindTaskCustomerChat,
|
|
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("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)));
|
|
});
|