Add episodeCacheMode/count/days preferences (default: date, 60 days). Apply the bound when reading instead of writing, so a preference change takes effect without a refetch; the full parse cache stays intact so fetch-more can page beyond the bound. Thread the window through load/saveFeedsToFile and update tests and task docs.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
/**
|
|
* Configurable episode cache + volatile merge tests.
|
|
*
|
|
* The episode list cache (what the Feed and My Shows pages show) is bounded by
|
|
* the user's preference: a date window (default 60 days) or a count (default
|
|
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
|
|
* bound from that cache (volatile — never written back). These tests pin:
|
|
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
|
|
* (fetched copy wins on id collision) and prunes by the supplied keep
|
|
* predicate (count or date). Undated episodes are always kept.
|
|
* 2. The store bounds the visible list by the configured mode, but the
|
|
* full parse cache survives — fetch-more pages beyond the bound.
|
|
* 3. Refresh merge never shrinks the in-memory list except via the bound.
|
|
*
|
|
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
|
|
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
|
|
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
|
|
* boundary is pinned with relative pubDates, never by moving the clock.
|
|
*/
|
|
|
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
|
import { mkdtempSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
|
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
|
// (their module-level init reads it).
|
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
|
process.env.XDG_CONFIG_HOME = configHome;
|
|
|
|
import { useFeedStore } from "../src/stores/feed";
|
|
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
|
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
|
import { useAppStore } from "../src/stores/app";
|
|
import type { Episode } from "../src/types/episode";
|
|
import type { Podcast } from "../src/types/podcast";
|
|
|
|
const HOUR = 3600 * 1000;
|
|
const DAY = 24 * HOUR;
|
|
|
|
interface ServedEpisode {
|
|
title: string;
|
|
date: string;
|
|
}
|
|
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
let servedEpisodes: ServedEpisode[] = [];
|
|
const addedFeedIds: string[] = [];
|
|
|
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
|
const items = episodes
|
|
.map(
|
|
(ep, i) => `<item>
|
|
<title>${ep.title}</title>
|
|
<pubDate>${ep.date}</pubDate>
|
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
|
</item>`,
|
|
)
|
|
.join("\n");
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0"><channel>
|
|
<title>Volatile Show</title>
|
|
<description>Volatile merge test feed</description>
|
|
${items}
|
|
</channel></rss>`;
|
|
}
|
|
|
|
const makePodcast = (feedUrl: string): Podcast => ({
|
|
id: feedUrl,
|
|
title: "Volatile Show",
|
|
description: "Volatile merge test feed",
|
|
author: "tester",
|
|
feedUrl,
|
|
lastUpdated: new Date(),
|
|
isSubscribed: true,
|
|
});
|
|
|
|
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
|
|
id,
|
|
podcastId: "pod",
|
|
title,
|
|
description: "",
|
|
audioUrl: `https://example.com/${id}.mp3`,
|
|
duration: 100,
|
|
pubDate,
|
|
});
|
|
|
|
beforeAll(() => {
|
|
server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
const url = new URL(req.url);
|
|
if (url.pathname.endsWith(".xml")) {
|
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
|
headers: { "Content-Type": "application/rss+xml" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers();
|
|
const store = useFeedStore();
|
|
for (const id of addedFeedIds) store.removeFeed(id);
|
|
server?.stop(true);
|
|
rmSync(configHome, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
|
|
|
|
const NOW = new Date("2026-08-10T00:00:00Z");
|
|
|
|
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
|
|
const existing = [
|
|
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
|
|
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
|
|
];
|
|
const fetched = [
|
|
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
|
|
];
|
|
const keepAll = () => true;
|
|
|
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
|
|
|
expect(merged).toHaveLength(2);
|
|
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
|
});
|
|
|
|
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
|
const existing = [
|
|
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
|
];
|
|
const fetched = [
|
|
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
|
|
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
|
|
];
|
|
const keepAll = () => true;
|
|
|
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
|
|
|
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
|
|
});
|
|
|
|
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
|
|
const existing = [
|
|
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
|
|
];
|
|
const fetched = [
|
|
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
|
|
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
|
|
];
|
|
const keepCount2 = (_ep: Episode, i: number) => i < 2;
|
|
|
|
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
|
|
|
|
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
|
|
});
|
|
|
|
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
|
|
const existing = [
|
|
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
|
|
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
|
|
makeEpisode("undated", "Undated", new Date(NaN)),
|
|
];
|
|
const fetched = [
|
|
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
|
|
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
|
|
];
|
|
// 30-day window from NOW (2026-08-10)
|
|
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
|
|
|
|
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
|
|
|
|
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
|
|
});
|
|
|
|
test("mergeEpisodesBounded never mutates its inputs", () => {
|
|
const existing = [
|
|
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
|
|
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
|
|
];
|
|
const fetched = [
|
|
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
|
|
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
|
|
];
|
|
const existingIds = existing.map((e) => e.id);
|
|
const existingTitles = existing.map((e) => e.title);
|
|
const keepAll = () => true;
|
|
|
|
mergeEpisodesBounded(existing, fetched, keepAll);
|
|
|
|
expect(existing.map((e) => e.id)).toEqual(existingIds);
|
|
expect(existing.map((e) => e.title)).toEqual(existingTitles);
|
|
});
|
|
|
|
// ── store integration (default date mode, 60-day window) ─────────────────
|
|
|
|
test("refresh merges new episodes without removing the volatile window", async () => {
|
|
const store = useFeedStore();
|
|
servedEpisodes = [
|
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
|
];
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
|
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
|
expect(feed).not.toBeNull();
|
|
const id = feed!.id;
|
|
addedFeedIds.push(id);
|
|
expect(store.getFeed(id)!.episodes.length).toBe(3);
|
|
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
|
|
|
|
servedEpisodes = [
|
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
|
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
|
|
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
|
|
];
|
|
|
|
vi.advanceTimersByTime(60_000);
|
|
await store.refreshFeed(id);
|
|
|
|
const afterFirst = store.getFeed(id)!;
|
|
expect(afterFirst.episodes.length).toBe(5);
|
|
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
|
|
|
|
// Identical second refresh: no lastUpdated bump, object identity kept.
|
|
vi.advanceTimersByTime(60_000);
|
|
await store.refreshFeed(id);
|
|
|
|
const afterSecond = store.getFeed(id)!;
|
|
expect(afterSecond).toBe(afterFirst);
|
|
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
|
|
});
|
|
|
|
test("date mode: episodes outside the 60-day window never enter the list", async () => {
|
|
const store = useFeedStore();
|
|
const now = Date.now();
|
|
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
|
|
// window, so all 600 are cached and loadable (no count ceiling).
|
|
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
|
|
title: `Ep ${600 - i}`,
|
|
date: new Date(now - i * 2 * HOUR).toISOString(),
|
|
}));
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
|
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
|
expect(feed).not.toBeNull();
|
|
const id = feed!.id;
|
|
addedFeedIds.push(id);
|
|
|
|
// Subscribe window (20) with more cached.
|
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
|
|
|
// Load everything — the cache holds all 600 (date mode keeps them all).
|
|
let iterations = 0;
|
|
while (store.hasMoreEpisodes(id) && iterations < 20) {
|
|
await store.loadMoreEpisodes(id);
|
|
iterations++;
|
|
}
|
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
|
expect(store.getFeed(id)!.episodes.length).toBe(600);
|
|
});
|
|
|
|
test("date mode boundary: 25 days in, 70 days out", async () => {
|
|
const store = useFeedStore();
|
|
const now = Date.now();
|
|
servedEpisodes = [
|
|
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
|
|
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
|
|
];
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`;
|
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
|
expect(feed).not.toBeNull();
|
|
const id = feed!.id;
|
|
addedFeedIds.push(id);
|
|
|
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
|
"In Window",
|
|
]);
|
|
// The full cache holds both, but the visible list only shows the in-window
|
|
// one — fetch-more surfaces the out-of-window one (volatile).
|
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
|
await store.loadMoreEpisodes(id);
|
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
|
"In Window",
|
|
"Out Window",
|
|
]);
|
|
});
|
|
|
|
// ── count mode ────────────────────────────────────────────────────────────
|
|
|
|
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
|
const store = useFeedStore();
|
|
const app = useAppStore();
|
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
|
|
|
const now = Date.now();
|
|
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
|
|
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
|
|
title: `Ep ${50 - i}`,
|
|
date: new Date(now - i * HOUR).toISOString(),
|
|
}));
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
|
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
|
expect(feed).not.toBeNull();
|
|
const id = feed!.id;
|
|
addedFeedIds.push(id);
|
|
|
|
// Subscribe window (20), but the cache holds all 50 — count mode only
|
|
// bounds the visible list (25), but the full parse cache is unbounded.
|
|
// The subscribe window returns min(20, 25) = 20.
|
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
|
|
|
// Fetch more: the visible list grows beyond the count bound — these
|
|
// episodes are volatile (held in feed.episodes, not extending the cache).
|
|
while (store.hasMoreEpisodes(id)) {
|
|
await store.loadMoreEpisodes(id);
|
|
}
|
|
expect(store.getFeed(id)!.episodes.length).toBe(50);
|
|
|
|
// Reset to date mode for subsequent tests.
|
|
app.updatePreferences({ episodeCacheMode: "date" });
|
|
});
|