import { beforeEach, describe, expect, it, vi } from 'vitest'; import { addItemsToPlaylist, createPlaylist, createPlaylistGroup, deletePlaylist, deletePlaylistGroup, getPlaylistById, getPlaylistGroups, getPlaylistItems, getPlaylists, messageFromPlaylistError, previewPlaylist, updatePlaylist, updatePlaylistGroup, type PlaylistItemRequest } from './playlists'; import { ApiError } from './client'; 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 lastCall(fetchMock: ReturnType) { const call = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; return { init: call[1] as RequestInit | undefined, url: String(call[0]) }; } const sampleItem: PlaylistItemRequest = { collectionId: 7, collectionType: 'Collection', count: null, includeInProgramGuide: true, index: 0, mediaItemId: null, multiCollectionId: null, playAll: true, playbackOrder: 'Chronological', smartCollectionId: null }; describe('playlists api client', () => { beforeEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); it('getPlaylistGroups fetches all playlist groups', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse([{ id: 1, isSystem: false, name: 'Idents', playlistCount: 2 }])); await expect(getPlaylistGroups()).resolves.toHaveLength(1); expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups', expect.objectContaining({ method: 'GET' })); }); it('createPlaylistGroup POSTs the name', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'New', playlistCount: 0 }, 201)); await createPlaylistGroup({ name: 'New' }); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists/groups'); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' }); }); it('updatePlaylistGroup PUTs the renamed group', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'Renamed', playlistCount: 0 })); await updatePlaylistGroup(5, { name: 'Renamed' }); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists/groups/5'); expect(init?.method).toBe('PUT'); expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed' }); }); it('deletePlaylistGroup DELETEs the group', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); await deletePlaylistGroup(9); expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups/9', expect.objectContaining({ method: 'DELETE' })); }); it('getPlaylists filters by playlist group id', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); await getPlaylists(3); expect(fetchMock).toHaveBeenCalledWith('/api/playlists?playlistGroupId=3', expect.objectContaining({ method: 'GET' })); }); it('getPlaylistById fetches a single playlist', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, isSystem: false, name: 'Bumps', playlistGroupId: 3 })); await expect(getPlaylistById(4)).resolves.toMatchObject({ id: 4 }); expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'GET' })); }); it('getPlaylistItems fetches the items of a playlist', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); await getPlaylistItems(4); expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4/items', expect.objectContaining({ method: 'GET' })); }); it('createPlaylist POSTs the group id and name', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8, isSystem: false, name: 'Bumps', playlistGroupId: 3 }, 201)); await createPlaylist({ name: 'Bumps', playlistGroupId: 3 }); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists'); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual({ name: 'Bumps', playlistGroupId: 3 }); }); it('updatePlaylist PUTs the name and item list', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); await updatePlaylist(4, { items: [sampleItem], name: 'Bumps' }); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists/4'); expect(init?.method).toBe('PUT'); expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Bumps' }); }); it('deletePlaylist DELETEs the playlist', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); await deletePlaylist(4); expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'DELETE' })); }); it('previewPlaylist POSTs the draft to the preview endpoint', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); await previewPlaylist({ items: [sampleItem], name: 'Draft' }); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists/preview'); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Draft' }); }); it('addItemsToPlaylist POSTs the bucketed ids to the items endpoint', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); const body = { artistIds: [], episodeIds: [], imageIds: [], movieIds: [12], musicVideoIds: [], otherVideoIds: [], remoteStreamIds: [], seasonIds: [], showIds: [3], songIds: [] }; await addItemsToPlaylist(6, body); const { init, url } = lastCall(fetchMock); expect(url).toBe('/api/playlists/6/items'); expect(init?.method).toBe('POST'); expect(JSON.parse(String(init?.body))).toEqual(body); }); it('messageFromPlaylistError prefers ApiError detail', () => { expect(messageFromPlaylistError(new ApiError(422, { detail: 'Name is required' }))).toBe('Name is required'); expect(messageFromPlaylistError('nope', 'fallback')).toBe('fallback'); }); });