/** * Scoped search through the library store. * * TRACES: UR-049 | DR-065 | UT-* */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { get } from "svelte/store"; const searchMock = vi.fn(); vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}), })); vi.mock("./auth", () => ({ auth: { getRepository: () => ({ search: searchMock }), }, })); import { library } from "./library"; function result(items: { id: string; type: string }[] = []) { return { items, totalRecordCount: items.length }; } describe("library.search scoping", () => { beforeEach(() => { searchMock.mockReset(); searchMock.mockResolvedValue(result()); library.clearSearch(); }); // The frontend sends the OPAQUE scope and never names a Jellyfin item type. // Expansion (music → MusicAlbum/MusicArtist/Audio/Playlist) is asserted in // Rust — see `search_scope_tests` in src-tauri/src/repository/types.rs. // Asserting item types here would mean the frontend knows the taxonomy again, // which is the leak docs/specs/scoped-search-boundary.md exists to prevent. it("sends the default (all) scope and never an item-type list", async () => { await library.search("office"); const options = searchMock.mock.calls[0][1]; expect(options.scope).toBe("all"); expect(options).not.toHaveProperty("includeItemTypes"); expect(options.limit).toBe(10000); }); it("sends the opaque scope when scoped to music", async () => { await library.search("office", "music"); const options = searchMock.mock.calls[0][1]; expect(options.scope).toBe("music"); expect(options).not.toHaveProperty("includeItemTypes"); }); it("sends the opaque scope when scoped to tv", async () => { await library.search("office", "tv"); const options = searchMock.mock.calls[0][1]; expect(options.scope).toBe("tv"); expect(options).not.toHaveProperty("includeItemTypes"); }); it("sends the opaque scope when scoped to movies", async () => { await library.search("office", "movies"); const options = searchMock.mock.calls[0][1]; expect(options.scope).toBe("movies"); expect(options).not.toHaveProperty("includeItemTypes"); }); it("stores results and the query on success", async () => { searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }])); await library.search("office", "movies"); const state = get(library); expect(state.searchQuery).toBe("office"); expect(state.searchResults.map((i) => i.id)).toEqual(["1"]); }); it("clears results for an empty query without hitting the repository", async () => { searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }])); await library.search("office", "movies"); searchMock.mockClear(); await library.search(" "); expect(searchMock).not.toHaveBeenCalled(); const state = get(library); expect(state.searchQuery).toBe(""); expect(state.searchResults).toEqual([]); }); it("discards a superseded response (stale requestId guard)", async () => { // First search resolves *after* a newer one has already started; its // results must not clobber the fresher ones. let resolveFirst: (value: unknown) => void = () => {}; searchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }])); const first = library.search("old", "all"); await library.search("new", "movies"); resolveFirst(result([{ id: "old", type: "Audio" }])); await first; expect(get(library).searchResults.map((i) => i.id)).toEqual(["new"]); }); it("passes an increasing requestId to the repository", async () => { await library.search("a"); await library.search("b"); const [firstId, secondId] = searchMock.mock.calls.map((c) => c[2]); expect(secondId).toBeGreaterThan(firstId); }); it("surfaces a repository failure as a store error", async () => { searchMock.mockRejectedValue(new Error("boom")); await expect(library.search("office", "tv")).rejects.toThrow("boom"); expect(get(library).error).toBe("boom"); expect(get(library).loadingCount).toBe(0); }); });