Files
gacha-event-tracker/test/feed.test.ts
T
Lucas Winther 6c09accc20 feat(feed): record crawler check and content update timestamps in SourceHealth
Add lastConfirmedAt and contentChangedAt to SourceHealth and enrich freshness() to track per-source check and content update times.
2026-09-18 08:55:10 +02:00

398 lines
14 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
brokenSources,
freshness,
staleSources,
quietSources,
STALE_AFTER_MS,
SourceHealth,
} from "../src/shared/feed.ts";
import { sourceHealth } from "../src/ingest/health.ts";
import type { GachaEvent, GameId } from "../src/shared/schema.ts";
/**
* Freshness disclosure (PRD F7).
*
* The footer's claim about its own age is load-bearing: a reader deciding
* whether to trust a countdown has nothing else to go on. These pin the two
* ways that claim could lie — reporting a build stamp instead of the data's age,
* and letting one fresh source speak for a game whose other source is a week
* behind.
*/
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
const HOUR = 60 * 60 * 1000;
function source(
game: GameId,
lastSuccessAt: string | null,
sourceId = `${game}-src`,
lastConfirmedAt: string | null = null,
contentChangedAt: string | null = lastSuccessAt,
): SourceHealth {
return {
sourceId,
game,
url: "https://example.test/events",
lastSuccessAt,
lastConfirmedAt,
contentChangedAt,
eventCount: 3,
parsedCount: 3,
statesNoEvents: false,
};
}
describe("freshness", () => {
test("reports the newest confirmation across sources", () => {
const result = freshness(
[
source("genshin", "2026-08-17T06:00:00.000Z"),
source("hsr", "2026-08-17T09:30:00.000Z"),
source("zzz", "2026-08-16T23:00:00.000Z"),
],
NOW,
);
expect(result.refreshedAt).toBe("2026-08-17T09:30:00.000Z");
expect(result.stale).toEqual([]);
});
test("a game is only as fresh as its oldest source", () => {
// Endfield has two. If the wiki refreshed an hour ago but Game8 has been
// down for a week, some of that lane's rows are a week old — so the lane is
// stale even though one of its sources is not.
const result = freshness(
[
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
source("endfield", "2026-08-10T11:00:00.000Z", "endfield-game8-events"),
],
NOW,
);
expect(result.refreshedAt).toBe("2026-08-17T11:00:00.000Z");
expect(result.stale).toEqual([
{
game: "endfield",
lastSuccessAt: "2026-08-10T11:00:00.000Z",
lastConfirmedAt: null,
contentChangedAt: "2026-08-10T11:00:00.000Z",
sources: [
{
sourceId: "endfield-game8-events",
url: "https://example.test/events",
lastSuccessAt: "2026-08-10T11:00:00.000Z",
lastConfirmedAt: null,
contentChangedAt: "2026-08-10T11:00:00.000Z",
},
{
sourceId: "endfield-wikigg-events",
url: "https://example.test/events",
lastSuccessAt: "2026-08-17T11:00:00.000Z",
lastConfirmedAt: null,
contentChangedAt: "2026-08-17T11:00:00.000Z",
},
],
},
]);
});
test("a source that never succeeded makes its game stale, whatever a sibling says", () => {
const result = freshness(
[
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
source("endfield", null, "endfield-game8-events"),
],
NOW,
);
expect(result.stale).toEqual([
{
game: "endfield",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
sources: [
{
sourceId: "endfield-game8-events",
url: "https://example.test/events",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
},
{
sourceId: "endfield-wikigg-events",
url: "https://example.test/events",
lastSuccessAt: "2026-08-17T11:00:00.000Z",
lastConfirmedAt: null,
contentChangedAt: "2026-08-17T11:00:00.000Z",
},
],
},
]);
});
test("order of sources does not change the answer", () => {
const a = source("endfield", null, "a");
const b = source("endfield", "2026-08-17T11:00:00.000Z", "b");
expect(freshness([a, b], NOW).stale).toEqual(freshness([b, a], NOW).stale);
});
test("48 hours is the boundary, and it is exclusive", () => {
const at = new Date(NOW - STALE_AFTER_MS).toISOString();
expect(freshness([source("genshin", at)], NOW).stale).toEqual([]);
const older = new Date(NOW - STALE_AFTER_MS - 1000).toISOString();
expect(freshness([source("genshin", older)], NOW).stale).toHaveLength(1);
});
test("lists the stale games oldest first, never-refreshed ahead of the rest", () => {
const result = freshness(
[
source("genshin", new Date(NOW - 50 * HOUR).toISOString()),
source("hsr", new Date(NOW - 200 * HOUR).toISOString()),
source("zzz", null),
source("wuwa", new Date(NOW - HOUR).toISOString()),
],
NOW,
);
expect(result.stale.map((s) => s.game)).toEqual(["zzz", "hsr", "genshin"]);
});
test("no source has ever succeeded", () => {
// A fresh checkout with no fixtures. Not a state a reader reaches, but the
// footer must say something honest rather than format a null.
const result = freshness([source("genshin", null)], NOW);
expect(result.refreshedAt).toBeNull();
expect(result.stale).toEqual([
{
game: "genshin",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
sources: [
{
sourceId: "genshin-src",
url: "https://example.test/events",
lastSuccessAt: null,
lastConfirmedAt: null,
contentChangedAt: null,
},
],
},
]);
});
test("differentiates blame when data was pulled recently but site has not changed", () => {
const PULL_TIME = new Date(NOW - 3 * HOUR).toISOString();
const OLD_SITE = new Date(NOW - 60 * HOUR).toISOString();
const result = freshness(
[
{
...source("endfield", OLD_SITE, "endfield-wikigg-events"),
lastConfirmedAt: PULL_TIME,
contentChangedAt: OLD_SITE,
},
],
NOW,
);
expect(result.stale).toHaveLength(1);
const staleEndfield = result.stale[0]!;
expect(staleEndfield.lastConfirmedAt).toBe(PULL_TIME);
expect(staleEndfield.contentChangedAt).toBe(OLD_SITE);
expect(staleEndfield.sources[0]?.lastConfirmedAt).toBe(PULL_TIME);
expect(staleEndfield.sources[0]?.contentChangedAt).toBe(OLD_SITE);
});
test("an empty feed reports nothing rather than throwing", () => {
expect(freshness([], NOW)).toEqual({ refreshedAt: null, stale: [] });
});
});
describe("telling a broken source from a stale one", () => {
// CI failed on Infinity Nikki yielding nothing, and the check was wrong to.
// Its snapshot parses to seven events; every one of them had simply ended by
// the day the build ran. `eventCount` is measured after expiry, so "our
// parser broke" and "this source has nothing current left" arrived as the
// same zero — and only the first is a reason to redden a build.
const health = (over: Partial<SourceHealth>): SourceHealth => ({
sourceId: "nikki-fandom-events",
game: "nikki" as GameId,
url: "https://example.test/nikki",
lastSuccessAt: "2026-08-19T00:00:00.000Z",
lastConfirmedAt: null,
contentChangedAt: "2026-08-19T00:00:00.000Z",
eventCount: 0,
parsedCount: 7,
statesNoEvents: false,
...over,
});
test("a source that parsed nothing is broken", () => {
// The failure this check exists for: a page changed shape and the parser
// now reads it as empty. Nothing to publish and nothing to expire.
expect(brokenSources([health({ parsedCount: 0 })]).map((s) => s.sourceId)).toEqual([
"nikki-fandom-events",
]);
});
test("a source whose page states it lists none is not broken", () => {
// The third empty, and the one the refresh runner already knew about while
// the feed did not. Infinity Nikki's wiki replaced both event tables with
// "There are no Events in this category" between 2.7 and 2.8, so the page
// parses to zero and is *answering*. A redesign yields zero too, which is
// why only the page's own words may say which — never the row count.
expect(
brokenSources([health({ parsedCount: 0, statesNoEvents: true })]),
).toEqual([]);
});
test("a page that states its emptiness still fails once it parses nothing at all", () => {
// Guard on the guard. `statesNoEvents` excuses an empty parse, so a parser
// whose selectors all broke must not be able to reach it: the flag is only
// ever set from the page's own declaration, and a source claiming both a
// declaration and rows is a contradiction we do not have to honour.
expect(
brokenSources([health({ parsedCount: 0, statesNoEvents: false })]).map(
(s) => s.sourceId,
),
).toEqual(["nikki-fandom-events"]);
});
test("a source whose events have all ended is not broken", () => {
// The Nikki case exactly. The parser did its job; the calendar moved past
// everything the page still lists.
expect(brokenSources([health({})])).toEqual([]);
});
test("a healthy source is none of the three", () => {
const ok = health({ eventCount: 5, parsedCount: 5 });
expect(brokenSources([ok])).toEqual([]);
expect(staleSources([ok])).toEqual([]);
expect(quietSources([ok])).toEqual([]);
});
test("a source whose page states it lists none is reported as quiet", () => {
// Excusing it from the build is not the same as saying nothing about it.
// The build log prints a count per source, and an unexplained 0 reads as
// the fault this whole distinction exists to deny — so the one empty we
// are content with is the one that has to say why.
expect(
quietSources([health({ parsedCount: 0, statesNoEvents: true })]).map(
(s) => s.sourceId,
),
).toEqual(["nikki-fandom-events"]);
expect(staleSources([health({ parsedCount: 0, statesNoEvents: true })])).toEqual(
[],
);
});
test("a source with nothing current left is reported as stale", () => {
// Worth saying out loud — a lane showing an empty calendar is a real
// problem — but it is a refresh problem, not a code one, so it is
// reported rather than thrown.
expect(staleSources([health({})]).map((s) => s.sourceId)).toEqual([
"nikki-fandom-events",
]);
});
test("a feed cached before the field existed is not called broken", () => {
// The service worker serves the last feed it downloaded, so a feed built
// before `statesNoEvents` shipped still has to validate and still has to
// mean what it meant. Absent parses to `false`, which is the strict
// reading — an old feed cannot vouch for a page it never asked.
const cached = SourceHealth.parse({
sourceId: "nikki-fandom-events",
game: "nikki",
url: "https://example.test/nikki",
lastSuccessAt: "2026-08-19T00:00:00.000Z",
eventCount: 0,
parsedCount: 7,
});
expect(cached.statesNoEvents).toBe(false);
expect(brokenSources([cached])).toEqual([]);
});
test("a feed that never recorded the count is not called broken", () => {
// An older feed — one the service worker cached before this field existed
// — says nothing either way, and absence of information is not evidence of
// a fault.
expect(brokenSources([health({ parsedCount: null })])).toEqual([]);
expect(staleSources([health({ parsedCount: null })])).toEqual([]);
});
});
/**
* What the feed builder records about a source it just parsed.
*
* The distinction above is only worth having if something sets it, and this is
* the seam where it was missing: `scripts/refresh-sources.ts` had asked
* `statesNoEvents` since 2026-09-03 while `scripts/build-feed.ts` never did, so
* a page declaring itself empty reached CI as a bare zero and failed the build.
* The rule was in a script nothing could import, which is why it now lives in a
* module and is exercised here rather than grepped for.
*/
describe("recording a source's health at build time", () => {
const EMPTY = "<p>There are no Events in this category</p>";
const FULL = "<p>Song of the Wandering Sky</p>";
const adapter = (over: Partial<Parameters<typeof sourceHealth>[0]> = {}) => ({
id: "nikki-fandom-events",
game: "nikki" as GameId,
url: "https://example.test/nikki",
parse: (html: string) => (html === FULL ? ([{}] as unknown as GachaEvent[]) : []),
statesNoEvents: (html: string) => html.includes("There are no Events"),
...over,
});
test("a page that declares itself empty is recorded as having answered", () => {
const health = sourceHealth(adapter(), EMPTY, "2026-09-06T15:39:19.376Z", 0);
expect(health.parsedCount).toBe(0);
expect(health.statesNoEvents).toBe(true);
expect(brokenSources([health])).toEqual([]);
});
test("a page that reads empty without saying so is left to fail", () => {
// The redesign case, and the whole reason the flag may not be inferred
// from the row count: this parse is zero too.
const health = sourceHealth(
adapter({ statesNoEvents: () => false }),
EMPTY,
"2026-09-06T15:39:19.376Z",
0,
);
expect(health.statesNoEvents).toBe(false);
expect(brokenSources([health]).map((s) => s.sourceId)).toEqual([
"nikki-fandom-events",
]);
});
test("a source with rows is never marked as declaring itself empty", () => {
// A loose `statesNoEvents` — one matching prose that survives a redesign —
// must not be able to excuse a source that is working. The flag qualifies
// an empty parse and states nothing on its own, so it is asked only of a
// zero, exactly as the refresh runner asks it.
const health = sourceHealth(
adapter({ statesNoEvents: () => true }),
FULL,
"2026-09-06T15:39:19.376Z",
1,
);
expect(health.parsedCount).toBe(1);
expect(health.statesNoEvents).toBe(false);
});
test("a document of unknown age records neither count nor declaration", () => {
// No capture date means no "as of" to parse against, and inventing one
// manufactures a figure the check then trusts. `parsedCount` is null for
// that reason and the declaration goes with it: both answer a question
// about bytes we cannot date.
const health = sourceHealth(adapter(), EMPTY, null, 0);
expect(health.parsedCount).toBeNull();
expect(health.statesNoEvents).toBe(false);
expect(brokenSources([health])).toEqual([]);
});
});