import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { DEFAULT_COLLECTION_DAYS, effectiveCollectionDays, isCollectionScheduleDue, parseCollectionDays, runDueScheduledCollections, } from "../lib/collection-service.ts"; 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"], ); }); 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'/); }); // —— 未设置采集计划时默认连采 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)), ); });