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
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>
185 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|