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
This commit is contained in:
@@ -78,7 +78,7 @@ test("project-owner management actions gate on project ownership", () => {
|
||||
"project_delete",
|
||||
"project_ensure_main_chat",
|
||||
"create_task",
|
||||
"create_screenshot_task",
|
||||
"task_enable_recovery",
|
||||
]) {
|
||||
const body = section(actionRoute, action);
|
||||
assert.match(
|
||||
@@ -319,7 +319,7 @@ test("project detail exposes inline main-chat member management", () => {
|
||||
assert.match(globalsCss, /\.project-member-remove/);
|
||||
});
|
||||
|
||||
test("every project_save payload carries parentProjectId(改总群成员不得清空层级)", () => {
|
||||
test("no project_save payload carries parentProjectId(项目层级已移除)", () => {
|
||||
const adminApp = readFileSync(
|
||||
new URL("../app/admin-app.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -330,10 +330,9 @@ test("every project_save payload carries parentProjectId(改总群成员不得
|
||||
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
|
||||
for (const start of starts) {
|
||||
const payload = adminApp.slice(start, start + 600);
|
||||
assert.match(
|
||||
payload,
|
||||
/parentProjectId/,
|
||||
"project_save 载荷缺 parentProjectId,会把 parent_id 重置为空",
|
||||
assert.ok(
|
||||
!/parentProjectId/.test(payload),
|
||||
"project_save 载荷不应再带 parentProjectId(项目层级已移除)",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,5 +109,5 @@ test("recover builds dynamic partner candidates including wecom identity", () =>
|
||||
routeSource,
|
||||
/wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/,
|
||||
);
|
||||
assert.match(routeSource, /\.bind\(task\.task_type, task\.task_type, task\.id, \.\.\.partnerCandidates\)/);
|
||||
assert.match(routeSource, /\.bind\(task\.id, \.\.\.partnerCandidates\)/);
|
||||
});
|
||||
|
||||
@@ -105,11 +105,10 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
brand: params[2],
|
||||
main_group_member_ids: params[3],
|
||||
owner_userid: params[4],
|
||||
parent_id: params[5],
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: params[6],
|
||||
created_at: params[7],
|
||||
note: params[5],
|
||||
created_at: params[6],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
@@ -119,7 +118,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
return row ? 1 : 0;
|
||||
}
|
||||
if (/^UPDATE projects/.test(normalized)) {
|
||||
const row = projects.find((item) => item.id === params[6]);
|
||||
const row = projects.find((item) => item.id === params[5]);
|
||||
if (!row) return 0;
|
||||
Object.assign(row, {
|
||||
name: params[0],
|
||||
@@ -127,7 +126,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
main_group_member_ids: params[2],
|
||||
owner_userid: params[3],
|
||||
note: params[4],
|
||||
parent_id: params[5],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
@@ -172,11 +170,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
},
|
||||
async first() {
|
||||
executed.push({ sql, params });
|
||||
if (/COUNT\(\*\) AS count FROM projects WHERE parent_id = \?/.test(sql)) {
|
||||
return {
|
||||
count: projects.filter((item) => item.parent_id === params[0]).length,
|
||||
};
|
||||
}
|
||||
if (/FROM projects WHERE id = \?/.test(sql)) {
|
||||
return projects.find((item) => item.id === params[0]) ?? null;
|
||||
}
|
||||
@@ -196,22 +189,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
},
|
||||
async all() {
|
||||
executed.push({ sql, params });
|
||||
if (/^SELECT id, parent_id FROM projects/.test(sql.trim())) {
|
||||
return {
|
||||
results: projects.map((item) => ({
|
||||
id: item.id,
|
||||
parent_id: item.parent_id ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (/FROM projects p/.test(sql)) {
|
||||
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
|
||||
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
|
||||
let rows = projects;
|
||||
if (ownerFiltered) {
|
||||
rows = rows.filter((item) => item.owner_userid === params[0]);
|
||||
} else if (parentFiltered) {
|
||||
rows = rows.filter((item) => item.parent_id === params[0]);
|
||||
}
|
||||
return {
|
||||
results: rows.map((item) => ({
|
||||
@@ -220,22 +202,15 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (/FROM tasks WHERE project_id/.test(sql)) {
|
||||
if (/FROM tasks( t)? WHERE (t\.)?project_id/.test(sql)) {
|
||||
return {
|
||||
results: tasks
|
||||
.filter((task) => task.project_id === params[0])
|
||||
.map((task) => ({ ...task })),
|
||||
};
|
||||
}
|
||||
if (/FROM tasks t\s+LEFT JOIN projects pj/.test(sql)) {
|
||||
const wanted = new Set(params);
|
||||
return {
|
||||
results: tasks
|
||||
.filter((task) => wanted.has(task.project_id))
|
||||
.map((task) => ({
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "",
|
||||
recovery_submitted: 0,
|
||||
...task,
|
||||
project_name:
|
||||
projects.find((item) => item.id === task.project_id)?.name ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -267,7 +242,6 @@ const projectRow = (overrides = {}) => ({
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
owner_userid: "",
|
||||
parent_id: "",
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: "",
|
||||
@@ -436,6 +410,9 @@ test("getProjectDetail returns tasks with owner and mainChat members", async ()
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -444,6 +421,9 @@ test("getProjectDetail returns tasks with owner and mainChat members", async ()
|
||||
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"]);
|
||||
});
|
||||
@@ -728,100 +708,3 @@ test("findOrCreateGroupOwnerPartner creates partner with userid fallback name",
|
||||
assert.equal(db.partners.length, 1);
|
||||
});
|
||||
|
||||
test("saveProject persists parentProjectId on create and update", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [projectRow({ id: "proj-root", name: "总项目" })],
|
||||
});
|
||||
await saveProject(db, {
|
||||
name: "子项目",
|
||||
brand: "b",
|
||||
mainGroupMemberIds: [],
|
||||
ownerUserid: "",
|
||||
parentProjectId: "proj-root",
|
||||
});
|
||||
assert.equal(db.projects.length, 2);
|
||||
const child = db.projects.find((item) => item.name === "子项目");
|
||||
assert.equal(child.parent_id, "proj-root");
|
||||
|
||||
await saveProject(db, {
|
||||
id: "proj-root",
|
||||
name: "总项目",
|
||||
mainGroupMemberIds: [],
|
||||
parentProjectId: "",
|
||||
});
|
||||
assert.equal(db.projects.find((item) => item.id === "proj-root").parent_id, "");
|
||||
});
|
||||
|
||||
test("saveProject rejects invalid parentProjectId (missing / self / descendant)", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({ id: "proj-1", name: "一级" }),
|
||||
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
|
||||
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
|
||||
],
|
||||
});
|
||||
await assert.rejects(
|
||||
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-404" }),
|
||||
(error) => error instanceof ProjectError && error.status === 404,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-1" }),
|
||||
(error) => error instanceof ProjectError && /自身/.test(error.message),
|
||||
);
|
||||
await assert.rejects(
|
||||
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-3" }),
|
||||
(error) =>
|
||||
error instanceof ProjectError && /上级项目不能是当前项目的子项目/.test(error.message),
|
||||
);
|
||||
// 兄弟项目/父项目合法
|
||||
await saveProject(db, { id: "proj-3", name: "三级", parentProjectId: "proj-1" });
|
||||
assert.equal(db.projects.find((item) => item.id === "proj-3").parent_id, "proj-1");
|
||||
});
|
||||
|
||||
test("deleteProject refuses while child projects exist", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({ id: "proj-1", name: "一级" }),
|
||||
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
|
||||
],
|
||||
});
|
||||
await assert.rejects(
|
||||
() => deleteProject(db, "proj-1"),
|
||||
(error) =>
|
||||
error instanceof ProjectError && /还有子项目/.test(error.message),
|
||||
);
|
||||
assert.equal(db.projects.length, 2);
|
||||
await deleteProject(db, "proj-2");
|
||||
await deleteProject(db, "proj-1");
|
||||
assert.equal(db.projects.length, 0);
|
||||
});
|
||||
|
||||
test("getProjectDetail returns subprojects and aggregated push tasks", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({ id: "proj-1", name: "一级" }),
|
||||
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1", main_chat_id: "pgc2" }),
|
||||
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
|
||||
projectRow({ id: "proj-other", name: "无关项目" }),
|
||||
],
|
||||
tasks: [
|
||||
{ id: "t1", name: "直属任务", project_id: "proj-1" },
|
||||
{ id: "t2", name: "子项目任务", project_id: "proj-2" },
|
||||
{ id: "t3", name: "孙项目任务", project_id: "proj-3" },
|
||||
{ id: "t4", name: "无关任务", project_id: "proj-other" },
|
||||
],
|
||||
});
|
||||
const detail = await getProjectDetail(db, "proj-1");
|
||||
assert.equal(detail.project.parentProjectId, "");
|
||||
assert.deepEqual(detail.tasks.map((task) => task.id), ["t1"]);
|
||||
assert.deepEqual(
|
||||
detail.subprojects.map((sub) => sub.id),
|
||||
["proj-2"],
|
||||
);
|
||||
assert.equal(detail.subprojects[0].mainChatId, "pgc2");
|
||||
assert.equal(detail.subprojects[0].taskCount, 1);
|
||||
assert.deepEqual(
|
||||
detail.pushTasks.map((task) => `${task.project_name}:${task.name}`).sort(),
|
||||
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ test("merges distribution detail into the task center", async () => {
|
||||
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /查看明细/);
|
||||
assert.match(adminApp, /function TaskDistributionDetail/);
|
||||
assert.match(adminApp, /选择一个数据回收任务/);
|
||||
assert.doesNotMatch(adminApp, /"distributions"/);
|
||||
@@ -212,7 +212,7 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
||||
assert.match(adminApp, /platform-logo/);
|
||||
assert.match(adminApp, /task-scope-subline/);
|
||||
assert.match(adminApp, /识别失败,请手动填写/);
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /查看明细/);
|
||||
assert.match(adminApp, /AdminImageLightbox/);
|
||||
assert.match(adminApp, /CreatorScreenshotPreview/);
|
||||
assert.match(adminApp, /admin-image-lightbox/);
|
||||
@@ -362,7 +362,7 @@ test("supports task collection schedules and latest public metrics", async () =>
|
||||
assert.doesNotMatch(mcpClient, /"collect_xhs_wen_note_detail"/);
|
||||
});
|
||||
|
||||
test("supports fixed screenshot collection tasks without publish metrics", async () => {
|
||||
test("supports merged task recovery screenshots without publish metrics", async () => {
|
||||
const [adminApp, actionRoute, partnerRoute, uploadRoute, migration, multiMigration, exportRoute] =
|
||||
await Promise.all([
|
||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||
@@ -374,13 +374,13 @@ test("supports fixed screenshot collection tasks without publish metrics", async
|
||||
readFile(new URL("../app/api/screenshot-task-export/route.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(adminApp, /内容发布/);
|
||||
assert.match(adminApp, /截图回收/);
|
||||
assert.match(adminApp, /小红书搜索关键词/);
|
||||
assert.match(adminApp, /开启截图回收(第二阶段)/);
|
||||
assert.match(adminApp, /批量下载截图/);
|
||||
assert.match(actionRoute, /create_screenshot_task/);
|
||||
assert.match(actionRoute, /task_enable_recovery/);
|
||||
assert.doesNotMatch(actionRoute, /create_screenshot_task/);
|
||||
assert.match(partnerRoute, /submit_screenshot_result/);
|
||||
assert.match(partnerRoute, /截图回收任务无需填写发布链接/);
|
||||
assert.match(partnerRoute, /请先回填这篇笔记的发布链接,再提交回收截图/);
|
||||
assert.match(partnerRoute, /recovery_enabled/);
|
||||
assert.match(uploadRoute, /task-results\//);
|
||||
assert.match(uploadRoute, /task-result/);
|
||||
assert.match(uploadRoute, /MAX_RESULT_SCREENSHOTS/);
|
||||
|
||||
95
tests/task-pairing.test.mjs
Normal file
95
tests/task-pairing.test.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const adminApp = readFileSync(
|
||||
new URL("../app/admin-app.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const adminCss = readFileSync(
|
||||
new URL("../app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const actionRoute = readFileSync(
|
||||
new URL("../app/api/action/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const taskService = readFileSync(
|
||||
new URL("../lib/task-service.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const projectService = readFileSync(
|
||||
new URL("../lib/project-service.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const migration = readFileSync(
|
||||
new URL("../mysql/0020_task_recovery_merge.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("单任务模型:配对聚合与截图任务分支已删除", () => {
|
||||
assert.doesNotMatch(adminApp, /groupTaskMasters/);
|
||||
assert.doesNotMatch(adminApp, /PairableTask/);
|
||||
assert.doesNotMatch(adminApp, /companion/);
|
||||
assert.doesNotMatch(adminApp, /screenshot_collect/);
|
||||
assert.doesNotMatch(adminApp, /withScreenshotTask/);
|
||||
assert.doesNotMatch(adminApp, /仅回收/);
|
||||
assert.doesNotMatch(adminApp, /回收群/);
|
||||
assert.doesNotMatch(taskService, /createScreenshotTask/);
|
||||
assert.doesNotMatch(actionRoute, /create_screenshot_task/);
|
||||
assert.doesNotMatch(adminCss, /\.task-row-companion/);
|
||||
});
|
||||
|
||||
test("TaskRowCard 双面板:发布面板 + 回收实面板/ghost 开通面板", () => {
|
||||
assert.match(adminApp, /master-panel publish/);
|
||||
assert.match(adminApp, /master-panel recovery/);
|
||||
assert.match(adminApp, /master-panel ghost/);
|
||||
assert.match(adminApp, /未开通回收/);
|
||||
assert.match(adminApp, /开通回收 ›/);
|
||||
assert.match(adminApp, /已收取/);
|
||||
assert.match(adminApp, /recovery_submitted/);
|
||||
assert.match(adminApp, /回收已开通/);
|
||||
assert.match(adminCss, /\.master-panel\.ghost/);
|
||||
});
|
||||
|
||||
test("创建/编辑表单携带回收设置", () => {
|
||||
assert.match(adminApp, /开启截图回收(第二阶段)/);
|
||||
assert.match(adminApp, /recoveryEnabled/);
|
||||
assert.match(adminApp, /recoveryDueAt/);
|
||||
assert.match(adminApp, /recoveryInstructions/);
|
||||
assert.match(adminApp, /recoveryExampleImageKey/);
|
||||
assert.match(adminApp, /发布截止日期/);
|
||||
});
|
||||
|
||||
test("create_task / task_update / task_enable_recovery 三个 action 都落 recovery 字段", () => {
|
||||
const createBlock = actionRoute.slice(
|
||||
actionRoute.indexOf('body.action === "create_task"'),
|
||||
actionRoute.indexOf('body.action === "task_update"'),
|
||||
);
|
||||
assert.match(createBlock, /recoveryEnabled/);
|
||||
assert.match(createBlock, /recoveryExampleImageKey/);
|
||||
const enableBlock = actionRoute.slice(
|
||||
actionRoute.indexOf('body.action === "task_enable_recovery"'),
|
||||
);
|
||||
assert.match(enableBlock, /enableTaskRecovery/);
|
||||
assert.match(enableBlock, /recoveryDueAt/);
|
||||
assert.match(taskService, /export async function enableTaskRecovery/);
|
||||
assert.match(taskService, /recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images/);
|
||||
});
|
||||
|
||||
test("项目详情查询带回 recovery 字段(不再依赖 related_task_id)", () => {
|
||||
assert.match(projectService, /recovery_enabled, recovery_due_at/);
|
||||
assert.match(projectService, /recovery_submitted/);
|
||||
assert.doesNotMatch(projectService, /related_task_id/);
|
||||
});
|
||||
|
||||
test("存量迁移 SQL:配对并入、独立转正、删除老截图任务", () => {
|
||||
assert.match(migration, /ALTER TABLE tasks ADD COLUMN recovery_enabled/);
|
||||
assert.match(migration, /ADD COLUMN recovery_due_at/);
|
||||
assert.match(migration, /ADD COLUMN recovery_instructions/);
|
||||
assert.match(migration, /ADD COLUMN recovery_example_images/);
|
||||
assert.match(migration, /child\.related_task_id = parent\.id/);
|
||||
assert.match(migration, /target\.result_screenshot_key = legacy\.result_screenshot_key/);
|
||||
assert.match(migration, /DELETE FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != ''/);
|
||||
assert.match(migration, /standalone\.task_type = 'content_publish'/);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { updateTask } from "../lib/task-service.ts";
|
||||
import { updateTask, enableTaskRecovery } from "../lib/task-service.ts";
|
||||
|
||||
function fakeDb({ task, contents = [] }) {
|
||||
const tasks = task ? [task] : [];
|
||||
@@ -14,21 +14,40 @@ function fakeDb({ task, contents = [] }) {
|
||||
if (/FROM tasks WHERE id = \?/.test(sql)) {
|
||||
return tasks.find((row) => row.id === values[0]) ?? null;
|
||||
}
|
||||
if (/SELECT image_assets FROM contents/.test(sql)) {
|
||||
const sorted = rows
|
||||
.filter((row) => row.task_id === values[0])
|
||||
.sort((a, b) => a.source_row - b.source_row);
|
||||
return sorted[0] ? { image_assets: sorted[0].image_assets } : null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async run() {
|
||||
const normalized = sql.trim();
|
||||
if (/^UPDATE tasks/.test(normalized)) {
|
||||
const [name, brand, due_at, project_id, platform, content_format, quantity, id] =
|
||||
if (/^UPDATE tasks\s+SET recovery_enabled = 1/.test(normalized)) {
|
||||
const [recovery_due_at, recovery_instructions, recovery_example_images, id] =
|
||||
values;
|
||||
const row = tasks.find((item) => item.id === id);
|
||||
if (!row) return { meta: { changes: 0 } };
|
||||
Object.assign(row, {
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at,
|
||||
recovery_instructions,
|
||||
recovery_example_images,
|
||||
});
|
||||
return { meta: { changes: 1 } };
|
||||
}
|
||||
if (/^UPDATE tasks/.test(normalized)) {
|
||||
const [
|
||||
name,
|
||||
brand,
|
||||
due_at,
|
||||
project_id,
|
||||
platform,
|
||||
content_format,
|
||||
quantity,
|
||||
recovery_enabled,
|
||||
recovery_due_at,
|
||||
recovery_instructions,
|
||||
recovery_example_images,
|
||||
id,
|
||||
] = values;
|
||||
const row = tasks.find((item) => item.id === id);
|
||||
if (!row) return { meta: { changes: 0 } };
|
||||
Object.assign(row, {
|
||||
name,
|
||||
brand,
|
||||
@@ -37,50 +56,13 @@ function fakeDb({ task, contents = [] }) {
|
||||
platform,
|
||||
content_format,
|
||||
quantity,
|
||||
recovery_enabled,
|
||||
recovery_due_at,
|
||||
recovery_instructions,
|
||||
recovery_example_images,
|
||||
});
|
||||
return { meta: { changes: 1 } };
|
||||
}
|
||||
if (/^INSERT INTO contents/.test(normalized)) {
|
||||
const [id, task_id, title, body, image_assets, source_row] = values;
|
||||
rows.push({
|
||||
id,
|
||||
task_id,
|
||||
title,
|
||||
body,
|
||||
image_assets,
|
||||
status: "available",
|
||||
source_row,
|
||||
});
|
||||
return { meta: { changes: 1 } };
|
||||
}
|
||||
if (/^DELETE FROM contents/.test(normalized)) {
|
||||
const [taskId, limit] = values;
|
||||
const available = rows
|
||||
.filter((row) => row.task_id === taskId && row.status === "available")
|
||||
.sort((a, b) => b.source_row - a.source_row);
|
||||
const doomed = new Set(available.slice(0, limit).map((row) => row.id));
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
if (doomed.has(rows[index].id)) rows.splice(index, 1);
|
||||
}
|
||||
return { meta: { changes: doomed.size } };
|
||||
}
|
||||
if (/^UPDATE contents SET title/.test(normalized)) {
|
||||
const [title, body, taskId] = values;
|
||||
for (const row of rows) {
|
||||
if (row.task_id === taskId) {
|
||||
row.title = title;
|
||||
row.body = body;
|
||||
}
|
||||
}
|
||||
return { meta: { changes: rows.length } };
|
||||
}
|
||||
if (/^UPDATE contents SET image_assets/.test(normalized)) {
|
||||
const [image_assets, taskId] = values;
|
||||
for (const row of rows) {
|
||||
if (row.task_id === taskId) row.image_assets = image_assets;
|
||||
}
|
||||
return { meta: { changes: rows.length } };
|
||||
}
|
||||
return { meta: { changes: 0 } };
|
||||
},
|
||||
async all() {
|
||||
@@ -101,234 +83,78 @@ function fakeDb({ task, contents = [] }) {
|
||||
return { db, tasks, contents: rows };
|
||||
}
|
||||
|
||||
function screenshotTask(overrides = {}) {
|
||||
function publishTask(overrides = {}) {
|
||||
return {
|
||||
id: "task-shot-1",
|
||||
task_type: "screenshot_collect",
|
||||
quantity: 3,
|
||||
claimed_quantity: 0,
|
||||
platform: "小红书",
|
||||
content_format: "image_text",
|
||||
id: "task-pub-1",
|
||||
task_type: "content_publish",
|
||||
quantity: 18,
|
||||
claimed_quantity: 2,
|
||||
platform: "抖音",
|
||||
content_format: "video",
|
||||
name: "旧名称",
|
||||
brand: "旧品牌",
|
||||
due_at: "2026-08-01",
|
||||
project_id: "",
|
||||
status: "active",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-10",
|
||||
recovery_instructions: "请提交发布后的笔记截图",
|
||||
recovery_example_images: "[]",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function screenshotContents(quantity, overrides = {}) {
|
||||
function publishContents(quantity) {
|
||||
return Array.from({ length: quantity }, (_, index) => ({
|
||||
id: `content-${index + 1}`,
|
||||
task_id: "task-shot-1",
|
||||
title: overrides.title ?? "旧关键词",
|
||||
body: overrides.body ?? "旧说明",
|
||||
image_assets: overrides.imageAssets ?? "[]",
|
||||
status: index < (overrides.claimed ?? 0) ? "claimed" : "available",
|
||||
task_id: "task-pub-1",
|
||||
title: "笔记标题",
|
||||
body: "笔记正文",
|
||||
image_assets: "[]",
|
||||
status: index < 2 ? "claimed" : "available",
|
||||
source_row: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
test("updateTask rewrites basic fields and syncs keyword/instructions on all rows", async () => {
|
||||
test("updateTask rewrites basic fields, quantity and contents stay untouched", async () => {
|
||||
const { db, tasks, contents } = fakeDb({
|
||||
task: screenshotTask(),
|
||||
contents: screenshotContents(3),
|
||||
task: publishTask(),
|
||||
contents: publishContents(5),
|
||||
});
|
||||
const result = await updateTask(
|
||||
{
|
||||
taskId: "task-shot-1",
|
||||
name: "新名称",
|
||||
brand: "新品牌",
|
||||
dueAt: "2026-09-30",
|
||||
projectId: "proj-1",
|
||||
keyword: "新关键词",
|
||||
instructions: "新说明",
|
||||
quantity: 3,
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(result.quantity, 3);
|
||||
assert.equal(tasks[0].name, "新名称");
|
||||
assert.equal(tasks[0].brand, "新品牌");
|
||||
assert.equal(tasks[0].due_at, "2026-09-30");
|
||||
assert.equal(tasks[0].project_id, "proj-1");
|
||||
assert.equal(contents.length, 3);
|
||||
for (const row of contents) {
|
||||
assert.equal(row.title, "新关键词");
|
||||
assert.equal(row.body, "新说明");
|
||||
}
|
||||
});
|
||||
|
||||
test("updateTask grows placeholder contents when quantity increases", async () => {
|
||||
const imageAssets = JSON.stringify([{ index: 1, key: "task-assets/a.png", width: null, height: null }]);
|
||||
const { db, tasks, contents } = fakeDb({
|
||||
task: screenshotTask(),
|
||||
contents: screenshotContents(3, { imageAssets }),
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-shot-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
keyword: "k",
|
||||
instructions: "i",
|
||||
quantity: 5,
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].quantity, 5);
|
||||
assert.equal(contents.length, 5);
|
||||
const added = contents.filter((row) => row.source_row > 3);
|
||||
assert.equal(added.length, 2);
|
||||
for (const row of added) {
|
||||
assert.equal(row.status, "available");
|
||||
assert.equal(row.image_assets, imageAssets, "new rows copy example image");
|
||||
assert.equal(row.title, "k");
|
||||
}
|
||||
});
|
||||
|
||||
test("updateTask shrinks only available tail rows when quantity decreases", async () => {
|
||||
const { db, tasks, contents } = fakeDb({
|
||||
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
|
||||
contents: screenshotContents(5, { claimed: 2 }),
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-shot-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
keyword: "k",
|
||||
instructions: "i",
|
||||
quantity: 3,
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].quantity, 3);
|
||||
assert.equal(contents.length, 3);
|
||||
const claimedRows = contents.filter((row) => row.status === "claimed");
|
||||
assert.equal(claimedRows.length, 2, "claimed rows preserved");
|
||||
assert.ok(
|
||||
contents.every((row) => row.source_row <= 3),
|
||||
"tail rows removed",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateTask rejects quantity below claimed_quantity", async () => {
|
||||
const { db } = fakeDb({
|
||||
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
|
||||
contents: screenshotContents(5, { claimed: 2 }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{
|
||||
taskId: "task-shot-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
keyword: "k",
|
||||
instructions: "i",
|
||||
quantity: 1,
|
||||
},
|
||||
db,
|
||||
),
|
||||
/不能低于已领取的 2 份/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateTask validates screenshot quantity and keyword", async () => {
|
||||
const { db } = fakeDb({ task: screenshotTask(), contents: screenshotContents(3) });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: "k", instructions: "i", quantity: 0 },
|
||||
db,
|
||||
),
|
||||
/1—500/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: " ", instructions: "i", quantity: 3 },
|
||||
db,
|
||||
),
|
||||
/请补全搜索关键词和任务说明/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateTask replaces example image across contents when re-uploaded", async () => {
|
||||
const { db, contents } = fakeDb({
|
||||
task: screenshotTask(),
|
||||
contents: screenshotContents(3),
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-shot-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
keyword: "k",
|
||||
instructions: "i",
|
||||
quantity: 3,
|
||||
exampleImageKey: "task-assets/new.png",
|
||||
},
|
||||
db,
|
||||
);
|
||||
for (const row of contents) {
|
||||
assert.equal(JSON.parse(row.image_assets)[0].key, "task-assets/new.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("updateTask ignores quantity and keyword for content_publish tasks", async () => {
|
||||
const { db, tasks, contents } = fakeDb({
|
||||
task: screenshotTask({
|
||||
id: "task-pub-1",
|
||||
task_type: "content_publish",
|
||||
quantity: 18,
|
||||
platform: "抖音",
|
||||
content_format: "video",
|
||||
}),
|
||||
contents: [
|
||||
{
|
||||
id: "content-pub-1",
|
||||
task_id: "task-pub-1",
|
||||
title: "笔记标题",
|
||||
body: "笔记正文",
|
||||
image_assets: "[]",
|
||||
status: "available",
|
||||
source_row: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-pub-1",
|
||||
name: "新名称",
|
||||
brand: "新品牌",
|
||||
dueAt: "2026-09-30",
|
||||
projectId: "proj-2",
|
||||
quantity: 99,
|
||||
keyword: "ignored",
|
||||
instructions: "ignored",
|
||||
projectId: "proj-1",
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].quantity, 18, "feishu row count untouched");
|
||||
assert.equal(tasks[0].platform, "抖音", "platform kept when not provided");
|
||||
assert.equal(tasks[0].content_format, "video");
|
||||
assert.equal(contents[0].title, "笔记标题", "contents untouched");
|
||||
assert.equal(result.quantity, 18);
|
||||
assert.equal(result.claimedQuantity, 2);
|
||||
assert.equal(tasks[0].name, "新名称");
|
||||
assert.equal(tasks[0].brand, "新品牌");
|
||||
assert.equal(tasks[0].due_at, "2026-09-30");
|
||||
assert.equal(tasks[0].project_id, "proj-1");
|
||||
assert.equal(tasks[0].quantity, 18, "quantity 由飞书行数决定,不可编辑");
|
||||
assert.equal(contents.length, 5, "contents 不随编辑变动");
|
||||
assert.equal(contents[0].title, "笔记标题");
|
||||
});
|
||||
|
||||
test("updateTask applies provided platform/contentFormat for content_publish", async () => {
|
||||
test("updateTask keeps platform/contentFormat when not provided", async () => {
|
||||
const { db, tasks } = fakeDb({ task: publishTask() });
|
||||
await updateTask(
|
||||
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].platform, "抖音");
|
||||
assert.equal(tasks[0].content_format, "video");
|
||||
});
|
||||
|
||||
test("updateTask applies provided platform/contentFormat", async () => {
|
||||
const { db, tasks } = fakeDb({
|
||||
task: screenshotTask({
|
||||
id: "task-pub-1",
|
||||
task_type: "content_publish",
|
||||
platform: "小红书",
|
||||
content_format: "image_text",
|
||||
}),
|
||||
task: publishTask({ platform: "小红书", content_format: "image_text" }),
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
@@ -345,22 +171,107 @@ test("updateTask applies provided platform/contentFormat for content_publish", a
|
||||
assert.equal(tasks[0].content_format, "video");
|
||||
});
|
||||
|
||||
test("updateTask rejects unknown tasks and invalid dates", async () => {
|
||||
const { db } = fakeDb({ task: screenshotTask() });
|
||||
test("updateTask updates recovery fields and can toggle recovery off", async () => {
|
||||
const { db, tasks } = fakeDb({ task: publishTask() });
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-pub-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
recoveryEnabled: false,
|
||||
recoveryDueAt: "2026-10-15",
|
||||
recoveryInstructions: "新回收说明",
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].recovery_enabled, 0);
|
||||
assert.equal(tasks[0].recovery_due_at, "2026-10-15");
|
||||
assert.equal(tasks[0].recovery_instructions, "新回收说明");
|
||||
});
|
||||
|
||||
test("updateTask keeps row recovery values when input omits them", async () => {
|
||||
const { db, tasks } = fakeDb({
|
||||
task: publishTask({
|
||||
recovery_enabled: 0,
|
||||
recovery_due_at: "",
|
||||
recovery_instructions: "",
|
||||
}),
|
||||
});
|
||||
await updateTask(
|
||||
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].recovery_enabled, 0, "未传开关时保持关闭");
|
||||
assert.equal(
|
||||
tasks[0].recovery_due_at,
|
||||
"2026-09-30",
|
||||
"回收截止为空时兜底为发布截止",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateTask replaces example image JSON when key provided", async () => {
|
||||
const { db, tasks } = fakeDb({ task: publishTask() });
|
||||
await updateTask(
|
||||
{
|
||||
taskId: "task-pub-1",
|
||||
name: "n",
|
||||
brand: "b",
|
||||
dueAt: "2026-09-30",
|
||||
recoveryExampleImageKey: "task-assets/new.png",
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(tasks[0].recovery_example_images), [
|
||||
{ index: 1, key: "task-assets/new.png", width: null, height: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("enableTaskRecovery 开通老任务回收:默认截止=发布截止、默认说明、保留示例图", async () => {
|
||||
const { db, tasks } = fakeDb({
|
||||
task: publishTask({
|
||||
recovery_enabled: 0,
|
||||
recovery_due_at: "",
|
||||
recovery_instructions: "",
|
||||
recovery_example_images: "[]",
|
||||
}),
|
||||
});
|
||||
const result = await enableTaskRecovery({ taskId: "task-pub-1" }, db);
|
||||
assert.equal(result.recoveryDueAt, "2026-08-01");
|
||||
assert.equal(tasks[0].recovery_enabled, 1);
|
||||
assert.equal(tasks[0].recovery_due_at, "2026-08-01");
|
||||
assert.equal(tasks[0].recovery_instructions, "请提交发布后的笔记截图");
|
||||
const keepDb = fakeDb({
|
||||
task: publishTask({
|
||||
recovery_example_images:
|
||||
'[{"index":1,"key":"task-assets/keep.png","width":null,"height":null}]',
|
||||
}),
|
||||
});
|
||||
await enableTaskRecovery(
|
||||
{ taskId: "task-pub-1", recoveryDueAt: "2026-10-01", recoveryInstructions: "定制说明" },
|
||||
keepDb.db,
|
||||
);
|
||||
assert.equal(keepDb.tasks[0].recovery_due_at, "2026-10-01");
|
||||
assert.equal(keepDb.tasks[0].recovery_instructions, "定制说明");
|
||||
assert.deepEqual(JSON.parse(keepDb.tasks[0].recovery_example_images), [
|
||||
{ index: 1, key: "task-assets/keep.png", width: null, height: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("updateTask / enableTaskRecovery reject unknown tasks and invalid dates", async () => {
|
||||
const { db } = fakeDb({ task: publishTask() });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" },
|
||||
db,
|
||||
),
|
||||
updateTask({ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" }, db),
|
||||
/任务不存在/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-13-01" },
|
||||
db,
|
||||
),
|
||||
updateTask({ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-13-01" }, db),
|
||||
/截止日期/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => enableTaskRecovery({ taskId: "missing" }, db),
|
||||
/任务不存在/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -38,7 +39,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -50,7 +52,8 @@ const overdueRows = [
|
||||
task_id: "task-B",
|
||||
task_name: "无项目任务B",
|
||||
due_at: "2026-08-25",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 0,
|
||||
recovery_due_at: "",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -62,7 +65,6 @@ const overdueRows = [
|
||||
const releasableDistribution = {
|
||||
id: "d-1",
|
||||
task_id: "task-A",
|
||||
task_type: "content_publish",
|
||||
content_id: "c-1",
|
||||
content_title: "超期内容",
|
||||
partner_id: "p-1",
|
||||
@@ -73,13 +75,30 @@ const releasableDistribution = {
|
||||
result_submitted_at: null,
|
||||
};
|
||||
|
||||
function filterOverdue(rows, cutoff) {
|
||||
return rows.filter((row) => {
|
||||
if (!(row.due_at && row.due_at < cutoff)) return false;
|
||||
return row.task_type === "screenshot_collect"
|
||||
? !row.result_submitted_at
|
||||
: !row.publish_url;
|
||||
});
|
||||
// 双阶段超期判定:发布阶段看 due_at,回收阶段(已发布未收截图)看 recovery_due_at
|
||||
function filterOverdue(rows, cutoff, { phases = "all" } = {}) {
|
||||
const output = [];
|
||||
for (const row of rows) {
|
||||
if (!row.publish_url && row.due_at && row.due_at < cutoff) {
|
||||
output.push({ ...row, pending_phase: "publish", pending_due_at: row.due_at });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
phases === "all" &&
|
||||
row.recovery_enabled === 1 &&
|
||||
row.publish_url &&
|
||||
!row.result_submitted_at &&
|
||||
row.recovery_due_at &&
|
||||
row.recovery_due_at < cutoff
|
||||
) {
|
||||
output.push({
|
||||
...row,
|
||||
pending_phase: "recovery",
|
||||
pending_due_at: row.recovery_due_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
@@ -95,11 +114,12 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -138,11 +158,12 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -250,38 +271,44 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => {
|
||||
test("runOverdueEscalation 按发布/回收两阶段分别出文案", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "d-s1",
|
||||
// 回收阶段超期:已发布、未收截图、recovery_due_at 已过
|
||||
id: "d-r1",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s1",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
partner_id: "p-1",
|
||||
partner_name: "合作方甲",
|
||||
},
|
||||
{
|
||||
id: "d-s2",
|
||||
// 已提交截图:两阶段都不超期
|
||||
id: "d-r2",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s2",
|
||||
result_submitted_at: "2026-08-21 09:00:00",
|
||||
project_id: "",
|
||||
partner_id: "p-2",
|
||||
partner_name: "合作方乙",
|
||||
},
|
||||
{
|
||||
// 已发布且回收未到期:不算超期
|
||||
id: "d-c1",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-09-10",
|
||||
publish_url: "https://www.xiaohongshu.com/note/1",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -289,11 +316,13 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
partner_name: "合作方丙",
|
||||
},
|
||||
{
|
||||
// 发布阶段超期:未回填链接
|
||||
id: "d-c2",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-09-10",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -308,15 +337,14 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
wecomFetch(calls),
|
||||
NOW,
|
||||
);
|
||||
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期
|
||||
assert.equal(summary.overdueTasks, 2);
|
||||
assert.equal(summary.overdueTasks, 2, "任务×阶段 各一条");
|
||||
assert.equal(summary.overdueDistributions, 2);
|
||||
const robotBodies = calls
|
||||
.filter((call) => call.url.host === "qyapi.example.com")
|
||||
.map((call) => JSON.parse(String(call.init.body)));
|
||||
assert.equal(robotBodies.length, 1);
|
||||
assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/);
|
||||
assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/);
|
||||
assert.match(robotBodies[0].text.content, /回收任务S(截止 2026-08-20):1 条截图超期未提交/);
|
||||
assert.match(robotBodies[0].text.content, /发布任务A(截止 2026-08-20):1 条超期未发布/);
|
||||
});
|
||||
|
||||
test("runOverdueAutoRelease is disabled without env switch", async () => {
|
||||
|
||||
@@ -54,17 +54,18 @@ test("buildTaskGroupPushMessage composes text and link with claim url", () => {
|
||||
assert.match(message.link.desc, /品牌A · 3 份 · 截止 2026-09-01/);
|
||||
});
|
||||
|
||||
test("buildTaskGroupPushMessage marks video and screenshot tasks", () => {
|
||||
test("buildTaskGroupPushMessage marks video tasks (单模型下不再有截图任务文案)", () => {
|
||||
const video = buildTaskGroupPushMessage(
|
||||
{ ...baseTask, content_format: "video" },
|
||||
"https://portal.example.com",
|
||||
);
|
||||
assert.match(video.text, /形式:视频/);
|
||||
const screenshot = buildTaskGroupPushMessage(
|
||||
{ ...baseTask, task_type: "screenshot_collect" },
|
||||
const imageText = buildTaskGroupPushMessage(
|
||||
baseTask,
|
||||
"https://portal.example.com",
|
||||
);
|
||||
assert.match(screenshot.text, /形式:截图回收/);
|
||||
assert.match(imageText.text, /形式:图文/);
|
||||
assert.doesNotMatch(imageText.text, /截图回收/);
|
||||
});
|
||||
|
||||
test("buildTaskGroupPushMessage drops link when share_token missing", () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ test("runDueSoonWecomNotifications filters by taskId when given", async () => {
|
||||
);
|
||||
assert.equal(executed.length, 1);
|
||||
assert.match(executed[0].sql, /AND t\.id = \?/);
|
||||
assert.deepEqual(executed[0].values, ["2026-08-21", "task-9"]);
|
||||
assert.deepEqual(executed[0].values, ["2026-08-21", "2026-08-21", "task-9"]);
|
||||
assert.equal(summary.dueSoonAttempted, 0);
|
||||
assert.equal(summary.digestSent, false);
|
||||
});
|
||||
@@ -73,5 +73,5 @@ test("runDueSoonWecomNotifications keeps global scan without taskId", async () =
|
||||
await runDueSoonWecomNotifications(db, NOTIFY_CONFIG, undefined, now);
|
||||
assert.equal(executed.length, 1);
|
||||
assert.ok(!/AND t\.id = \?/.test(executed[0].sql));
|
||||
assert.deepEqual(executed[0].values, ["2026-08-21"]);
|
||||
assert.deepEqual(executed[0].values, ["2026-08-21", "2026-08-21"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user