Stage 1 of scoped-search-boundary-implementation.md — the query side.
scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.
Rust now owns the taxonomy:
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }
- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
over include_item_types, which stays for the non-search get_items
callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
paths diverge, so online and offline filter identically — the failure
mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
an explicit includeItemTypes list would silently drop People, folders,
and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.
8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.
The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.
Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.
Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
131 lines
4.2 KiB
TypeScript
131 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);
|
|
});
|
|
});
|