Files
koc-loop/tests/project-service.test.mjs
ABAPPLO e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00

711 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import {
projectMainChatId,
deleteProject,
ensureProjectMainChat,
effectiveProjectMembers,
findOrCreateGroupOwnerPartner,
getProjectDetail,
listProjects,
parseMemberIds,
ProjectError,
saveGroupOwnerPartner,
saveProject,
} from "../lib/project-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
function fakeWecom(options = {}) {
const calls = [];
const chats = new Map(options.chats ?? []);
const failCreate = options.failCreate ?? false;
const failGet = options.failGet ?? false;
const createExisted = options.createExisted ?? false;
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/appchat/create")) {
if (createExisted) {
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
}
if (failCreate) {
return Response.json({ errcode: 60111, errmsg: "user not in scope" });
}
const body = JSON.parse(String(init.body ?? "{}"));
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
}
if (url.pathname.endsWith("/cgi-bin/appchat/update")) {
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
for (const userid of body.add_user_list ?? []) {
if (!chat.userlist.includes(userid)) chat.userlist.push(userid);
}
for (const userid of body.del_user_list ?? []) {
chat.userlist = chat.userlist.filter((id) => id !== userid);
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
if (failGet) {
return Response.json({ errcode: 86001, errmsg: "chatid invalid" });
}
const body = JSON.parse(String(init.body ?? "{}"));
const chat = chats.get(body.chatid);
if (!chat) {
return Response.json({ errcode: 60111, errmsg: "chat not found" });
}
return Response.json({
errcode: 0,
errmsg: "ok",
chat_info: {
chatid: body.chatid,
name: chat.name,
userlist: chat.userlist,
},
});
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl, chats };
}
function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
const executed = [];
const applyWrite = (sql, params) => {
const normalized = sql.trim();
if (/^INSERT INTO projects/.test(normalized)) {
projects.push({
id: params[0],
name: params[1],
brand: params[2],
main_group_member_ids: params[3],
owner_userid: params[4],
main_chat_id: "",
status: "active",
note: params[5],
created_at: params[6],
});
return 1;
}
if (/^UPDATE projects SET main_chat_id/.test(normalized)) {
const row = projects.find((item) => item.id === params[1]);
if (row) row.main_chat_id = params[0];
return row ? 1 : 0;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[5]);
if (!row) return 0;
Object.assign(row, {
name: params[0],
brand: params[1],
main_group_member_ids: params[2],
owner_userid: params[3],
note: params[4],
});
return 1;
}
if (/^DELETE FROM projects/.test(normalized)) {
const index = projects.findIndex((item) => item.id === params[0]);
if (index < 0) return 0;
projects.splice(index, 1);
return 1;
}
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
for (const task of tasks) {
if (task.project_id === params[0]) task.project_id = "";
}
return 1;
}
if (/^INSERT INTO partners/.test(normalized)) {
partners.push({
id: params[0],
name: params[1],
wecom_userid: params[2],
partner_type: "group_owner",
});
return 1;
}
if (/^UPDATE partners/.test(normalized)) {
const row = partners.find((item) => item.id === params[2]);
if (!row) return 0;
Object.assign(row, { name: params[0], wecom_userid: params[1] });
return 1;
}
return 1;
};
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: applyWrite(sql, params) } };
},
async first() {
executed.push({ sql, params });
if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null;
}
if (/FROM partners WHERE id = \?/.test(sql)) {
return partners.find((item) => item.id === params[0]) ?? null;
}
if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) {
return (
partners.find(
(item) =>
item.partner_type === "group_owner" &&
item.wecom_userid === params[0],
) ?? null
);
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM projects p/.test(sql)) {
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
let rows = projects;
if (ownerFiltered) {
rows = rows.filter((item) => item.owner_userid === params[0]);
}
return {
results: rows.map((item) => ({
...item,
task_count: tasks.filter((task) => task.project_id === item.id).length,
})),
};
}
if (/FROM tasks( t)? WHERE (t\.)?project_id/.test(sql)) {
return {
results: tasks
.filter((task) => task.project_id === params[0])
.map((task) => ({
recovery_enabled: 1,
recovery_due_at: "",
recovery_submitted: 0,
...task,
})),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
return { results: [] };
},
});
return {
projects,
tasks,
partners,
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
const output = [];
for (const statement of statements) output.push(await statement.run());
return output;
},
};
}
const projectRow = (overrides = {}) => ({
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
owner_userid: "",
main_chat_id: "",
status: "active",
note: "",
created_at: "2026-08-01",
...overrides,
});
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]);
assert.deepEqual(parseMemberIds(" \n "), []);
});
test("effectiveProjectMembers parses stored JSON and merges owner", () => {
assert.deepEqual(
effectiveProjectMembers('["u1","u2"]', "owner-a"),
["u1", "u2", "owner-a"],
);
assert.deepEqual(effectiveProjectMembers("u1\nu1,u2", ""), ["u1", "u2"]);
assert.deepEqual(effectiveProjectMembers('["u1"]', "u1"), ["u1"]);
});
test("saveProject inserts with proj- prefixed id and JSON members incl owner", async () => {
const db = fakeProjectDb();
const { project, mainChat } = await saveProject(db, {
name: "暑期焕新",
brand: "青柠实验室",
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
ownerUserid: "owner-zhang",
note: "备注",
});
assert.match(project.id, /^proj-/);
assert.equal(project.name, "暑期焕新");
assert.equal(project.ownerUserid, "owner-zhang");
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi", "owner-zhang"]);
assert.equal(db.projects.length, 1);
assert.equal(db.projects[0].status, "active");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
"zhangsan",
"lisi",
"owner-zhang",
]);
assert.equal(mainChat.reason, "no_credentials", "no config → lazy chat");
});
test("saveProject updates existing row and rejects blank name", async () => {
const db = fakeProjectDb({
projects: [projectRow({ id: "proj-1", name: "旧名" })],
});
await saveProject(db, {
id: "proj-1",
name: "新名",
brand: "品牌",
mainGroupMemberIds: ["wangwu"],
ownerUserid: "owner-li",
});
assert.equal(db.projects[0].name, "新名");
assert.equal(db.projects[0].brand, "品牌");
assert.equal(db.projects[0].owner_userid, "owner-li");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
"wangwu",
"owner-li",
]);
assert.equal(db.projects.length, 1, "update must not insert");
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: " " }),
(error) => error instanceof ProjectError && error.status === 400,
);
});
test("saveProject rejects unknown id on update", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => saveProject(db, { id: "proj-404", name: "x" }),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("saveProject with config creates main chat once members reach 3", async () => {
const db = fakeProjectDb();
const wecom = fakeWecom();
const { project, mainChat } = await saveProject(
db,
{
name: "满三人群",
mainGroupMemberIds: "u1\nu2",
ownerUserid: "owner-a",
},
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(mainChat.chatId, projectMainChatId(project.id));
assert.equal(mainChat.reason, undefined);
assert.equal(db.projects[0].main_chat_id, projectMainChatId(project.id));
const createBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.init.body,
),
);
assert.equal(createBody.chatid, projectMainChatId(project.id));
assert.deepEqual(createBody.userlist.sort(), ["owner-a", "u1", "u2"]);
});
test("listProjects aggregates task counts and exposes mainChatId", async () => {
const db = fakeProjectDb({
projects: [
projectRow({
id: "proj-1",
main_group_member_ids: '["u1","u2"]',
main_chat_id: "pgcproj1",
}),
],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-1" },
{ id: "t3", project_id: "" },
],
});
const projects = await listProjects(db);
assert.equal(projects.length, 1);
assert.equal(projects[0].taskCount, 2);
assert.equal(projects[0].mainChatId, "pgcproj1");
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
});
test("listProjects filters by project owner when ownerUserid given", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", owner_userid: "owner-a" }),
projectRow({ id: "proj-2", owner_userid: "owner-b" }),
projectRow({ id: "proj-3", owner_userid: "" }),
],
});
const mine = await listProjects(db, "owner-a");
assert.deepEqual(
mine.map((item) => item.id),
["proj-1"],
);
const unbound = await listProjects(db, "");
assert.deepEqual(
unbound,
[],
"已登录未绑定企微的空范围不应漏出无主项目",
);
const all = await listProjects(db);
assert.equal(all.length, 3);
});
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
const db = fakeProjectDb({
projects: [
projectRow({
id: "proj-1",
main_group_member_ids: '["u1"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
tasks: [
{
id: "t1",
name: "任务一",
project_id: "proj-1",
owner_userid: "owner-a",
task_chat_id: "tgc_t1",
recovery_enabled: 0,
recovery_due_at: "2026-08-10",
recovery_submitted: 2,
},
],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.project.taskCount, 1);
assert.equal(detail.project.mainChatId, "pgcproj1");
assert.equal(detail.tasks[0].owner_userid, "owner-a");
assert.equal(detail.tasks[0].task_chat_id, "tgc_t1");
assert.equal(detail.tasks[0].recovery_enabled, 0);
assert.equal(detail.tasks[0].recovery_due_at, "2026-08-10");
assert.equal(detail.tasks[0].recovery_submitted, 2);
assert.equal(detail.mainChat.chatId, "pgcproj1");
assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]);
});
test("getProjectDetail throws 404 for unknown project", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => getProjectDetail(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("deleteProject unlinks tasks", async () => {
const db = fakeProjectDb({
projects: [projectRow({ id: "proj-1" })],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-2" },
],
});
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
assert.equal(db.tasks[0].project_id, "");
assert.equal(db.tasks[1].project_id, "proj-2");
await assert.rejects(
() => deleteProject(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("ensureProjectMainChat returns no_project for unknown ids", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb();
const result = await ensureProjectMainChat(
db,
"proj-404",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_project");
assert.equal(result.chatId, null);
});
test("ensureProjectMainChat skips without app credentials", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_credentials");
assert.equal(result.chatId, null);
assert.equal(wecom.calls.length, 0);
});
test("ensureProjectMainChat skips creation when fewer than 3 members", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.reason, "not_enough_members");
assert.equal(result.chatId, null);
assert.equal(db.projects[0].main_chat_id, "");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
);
});
test("ensureProjectMainChat probes before creating and persists chat id", async () => {
const wecom = fakeWecom();
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.deepEqual(result.members.sort(), ["owner-a", "u1", "u2"]);
assert.equal(db.projects[0].main_chat_id, "pgcproj1");
const paths = wecom.calls.map((c) => c.url.pathname);
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/get")), "probe first");
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/create")));
assert.ok(
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/get"))) <
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/create"))),
"probe precedes create",
);
});
test("ensureProjectMainChat is idempotent when chat already exists remotely", async () => {
const wecom = fakeWecom({
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "u2", "owner-a"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "id persisted on recovery");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
"no duplicate create",
);
});
test("ensureProjectMainChat diffs members add and del on existing chat", async () => {
const wecom = fakeWecom({
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u2"]);
assert.deepEqual(updateBody.del_user_list, ["gone-user"]);
});
test("ensureProjectMainChat reports warning and keeps main_chat_id empty on create failure", async () => {
const wecom = fakeWecom({ failCreate: true });
const db = fakeProjectDb({
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.match(result.warning, /创建失败/);
assert.equal(db.projects[0].main_chat_id, "", "chat id not persisted on failure");
});
test("ensureProjectMainChat adopts chat when create reports chatid existed", async () => {
// 可见范围非根部门时 appchat/get 永远 86001,但群实际已建过(create 返回 86215)
const wecom = fakeWecom({ failGet: true, createExisted: true });
const db = fakeProjectDb({
projects: [
projectRow({
name: "项目一",
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(result.warning, undefined);
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "existing chat id adopted");
});
test("ensureProjectMainChat falls back to add-only sync when get is unavailable", async () => {
const wecom = fakeWecom({
failGet: true,
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
});
const db = fakeProjectDb({
projects: [
projectRow({
main_group_member_ids: '["u1","u2"]',
owner_userid: "owner-a",
main_chat_id: "pgcproj1",
}),
],
});
const result = await ensureProjectMainChat(
db,
"proj-1",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "pgcproj1");
assert.equal(result.warning, undefined);
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u1", "u2"]);
assert.equal(updateBody.del_user_list, undefined, "no removals without member list");
});
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
const db = fakeProjectDb();
const created = await saveGroupOwnerPartner(db, {
name: "张三",
wecomUserid: "zhangsan",
});
assert.match(created.id, /^partner-go-/);
assert.equal(db.partners[0].partner_type, "group_owner");
const updated = await saveGroupOwnerPartner(db, {
id: created.id,
name: "张三丰",
wecomUserid: "zhangsanfeng",
});
assert.equal(updated.name, "张三丰");
assert.equal(db.partners.length, 1);
await assert.rejects(
() => saveGroupOwnerPartner(db, { name: "李四", wecomUserid: "" }),
(error) => error instanceof ProjectError,
);
});
test("findOrCreateGroupOwnerPartner returns existing row without duplicating", async () => {
const db = fakeProjectDb({
partners: [
{ id: "partner-go-old", name: "负责人", wecom_userid: "owner-a", partner_type: "group_owner" },
],
});
const found = await findOrCreateGroupOwnerPartner(db, "owner-a");
assert.equal(found.id, "partner-go-old");
assert.equal(db.partners.length, 1);
});
test("findOrCreateGroupOwnerPartner creates partner with userid fallback name", async () => {
const db = fakeProjectDb();
const created = await findOrCreateGroupOwnerPartner(db, "owner-b");
assert.match(created.id, /^partner-go-/);
assert.equal(created.name, "owner-b");
assert.equal(created.wecom_userid, "owner-b");
assert.equal(db.partners.length, 1);
});