Files
ersatztv/web/src/api/collections.test.ts
T
timothyandClaude Fable 5 5995707c84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(web): collections management screen for manual + smart collections (#140)
Replaces the PlaceholderScreen at /app/collections with a real screen that
covers manual and smart collections, matching the SPA's extracted-screen
pattern (screens/CollectionsScreen.tsx; App.tsx gets only the route branch +
import).

Manual collections: list, create, rename, delete, and a per-row toggle for
UseCustomPlaybackOrder (PUT). Item management: an add-items picker that searches
the library (library/browse) and buckets results into the typed
AddItemsToCollectionRequest, plus per-item remove.

Smart collections: list, create, edit (name + query), delete, with a live
result preview that runs the query through library/browse.

API layer: new api/collections.ts owns the typed CRUD (getCollections/
getSmartCollections moved here from schedules.ts; schedules imports them). Unit
tests cover the client, the add-items bucket mapping, and the screen.

Honest gap: no API endpoint lists a manual collection's items by id. The items
view uses a best-effort collection:"name" Lucene search (movies/shows/seasons/
artists only) behind a prominent note. Multi/rerun/playlist collections are out
of scope (no API) and pointed to the Classic UI. Follow-ups #151/#152/#153.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:44:51 +02:00

224 lines
8.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
addItemsToCollection,
createCollection,
createSmartCollection,
deleteCollection,
deleteSmartCollection,
emptyAddItemsRequest,
getCollectionItemsPreview,
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('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await getCollectionItemsPreview('The Office');
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/library/browse');
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
});
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
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([]);
});
});