import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { charsetFromContentType, decodeBody, freshnessAt, hashBody, sniffMetaCharset, SnapshotStore, type SnapshotMeta, } from "../src/ingest/snapshots.ts"; const SIX_HOURS_MS = 6 * 60 * 60 * 1000; const T0 = "2026-08-15T00:00:00.000Z"; const T1 = "2026-08-15T12:00:00.000Z"; let root: string; let store: SnapshotStore; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "event-clock-snapshots-")); store = new SnapshotStore(root); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); function save( body: Uint8Array | string, at: string, extra: Partial<{ etag: string | null; lastModified: string | null; eventCount: number | null; charset: string; }> = {}, ) { return store.save("genshin-game8-events", { url: "https://game8.co/games/Genshin-Impact/archives/301601", body, ...(extra.charset === undefined ? {} : { charset: extra.charset }), etag: extra.etag ?? null, lastModified: extra.lastModified ?? null, at, eventCount: extra.eventCount ?? null, }); } describe("SnapshotStore", () => { test("an unfetched source reads as nothing, not as an error", async () => { expect(await store.read("genshin-game8-events")).toBeNull(); expect(await store.readMeta("genshin-game8-events")).toBeNull(); expect(await store.list()).toEqual([]); }); test("stores the body verbatim and its metadata", async () => { const { changed, meta } = await save("one", T0, { etag: 'W/"abc"', lastModified: "Fri, 14 Aug 2026 09:00:00 GMT", eventCount: 9, }); expect(changed).toBe(true); expect(meta.contentHash).toBe(hashBody("one")); expect(meta.eventCount).toBe(9); const snapshot = await store.read("genshin-game8-events"); expect(snapshot?.html).toBe("one"); expect(snapshot?.meta.etag).toBe('W/"abc"'); expect(await store.list()).toEqual(["genshin-game8-events"]); }); test("identical bytes are not a change", async () => { await save("one", T0, { etag: '"v1"', eventCount: 9 }); const again = await save("one", T1, { etag: '"v1"', eventCount: 9, }); expect(again.changed).toBe(false); // contentChangedAt still points at the fetch that produced these bytes, // which is what keeps an unchanged cycle out of the commit log. expect(again.meta.contentChangedAt).toBe(T0); }); test("identical bytes and identical validators rewrite nothing at all", async () => { await save("one", T0, { etag: '"v1"', eventCount: 9 }); const before = await Bun.file(store.metaPath("genshin-game8-events")).text(); await save("one", T1, { etag: '"v1"', eventCount: 9 }); expect(await Bun.file(store.metaPath("genshin-game8-events")).text()).toBe( before, ); }); test("a rotated ETag is stored even though the bytes did not change", async () => { // Servers rotate validators on identical bytes. Keeping the old one meant // sending a stale If-None-Match forever, so the wiki would serve the whole // page every cycle — the exact cost conditional requests exist to avoid. await save("one", T0, { etag: '"v1"', eventCount: 9 }); const again = await save("one", T1, { etag: '"v2"', lastModified: "Sat, 15 Aug 2026 12:00:00 GMT", eventCount: 9, }); expect(again.changed).toBe(false); expect(again.meta.contentChangedAt).toBe(T0); expect(again.meta.etag).toBe('"v2"'); const persisted = await store.readMeta("genshin-game8-events"); expect(persisted?.etag).toBe('"v2"'); expect(persisted?.contentChangedAt).toBe(T0); expect(store.conditionalHeaders(persisted)).toEqual({ "If-None-Match": '"v2"', "If-Modified-Since": "Sat, 15 Aug 2026 12:00:00 GMT", }); }); test("different bytes are a change", async () => { await save("one", T0, { eventCount: 9 }); const next = await save("two", T1, { eventCount: 10 }); expect(next.changed).toBe(true); expect(next.meta.contentChangedAt).toBe(T1); expect((await store.read("genshin-game8-events"))?.html).toBe( "two", ); }); test("re-saves when the metadata survived but the body did not", async () => { await save("one", T0); await rm(store.bodyPath("genshin-game8-events")); expect(await store.read("genshin-game8-events")).toBeNull(); expect((await save("one", T1)).changed).toBe(true); }); test("unreadable metadata is treated as no cache rather than crashing", async () => { await save("one", T0); await writeFile(store.metaPath("genshin-game8-events"), "{ truncated"); expect(await store.readMeta("genshin-game8-events")).toBeNull(); }); }); describe("charset", () => { // "イベント" as Shift_JIS, and "Café" as Latin-1: both are mojibake if the // bytes are read as UTF-8. const SJIS = new Uint8Array([0x83, 0x43, 0x83, 0x78, 0x83, 0x93, 0x83, 0x67]); const LATIN1 = new Uint8Array([0x43, 0x61, 0x66, 0xe9]); test("reads the charset out of a Content-Type header", () => { expect(charsetFromContentType('text/html; charset="Shift_JIS"')).toBe( "shift_jis", ); expect(charsetFromContentType("text/html;charset=iso-8859-1")).toBe( "iso-8859-1", ); expect(charsetFromContentType("text/html")).toBeNull(); expect(charsetFromContentType(null)).toBeNull(); }); test("falls back to the meta charset in the document head", () => { const html = new TextEncoder().encode( 'x', ); expect(sniffMetaCharset(html)).toBe("shift_jis"); expect( sniffMetaCharset( new TextEncoder().encode( '', ), ), ).toBe("euc-jp"); expect(sniffMetaCharset(new TextEncoder().encode(""))).toBeNull(); }); test("decodes with the declared charset, not with UTF-8", () => { expect(decodeBody(SJIS, "text/html; charset=shift_jis").text).toBe( "イベント", ); expect(decodeBody(LATIN1, "text/html; charset=iso-8859-1").text).toBe("Café"); expect(decodeBody(new TextEncoder().encode("ok"), null).text).toBe("ok"); }); test("an encoding label we do not know falls back to UTF-8 rather than throwing", () => { const decoded = decodeBody( new TextEncoder().encode("ok"), "text/html; charset=x-nonesuch", ); expect(decoded.text).toBe("ok"); expect(decoded.charset).toBe("utf-8"); }); test("stores the served bytes verbatim and reads them back as text", async () => { const { meta } = await save(SJIS, T0, { charset: "shift_jis", eventCount: 2 }); // The bytes on disk are the bytes the server sent; the hash and the size // describe those bytes, so a later re-decode is still possible. const onDisk = new Uint8Array( await Bun.file(store.bodyPath("genshin-game8-events")).arrayBuffer(), ); expect([...onDisk]).toEqual([...SJIS]); expect(meta.bytes).toBe(SJIS.byteLength); expect(meta.contentHash).toBe(hashBody(SJIS)); const snapshot = await store.read("genshin-game8-events"); expect(snapshot?.html).toBe("イベント"); expect(snapshot?.html).not.toContain("�"); }); test("metadata written before charsets were recorded reads as UTF-8", async () => { await save("é", T0); const meta = await store.readMeta("genshin-game8-events"); const { charset: _dropped, ...legacy } = meta as SnapshotMeta; await writeFile( store.metaPath("genshin-game8-events"), `${JSON.stringify(legacy, null, 2)}\n`, ); expect((await store.read("genshin-game8-events"))?.html).toBe("é"); }); }); describe("writes land whole or not at all", () => { test("leaves no temp files behind", async () => { await save("one", T0); await store.recordCheck("genshin-game8-events", { at: T0, status: 200, ok: true, }); const names = await readdir(root); expect(names.sort()).toEqual([ "genshin-game8-events.html", "genshin-game8-events.meta.json", "genshin-game8-events.state.json", ]); }); test("a temp file left by a crashed run is not mistaken for a snapshot", async () => { await writeFile( join(root, "genshin-game8-events.meta.json.tmp-abc"), "{ half", ); expect(await store.list()).toEqual([]); await save("one", T0); expect(await store.list()).toEqual(["genshin-game8-events"]); }); test("replaces each file rather than overwriting it in place", async () => { // Written in place, a snapshot exists in a truncated state for as long as // the write takes, and a crash there leaves it that way. Renaming a // complete temp file over it cannot: the replacement is one atomic step, // which shows up as a new inode. await save("one", T0, { etag: '"v1"' }); const before = { body: statSync(store.bodyPath("genshin-game8-events")).ino, meta: statSync(store.metaPath("genshin-game8-events")).ino, }; await save("two", T1, { etag: '"v2"' }); expect(statSync(store.bodyPath("genshin-game8-events")).ino).not.toBe( before.body, ); expect(statSync(store.metaPath("genshin-game8-events")).ino).not.toBe( before.meta, ); // Including the metadata-only rewrite that a rotated validator triggers. const metaIno = statSync(store.metaPath("genshin-game8-events")).ino; await save("two", T1, { etag: '"v3"' }); expect(statSync(store.metaPath("genshin-game8-events")).ino).not.toBe( metaIno, ); // And the state file, which is written on every single cycle. await store.recordCheck("genshin-game8-events", { at: T0, status: 200, ok: true, }); const stateIno = statSync(store.statePath("genshin-game8-events")).ino; await store.recordCheck("genshin-game8-events", { at: T1, status: 200, ok: true, }); expect(statSync(store.statePath("genshin-game8-events")).ino).not.toBe( stateIno, ); }); test("a reader never sees a half-written body", async () => { const first = `${"a".repeat(1_000_000)}`; const second = `${"b".repeat(1_000_000)}`; await save(first, T0); let stop = false; const seen = new Set(); const reader = (async () => { while (!stop) { const text = await Bun.file(store.bodyPath("genshin-game8-events")).text(); seen.add(`${text.length}:${text.slice(-8)}`); await Bun.sleep(0); } })(); await save(second, T1); stop = true; await reader; for (const observed of seen) { expect([ `${first.length}:${first.slice(-8)}`, `${second.length}:${second.slice(-8)}`, ]).toContain(observed); } }); }); describe("conditional requests", () => { test("emits both validators when both are known", async () => { const { meta } = await save("one", T0, { etag: 'W/"abc"', lastModified: "Fri, 14 Aug 2026 09:00:00 GMT", }); expect(store.conditionalHeaders(meta)).toEqual({ "If-None-Match": 'W/"abc"', "If-Modified-Since": "Fri, 14 Aug 2026 09:00:00 GMT", }); }); test("emits only what the server gave us", async () => { const { meta } = await save("one", T0, { etag: 'W/"abc"' }); expect(store.conditionalHeaders(meta)).toEqual({ "If-None-Match": 'W/"abc"' }); }); test("a source never fetched sends no validators", () => { expect(store.conditionalHeaders(null)).toEqual({}); }); }); describe("the six-hour floor", () => { const at = (iso: string) => Date.parse(iso); test("a source never checked is due", async () => { const state = await store.readState("genshin-game8-events"); expect(store.isDue(state, at(T0), SIX_HOURS_MS)).toBe(true); }); test("holds off until six hours have passed", async () => { await store.recordCheck("genshin-game8-events", { at: T0, status: 200, ok: true, }); const state = await store.readState("genshin-game8-events"); expect(store.isDue(state, at(T0) + SIX_HOURS_MS - 1, SIX_HOURS_MS)).toBe( false, ); expect(store.isDue(state, at(T0) + SIX_HOURS_MS, SIX_HOURS_MS)).toBe(true); expect(store.dueAt(state, SIX_HOURS_MS)).toBe(at(T0) + SIX_HOURS_MS); }); test("a failed attempt still counts as an attempt", async () => { await store.recordCheck("genshin-game8-events", { at: T0, status: 503, ok: false, }); const state = await store.readState("genshin-game8-events"); expect(state.consecutiveFailures).toBe(1); expect(state.lastConfirmedAt).toBeNull(); expect(store.isDue(state, at(T0) + 60_000, SIX_HOURS_MS)).toBe(false); }); test("a success clears the failure streak", async () => { await store.recordCheck("x", { at: T0, status: 503, ok: false }); await store.recordCheck("x", { at: T1, status: 304, ok: true }); const state = await store.readState("x"); expect(state.consecutiveFailures).toBe(0); expect(state.lastConfirmedAt).toBe(T1); }); test("check bookkeeping lives outside the committed metadata", async () => { await save("one", T0); const before = await Bun.file(store.metaPath("genshin-game8-events")).text(); await store.recordCheck("genshin-game8-events", { at: T1, status: 304, ok: true, }); const after = await Bun.file(store.metaPath("genshin-game8-events")).text(); expect(after).toBe(before); }); }); describe("freshnessAt", () => { const meta: SnapshotMeta = { sourceId: "x", url: "https://x.test", contentHash: "abc", bytes: 3, etag: null, lastModified: null, contentChangedAt: T0, eventCount: 1, }; test("reports the last confirmation when there is one", () => { expect( freshnessAt({ meta, state: { sourceId: "x", lastCheckedAt: T1, lastConfirmedAt: T1, lastStatus: 304, consecutiveFailures: 0, }, html: "", }), ).toBe(T1); }); test("never claims to be fresher than the bytes", () => { expect( freshnessAt({ meta, state: { sourceId: "x", lastCheckedAt: null, lastConfirmedAt: null, lastStatus: null, consecutiveFailures: 0, }, html: "", }), ).toBe(T0); }); });