feat(discover): drill into show episode previews without subscribing

This commit is contained in:
2026-08-13 17:46:30 -04:00
parent badbc6a037
commit 9df8eebf6c
5 changed files with 842 additions and 28 deletions

View File

@@ -0,0 +1,129 @@
/**
* discover-store-preview.test.ts — the Discover episode-preview store API.
*
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from
* a podcast result), caches it per podcast id for the session, records a
* per-show error on failure, and never refetches while cached or in flight.
* `refreshEpisodes` clears the cache/error and refetches. The feed store is
* mocked so the network never runs; the cache-hit/in-flight/error contracts
* are what this file defends.
*/
import { test, expect, mock } from "bun:test";
import type { Podcast } from "../src/types/podcast";
import type { Episode } from "../src/types/episode";
const fetchCalls: string[] = [];
const mockFeedStore = {
fetchEpisodes: async (feedUrl: string, limit: number) => {
fetchCalls.push(feedUrl);
return {
episodes: [makeEpisode("ep-1")] as Episode[] | null,
coverUrl: undefined,
};
},
};
mock.module("../src/stores/feed", () => ({
useFeedStore: () => mockFeedStore,
}));
const { useDiscoverStore } = await import("../src/stores/discover");
function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
return {
id: "show-1",
title: "Show 1",
description: "",
feedUrl: "https://example.test/feed.xml",
lastUpdated: new Date(),
isSubscribed: false,
...overrides,
};
}
function makeEpisode(id: string): Episode {
return {
id,
podcastId: "show-1",
title: `Ep ${id}`,
description: "",
audioUrl: "https://example.test/ep.mp3",
duration: 0,
pubDate: new Date("2026-08-01T00:00:00Z"),
};
}
test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
const store = useDiscoverStore();
const pod = makePodcast();
expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
await store.openEpisodes(pod);
expect(fetchCalls).toEqual([pod.feedUrl]);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.episodesForPodcast(pod.id)[0].id).toBe("ep-1");
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
expect(store.previewError(pod.id)).toBeUndefined();
// Cache hit: second open must not refetch.
await store.openEpisodes(pod);
expect(fetchCalls).toHaveLength(1);
// In-flight guard: a concurrent open during loading must not refetch.
const slow = mockFeedStore.fetchEpisodes;
const gate = Promise.withResolvers<void>();
mockFeedStore.fetchEpisodes = async (feedUrl: string, limit: number) => {
fetchCalls.push(feedUrl);
await gate.promise;
return { episodes: [makeEpisode("ep-2")] as Episode[] | null, coverUrl: undefined };
};
const pod2 = makePodcast({ id: "show-2", feedUrl: "https://example.test/feed2.xml" });
const pending = store.openEpisodes(pod2);
// Loading is set synchronously before the fetch resolves.
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
await store.openEpisodes(pod2); // must early-return, not queue a second fetch
gate.resolve();
await pending;
expect(fetchCalls).toEqual([pod.feedUrl, pod2.feedUrl]);
expect(store.episodesForPodcast(pod2.id)[0].id).toBe("ep-2");
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
mockFeedStore.fetchEpisodes = slow;
});
test("openEpisodes records an error for feedless shows and failed fetches", async () => {
const store = useDiscoverStore();
const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
await store.openEpisodes(feedless);
expect(fetchCalls).not.toContain(feedless.id);
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
// Failed fetch (null episodes) → error recorded, nothing cached.
const original = mockFeedStore.fetchEpisodes;
mockFeedStore.fetchEpisodes = async () => ({
episodes: null,
coverUrl: undefined,
});
const failing = makePodcast({ id: "show-4" });
await store.openEpisodes(failing);
expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
mockFeedStore.fetchEpisodes = original;
});
test("refreshEpisodes clears the cache and error, then refetches", async () => {
const store = useDiscoverStore();
const pod = makePodcast({ id: "show-5" });
await store.openEpisodes(pod);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
const callsBefore = fetchCalls.length;
await store.refreshEpisodes(pod);
expect(fetchCalls.length).toBe(callsBefore + 1);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.previewError(pod.id)).toBeUndefined();
});