/** * Regression test: media-list search must surface server results. * * `repository_search` is two-phase — `repo.search()` resolves instantly with * cache-only (downloaded) results, and the merged cache+server union arrives * later via a `search-event`. A consumer that ignores that event only ever * shows downloaded content, so search "finds nothing" for un-downloaded media. * * This test models that two-phase backend faithfully and would fail against a * version of GenericMediaListPage that does not subscribe to `search-event`. * * TRACES: UR-008 */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; import GenericMediaListPage from "./GenericMediaListPage.svelte"; import type { MediaListConfig } from "./GenericMediaListPage.svelte"; vi.mock("$app/navigation", () => ({ goto: vi.fn() })); vi.mock("$lib/stores/library", () => ({ currentLibrary: { subscribe: vi.fn((fn) => { fn({ id: "lib123", name: "Music" }); return vi.fn(); }), }, // Consumed as a store ($viewMode) by LibraryGrid, which this page renders. viewMode: { subscribe: vi.fn((fn) => { fn("grid"); return vi.fn(); }), }, })); vi.mock("$lib/stores/auth", () => ({ auth: { getRepository: vi.fn() }, })); vi.mock("$lib/composables/useServerReachabilityReload", () => ({ useServerReachabilityReload: vi.fn(() => ({ markLoaded: vi.fn() })), })); // Capture the `search-event` handler the component registers so the test can // drive the deferred (server) phase manually. let searchEventHandler: ((event: { payload: unknown }) => void) | null = null; vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async (name: string, handler: (event: { payload: unknown }) => void) => { if (name === "search-event") searchEventHandler = handler; return () => {}; }), })); const ALBUM_CONFIG: MediaListConfig = { itemType: "MusicAlbum", title: "Albums", backPath: "/library/music", searchPlaceholder: "Search albums...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "grid", }; describe("GenericMediaListPage — two-phase search", () => { beforeEach(() => { searchEventHandler = null; vi.clearAllMocks(); }); it("renders server results that arrive after the cache-only phase", async () => { // Phase 1 (synchronous) returns cache-only — empty, as it is for a user who // has downloaded nothing. This is the exact condition that used to show // "nothing found" even though the server has matching albums. let capturedRequestId: number | undefined; const search = vi.fn(async (_q: string, _opts: unknown, requestId: number) => { capturedRequestId = requestId; return { items: [], totalRecordCount: 0 }; }); const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 }); vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({ getItems, search, } as any); const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } }); // Let the initial (mount) load finish so the debounced search effect is armed. await waitFor(() => expect(getItems).toHaveBeenCalled()); const input = container.querySelector("input") as HTMLInputElement; fireEvent.input(input, { target: { value: "Rumours" } }); // Debounced search fires after 300ms and returns the empty cache result. // The `search-event` listener is registered lazily as part of searching. await waitFor(() => expect(search).toHaveBeenCalled()); await waitFor(() => expect(searchEventHandler).not.toBeNull()); // Cache-only phase: nothing to show yet (the results counter reads zero). await waitFor(() => expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()); // Phase 2: backend emits the merged cache+server union for this request. expect(capturedRequestId).toBeTypeOf("number"); searchEventHandler!({ payload: { requestId: capturedRequestId, result: { items: [{ id: "album1", name: "Rumours", type: "MusicAlbum" }], totalRecordCount: 1, }, }, }); // The server result must now be reflected in the list. Old code (no // listener) never reached this state — the count stayed at zero. await waitFor(() => expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()); }); it("ignores a search-event whose requestId is stale", async () => { const search = vi.fn(async () => ({ items: [], totalRecordCount: 0 })); const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 }); vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({ getItems, search, } as any); const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } }); await waitFor(() => expect(getItems).toHaveBeenCalled()); const input = container.querySelector("input") as HTMLInputElement; fireEvent.input(input, { target: { value: "Rumours" } }); await waitFor(() => expect(search).toHaveBeenCalled()); await waitFor(() => expect(searchEventHandler).not.toBeNull()); // A superseded query's late result (wrong requestId) must not render. searchEventHandler!({ payload: { requestId: -999, result: { items: [{ id: "stale", name: "Stale Album", type: "MusicAlbum" }], totalRecordCount: 1, }, }, }); await new Promise((r) => setTimeout(r, 0)); expect(screen.queryByText("Stale Album")).toBeNull(); }); });