/** * Per-feed pagination test — the store contract behind the "[Fetch More]" * row in a drilled show's episode list (My Shows depth 1) and the Feed * page's row. * * addFeed caches every episode inside the lifecycle window (the last * EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing * only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` * reports when the cache holds more than the loaded window; * `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50) * chunks until it is exhausted. This pins: * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch * needed) and hasMoreEpisodes flips false once the window reaches the end. * 3. loadMoreEpisodes past the end is a no-op (the feed is untouched). */ import { test, expect, beforeAll, afterAll } 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-pagination-")); process.env.XDG_CONFIG_HOME = configHome; import { useFeedStore } from "../src/stores/feed"; import type { Podcast } from "../src/types/podcast"; const HOUR = 3600 * 1000; interface ServedEpisode { title: string; date: string; } let server: ReturnType | null = null; let servedEpisodes: ServedEpisode[] = []; // Bun runs test files in ONE process, so the store singleton is shared with // feed-refresh.test.ts / feedless-subscribe.test.ts. Track the feeds we add // and remove them in afterAll so whichever file runs next sees a pristine // store (execution order between files is not guaranteed). const addedFeedIds: string[] = []; /** XML for the current served episode list (episode ids = feedUrl#index). */ function feedXml(episodes: ServedEpisode[], origin: string): string { const items = episodes .map( (ep, i) => ` ${ep.title} ${ep.date} `, ) .join("\n"); return ` Paged Show Pagination test feed ${items} `; } const makePodcast = (feedUrl: string): Podcast => ({ id: feedUrl, title: "Paged Show", description: "Pagination test feed", author: "tester", feedUrl, lastUpdated: new Date(), isSubscribed: true, }); 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 }); }, }); }); afterAll(() => { // Leave the shared singleton as we found it (see addedFeedIds note). const store = useFeedStore(); for (const id of addedFeedIds) store.removeFeed(id); server?.stop(true); rmSync(configHome, { recursive: true, force: true }); }); test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => { const store = useFeedStore(); // 60 episodes: 20 shown at subscribe, 40 held back in the cache. All // inside the lifecycle window (11h apart ≈ 27.5 days) so every one is // cacheable — the cache bound is the date window, not a count. servedEpisodes = Array.from({ length: 60 }, (_, i) => ({ title: `Ep ${60 - i}`, date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(), })); const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); expect(feed).not.toBeNull(); const id = feed!.id; addedFeedIds.push(id); // Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with more cached. expect(store.getFeed(id)!.episodes.length).toBe(20); expect(store.hasMoreEpisodes(id)).toBe(true); // One load-more covers the remaining 40 (20 + 50 >= 60). await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.length).toBe(60); expect(store.hasMoreEpisodes(id)).toBe(false); // Exhausted: loadMoreEpisodes is a no-op — the feed object is untouched. const before = store.getFeed(id)!; await store.loadMoreEpisodes(id); expect(store.getFeed(id)).toBe(before); }); test("hasMoreEpisodes stays true across chunked loads until the end", async () => { const store = useFeedStore(); // 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining. // All inside the lifecycle window (5h apart = 25 days). servedEpisodes = Array.from({ length: 120 }, (_, i) => ({ title: `Ep ${120 - i}`, date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(), })); const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); expect(feed).not.toBeNull(); const id = feed!.id; addedFeedIds.push(id); await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.length).toBe(70); expect(store.hasMoreEpisodes(id)).toBe(true); await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.length).toBe(120); expect(store.hasMoreEpisodes(id)).toBe(false); });