diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts new file mode 100644 index 0000000..74b7a06 --- /dev/null +++ b/src/services/FeedCacheService.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import type { Episode } from "src/types/Episode"; +import type { PodcastFeed } from "src/types/PodcastFeed"; +import { + clearFeedCache, + getCachedEpisodes, + setCachedEpisodes, +} from "./FeedCacheService"; + +const testFeed: PodcastFeed = { + title: "Accidental Tech Podcast", + url: "https://pod.example.com/feed.xml", + artworkUrl: "https://pod.example.com/art.jpg", +}; + +function createEpisode(number: number): Episode { + return { + title: `Episode ${number}`, + streamUrl: `https://pod.example.com/ep-${number}.mp3`, + url: `https://pod.example.com/ep-${number}`, + description: `Description for episode ${number}`, + content: `
Episode ${number}
`, + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date(`2024-01-${String((number % 28) + 1).padStart(2, "0")}T00:00:00.000Z`), + }; +} + +describe("FeedCacheService", () => { + beforeEach(() => { + clearFeedCache(); + }); + + test("persists at most 75 episodes per feed (#124 cap)", () => { + const episodes = Array.from({ length: 100 }, (_, index) => + createEpisode(index + 1), + ); + + setCachedEpisodes(testFeed, episodes); + + const cached = getCachedEpisodes(testFeed); + expect(cached).toHaveLength(75); + expect(cached?.[0]?.title).toBe("Episode 1"); + expect(cached?.[74]?.title).toBe("Episode 75"); + expect(cached?.some((episode) => episode.title === "Episode 100")).toBe( + false, + ); + }); + + test("returns persisted episodes within TTL", () => { + const episodes = [createEpisode(1), createEpisode(2)]; + + setCachedEpisodes(testFeed, episodes); + + expect(getCachedEpisodes(testFeed)).toEqual(episodes); + }); +}); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 3e04885..2654539 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -10,6 +10,10 @@ import { } from "vitest"; import createPodcastNote from "src/createPodcastNote"; +import { + clearFeedCache, + setCachedEpisodes, +} from "src/services/FeedCacheService"; import { currentEpisode, episodeCache, @@ -67,6 +71,43 @@ function resetStores() { viewState.set(ViewState.PodcastGrid); currentEpisode.update(() => undefined as unknown as Episode); plugin.set(undefined as never); + clearFeedCache(); +} + +function createNumberedEpisode(number: number): Episode { + return { + title: `Episode ${number}`, + streamUrl: `https://pod.example.com/ep-${number}.mp3`, + url: `https://pod.example.com/ep-${number}`, + description: `Description for episode ${number}`, + content: `Episode ${number}
`, + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date( + `2024-${String((number % 12) + 1).padStart(2, "0")}-15T00:00:00.000Z`, + ), + }; +} + +function createTruncatedFeedCache(): Episode[] { + return Array.from({ length: 75 }, (_, index) => + createNumberedEpisode(622 + index), + ); +} + +function createFullFeed(): Episode[] { + return [createNumberedEpisode(100), ...createTruncatedFeedCache()]; +} + +function enableFeedCache() { + plugin.set({ + settings: { + feedCache: { + enabled: true, + ttlHours: 6, + }, + }, + } as never); } beforeEach(() => { @@ -326,3 +367,199 @@ describe("PodcastView integration flow", () => { expect(screen.queryByText("Already Finished")).not.toBeInTheDocument(); }); }); + +describe("issue #174 feed cache cap regression", () => { + const oldEpisode = createNumberedEpisode(100); + const truncatedCache = createTruncatedFeedCache(); + const fullFeed = createFullFeed(); + + beforeEach(() => { + enableFeedCache(); + episodeCache.set({ [testFeed.title]: truncatedCache }); + setCachedEpisodes(testFeed, truncatedCache); + mockGetEpisodes.mockResolvedValue(fullFeed); + }); + + test("opening a show bypasses the truncated cache and loads older episodes", async () => { + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(get(episodeCache)[testFeed.title]).toHaveLength(76); + expect( + screen.queryByText(createNumberedEpisode(621).title), + ).not.toBeInTheDocument(); + }); + + test("played view bypasses the truncated cache for older finished episodes", async () => { + hidePlayedEpisodes.set(true); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Played")).toBeInTheDocument(); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect( + screen.queryByText("Unavailable in current feeds"), + ).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByText(oldEpisode.title)); + expect(get(currentEpisode)).toMatchObject({ title: oldEpisode.title }); + expect(get(viewState)).toBe(ViewState.Player); + }); + + test("latest episodes background fetch still uses the truncated cache", async () => { + viewState.set(ViewState.EpisodeList); + + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + expect( + screen.queryByText(oldEpisode.title), + ).not.toBeInTheDocument(); + expect( + await screen.findByText(createNumberedEpisode(622).title), + ).toBeInTheDocument(); + }); + + test("reopening a show reuses full in-memory cache without refetching", async () => { + render(PodcastView); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: /podcast grid/i }), + ); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + + await fireEvent.click(feedImage); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); + + test("falls back to truncated cache when a full feed fetch fails", async () => { + mockGetEpisodes.mockRejectedValue(new Error("network unavailable")); + + render(PodcastView); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + expect( + await screen.findByText(createNumberedEpisode(622).title), + ).toBeInTheDocument(); + expect(screen.queryByText(oldEpisode.title)).not.toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); + + test("played view only fetches feeds with finished played episodes", async () => { + const secondFeed: PodcastFeed = { + title: "Second Podcast", + url: "https://pod.example.com/feed-two.xml", + artworkUrl: "https://pod.example.com/art-two.jpg", + }; + + hidePlayedEpisodes.set(true); + savedFeeds.set({ + [testFeed.title]: testFeed, + [secondFeed.title]: secondFeed, + }); + episodeCache.set({ + [testFeed.title]: truncatedCache, + [secondFeed.title]: [createNumberedEpisode(900)], + }); + setCachedEpisodes(secondFeed, [createNumberedEpisode(900)]); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + }); + + test("reopening played view reuses full in-memory cache without refetching", async () => { + hidePlayedEpisodes.set(true); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: /latest episodes/i }), + ); + await fireEvent.click(playedCard); + + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 3cb5c8c..9ab51d8 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -139,16 +139,38 @@ }; }); + type EpisodeFetchStrategy = "cached" | "full" | "network"; + + function getFeedCacheTtlMs(): number { + const feedCacheSettings = get(plugin)?.settings?.feedCache; + return Math.max(1, feedCacheSettings?.ttlHours ?? 6) * 60 * 60 * 1000; + } + + function isFeedCacheEnabled(): boolean { + return get(plugin)?.settings?.feedCache?.enabled !== false; + } + + function hasFullInMemoryFeed( + inMemoryEpisodes: Episode[] | undefined, + persistedEpisodes: Episode[] | null, + ): boolean { + if (!inMemoryEpisodes?.length) { + return false; + } + + if (!persistedEpisodes?.length) { + return true; + } + + return inMemoryEpisodes.length > persistedEpisodes.length; + } + async function fetchEpisodes( feed: PodcastFeed, useCache: boolean = true, ): Promise