- 项目总群:≥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 渲染/解绑清空)
205 lines
6.5 KiB
JavaScript
205 lines
6.5 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { readFileSync } from "node:fs";
|
||
|
||
const actionRoute = readFileSync(
|
||
new URL("../app/api/action/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const bootstrapRoute = readFileSync(
|
||
new URL("../app/api/bootstrap/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const taskReportExportRoute = readFileSync(
|
||
new URL("../app/api/task-report-export/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const recoveryExportRoute = readFileSync(
|
||
new URL("../app/api/recovery-export/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const screenshotTaskExportRoute = readFileSync(
|
||
new URL("../app/api/screenshot-task-export/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const taskResultImageRoute = readFileSync(
|
||
new URL("../app/api/task-result-image/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const usersRoute = readFileSync(
|
||
new URL("../app/api/users/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const mvpDb = readFileSync(
|
||
new URL("../lib/mvp-db.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
|
||
function section(source, action) {
|
||
const start = source.indexOf(`"${action}"`);
|
||
assert.ok(start >= 0, `action ${action} 不存在于路由源码`);
|
||
const next = source.indexOf("} else if (body.action ===", start);
|
||
return source.slice(start, next === -1 ? undefined : next);
|
||
}
|
||
|
||
test("manager-only actions gate on owner scope", () => {
|
||
const managerOnly = [
|
||
"inspect_feishu",
|
||
"create_task",
|
||
"create_screenshot_task",
|
||
"claim",
|
||
"save_collection_schedule",
|
||
"run_due_collections",
|
||
"retry_failed_collections",
|
||
"backfill_account_profiles",
|
||
"set_public_account_ids",
|
||
"bind_wecom_external_id",
|
||
"send_test_wecom",
|
||
"trigger_wecom_overdue_escalation",
|
||
"wecom_list_external_contacts",
|
||
"wecom_list_internal_contacts",
|
||
"wecom_sync_group_chats",
|
||
"project_list",
|
||
"project_detail",
|
||
"project_save",
|
||
"project_delete",
|
||
"project_ensure_main_chat",
|
||
"task_set_owner",
|
||
"task_update",
|
||
"partner_save",
|
||
"partner_group_owners",
|
||
"project_push_task",
|
||
"refresh_account_fields",
|
||
"account_field_refresh_status",
|
||
];
|
||
for (const action of managerOnly) {
|
||
const body = section(actionRoute, action);
|
||
assert.match(
|
||
body,
|
||
/managerGate\(\)/,
|
||
`${action} 必须 managerGate() 拒绝任务负责人`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("collect actions stay manager-only", () => {
|
||
const body = section(actionRoute, "collect_now");
|
||
assert.match(body, /managerGate\(\)/);
|
||
});
|
||
|
||
test("task-scoped actions verify ownership for task owners", () => {
|
||
for (const action of [
|
||
"wecom_push_task_to_groups",
|
||
"task_group_links",
|
||
"task_group_push",
|
||
"task_customer_chats",
|
||
"task_bind_customer_chat",
|
||
]) {
|
||
const body = section(actionRoute, action);
|
||
assert.match(
|
||
body,
|
||
/taskOwnerGate\(/,
|
||
`${action} 必须校验任务归属`,
|
||
);
|
||
}
|
||
const dueSoon = section(actionRoute, "trigger_wecom_due_soon");
|
||
assert.match(dueSoon, /taskOwnerGate\(/);
|
||
assert.match(dueSoon, /runDueSoonWecomNotifications\(\s*db,\s*wecomConfig,\s*undefined,\s*undefined,\s*taskId \|\| undefined,/);
|
||
|
||
const pushes = section(actionRoute, "wecom_task_group_pushes");
|
||
assert.match(pushes, /taskOwnerGate\(/);
|
||
|
||
const manual = section(actionRoute, "manual_metrics");
|
||
assert.match(manual, /distributionOwnerGate\(/);
|
||
});
|
||
|
||
test("task_report_list scopes reports by owner and drops the broken gate", () => {
|
||
const body = section(actionRoute, "task_report_list");
|
||
assert.match(body, /getTaskReportData\(db, ownerScopeUserid\(scope\)\)/);
|
||
assert.ok(
|
||
!/isManagerRequest/.test(body),
|
||
"task_report_list 不再是管理员专属(负责人按 scope 过滤)",
|
||
);
|
||
});
|
||
|
||
test("no unawaited isManagerRequest gates remain", () => {
|
||
assert.ok(
|
||
!actionRoute.includes("if (!isManagerRequest(request))"),
|
||
"isManagerRequest 返回 Promise,缺 await 的门形同虚设",
|
||
);
|
||
});
|
||
|
||
test("action fallthrough dashboard is owner-scoped", () => {
|
||
assert.match(
|
||
actionRoute,
|
||
/getDashboardData\(ownerScopeUserid\(scope\)\)/,
|
||
);
|
||
});
|
||
|
||
test("customer chat actions require a task owner before binding", () => {
|
||
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
|
||
const body = section(actionRoute, action);
|
||
assert.match(
|
||
body,
|
||
/请先设置任务负责人/,
|
||
`${action} 必须在无负责人时明确报错`,
|
||
);
|
||
assert.match(body, /Response\.json\(/);
|
||
assert.ok(
|
||
!/getDashboardData/.test(body),
|
||
`${action} 显式返回,不能落兜底 dashboard`,
|
||
);
|
||
}
|
||
const bind = section(actionRoute, "task_bind_customer_chat");
|
||
assert.match(bind, /bindTaskCustomerChat\(/);
|
||
assert.match(bind, /unbindTaskCustomerChat\(/);
|
||
});
|
||
|
||
test("partner claim detail exposes join qr only for claimed tasks", () => {
|
||
const partnerRoute = readFileSync(
|
||
new URL("../app/api/partner/route.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(partnerRoute, /join_qr_code FROM tasks/);
|
||
assert.match(partnerRoute, /joinQrCode,/);
|
||
const portalPage = readFileSync(
|
||
new URL("../koc-portal/app/page.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
assert.match(portalPage, /joinQrCode\?: string/);
|
||
assert.match(portalPage, /join-qr-card/);
|
||
assert.match(portalPage, /isClaimOwner && payload\.claim\?\.joinQrCode/);
|
||
});
|
||
|
||
test("bootstrap scopes dashboard data by owner scope", () => {
|
||
assert.match(bootstrapRoute, /getOwnerScope\(request\)/);
|
||
assert.match(bootstrapRoute, /getDashboardData\(ownerScopeUserid\(scope\)\)/);
|
||
});
|
||
|
||
test("getDashboardData filters tasks and distributions by owner", () => {
|
||
const dashboard = mvpDb.slice(
|
||
mvpDb.indexOf("export async function getDashboardData"),
|
||
mvpDb.indexOf("export type TaskReportRow"),
|
||
);
|
||
const occurrences = dashboard.match(/WHERE t\.owner_userid = \?/g) ?? [];
|
||
assert.equal(
|
||
occurrences.length,
|
||
2,
|
||
"tasks 与 distributions 两条查询都要按 owner 过滤",
|
||
);
|
||
});
|
||
|
||
test("export routes enforce owner scope", () => {
|
||
assert.match(taskReportExportRoute, /getOwnerScope\(request\)/);
|
||
assert.match(taskReportExportRoute, /ownerScopeUserid\(await getOwnerScope\(request\)\)/);
|
||
assert.match(recoveryExportRoute, /isTaskOwner\(/);
|
||
assert.match(screenshotTaskExportRoute, /isTaskOwner\(/);
|
||
assert.match(taskResultImageRoute, /isDistributionOwner\(/);
|
||
});
|
||
|
||
test("users route supports wecom binding lifecycle", () => {
|
||
assert.match(usersRoute, /action === "set_wecom"/);
|
||
assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/);
|
||
assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/);
|
||
});
|