Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|