import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { channelPostsArchivePath, countPosts, latestPostCreatedAt, listPostShards, readAllPosts, readPostShard, readSeenPostIds, writePosts, } from "./posts-server"; import { uploadDateFromCreatedAt, type Post } from "./posts"; function makePost(id: string, createdAt: string): Post { return { id, slug: `chan/${id}`, channelSlug: "chan", author: "someone.bsky.social", createdAt, uploadDate: uploadDateFromCreatedAt(createdAt), text: `post ${id}`, url: `https://bsky.app/profile/someone.bsky.social/post/${id}`, platform: "bluesky", isReply: false, isRepost: false, links: [], }; } async function withTempChannel( fn: (root: string) => Promise, ): Promise { const root = await mkdtemp(path.join(tmpdir(), "posts-store-")); try { await fn(root); } finally { await rm(root, { recursive: true, force: true }); } } test("writes posts into month shards and records their ids", async () => { await withTempChannel(async (root) => { const result = await writePosts(root, [ makePost("a1", "2026-01-15T10:00:00.000Z"), makePost("a2", "2026-01-20T10:00:00.000Z"), makePost("b1", "2026-02-01T10:00:00.000Z"), ]); assert.equal(result.written, 3); assert.equal(result.skipped, 0); assert.deepEqual(result.shards, ["2026-01", "2026-02"]); assert.deepEqual(await listPostShards(root), ["2026-01", "2026-02"]); assert.equal((await readPostShard(root, "2026-01")).length, 2); assert.equal((await readPostShard(root, "2026-02")).length, 1); const archive = await readFile(channelPostsArchivePath(root), "utf8"); assert.match(archive, /^bluesky a1$/m); assert.match(archive, /^bluesky b1$/m); assert.deepEqual([...(await readSeenPostIds(root))].sort(), ["a1", "a2", "b1"]); }); }); test("a re-run over overlapping pages writes no duplicates", async () => { await withTempChannel(async (root) => { await writePosts(root, [ makePost("a1", "2026-01-15T10:00:00.000Z"), makePost("a2", "2026-01-20T10:00:00.000Z"), ]); // Second run re-delivers a1/a2 and adds one new post — the incremental // guarantee the whole fetch loop rests on. const second = await writePosts(root, [ makePost("a1", "2026-01-15T10:00:00.000Z"), makePost("a2", "2026-01-20T10:00:00.000Z"), makePost("a3", "2026-01-25T10:00:00.000Z"), ]); assert.equal(second.written, 1); assert.equal(second.skipped, 2); const all = await readAllPosts(root); assert.deepEqual(all.map((p) => p.id), ["a3", "a2", "a1"]); assert.equal(await countPosts(root), 3); }); }); test("dedupes within a single batch", async () => { await withTempChannel(async (root) => { const result = await writePosts(root, [ makePost("x", "2026-03-01T10:00:00.000Z"), makePost("x", "2026-03-01T10:00:00.000Z"), ]); assert.equal(result.written, 1); assert.equal(result.skipped, 1); assert.equal(await countPosts(root), 1); }); }); test("readAllPosts returns newest first across shards", async () => { await withTempChannel(async (root) => { await writePosts(root, [ makePost("old", "2025-11-02T00:00:00.000Z"), makePost("new", "2026-04-09T00:00:00.000Z"), makePost("mid", "2026-01-01T00:00:00.000Z"), ]); const all = await readAllPosts(root); assert.deepEqual(all.map((p) => p.id), ["new", "mid", "old"]); }); }); test("latestPostCreatedAt is the incremental watermark", async () => { await withTempChannel(async (root) => { assert.equal(await latestPostCreatedAt(root), null); await writePosts(root, [ makePost("old", "2025-11-02T00:00:00.000Z"), makePost("new", "2026-04-09T12:34:56.789Z"), ]); assert.equal(await latestPostCreatedAt(root), "2026-04-09T12:34:56.789Z"); }); }); test("an empty channel reads as empty rather than throwing", async () => { await withTempChannel(async (root) => { assert.deepEqual(await listPostShards(root), []); assert.deepEqual(await readAllPosts(root), []); assert.equal(await countPosts(root), 0); assert.deepEqual([...(await readSeenPostIds(root))], []); }); }); test("a truncated JSONL line does not poison the rest of its shard", async () => { await withTempChannel(async (root) => { await writePosts(root, [makePost("good1", "2026-05-01T00:00:00.000Z")]); // Simulate an append interrupted mid-line, then a later clean append. const { appendFile } = await import("node:fs/promises"); await appendFile( path.join(root, "posts", "2026-05.jsonl"), '{"id":"trunc","channelSl\n', "utf8", ); await writePosts(root, [makePost("good2", "2026-05-02T00:00:00.000Z")]); const posts = await readPostShard(root, "2026-05"); assert.deepEqual(posts.map((p) => p.id).sort(), ["good1", "good2"]); }); }); test("a renamed channel's posts read as the new slug, not the one stamped in the line", async () => { await withTempChannel(async (parent) => { const oldRoot = path.join(parent, "chan"); await writePosts(oldRoot, [makePost("p1", "2026-05-01T00:00:00.000Z")]); const { rename } = await import("node:fs/promises"); const newRoot = path.join(parent, "chan-renamed"); await rename(oldRoot, newRoot); // The JSONL still says "chan" — renameChannel does not rewrite it. const raw = await readFile(path.join(newRoot, "posts", "2026-05.jsonl"), "utf8"); assert.match(raw, /"channelSlug":"chan"/); const [post] = await readPostShard(newRoot, "2026-05"); assert.equal(post.channelSlug, "chan-renamed"); assert.equal(post.slug, "chan-renamed/p1"); const [viaAll] = await readAllPosts(newRoot); assert.equal(viaAll.slug, "chan-renamed/p1"); }); }); // ─── deleted-post tracking ─── test("availability merges, and history records only real changes", async () => { const { mergePostAvailability } = await import("./posts-server"); const t1 = "2026-01-01T00:00:00.000Z"; const t2 = "2026-01-02T00:00:00.000Z"; const t3 = "2026-01-03T00:00:00.000Z"; // First observation: no history yet. const a = mergePostAvailability({}, new Map([["p1", "available"]]), t1); assert.equal(a.map.p1.availability, "available"); assert.equal(a.map.p1.history?.length ?? 0, 0); assert.deepEqual(a.newlyDeleted, []); assert.equal(a.changed, 1); // Same verdict again: only the checkedAt moves, no history growth. const b = mergePostAvailability(a.map, new Map([["p1", "available"]]), t2); assert.equal(b.map.p1.checkedAt, t2); assert.equal(b.map.p1.history?.length ?? 0, 0); assert.equal(b.changed, 0); // A real change appends the PREVIOUS verdict, preserving when it was true. const c = mergePostAvailability(b.map, new Map([["p1", "deleted"]]), t3); assert.equal(c.map.p1.availability, "deleted"); assert.deepEqual(c.newlyDeleted, ["p1"]); assert.equal(c.map.p1.history?.at(-1)?.availability, "available"); assert.equal(c.map.p1.history?.at(-1)?.at, t2); // Already-deleted is not "newly" deleted on a later sweep. const d = mergePostAvailability(c.map, new Map([["p1", "deleted"]]), t3); assert.deepEqual(d.newlyDeleted, []); }); test("a failed check records error, never a deletion", async () => { const { mergePostAvailability } = await import("./posts-server"); const r = mergePostAvailability( {}, new Map([["p1", "error"], ["p2", "account_unavailable"]]), "2026-01-01T00:00:00.000Z", ); // Neither is evidence the post itself was removed. assert.deepEqual(r.newlyDeleted, []); assert.equal(r.map.p1.availability, "error"); assert.equal(r.map.p2.availability, "account_unavailable"); }); test("availability round-trips through the sidecar file", async () => { const { readPostAvailability, writePostAvailability } = await import("./posts-server"); await withTempChannel(async (root) => { assert.deepEqual(await readPostAvailability(root), {}); await writePostAvailability(root, { p1: { availability: "deleted", checkedAt: "2026-01-01T00:00:00.000Z" }, bad: { availability: "nonsense", checkedAt: "x" } as never, }); const back = await readPostAvailability(root); assert.equal(back.p1.availability, "deleted"); // A malformed entry is dropped rather than crashing the read. assert.equal(back.bad, undefined); }); }); test("selectPostsToCheck sweeps least-recently-checked first", async () => { const { selectPostsToCheck } = await import("../controller/checkPostAvailability"); const avail = { a: { availability: "available" as const, checkedAt: "2026-03-01T00:00:00.000Z" }, b: { availability: "available" as const, checkedAt: "2026-01-01T00:00:00.000Z" }, }; const ids = ["a", "b", "c"]; // "c" was never checked, so it sorts first; then the oldest check. assert.deepEqual(selectPostsToCheck(ids, avail, "stale", 3), ["c", "b", "a"]); // Repeated runs therefore cover the whole archive rather than the same head. assert.deepEqual(selectPostsToCheck(ids, avail, "stale", 1), ["c"]); assert.deepEqual(selectPostsToCheck(ids, avail, "unchecked", 10), ["c"]); assert.equal(selectPostsToCheck(ids, avail, "all", 10).length, 3); });