Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/services/FeedCacheService.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<p>Episode ${number}</p>`,
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);
});
});
237 changes: 237 additions & 0 deletions src/ui/PodcastView/PodcastView.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from "vitest";

import createPodcastNote from "src/createPodcastNote";
import {
clearFeedCache,
setCachedEpisodes,
} from "src/services/FeedCacheService";
import {
currentEpisode,
episodeCache,
Expand Down Expand Up @@ -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: `<p>Episode ${number}</p>`,
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(() => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading