Files
ersatztv/web/src/screens/LocalLibraryEditScreen.test.tsx
T
timothy 9b83be57f5
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(202): clear dirty flag before post-create navigation so the guard doesn't spuriously prompt (live E2E)
2026-07-11 17:17:35 +02:00

339 lines
14 KiB
TypeScript

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<string, unknown>;
libraryOverrides?: Record<string, unknown>;
libraryStatus?: number;
onCreate?: (body: unknown) => void;
onDelete?: () => void;
onMove?: (pathId: number, body: unknown) => void;
onPathExists?: (path: string) => boolean;
onPut?: (body: unknown) => void;
putResponseOverrides?: Record<string, unknown>;
}
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
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(<LocalLibraryEditScreen />);
// 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();
});
});