367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
|
|
import test from "node:test";
|
||
|
|
import assert from "node:assert/strict";
|
||
|
|
import { updateTask } from "../lib/task-service.ts";
|
||
|
|
|
||
|
|
function fakeDb({ task, contents = [] }) {
|
||
|
|
const tasks = task ? [task] : [];
|
||
|
|
const rows = contents.map((row) => ({ ...row }));
|
||
|
|
const db = {
|
||
|
|
prepare(sql) {
|
||
|
|
return {
|
||
|
|
bind(...values) {
|
||
|
|
return {
|
||
|
|
async first() {
|
||
|
|
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] =
|
||
|
|
values;
|
||
|
|
const row = tasks.find((item) => item.id === id);
|
||
|
|
if (!row) return { meta: { changes: 0 } };
|
||
|
|
Object.assign(row, {
|
||
|
|
name,
|
||
|
|
brand,
|
||
|
|
due_at,
|
||
|
|
project_id,
|
||
|
|
platform,
|
||
|
|
content_format,
|
||
|
|
quantity,
|
||
|
|
});
|
||
|
|
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() {
|
||
|
|
return { results: [] };
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async batch(statements) {
|
||
|
|
const results = [];
|
||
|
|
for (const statement of statements) {
|
||
|
|
results.push(await statement.run());
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
return { db, tasks, contents: rows };
|
||
|
|
}
|
||
|
|
|
||
|
|
function screenshotTask(overrides = {}) {
|
||
|
|
return {
|
||
|
|
id: "task-shot-1",
|
||
|
|
task_type: "screenshot_collect",
|
||
|
|
quantity: 3,
|
||
|
|
claimed_quantity: 0,
|
||
|
|
platform: "小红书",
|
||
|
|
content_format: "image_text",
|
||
|
|
name: "旧名称",
|
||
|
|
brand: "旧品牌",
|
||
|
|
due_at: "2026-08-01",
|
||
|
|
project_id: "",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function screenshotContents(quantity, overrides = {}) {
|
||
|
|
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",
|
||
|
|
source_row: index + 1,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
test("updateTask rewrites basic fields and syncs keyword/instructions on all rows", async () => {
|
||
|
|
const { db, tasks, contents } = fakeDb({
|
||
|
|
task: screenshotTask(),
|
||
|
|
contents: screenshotContents(3),
|
||
|
|
});
|
||
|
|
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",
|
||
|
|
},
|
||
|
|
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");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("updateTask applies provided platform/contentFormat for content_publish", async () => {
|
||
|
|
const { db, tasks } = fakeDb({
|
||
|
|
task: screenshotTask({
|
||
|
|
id: "task-pub-1",
|
||
|
|
task_type: "content_publish",
|
||
|
|
platform: "小红书",
|
||
|
|
content_format: "image_text",
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
await updateTask(
|
||
|
|
{
|
||
|
|
taskId: "task-pub-1",
|
||
|
|
name: "n",
|
||
|
|
brand: "b",
|
||
|
|
dueAt: "2026-09-30",
|
||
|
|
platform: "抖音",
|
||
|
|
contentFormat: "video",
|
||
|
|
},
|
||
|
|
db,
|
||
|
|
);
|
||
|
|
assert.equal(tasks[0].platform, "抖音");
|
||
|
|
assert.equal(tasks[0].content_format, "video");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("updateTask rejects unknown tasks and invalid dates", async () => {
|
||
|
|
const { db } = fakeDb({ task: screenshotTask() });
|
||
|
|
await assert.rejects(
|
||
|
|
() =>
|
||
|
|
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,
|
||
|
|
),
|
||
|
|
/截止日期/,
|
||
|
|
);
|
||
|
|
});
|