fix(spa): #271 disable all family rows on click (family-global lock parity)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s

Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
  any pending key of that family), not just the exact key — a sibling source of a
  family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
  has a pending or active scan (derives pendingFamilies from pendingKeys), matching
  Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
  deferred POST (was only asserting the promoted end state), and add a test proving
  a sibling-source click fires no second POST while the family is pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #298.
This commit is contained in:
2026-07-12 14:34:05 +02:00
co-authored by Claude Opus 4.8
parent 6d31758cca
commit 4112413ca5
3 changed files with 66 additions and 18 deletions
+52 -12
View File
@@ -1,4 +1,4 @@
import { renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getCollectionsScanStatus,
@@ -24,31 +24,41 @@ function lastCall(fetchMock: ReturnType<typeof vi.spyOn>) {
// Stateful collections backend mock: the scan-collections POST acquires the family-global lock
// synchronously (as the real controller does before returning 202/409), so a subsequent status GET
// reports that family active. `postStatus` overrides the POST response for error-path tests.
function mockCollectionsBackend(opts: { postStatus?: number; seededFamilies?: string[] } = {}) {
function mockCollectionsBackend(opts: { deferPost?: boolean; postStatus?: number; seededFamilies?: string[] } = {}) {
const active = new Set<string>(opts.seededFamilies ?? []);
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
// When deferPost is set, the scan-collections POST does not resolve until releasePost() is called -
// this lets a test observe the optimistic-pending window before the POST completes.
let releasePost = () => {};
const postGate = opts.deferPost ? new Promise<void>((resolve) => (releasePost = resolve)) : Promise.resolve();
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(async (input, init) => {
const url = String(input);
const method = (init?.method ?? 'GET').toUpperCase();
if (url.includes('/api/media-sources/collections-scan-status')) {
return Promise.resolve(json([...active].map((family) => ({ family }))));
return json([...active].map((family) => ({ family })));
}
const match = url.match(/\/api\/media-sources\/(\w+)\/\d+\/scan-collections/);
if (match && method === 'POST') {
await postGate;
const status = opts.postStatus ?? 202;
if (status === 202 || status === 409) {
// Both mean a collections scan is now running for this family -> the lock is held.
active.add(match[1]);
return Promise.resolve(status === 202 ? noContent() : json({ detail: 'already scanning' }, 409));
return status === 202 ? noContent() : json({ detail: 'already scanning' }, 409);
}
return Promise.resolve(json({ detail: 'not found' }, status));
return json({ detail: 'not found' }, status);
}
return Promise.resolve(json({ detail: 'unexpected' }, 500));
return json({ detail: 'unexpected' }, 500);
});
return { active, fetchMock };
function scanPostCount(): number {
return fetchMock.mock.calls.filter((call) => String(call[0]).includes('scan-collections')).length;
}
return { active, fetchMock, releasePost: () => releasePost(), scanPostCount };
}
describe('libraries api client', () => {
@@ -120,20 +130,50 @@ describe('useCollectionsScan', () => {
expect(result.current.pendingKeys.size).toBe(0);
});
it('optimistically marks pending on scan, then promotes to active once the family is observed', async () => {
mockCollectionsBackend();
it('bridges the click->observed-active gap with optimistic pending, then promotes', async () => {
const backend = mockCollectionsBackend({ deferPost: true });
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.size).toBe(0));
await result.current.scan('jellyfin', 7, false);
let scanPromise: Promise<void> = Promise.resolve();
act(() => {
scanPromise = result.current.scan('jellyfin', 7, false);
});
// The scan()'s 202 poll observes the (synchronously-locked) family active and reconciles.
// Optimistic pending is visible immediately, BEFORE the (deferred) POST resolves.
await waitFor(() => expect(result.current.pendingKeys.has('jellyfin:7')).toBe(true));
expect(result.current.activeFamilies.has('jellyfin')).toBe(false);
// Release the POST (lock now held) -> the follow-up poll promotes the family to active.
act(() => backend.releasePost());
await scanPromise;
await waitFor(() => expect(result.current.activeFamilies.has('jellyfin')).toBe(true));
expect(result.current.pendingKeys.has('jellyfin:7')).toBe(false);
expect(result.current.error).toBeNull();
});
it('does not fire a second scan for a sibling source while the family is already pending', async () => {
const backend = mockCollectionsBackend({ deferPost: true });
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.size).toBe(0));
act(() => {
void result.current.scan('plex', 1, false);
});
await waitFor(() => expect(result.current.pendingKeys.has('plex:1')).toBe(true));
const postsAfterFirst = backend.scanPostCount();
// Sibling source of the same (family-global-locked) family - must be ignored, no second POST.
await act(async () => {
await result.current.scan('plex', 2, false);
});
expect(backend.scanPostCount()).toBe(postsAfterFirst);
backend.releasePost();
});
it('keeps the family disabled and surfaces no error on a 409 (already scanning)', async () => {
mockCollectionsBackend({ postStatus: 409 });
const { result } = renderHook(() => useCollectionsScan());
+8 -4
View File
@@ -430,7 +430,7 @@ export function collectionsScanKey(source: CollectionsScanSource, sourceId: numb
}
// Recover the family from a `${family}:${sourceId}` key (the family segment has no colon).
function collectionsScanKeyFamily(key: string): CollectionsScanSource {
export function collectionsScanKeyFamily(key: string): CollectionsScanSource {
return key.slice(0, key.indexOf(':')) as CollectionsScanSource;
}
@@ -522,9 +522,13 @@ export function useCollectionsScan(pollMs = 10000): CollectionsScanState {
const scan = useCallback(
(source: CollectionsScanSource, sourceId: number, deep: boolean): Promise<void> => {
const key = collectionsScanKey(source, sourceId);
if (pendingKeysRef.current.has(key) || activeFamiliesRef.current.has(source)) {
// Already pending, or the whole family is already scanning (family-global lock) - ignore the
// duplicate submission. Quick and deep scans share the same lock, so both gate on this.
const familyBusy =
activeFamiliesRef.current.has(source) ||
[...pendingKeysRef.current].some((pendingKey) => collectionsScanKeyFamily(pendingKey) === source);
if (familyBusy) {
// The whole family is already scanning or has a pending scan - ignore the duplicate. The lock
// is family-global, so a sibling source of a busy family would only earn a benign 409; don't
// fire it. (Covers the same-key case too - a pending key implies its family is pending.)
return Promise.resolve();
}