import { beforeEach, describe, expect, it, vi } from 'vitest'; import { addItemsToCollection, createCollection, createSmartCollection, deleteCollection, deleteSmartCollection, emptyAddItemsRequest, getCollectionItems, getCollections, getSmartCollections, removeItemFromCollection, toAddItemsRequest, updateCollection, updateSmartCollection } from './collections'; import type { LibraryBrowseItem } from './libraryBrowse'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); } function noContent(): Response { return new Response(null, { status: 204 }); } function browseItem(id: number, mediaType: LibraryBrowseItem['mediaType']): LibraryBrowseItem { return { artwork: '', collectionId: null, collectionKind: null, collectionType: 'Collection', duration: null, id, itemCount: null, libraryId: null, libraryName: null, mediaItemId: id, mediaType, multiCollectionId: null, playlistId: null, rerunCollectionId: null, smartCollectionId: null, title: 'Item' }; } describe('collections api client', () => { beforeEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); it('getCollections fetches the manual collections list', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse([{ id: 1, name: 'Movies', useCustomPlaybackOrder: false }])); await expect(getCollections()).resolves.toHaveLength(1); expect(fetchMock).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'GET' })); }); it('createCollection POSTs the name and returns the created collection', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ id: 7, name: 'New', useCustomPlaybackOrder: false }, 201)); await expect(createCollection({ name: 'New' })).resolves.toMatchObject({ id: 7 }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/collections'); expect(init).toMatchObject({ method: 'POST' }); expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' }); }); it('updateCollection PUTs name and useCustomPlaybackOrder', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ id: 3, name: 'Renamed', useCustomPlaybackOrder: true })); await updateCollection(3, { name: 'Renamed', useCustomPlaybackOrder: true }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/collections/3'); expect(init).toMatchObject({ method: 'PUT' }); expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed', useCustomPlaybackOrder: true }); }); it('deleteCollection issues a DELETE and resolves on 204', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); await expect(deleteCollection(9)).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledWith('/api/collections/9', expect.objectContaining({ method: 'DELETE' })); }); it('addItemsToCollection POSTs the bucketed request', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); const body = { ...emptyAddItemsRequest(), movieIds: [11, 12] }; await addItemsToCollection(5, body); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/collections/5/items'); expect(init).toMatchObject({ method: 'POST' }); expect(JSON.parse(String(init?.body)).movieIds).toEqual([11, 12]); }); it('removeItemFromCollection DELETEs the item by media-item id', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); await removeItemFromCollection(5, 42); expect(fetchMock).toHaveBeenCalledWith('/api/collections/5/items/42', expect.objectContaining({ method: 'DELETE' })); }); it('rethrows API errors (e.g. 422 on delete)', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( jsonResponse({ status: 422, title: 'In use' }, 422) ); await expect(deleteCollection(1)).rejects.toMatchObject({ status: 422 }); }); it('getSmartCollections / create / update / delete hit the smart-collection routes', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = (init?.method ?? 'GET').toUpperCase(); if (url === '/api/smart-collections' && method === 'GET') { return Promise.resolve(jsonResponse([{ id: 1, name: 'Action', query: 'genre:action' }])); } if (url === '/api/smart-collections' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:scifi' }, 201)); } if (url === '/api/smart-collections/2' && method === 'PUT') { return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:"science fiction"' })); } if (url === '/api/smart-collections/2' && method === 'DELETE') { return Promise.resolve(noContent()); } throw new Error(`unexpected ${method} ${url}`); }); await expect(getSmartCollections()).resolves.toHaveLength(1); await expect(createSmartCollection({ name: 'Sci-Fi', query: 'genre:scifi' })).resolves.toMatchObject({ id: 2 }); await expect( updateSmartCollection(2, { name: 'Sci-Fi', query: 'genre:"science fiction"' }) ).resolves.toMatchObject({ query: 'genre:"science fiction"' }); await expect(deleteSmartCollection(2)).resolves.toBeUndefined(); const createCall = fetchMock.mock.calls.find(([, init]) => (init?.method ?? '').toUpperCase() === 'POST'); expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' }); }); it('getCollectionItems requests the paged collection-items endpoint', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ totalCount: 1, page: [browseItem(5, 'Movie')] })); const result = await getCollectionItems(7, 2, 50); const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); expect(url.pathname).toBe('/api/collections/7/items'); expect(url.searchParams.get('pageNum')).toBe('2'); expect(url.searchParams.get('pageSize')).toBe('50'); expect(result.totalCount).toBe(1); }); it('getCollectionItems defaults to the first page of 100', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ totalCount: 0, page: [] })); await getCollectionItems(9); const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); expect(url.pathname).toBe('/api/collections/9/items'); expect(url.searchParams.get('pageNum')).toBe('0'); expect(url.searchParams.get('pageSize')).toBe('100'); }); }); describe('toAddItemsRequest bucket mapping', () => { it('routes each browse media type into its own bucket using item.id', () => { const result = toAddItemsRequest([ browseItem(10, 'Movie'), browseItem(11, 'Movie'), browseItem(20, 'TelevisionShow'), browseItem(30, 'TelevisionSeason'), browseItem(40, 'Artist') ]); expect(result.movieIds).toEqual([10, 11]); expect(result.showIds).toEqual([20]); expect(result.seasonIds).toEqual([30]); expect(result.artistIds).toEqual([40]); }); it('skips kinds that are not addable media items (collections, playlists, etc.)', () => { const result = toAddItemsRequest([ browseItem(1, 'Collection'), browseItem(2, 'SmartCollection'), browseItem(3, 'MultiCollection'), browseItem(4, 'RerunCollection'), browseItem(5, 'Playlist'), browseItem(6, 'Movie') ]); expect(result.movieIds).toEqual([6]); expect(result.showIds).toEqual([]); expect(result.seasonIds).toEqual([]); expect(result.artistIds).toEqual([]); }); it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => { const empty = emptyAddItemsRequest(); expect(empty.episodeIds).toEqual([]); expect(empty.musicVideoIds).toEqual([]); expect(empty.otherVideoIds).toEqual([]); expect(empty.songIds).toEqual([]); expect(empty.imageIds).toEqual([]); expect(empty.remoteStreamIds).toEqual([]); }); });