fix(tests): stop feed-store mock.module leak that broke the full suite

discover-store-preview mocked src/stores/feed via mock.module, which bun
applies process-globally: with workers reused across test files, every file
that later shared a worker imported the stub (fetchEpisodes only) and failed
with 'addFeed is not a function' — ~35 tests, drifting run to run with worker
scheduling. Rewrote the test against the REAL feed store and a local gated
Bun.serve server (repo-dominant harness), importing the discover store via a
query-suffixed specifier so a sibling discover-store mock cannot leak in.
Full suite: 215 pass, 0 fail (baseline: 194).
This commit is contained in:
2026-08-13 18:01:40 -04:00
parent 4ef9ab7e59
commit 4b44623891

View File

@@ -4,30 +4,99 @@
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from * `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 * 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. * per-show error on failure, and never refetches while cached or in flight.
* `refreshEpisodes` clears the cache/error and refetches. The feed store is * `refreshEpisodes` clears the cache/error and refetches.
* mocked so the network never runs; the cache-hit/in-flight/error contracts *
* are what this file defends. * The REAL feed store runs against a local RSS server. No `mock.module`:
* bun test reuses workers across files and module mocks leak into the shared
* registry, so a feed-store mock here (whose stub lacks addFeed/refreshFeed/
* isLoadingFeeds) breaks every later file that shares a worker — the suite's
* documented failure mode. The repo's defense is importing the REAL modules
* via a query-suffixed specifier, which `mock.module` does not intercept.
*/ */
import { test, expect, mock } from "bun:test"; import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import type { Podcast } from "../src/types/podcast"; import type { Podcast } from "../src/types/podcast";
import type { Episode } from "../src/types/episode";
const fetchCalls: string[] = []; // Point the config dir at a throwaway directory BEFORE importing the stores
const mockFeedStore = { // (their module-level init reads it).
fetchEpisodes: async (feedUrl: string, limit: number) => { const configHome = mkdtempSync(join(tmpdir(), "podtui-discprev-"));
fetchCalls.push(feedUrl); process.env.XDG_CONFIG_HOME = configHome;
return {
episodes: [makeEpisode("ep-1")] as Episode[] | null, // Query-suffixed module identity: loads the REAL discover store even when a
coverUrl: undefined, // sibling file's `mock.module("../src/stores/discover")` leaked into this
}; // worker. Its internal `./feed` import resolves the real feed store, which
// no file mocks anymore.
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useDiscoverStore } = await import("../src/stores/discover?discover-store-preview");
interface ServedEpisode {
title: string;
date: string;
}
/** Pathnames the local server has served, in order (fetch tracking). */
const requests: string[] = [];
/** Per-path episode lists served by the local server. */
const served: Record<string, ServedEpisode[]> = {};
/** When set, responses for this path wait on the release callback. */
let gatePath: string | null = null;
let releaseGate: (() => void) | null = null;
/** XML for one show's episode list (ids derive from enclosure URLs). */
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>Test Show</title>
<description>Discover preview test feed</description>
${items}
</channel></rss>`;
}
let server: Bun.Server<undefined> | null = null;
let origin = "";
beforeAll(() => {
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
requests.push(url.pathname);
if (gatePath && url.pathname === gatePath) {
await new Promise<void>((resolve) => {
releaseGate = resolve;
});
}
// A permanently-failing feed (simulates a show that went down).
if (url.pathname === "/fail.xml") {
return new Response("feed unavailable", { status: 503 });
}
const eps = served[url.pathname];
if (!eps) return new Response("not found", { status: 404 });
return new Response(feedXml(eps, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}, },
}; });
mock.module("../src/stores/feed", () => ({ origin = `http://127.0.0.1:${server.port}`;
useFeedStore: () => mockFeedStore, });
}));
const { useDiscoverStore } = await import("../src/stores/discover"); afterAll(() => {
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
function makePodcast(overrides: Partial<Podcast> = {}): Podcast { function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
return { return {
@@ -41,54 +110,45 @@ function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
}; };
} }
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 () => { test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
const store = useDiscoverStore(); const store = useDiscoverStore();
const pod = makePodcast(); const pod = makePodcast({ feedUrl: `${origin}/show1.xml` });
served["/show1.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
expect(store.episodesForPodcast(pod.id)).toHaveLength(0); expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
await store.openEpisodes(pod); await store.openEpisodes(pod);
expect(fetchCalls).toEqual([pod.feedUrl]); expect(requests).toEqual(["/show1.xml"]);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1); expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.episodesForPodcast(pod.id)[0].id).toBe("ep-1"); expect(store.episodesForPodcast(pod.id)[0].title).toBe("Ep 1");
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false); expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
expect(store.previewError(pod.id)).toBeUndefined(); expect(store.previewError(pod.id)).toBeUndefined();
// Cache hit: second open must not refetch. // Cache hit: second open must not refetch.
await store.openEpisodes(pod); await store.openEpisodes(pod);
expect(fetchCalls).toHaveLength(1); expect(requests).toEqual(["/show1.xml"]);
// In-flight guard: a concurrent open during loading must not refetch. // In-flight guard: a concurrent open during loading must not refetch.
const slow = mockFeedStore.fetchEpisodes; // The server holds this show's response until the gate is released.
const gate = Promise.withResolvers<void>(); const pod2 = makePodcast({ id: "show-2", feedUrl: `${origin}/slow.xml` });
mockFeedStore.fetchEpisodes = async (feedUrl: string, limit: number) => { served["/slow.xml"] = [{ title: "Ep 2", date: "2026-08-09T00:00:00Z" }];
fetchCalls.push(feedUrl); gatePath = "/slow.xml";
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); const pending = store.openEpisodes(pod2);
// Loading is set synchronously before the fetch resolves. // Loading is set synchronously before the fetch resolves.
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true); expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
await store.openEpisodes(pod2); // must early-return, not queue a second fetch await store.openEpisodes(pod2); // must early-return, not queue a second fetch
gate.resolve(); // The request is held by the server gate; wait until it was actually
// received so the assertion isn't racing the network.
const deadline = Date.now() + 1000;
while (requests.length < 2 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
expect(requests).toEqual(["/show1.xml", "/slow.xml"]);
releaseGate?.();
gatePath = null;
await pending; await pending;
expect(fetchCalls).toEqual([pod.feedUrl, pod2.feedUrl]); expect(store.episodesForPodcast(pod2.id)[0].title).toBe("Ep 2");
expect(store.episodesForPodcast(pod2.id)[0].id).toBe("ep-2");
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false); expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
mockFeedStore.fetchEpisodes = slow;
}); });
test("openEpisodes records an error for feedless shows and failed fetches", async () => { test("openEpisodes records an error for feedless shows and failed fetches", async () => {
@@ -96,34 +156,29 @@ test("openEpisodes records an error for feedless shows and failed fetches", asyn
const feedless = makePodcast({ id: "show-3", feedUrl: undefined }); const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
await store.openEpisodes(feedless); await store.openEpisodes(feedless);
expect(fetchCalls).not.toContain(feedless.id); expect(requests).not.toContain(feedless.id);
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show."); expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0); expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
// Failed fetch (null episodes) → error recorded, nothing cached. // Failed fetch (server 503) → error recorded, nothing cached.
const original = mockFeedStore.fetchEpisodes; const failing = makePodcast({ id: "show-4", feedUrl: `${origin}/fail.xml` });
mockFeedStore.fetchEpisodes = async () => ({
episodes: null,
coverUrl: undefined,
});
const failing = makePodcast({ id: "show-4" });
await store.openEpisodes(failing); await store.openEpisodes(failing);
expect(store.previewError(failing.id)).toBe("Couldn't load episodes."); expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
expect(store.episodesForPodcast(failing.id)).toHaveLength(0); expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false); expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
mockFeedStore.fetchEpisodes = original;
}); });
test("refreshEpisodes clears the cache and error, then refetches", async () => { test("refreshEpisodes clears the cache and error, then refetches", async () => {
const store = useDiscoverStore(); const store = useDiscoverStore();
const pod = makePodcast({ id: "show-5" }); const pod = makePodcast({ id: "show-5", feedUrl: `${origin}/show5.xml` });
served["/show5.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
await store.openEpisodes(pod); await store.openEpisodes(pod);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1); expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
const callsBefore = fetchCalls.length; const callsBefore = requests.length;
await store.refreshEpisodes(pod); await store.refreshEpisodes(pod);
expect(fetchCalls.length).toBe(callsBefore + 1); expect(requests.length).toBe(callsBefore + 1);
expect(store.episodesForPodcast(pod.id)).toHaveLength(1); expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
expect(store.previewError(pod.id)).toBeUndefined(); expect(store.previewError(pod.id)).toBeUndefined();
}); });