import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { localLibraryModeFromPathname, LocalLibraryEditScreen } from './LocalLibraryEditScreen'; import { canLeaveCurrentScreen } from '../navigationGuard'; const library = { id: 3, name: 'Feature Films', mediaKind: 'Movies', isLocked: false, mediaItemCount: 2, paths: [ { id: 10, path: '/media/movies', mediaItemCount: 2 }, { id: 11, path: '/media/movies2', mediaItemCount: 0 } ] }; const otherLibraries = [ { id: 3, name: 'Movies', mediaKind: 'Movies', isLocked: false }, { id: 4, name: 'Movies (backup)', mediaKind: 'Movies', isLocked: false }, { id: 5, name: 'TV Shows', mediaKind: 'Shows', isLocked: false } ]; function json(body: unknown, status = 200): Response { return new Response(body === null ? null : JSON.stringify(body), { headers: body === null ? undefined : { 'Content-Type': 'application/json' }, status }); } interface FetchOptions { createResponse?: Record; libraryOverrides?: Record; libraryStatus?: number; onCreate?: (body: unknown) => void; onDelete?: () => void; onMove?: (pathId: number, body: unknown) => void; onPathExists?: (path: string) => boolean; onPut?: (body: unknown) => void; putResponseOverrides?: Record; } function mockApi({ createResponse, libraryOverrides = {}, libraryStatus = 200, onCreate, onDelete, onMove, onPathExists, onPut, putResponseOverrides = {} }: FetchOptions = {}) { const loadedLibrary = { ...library, ...libraryOverrides }; return vi.spyOn(window, 'fetch').mockImplementation((input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = (init?.method ?? 'GET').toUpperCase(); if (url === '/api/libraries/local/path-exists' && method === 'POST') { const body = JSON.parse(init?.body as string) as { path: string }; const exists = onPathExists ? onPathExists(body.path) : true; return Promise.resolve(json({ exists })); } if (url === '/api/libraries/local' && method === 'POST') { const body = JSON.parse(init?.body as string); onCreate?.(body); return Promise.resolve(json(createResponse ?? { id: 99, name: body.name, mediaKind: body.mediaKind, isLocked: false }, 201)); } if (url === '/api/libraries/local') { return Promise.resolve(json(otherLibraries)); } if (url.startsWith('/api/libraries/local/paths/') && url.endsWith('/move') && method === 'POST') { const pathId = Number(url.split('/')[5]); onMove?.(pathId, JSON.parse(init?.body as string)); return Promise.resolve(json(null, 204)); } if (url === '/api/libraries/local/3' && method === 'PUT') { onPut?.(JSON.parse(init?.body as string)); return Promise.resolve(json({ ...loadedLibrary, ...putResponseOverrides })); } if (url === '/api/libraries/local/3' && method === 'DELETE') { onDelete?.(); return Promise.resolve(json(null, 204)); } if (url === '/api/libraries/local/3') { return libraryStatus === 200 ? Promise.resolve(json(loadedLibrary)) : Promise.resolve(json({ status: libraryStatus, title: 'Not Found' }, libraryStatus)); } return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404)); }); } describe('localLibraryModeFromPathname', () => { it('parses the create route', () => { expect(localLibraryModeFromPathname('/app/libraries/local/new')).toEqual({ kind: 'create' }); }); it('parses the edit route', () => { expect(localLibraryModeFromPathname('/app/libraries/local/3')).toEqual({ kind: 'edit', id: 3 }); }); it('rejects a non-numeric id and unrelated paths', () => { expect(localLibraryModeFromPathname('/app/libraries/local/abc')).toBeNull(); expect(localLibraryModeFromPathname('/app/libraries/plex')).toBeNull(); }); }); describe('LocalLibraryEditScreen', () => { beforeEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); afterEach(() => { cleanup(); window.history.replaceState(null, '', '/'); }); it('hydrates the edit form from the loaded library and disables the media-kind select', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi(); render(); expect(await screen.findByDisplayValue('Feature Films')).toBeInTheDocument(); expect(screen.getByText('/media/movies')).toBeInTheDocument(); const mediaKindSelect = screen.getByDisplayValue('Movies') as HTMLSelectElement; expect(mediaKindSelect).toBeDisabled(); }); it('rejects Add Path for a path that does not exist on the filesystem', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi({ onPathExists: () => false }); render(); await screen.findByDisplayValue('Feature Films'); const input = screen.getByPlaceholderText('/media/movies'); fireEvent.change(input, { target: { value: '/media/missing' } }); fireEvent.click(screen.getByRole('button', { name: 'Add Path' })); expect(await screen.findByText('Path must exist on filesystem.')).toBeInTheDocument(); expect(screen.queryByText('/media/missing')).not.toBeInTheDocument(); }); it('accepts Add Path for an existing path and rejects an in-draft duplicate', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi({ onPathExists: () => true }); render(); await screen.findByDisplayValue('Feature Films'); const input = screen.getByPlaceholderText('/media/movies'); fireEvent.change(input, { target: { value: '/media/movies3' } }); fireEvent.click(screen.getByRole('button', { name: 'Add Path' })); expect(await screen.findByText('/media/movies3')).toBeInTheDocument(); // Duplicate of an already-added path, case/trailing-slash-insensitive (mirrors the server's // NormalizePath compare, design §C4c). fireEvent.change(input, { target: { value: '/MEDIA/MOVIES3/' } }); fireEvent.click(screen.getByRole('button', { name: 'Add Path' })); expect(await screen.findByText('This path is already in the list.')).toBeInTheDocument(); }); it('retains the draft when Save fails with a 422', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); const spy = vi.spyOn(window, 'fetch').mockImplementation((input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = (init?.method ?? 'GET').toUpperCase(); if (url === '/api/libraries/local/3' && method === 'PUT') { return Promise.resolve(json({ status: 422, title: 'Validation error', detail: 'Path does not exist' }, 422)); } if (url === '/api/libraries/local/3') { return Promise.resolve(json(library)); } return Promise.resolve(json({ status: 404 }, 404)); }); void spy; render(); const nameInput = await screen.findByDisplayValue('Feature Films'); fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } }); const saveButton = await screen.findByRole('button', { name: 'Save changes' }); fireEvent.click(saveButton); expect(await screen.findByText('Path does not exist')).toBeInTheDocument(); // The draft (the renamed value) is retained, not reverted. expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument(); }); it('saves an edited name with a PUT carrying the updated paths', async () => { const puts: unknown[] = []; window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi({ onPut: (body) => puts.push(body) }); render(); const nameInput = await screen.findByDisplayValue('Feature Films'); fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } }); const saveButton = await screen.findByRole('button', { name: 'Save changes' }); fireEvent.click(saveButton); await waitFor(() => expect(puts).toHaveLength(1)); expect(puts[0]).toEqual({ name: 'Feature Films (renamed)', paths: [ { id: 10, path: '/media/movies' }, { id: 11, path: '/media/movies2' } ] }); }); it('confirms before removing a path with media items, but not an empty one', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi(); render(); await screen.findByDisplayValue('Feature Films'); // /media/movies2 has 0 items -> removed immediately, no confirm dialog. fireEvent.click(screen.getByRole('button', { name: 'Remove /media/movies2' })); expect(screen.queryByText('/media/movies2')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: 'Remove path' })).not.toBeInTheDocument(); // /media/movies has 2 items -> confirm dialog appears and is required. fireEvent.click(screen.getByRole('button', { name: 'Remove /media/movies' })); expect(await screen.findByRole('dialog', { name: 'Remove path' })).toBeInTheDocument(); expect(screen.getByText('/media/movies')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => expect(screen.queryByText('/media/movies')).not.toBeInTheDocument()); }); it('moves a path via the move dialog, including composing "(New Library)"', async () => { const moves: Array<[number, unknown]> = []; const creates: unknown[] = []; window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi({ onCreate: (body) => creates.push(body), onMove: (pathId, body) => moves.push([pathId, body]), createResponse: { id: 77, name: 'New home', mediaKind: 'Movies', isLocked: false } }); render(); await screen.findByDisplayValue('Feature Films'); fireEvent.click(screen.getByRole('button', { name: 'Move /media/movies to another library' })); const dialog = await screen.findByRole('dialog', { name: /Move \/media\/movies/ }); const select = within(dialog).getByRole('combobox'); // Same-kind, excludes the source library; "(New Library)" is always offered. expect(within(dialog).getByText('Movies (backup)')).toBeInTheDocument(); expect(within(dialog).queryByText('TV Shows')).not.toBeInTheDocument(); expect(within(dialog).getByText('(New Library)')).toBeInTheDocument(); fireEvent.change(select, { target: { value: '__new__' } }); fireEvent.change(within(dialog).getByPlaceholderText('New library name'), { target: { value: 'New home' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Move' })); await waitFor(() => expect(creates).toHaveLength(1)); expect(creates[0]).toEqual({ mediaKind: 'Movies', name: 'New home', paths: [] }); await waitFor(() => expect(moves).toHaveLength(1)); expect(moves[0]).toEqual([10, { targetLibraryId: 77 }]); }); it('deletes the library with a media-item-count confirm', async () => { let deleted = false; window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi({ onDelete: () => { deleted = true; } }); render(); await screen.findByDisplayValue('Feature Films'); fireEvent.click(screen.getByRole('button', { name: 'Delete library' })); const dialog = await screen.findByRole('dialog', { name: 'Delete library' }); expect(within(dialog).getByText(/2 media item/)).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm' })); await waitFor(() => expect(deleted).toBe(true)); }); it('rejects Save when the name is blank', async () => { window.history.replaceState(null, '', '/app/libraries/local/3'); mockApi(); render(); const nameInput = await screen.findByDisplayValue('Feature Films'); fireEvent.change(nameInput, { target: { value: '' } }); const saveButton = await screen.findByRole('button', { name: 'Save changes' }); expect(saveButton).toBeDisabled(); }); it('creates a new library and navigates to its edit route', async () => { const creates: unknown[] = []; window.history.replaceState(null, '', '/app/libraries/local/new'); mockApi({ onCreate: (body) => creates.push(body), createResponse: { id: 42, name: 'New Library', mediaKind: 'Shows', isLocked: false } }); render(); // Two textboxes render in create mode (Name + the Add-Path field); Name is the first. const [nameInput] = await screen.findAllByRole('textbox'); fireEvent.change(nameInput, { target: { value: 'New Library' } }); const kindSelect = screen.getByDisplayValue('Movies'); fireEvent.change(kindSelect, { target: { value: 'Shows' } }); fireEvent.click(await screen.findByRole('button', { name: 'Create' })); await waitFor(() => expect(creates).toHaveLength(1)); expect(creates[0]).toEqual({ mediaKind: 'Shows', name: 'New Library', paths: [] }); await waitFor(() => expect(window.location.pathname).toBe('/app/libraries/local/42')); // Regression (live E2E): after a successful create the dirty guard must NOT prompt on the // post-save navigation to the edit route. window.confirm returns false here, so a spurious // prompt would make the guard block; assert it allows the leave and confirm is never called. const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); expect(canLeaveCurrentScreen()).toBe(true); expect(confirmSpy).not.toHaveBeenCalled(); confirmSpy.mockRestore(); }); });