2026-08-12 11:12:23 +08:00
|
|
|
|
import assert from "node:assert/strict";
|
2026-10-08 14:30:36 +08:00
|
|
|
|
import { readFile } from "node:fs/promises";
|
2026-08-12 11:12:23 +08:00
|
|
|
|
import test from "node:test";
|
2026-10-12 11:11:31 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_COLLECTION_DAYS,
|
|
|
|
|
|
effectiveCollectionDays,
|
|
|
|
|
|
isCollectionScheduleDue,
|
|
|
|
|
|
parseCollectionDays,
|
|
|
|
|
|
runDueScheduledCollections,
|
|
|
|
|
|
} from "../lib/collection-service.ts";
|
2026-08-12 11:12:23 +08:00
|
|
|
|
import { sortWithNullsLast } from "../lib/sort-utils.ts";
|
|
|
|
|
|
|
|
|
|
|
|
test("runs the Beijing collection schedule from 09:00", () => {
|
|
|
|
|
|
const scheduledDate = "2026-08-12";
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
isCollectionScheduleDue(
|
|
|
|
|
|
scheduledDate,
|
|
|
|
|
|
Date.parse("2026-08-12T00:59:59Z"),
|
|
|
|
|
|
),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
isCollectionScheduleDue(
|
|
|
|
|
|
scheduledDate,
|
|
|
|
|
|
Date.parse("2026-08-12T01:00:00Z"),
|
|
|
|
|
|
),
|
|
|
|
|
|
true,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
isCollectionScheduleDue(
|
|
|
|
|
|
scheduledDate,
|
|
|
|
|
|
Date.parse("2026-08-13T00:00:00Z"),
|
|
|
|
|
|
),
|
|
|
|
|
|
true,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
isCollectionScheduleDue(
|
|
|
|
|
|
scheduledDate,
|
|
|
|
|
|
Date.parse("2026-08-11T16:00:00Z"),
|
|
|
|
|
|
),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("sorts both directions while keeping missing values last", () => {
|
|
|
|
|
|
const values = [
|
|
|
|
|
|
{ id: "missing-a", value: null },
|
|
|
|
|
|
{ id: "middle", value: 20 },
|
|
|
|
|
|
{ id: "high", value: 50 },
|
|
|
|
|
|
{ id: "missing-b", value: null },
|
|
|
|
|
|
{ id: "low", value: 10 },
|
|
|
|
|
|
];
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
sortWithNullsLast(values, (item) => item.value, "asc").map(
|
|
|
|
|
|
(item) => item.id,
|
|
|
|
|
|
),
|
|
|
|
|
|
["low", "middle", "high", "missing-a", "missing-b"],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
sortWithNullsLast(values, (item) => item.value, "desc").map(
|
|
|
|
|
|
(item) => item.id,
|
|
|
|
|
|
),
|
|
|
|
|
|
["high", "middle", "low", "missing-a", "missing-b"],
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-10-08 14:30:36 +08:00
|
|
|
|
|
|
|
|
|
|
test("a failed collection keeps historically successful rows on success", async () => {
|
|
|
|
|
|
const source = await readFile(
|
|
|
|
|
|
new URL("../lib/collection-service.ts", import.meta.url),
|
|
|
|
|
|
"utf8",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 失败写入必须按 latest_likes 分流:无数据 → failed;
|
|
|
|
|
|
// 已有历史数据 → 保持 success,仅描述里说明沿用上次数据。
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
source,
|
|
|
|
|
|
/collection_status = CASE\s+WHEN latest_likes IS NULL THEN 'failed'\s+ELSE 'success'\s+END/,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
source,
|
|
|
|
|
|
/ELSE \?\s+END,\s+collection_updated_at = CASE\s+WHEN latest_likes IS NULL THEN CURRENT_TIMESTAMP\s+ELSE collection_updated_at/,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.match(source, /`\$\{message\},沿用上次采集数据`/);
|
|
|
|
|
|
// run 行照实记录失败,保留可观测性
|
|
|
|
|
|
assert.match(source, /UPDATE collection_runs\s+SET status = 'failed'/);
|
|
|
|
|
|
});
|
2026-10-12 11:11:31 +08:00
|
|
|
|
|
|
|
|
|
|
// —— 未设置采集计划时默认连采 7 天 ——
|
|
|
|
|
|
|
|
|
|
|
|
test("effective collection days fall back to 7 days when unset", () => {
|
|
|
|
|
|
assert.deepEqual(effectiveCollectionDays(null), [1, 2, 3, 4, 5, 6, 7]);
|
|
|
|
|
|
assert.deepEqual(effectiveCollectionDays("[]"), DEFAULT_COLLECTION_DAYS);
|
|
|
|
|
|
assert.deepEqual(effectiveCollectionDays('{"bad":1}'), DEFAULT_COLLECTION_DAYS);
|
|
|
|
|
|
assert.deepEqual(effectiveCollectionDays("[0,9]"), DEFAULT_COLLECTION_DAYS);
|
|
|
|
|
|
assert.deepEqual(effectiveCollectionDays("[3,1,2,2]"), [1, 2, 3]);
|
|
|
|
|
|
assert.deepEqual(parseCollectionDays("[7,2]"), [2, 7]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function fakeSchedulerDb({ tasks = [], published = [] } = {}) {
|
|
|
|
|
|
const executed = [];
|
|
|
|
|
|
const record = (sql, values) => executed.push({ sql, values });
|
|
|
|
|
|
const db = {
|
|
|
|
|
|
prepare(sql) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
bind(...values) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
async first() {
|
|
|
|
|
|
record(sql, values);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
},
|
|
|
|
|
|
async run() {
|
|
|
|
|
|
record(sql, values);
|
|
|
|
|
|
return { meta: { changes: 0 } };
|
|
|
|
|
|
},
|
|
|
|
|
|
async all() {
|
|
|
|
|
|
record(sql, values);
|
|
|
|
|
|
if (/FROM tasks/.test(sql)) {
|
|
|
|
|
|
const scoped = tasks.filter(
|
|
|
|
|
|
(task) => !values[0] || task.id === values[0],
|
|
|
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
|
|
|
results: scoped.filter(
|
|
|
|
|
|
(task) =>
|
|
|
|
|
|
(task.task_type ?? "content_publish") !==
|
|
|
|
|
|
"screenshot_collect",
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/COALESCE\(d\.publish_time, d\.claimed_at\)/.test(sql)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
results: published.filter((row) => row.task_id === values[0]),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// 无待采集行,避免触发真实 MCP 采集
|
|
|
|
|
|
return { results: [] };
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
async batch() {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
return { db, executed };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test("collects unplanned tasks on the default 7-day schedule from publish date", async () => {
|
|
|
|
|
|
const { db, executed } = fakeSchedulerDb({
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "task-a",
|
|
|
|
|
|
task_type: "content_publish",
|
|
|
|
|
|
collection_start_date: null,
|
|
|
|
|
|
collection_days: "[]",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
published: [
|
|
|
|
|
|
// UTC 2026-10-09 01:30 → 上海 2026-10-09,D1–D4 在 10-12 09:00 前到期
|
|
|
|
|
|
{ id: "dist-a", task_id: "task-a", publish_anchor: "2026-10-09 01:30:00" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const summary = await runDueScheduledCollections(
|
|
|
|
|
|
db,
|
|
|
|
|
|
Date.parse("2026-10-12T01:00:00Z"),
|
|
|
|
|
|
{},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(summary.dueTasks, 4);
|
|
|
|
|
|
const anchorQuery = executed.find((entry) =>
|
|
|
|
|
|
/COALESCE\(d\.publish_time/.test(entry.sql),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(anchorQuery, "未设置计划时应查询每篇的发布时间");
|
|
|
|
|
|
assert.equal(anchorQuery.values[0], "task-a");
|
|
|
|
|
|
const scoped = executed.filter((entry) =>
|
|
|
|
|
|
/SELECT d\.id FROM distributions d/.test(entry.sql),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(scoped.length, 4);
|
|
|
|
|
|
assert.ok(scoped.every((entry) => entry.values.includes("dist-a")));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("skips the default window once 7 days have passed since publish", async () => {
|
|
|
|
|
|
const { db, executed } = fakeSchedulerDb({
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "task-old",
|
|
|
|
|
|
task_type: "content_publish",
|
|
|
|
|
|
collection_start_date: null,
|
|
|
|
|
|
collection_days: "[]",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
published: [
|
|
|
|
|
|
{ id: "dist-old", task_id: "task-old", publish_anchor: "2026-09-01 00:00:00" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const summary = await runDueScheduledCollections(
|
|
|
|
|
|
db,
|
|
|
|
|
|
Date.parse("2026-10-12T01:00:00Z"),
|
|
|
|
|
|
{},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(summary.dueTasks, 0);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!executed.some((entry) =>
|
|
|
|
|
|
/SELECT d\.id FROM distributions d/.test(entry.sql),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("keeps the explicit task-level schedule when a plan is saved", async () => {
|
|
|
|
|
|
const { db, executed } = fakeSchedulerDb({
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "task-b",
|
|
|
|
|
|
task_type: "content_publish",
|
|
|
|
|
|
collection_start_date: "2026-10-10",
|
|
|
|
|
|
collection_days: "[2]",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const summary = await runDueScheduledCollections(
|
|
|
|
|
|
db,
|
|
|
|
|
|
Date.parse("2026-10-12T01:00:00Z"),
|
|
|
|
|
|
{},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(summary.dueTasks, 1);
|
|
|
|
|
|
const needing = executed.filter((entry) =>
|
|
|
|
|
|
/SELECT d\.id FROM distributions d/.test(entry.sql),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.deepEqual(needing.map((entry) => entry.values), [
|
|
|
|
|
|
["task-b", "2026-10-11"],
|
|
|
|
|
|
]);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!executed.some((entry) => /COALESCE\(d\.publish_time/.test(entry.sql)),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("ignores screenshot collection tasks in the scheduler", async () => {
|
|
|
|
|
|
const { db, executed } = fakeSchedulerDb({
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "task-shot",
|
|
|
|
|
|
task_type: "screenshot_collect",
|
|
|
|
|
|
collection_start_date: null,
|
|
|
|
|
|
collection_days: "[]",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
published: [
|
|
|
|
|
|
{ id: "dist-shot", task_id: "task-shot", publish_anchor: "2026-10-11 00:00:00" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const summary = await runDueScheduledCollections(
|
|
|
|
|
|
db,
|
|
|
|
|
|
Date.parse("2026-10-12T01:00:00Z"),
|
|
|
|
|
|
{},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(summary.dueTasks, 0);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!executed.some((entry) => /COALESCE\(d\.publish_time/.test(entry.sql)),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|