Files
ersatztv/web/src/screens/MultiCollectionsScreen.test.tsx
T
timothyandClaude Opus 4.8 c40ffefa99
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(253): PR3 optimistic-concurrency fan-out — Diff + Scalar aggregates
Fans the frozen Block recipe (api-conventions §7a) across the five Diff/Scalar
replace-all endpoints, completing the #253 PR2→PR4 arc's implementable core:

- #6 Collection custom-order, #7 Playout alternate-schedules, #8 Playout templates
  (shared Playout.Version), #9 MultiCollection, #10 RerunCollection — each: pre-check
  412 as a standalone Either after validation (H2, subtype survives the Join flatten),
  unconditional Version++ (M1), guarded save, controller If-Match/ETag/400/412, SPA
  editor ETag round-trip + 412 conflict dialog.
- H1: the two Playout handlers' catch(Exception)→422 restructured so the guard's
  PreconditionFailedError returns before the catch (412, not 422).
- M2: RerunCollection/Collection refresh runs unconditionally on save; MultiCollection
  keeps its name-only→no-rebuild optimization by bumping on the first (name) save.
- H3: UpdateDefaultDecoHandler bulk-bumps Playout.Version via .SetProperty.
- Shared ConcurrencyHeaders.MalformedIfMatchProblem() for the 400 guard.
- Deferred (→ #269): same-root non-bulk sibling config writers' ETag rotation.

Tests: per-handler pre-check/bump concurrency tests (Playout ×2 incl. non-vacuous
racing-save backstop, Rerun, Multi incl. name-only-no-rebuild M2, Collection);
controller tests get a DefaultHttpContext for the header read/write. Docs:
api-conventions §7a fan-out status, spa-conventions §4a list-editor note, decisions.

Refs #253

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

185 lines
6.7 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MultiCollectionsScreen } from './MultiCollectionsScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
const multiCollections = [
{
id: 1,
items: [
{ collectionId: 5, name: 'Favorites', playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null },
{ collectionId: null, name: 'Action', playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9 }
],
name: 'Saturday Bundle'
},
{ id: 2, items: [], name: 'Empty Bundle' }
];
const manualCollections = [
{ collectionType: 'Collection', id: 5, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: false }
];
const smartCollections = [{ id: 9, name: 'Action', query: 'genre:action' }];
interface MockOptions {
multi?: unknown[];
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
function mockApi(options: MockOptions = {}) {
const multi = options.multi ?? multiCollections;
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
if (options.onRequest) {
const override = options.onRequest(url, method, body);
if (override) {
return Promise.resolve(override);
}
}
// Single-record GET (the editor loads it on open for the #253 concurrency ETag) — must be
// matched before the list handler, which would otherwise swallow it with the paged shape.
const multiById = url.match(/^\/api\/multi-collections\/(\d+)$/);
if (multiById && method === 'GET') {
const found = (multi as Array<{ id: number }>).find((m) => String(m.id) === multiById[1]) ?? multi[0];
return Promise.resolve(jsonResponse(found));
}
if (url.startsWith('/api/multi-collections') && method === 'GET') {
return Promise.resolve(jsonResponse({ page: multi, totalCount: multi.length }));
}
if (url === '/api/collections' && method === 'GET') {
return Promise.resolve(jsonResponse(manualCollections));
}
if (url === '/api/smart-collections' && method === 'GET') {
return Promise.resolve(jsonResponse(smartCollections));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('MultiCollectionsScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
window.localStorage.clear();
});
it('renders the multi-collections list with item counts', async () => {
mockApi();
render(<MultiCollectionsScreen />);
expect(await screen.findByText('Saturday Bundle')).toBeInTheDocument();
expect(screen.getByText('Empty Bundle')).toBeInTheDocument();
expect(screen.getByText('2 items')).toBeInTheDocument();
expect(screen.getByText('0 items')).toBeInTheDocument();
});
it('creates a multi-collection with an added manual collection', async () => {
const fetchMock = mockApi({
multi: [],
onRequest: (url, method) =>
url === '/api/multi-collections' && method === 'POST'
? jsonResponse({ id: 3, items: [], name: 'New Bundle' }, 201)
: null
});
render(<MultiCollectionsScreen />);
fireEvent.click(await screen.findByRole('button', { name: 'New multi-collection' }));
// Editor loads the collections list; select and add the manual collection.
const collectionSelect = (await screen.findAllByRole('combobox'))[0];
await within(collectionSelect).findByText('Favorites');
fireEvent.change(collectionSelect, { target: { value: '5' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Collection' }));
// The added item appears in the items list.
expect(await screen.findByText('Favorites')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Multi-collection name'), { target: { value: 'New Bundle' } });
fireEvent.click(screen.getByRole('button', { name: 'Add multi-collection' }));
await waitFor(() => {
const postCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/multi-collections' && (init?.method ?? '').toUpperCase() === 'POST'
);
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({
items: [{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null }],
name: 'New Bundle'
});
});
});
it('opens an existing multi-collection and PUTs its edits', async () => {
const fetchMock = mockApi({
onRequest: (url, method) =>
url === '/api/multi-collections/1' && method === 'PUT'
? jsonResponse({ id: 1, items: [], name: 'Saturday Bundle' })
: null
});
render(<MultiCollectionsScreen />);
fireEvent.click(await screen.findByText('Saturday Bundle'));
// Existing items are seeded from the response (manual + smart).
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save multi-collection' }));
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
const parsed = JSON.parse(String(putCall?.[1]?.body));
expect(parsed.name).toBe('Saturday Bundle');
expect(parsed.items).toEqual(
expect.arrayContaining([
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null },
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9 }
])
);
});
});
it('confirms and DELETEs a multi-collection', async () => {
const fetchMock = mockApi();
render(<MultiCollectionsScreen />);
const row = (await screen.findByText('Saturday Bundle')).closest('.ctv-settings-flush-row') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([u, init]) => u === '/api/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'DELETE'
)
).toBe(true);
});
});
});