import { afterEach, describe, expect, it, vi } from 'vitest'; import { getLibraryBrowseItems, searchLibraryPickerOptions, titleContainsQuery, LIBRARY_PICKER_LUCENE_SPECIALS, LIBRARY_PICKER_RESULTS } from './libraryBrowse'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); } function browseUrl(fetchMock: ReturnType): URL { return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); } describe('getLibraryBrowseItems', () => { afterEach(() => { vi.restoreAllMocks(); }); it('maps the paging/library/mediaType params into the query string', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); await getLibraryBrowseItems({ query: 'star', libraryId: 5, mediaType: 'TelevisionShow', pageNum: 2, pageSize: 25 }); const url = browseUrl(fetchMock); expect(url.pathname).toBe('/api/v1/library/browse'); expect(url.searchParams.get('query')).toBe('star'); expect(url.searchParams.get('libraryId')).toBe('5'); expect(url.searchParams.get('mediaType')).toBe('TelevisionShow'); expect(url.searchParams.get('pageNum')).toBe('2'); expect(url.searchParams.get('pageSize')).toBe('25'); expect(url.searchParams.has('parentId')).toBe(false); }); it('sends parentId to scope seasons to a specific show', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 }); const url = browseUrl(fetchMock); expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason'); expect(url.searchParams.get('parentId')).toBe('42'); }); it('omits parentId when it is not provided', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); await getLibraryBrowseItems({ mediaType: 'Movie' }); expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false); }); }); describe('titleContainsQuery (#651 — compile typed text, never forward raw Lucene)', () => { it('wraps the escaped text in boundary wildcards on the title field', () => { expect(titleContainsQuery('Show Alpha')).toBe('title:*Show\\ Alpha*'); }); // The previous version of this test hand-copied a sample string and claimed to cover "every // Lucene special" — it silently omitted `&` and `|`, and a completeness test that carries its own // list of what to check cannot see what is missing from that list (#651 F2). Drive the assertion // from the exported character set instead, one character at a time, so adding a character to the // set without escaping it fails here. it.each(LIBRARY_PICKER_LUCENE_SPECIALS.split(''))('escapes the Lucene special %j', (char) => { expect(titleContainsQuery(`a${char}b`)).toBe(`title:*a\\${char}b*`); }); it.each([' ', '\t', '\n'])('escapes whitespace %j so it cannot split the term', (char) => { expect(titleContainsQuery(`a${char}b`)).toBe(`title:*a\\${char}b*`); }); it('leaves every character that is NOT special untouched', () => { const plain = 'abcXYZ019_,.\'@#$%'; for (const char of plain) { expect(LIBRARY_PICKER_LUCENE_SPECIALS).not.toContain(char); } expect(titleContainsQuery(plain)).toBe(`title:*${plain}*`); }); it('neutralises the && and || BOOLEAN operators, not just single characters (#651 F2)', () => { // The regression: `Rock && Roll` used to compile with `&&` live, so Lucene parsed it as boolean // syntax (or rejected the query) and an exactly-matching title returned nothing. expect(titleContainsQuery('Rock && Roll')).toBe('title:*Rock\\ \\&\\&\\ Roll*'); expect(titleContainsQuery('A || B')).toBe('title:*A\\ \\|\\|\\ B*'); expect(titleContainsQuery('Rock & Roll')).toBe('title:*Rock\\ \\&\\ Roll*'); }); it('leaves a plain single word alone apart from the boundary stars', () => { expect(titleContainsQuery('Alpha')).toBe('title:*Alpha*'); }); }); describe('searchLibraryPickerOptions (#651)', () => { afterEach(() => { vi.restoreAllMocks(); }); it('issues ONE bounded request with the compiled query and maps to {id, name}', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue( jsonResponse({ page: [ { id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }, { id: 2, mediaItemId: null, mediaType: 'Movie', title: null } ], totalCount: 20000 }) ); const options = await searchLibraryPickerOptions('Movie', ' Show Alpha '); expect(fetchMock).toHaveBeenCalledTimes(1); const url = browseUrl(fetchMock); expect(url.searchParams.get('query')).toBe('title:*Show\\ Alpha*'); expect(url.searchParams.get('mediaType')).toBe('Movie'); expect(url.searchParams.get('pageNum')).toBe('0'); expect(url.searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS)); // `mediaItemId` wins when present; `id` is the fallback, and a missing title degrades to `#id`. expect(options).toEqual([ { id: 7, name: 'Show Alpha' }, { id: 2, name: '#2' } ]); }); it('#651 F4: CLAMPS an oversized pageSize rather than forwarding it', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 20000 })); await searchLibraryPickerOptions('Episode', 'Alpha', 5000); // The 25-row bound is a property of the helper, not of caller discipline. expect(browseUrl(fetchMock).searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS)); }); it('issues NO request for a query below the minimum length', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); expect(await searchLibraryPickerOptions('Episode', 'a')).toEqual([]); expect(await searchLibraryPickerOptions('Episode', ' ')).toEqual([]); expect(fetchMock).not.toHaveBeenCalled(); }); });