Files
ersatztv/web/src/api/collections.test.ts
T
timothyandClaude Opus 4.8 f869dfe87a feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155)
Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.

fixes #155

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:26:52 +02:00

232 lines
8.3 KiB
TypeScript

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([]);
});
});