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>
149 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
});
|