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:
@@ -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,
|
|
||||||
coverUrl: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mock.module("../src/stores/feed", () => ({
|
|
||||||
useFeedStore: () => mockFeedStore,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { useDiscoverStore } = await import("../src/stores/discover");
|
// Query-suffixed module identity: loads the REAL discover store even when a
|
||||||
|
// 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" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
origin = `http://127.0.0.1:${server.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user