Files
ersatztv/web/src/screens/RemoteLibrariesEditScreen.test.tsx
T
timothyandClaude Opus 4.8 edbecc01d7 feat(spa): S6b — Plex/Jellyfin/Emby Remote media-source screens (#202)
Build the Remote media-source SPA screens over the S5 foundation, replacing
the MediaSourceEditorPlaceholder for the plex/jellyfin/emby dispatch branches
only (Local branches left for S6a):

- PlexSourceScreen: pin-flow sign-in / fix-credentials / sign-out with the
  §C1 poll state machine — polls GET /api/media-sources/plex every 2s up to
  150s and keeps polling while authorized-but-locked ("finalizing"); the
  terminal success is the lock releasing. Popup-blocked fallback link. Server
  table (Refresh disabled while locked / Edit Libraries / Edit Path
  Replacements) + sign-out content-removal confirm dialog.
- RemoteSourceScreen (shared Jellyfin/Emby): connect / edit-connection /
  disconnect (warning dialog) + server table.
- RemoteConnectionEditScreen (shared): secure key affordance (§C3/finding 1)
  — address prefilled, "leave blank to keep" when hasApiKey, required on first
  connect; stored key never rendered or requested.
- RemoteLibrariesEditScreen (shared): client-side sortable Name + MediaKind
  columns, per-library sync Switch, one Save; draft keyed by (name,mediaKind)
  not id, refetch after save (ids change on disable, §C4a).
- PathReplacementsEditScreen (shared): row list + selected-row edit form,
  add/remove, one Save; both fields required; family remote-path column label.

All editors use the ChannelEditScreen draft/save model + a shared useDirtyGuard
(registerNavigationGuard + beforeunload), Save gated !valid||!dirty||saving,
draft retained on 422/network, destructive actions gated on saving, 409 →
refetch. Colocated tests cover the poll (waiting→finalizing→success asserting
it does NOT stop at authorized&locked, timeout, budget-exhausted), the secure
key affordance, sortable columns, draft-retained-on-422, dirty-guard veto, and
the disconnect/sign-out dialogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:33:41 +02:00

149 lines
5.7 KiB
TypeScript

import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoteLibrariesEditScreen } from './RemoteLibrariesEditScreen';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
interface Library {
id: number;
name: string;
mediaKind: string;
shouldSyncItems: boolean;
}
interface Options {
libraries: Library[];
saveStatus?: number;
saveBody?: Library[];
}
function installFetch(family: string, id: number, options: Options) {
const putBodies: string[] = [];
const spy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const method = (init?.method ?? 'GET').toUpperCase();
const base = `/api/media-sources/${family}/${id}/libraries`;
if (url.endsWith(base) && method === 'GET') {
return Promise.resolve(json(options.libraries));
}
if (url.endsWith(base) && method === 'PUT') {
putBodies.push(String(init?.body ?? ''));
const status = options.saveStatus ?? 200;
if (status >= 400) {
return Promise.resolve(json({ title: 'Save failed', detail: 'Nope' }, status));
}
return Promise.resolve(json(options.saveBody ?? options.libraries));
}
return Promise.resolve(json({}, 404));
});
return { spy, putBodies };
}
function bodyRowNames(): string[] {
const rows = screen.getAllByRole('row').slice(1); // skip header
return rows.map((row) => within(row).getAllByRole('cell')[0]?.textContent ?? '');
}
describe('RemoteLibrariesEditScreen', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.history.replaceState(null, '', '/app/libraries/plex/7/sync');
});
afterEach(() => {
cleanup();
});
it('sorts by Name and toggles direction on repeat click', async () => {
installFetch('plex', 7, {
libraries: [
{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true },
{ id: 2, name: 'Anime', mediaKind: 'Shows', shouldSyncItems: false },
{ id: 3, name: 'Zed', mediaKind: 'Shows', shouldSyncItems: false }
]
});
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
await screen.findByRole('switch', { name: /Sync Movies/i });
// Default ascending by name.
expect(bodyRowNames()).toEqual(['Anime', 'Movies', 'Zed']);
// Click Name → toggles to descending.
fireEvent.click(screen.getByRole('button', { name: /Name/i }));
expect(bodyRowNames()).toEqual(['Zed', 'Movies', 'Anime']);
});
it('sorts by Media Kind', async () => {
installFetch('plex', 7, {
libraries: [
{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true },
{ id: 2, name: 'Anime', mediaKind: 'Shows', shouldSyncItems: false }
]
});
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
await screen.findByRole('switch', { name: /Sync Movies/i });
fireEvent.click(screen.getByRole('button', { name: /Media Kind/i }));
// Movies < Shows ascending, so Movies row first.
expect(bodyRowNames()).toEqual(['Movies', 'Anime']);
});
it('toggling a sync switch enables Save, and Save PUTs the preferences and refetches', async () => {
const { putBodies } = installFetch('plex', 7, {
libraries: [{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: false }],
// Reloaded list: id CHANGES on a toggle (design §C4a) — the SPA keys by name/mediaKind, so it copes.
saveBody: [{ id: 99, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true }]
});
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
await screen.findByRole('switch', { name: /Sync Movies/i });
const save = () => screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
expect(save().disabled).toBe(true); // clean
fireEvent.click(screen.getByRole('switch', { name: /Sync Movies/i }));
expect(save().disabled).toBe(false); // dirty
fireEvent.click(save());
await vi.waitFor(() => expect(putBodies.length).toBe(1));
const body = JSON.parse(putBodies[0]);
expect(body.libraries).toEqual([{ id: 1, shouldSyncItems: true }]);
// After the refetch the new baseline is clean again despite the id change.
await vi.waitFor(() => expect(save().disabled).toBe(true));
});
it('retains the draft on a 422 save failure', async () => {
const { putBodies } = installFetch('plex', 7, {
libraries: [{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: false }],
saveStatus: 422
});
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
await screen.findByRole('switch', { name: /Sync Movies/i });
fireEvent.click(screen.getByRole('switch', { name: /Sync Movies/i }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await vi.waitFor(() => expect(putBodies.length).toBe(1));
// Wait for the failure to settle (saving clears in the catch).
expect(await screen.findByText(/Nope/i)).toBeTruthy();
// Still dirty (draft retained) and the switch stays on.
expect((screen.getByRole('switch', { name: /Sync Movies/i })).getAttribute('aria-checked')).toBe('true');
expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(false);
});
it('renders a not-found state on a 404', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(json({ title: 'Not found' }, 404));
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
expect(await screen.findByText(/no longer exists/i)).toBeTruthy();
});
});