Files
reader/web/test/api-query.test.ts
T
hectorqinandClaude Opus 5.5 4ea2ea6096 fix(web): media browsing, lists, player queue and query cache regressions
- 影音浏览 preferences finally take effect: cover density (phone 3/2 columns),
  default catalog sort, the continue card (channel and 首页) and the 来源库
  selector, read live from the per-device preferences.
- A catalog or favorites page past the end clamps to the last page instead of
  showing nothing / claiming there are no favorites.
- 演播者 shows the 有声书 category tabs (shared CatalogTabs).
- The channel ⋯ menu offers 媒体库管理 only to administrators.
- History and search restore their scroll on back (also back after forward);
  a detail opened from search returns to the search; browser Back between two
  searches shows the earlier search again.
- The player's queue re-renders after 上移 / 移除.
- Media writes invalidate the cached queries they change (editions, metadata,
  hierarchy, libraries, recognition…); item details are always rechecked, so
  刷新资源状态 sees a rescan.
- Aborts surface as ApiError('aborted') everywhere (including body reads), so a
  shared request aborted by one caller is retried for a live one and never shows
  "signal is aborted without reason".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 23:51:33 +00:00

198 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest';
import { ReaderApi } from '../src/api/client.ts';
import { ApiError } from '../src/api/errors.ts';
import { affectedNavigationQuery } from '../src/api/query.ts';
import type { Session } from '../src/api/types.ts';
import { FakeTransport, makePlatform } from './helpers/env.ts';
const session: Session = {
user: { id: 'reader', username: 'reader', displayName: 'Reader', role: 'member', createdAt: 0 },
accessToken: 'access', refreshToken: 'refresh', accessTokenExpiresAt: 9e15, refreshTokenExpiresAt: 9e15,
};
async function apiWith(transport: FakeTransport): Promise<ReaderApi> {
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
return api;
}
describe('ReaderApi navigation query cache', () => {
it('refreshes explicitly, separates pages, and leaves polling uncached', async () => {
const transport = new FakeTransport();
transport.json({ items: [], total: 0 });
const api = await apiWith(transport);
await api.listBooks({ page: 1 });
await api.listBooks({ page: 2 });
api.refreshQueries();
await api.listBooks({ page: 1 });
expect(transport.requests).toHaveLength(3);
await api.mediaRequest('/api/v1/media/scan-jobs');
await api.mediaRequest('/api/v1/media/scan-jobs');
expect(transport.requests).toHaveLength(5);
});
it('keeps media catalog cached across playback heartbeats', async () => {
const transport = new FakeTransport();
transport.json({ items: [] });
const api = await apiWith(transport);
await api.mediaRequest('/api/v1/media/browse?channel=music');
await api.mediaRequest('/api/v1/media/playback/session/progress', 'PUT', { position: 10 });
await api.mediaRequest('/api/v1/media/browse?channel=music');
expect(transport.requests).toHaveLength(2);
});
it('deduplicates concurrent shelf reads and reuses them on return', async () => {
const transport = new FakeTransport();
transport.json({ items: [{ id: 'book-1' }], total: 1 });
const api = await apiWith(transport);
await Promise.all([api.listBooks(), api.listBooks()]);
await api.listBooks();
expect(transport.countMatching(request => request.url.startsWith('/api/v1/books'))).toBe(1);
});
it('invalidates navigation data after a write', async () => {
const transport = new FakeTransport();
transport.respondWith(request => request.method === 'GET'
? { status: 200, headers: {}, json: { items: [{ id: 'book-1' }], total: 1 } }
: { status: 200, headers: {}, json: {} });
const api = await apiWith(transport);
await api.listBooks();
await api.browseBatchShelf({ bookIds: ['book-1'] }, 'remove');
await api.listBooks();
expect(transport.countMatching(request => request.url.startsWith('/api/v1/books'))).toBe(2);
});
});
describe('a shared in-flight navigation query', () => {
it('does not hand the first caller’s abort to a caller that is still live', async () => {
let calls = 0;
const transport = { async send(request: { signal?: AbortSignal }) {
calls += 1;
if (calls === 1) {
// The first request hangs until its caller aborts it.
await new Promise((_, reject) => request.signal?.addEventListener('abort', () => reject(new ApiError('aborted', 'request aborted'))));
}
return { status: 200, headers: { 'content-type': 'application/json' }, json: { items: ['ok'] } };
} } as any;
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
const first = new AbortController();
const gone = api.listBooks({ page: 1 }, { signal: first.signal } as any).catch(error => error);
const live = api.listBooks({ page: 1 });
await vi.waitFor(() => expect(calls).toBe(1));
first.abort();
expect((await gone)?.kind).toBe('aborted');
expect((await live).items).toEqual(['ok']);
});
});
describe('media writes invalidate the navigation they change', () => {
const detail = '/api/v1/media/items/album-1';
const lists = ['/api/v1/media/browse?channel=music&kind=album', '/api/v1/media/search?query=a', '/api/v1/media/libraries/lib-1/items?kind=track', '/api/v1/media/favorites?offset=0', detail, detail + '/children'];
it.each([
'/api/v1/media/editions/ed-1', '/api/v1/media/editions/ed-1/item', '/api/v1/media/editions/ed-1/new-item',
'/api/v1/media/editions/ed-1/split', '/api/v1/media/editions/ed-1/merge', '/api/v1/media/editions/ed-1/order',
'/api/v1/media/items/track-1/metadata', '/api/v1/media/items/track-1/match', '/api/v1/media/items/track-1/auto-match',
'/api/v1/media/items/track-1/hierarchy', '/api/v1/media/items/track-1/parent',
'/api/v1/media/libraries/lib-1/missing-resources/cleanup', '/api/v1/media/libraries/lib-1/recognition-apply',
'/api/v1/media/libraries/lib-1/recognition-rule', '/api/v1/media/libraries/lib-1/scan', '/api/v1/media/libraries/lib-1/access',
'/api/v1/media/scan-jobs', '/api/v1/media/scrape-jobs',
])('%s refreshes item details and every catalog list', mutation => {
for (const query of lists) expect(affectedNavigationQuery(mutation, query)).toBe(true);
expect(affectedNavigationQuery(mutation, '/api/v1/books')).toBe(false);
});
it.each([
'/api/v1/media/playback', '/api/v1/media/playback/s-1/progress', '/api/v1/media/background/playback/s-1/progress',
'/api/v1/media/background-grants', '/api/v1/media/items/track-1/matches', '/api/v1/media/libraries/lib-1/recognition-preview',
'/api/v1/media/queue/preview', '/api/v1/media/jobs/j-1/cancel', '/api/v1/media/scan-jobs/j-1', '/api/v1/media/scrape-jobs/j-1/cancel',
'/api/v1/media/ai-scan-jobs', '/api/v1/media/ai-scan-jobs/j-1',
])('%s keeps the catalog cache', mutation => {
for (const query of lists) expect(affectedNavigationQuery(mutation, query)).toBe(false);
});
it('keeps playback, queue and favorite invalidation narrow', () => {
expect(affectedNavigationQuery('/api/v1/media/playback/s-1/progress', '/api/v1/media/history?channel=music')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/queue/q-1/move', '/api/v1/media/queue')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/queue/q-1/move', '/api/v1/media/browse?channel=music')).toBe(false);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/favorites?offset=0')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/items/track-1/favorite')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/browse?channel=music')).toBe(false);
expect(affectedNavigationQuery('/api/v1/media/libraries', '/api/v1/media/libraries')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/libraries', '/api/v1/media/browse?channel=music')).toBe(false);
});
it('serves the edited catalog after an edition merge', async () => {
const transport = new FakeTransport();
let version = 1;
transport.respondWith(request => request.method === 'GET'
? { status: 200, headers: {}, json: { items: [{ id: 'album-1', version }], total: 1 } }
: (version += 1, { status: 200, headers: {}, json: { id: 'album-1' } }));
const api = await apiWith(transport);
const browse = '/api/v1/media/browse?channel=music&kind=album';
await api.mediaRequest(browse);
await api.mediaRequest('/api/v1/media/editions/ed-1/merge', 'POST', { expectedRevision: 'a', targetEditionId: 'ed-2', targetRevision: 'b' });
expect((await api.mediaRequest<{ items: Array<{ version: number }> }>(browse)).items[0]!.version).toBe(2);
});
it('revalidates item details on every read so 刷新资源状态 sees a rescan', async () => {
const transport = new FakeTransport();
let available = false;
transport.respondWith(() => ({ status: 200, headers: {}, json: { id: 'album-1', editions: [{ parts: [{ available }] }] } }));
const api = await apiWith(transport);
type D = { editions: Array<{ parts: Array<{ available: boolean }> }> };
expect((await api.mediaRequest<D>(detail)).editions[0]!.parts[0]!.available).toBe(false);
available = true; // restored on disk and rescanned; no write from this client
expect((await api.mediaRequest<D>(detail)).editions[0]!.parts[0]!.available).toBe(true);
// Concurrent readers still share one request.
const before = transport.requests.length;
await Promise.all([api.mediaRequest(detail), api.mediaRequest(detail)]);
expect(transport.requests.length - before).toBe(1);
// The catalog lists stay cached.
await api.mediaRequest('/api/v1/media/browse?channel=music');
await api.mediaRequest('/api/v1/media/browse?channel=music');
expect(transport.countMatching(request => request.url.startsWith('/api/v1/media/browse'))).toBe(1);
});
});
describe('aborts never escape as raw DOMExceptions', () => {
it('retries for a live caller when the shared request failed with a raw AbortError', async () => {
let calls = 0;
const transport = { async send(request: { signal?: AbortSignal }) {
calls += 1;
if (calls === 1) {
// A transport that rejects with the signal's own reason ("signal is aborted without reason").
await new Promise((_, reject) => request.signal?.addEventListener('abort', () => reject(request.signal!.reason)));
}
return { status: 200, headers: {}, json: { items: ['ok'] } };
} } as any;
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
const first = new AbortController();
const gone = api.mediaRequest('/api/v1/media/browse?channel=music', 'GET', undefined, { signal: first.signal }).catch(error => error);
const live = api.mediaRequest<{ items: string[] }>('/api/v1/media/browse?channel=music');
await vi.waitFor(() => expect(calls).toBe(1));
first.abort();
const error = await gone as ApiError;
expect(error).toBeInstanceOf(ApiError);
expect(error.kind).toBe('aborted');
expect((await live).items).toEqual(['ok']);
});
it('reports a non-navigation request aborted by its caller as ApiError(aborted)', async () => {
const transport = { async send(request: { signal?: AbortSignal }) {
await new Promise((_, reject) => request.signal?.addEventListener('abort', () => reject(request.signal!.reason)));
return { status: 200, headers: {} };
} } as any;
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
const controller = new AbortController();
const pending = api.mediaRequest('/api/v1/media/scan-jobs', 'GET', undefined, { signal: controller.signal }).catch(error => error);
await Promise.resolve();
controller.abort();
const error = await pending as ApiError;
expect(error).toBeInstanceOf(ApiError);
expect(error.kind).toBe('aborted');
});
});