Files
Mangalord/frontend/src/lib/api/preferences.test.ts
MechaCat02 60cc7712fa feat: continuous reader mode with persisted preference
Add a vertical-scroll continuous mode to the reader alongside the
existing single-page mode. A segmented toggle in the reader top bar
switches between them; in continuous mode a gap selector
(None/Small/Medium/Large → 0/12/32/64px) controls the spacing
between stacked pages. Settings page mirrors the same controls.

Backend: new user_preferences table (one row per user, lazily
inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences
gated by the existing CurrentUser extractor. Allowed values are
enforced both by API validation and table-level CHECK constraints.
Eight integration tests cover defaults, persistence, partial
updates, validation errors, auth, per-user isolation, and cascade.

Frontend: a new preferences store mirrors the theme-store pattern
with a localStorage shadow so anonymous browsers get a consistent
experience and logged-in users don't flash defaults while the
server response is in flight. Server values that the frontend
doesn't recognize (forward-compat) are ignored rather than poisoning
the UI; non-401 PATCH errors revert the optimistic local update;
logout clears the shadow so user A's settings don't follow user B
on a shared browser.

In continuous mode native scrolling handles Space/PageDown/arrows;
Home/End remain wired and call scrollIntoView() so jumping to chapter
bounds stays one keystroke. Single-page mode (chevrons, arrow-key
pagination, next-page preload) is unchanged.

Versions bumped 0.13.0 → 0.14.0 in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:15:03 +02:00

99 lines
3.5 KiB
TypeScript

import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance
} from 'vitest';
import { getPreferences, updatePreferences, GAP_PX } from './preferences';
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
const prefsFixture = {
reader_mode: 'continuous' as const,
reader_page_gap: 'medium' as const,
updated_at: '2026-05-17T12:00:00Z'
};
describe('preferences api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('GAP_PX exposes the four named options with increasing values', () => {
expect(GAP_PX.none).toBe(0);
expect(GAP_PX.small).toBeGreaterThan(GAP_PX.none);
expect(GAP_PX.medium).toBeGreaterThan(GAP_PX.small);
expect(GAP_PX.large).toBeGreaterThan(GAP_PX.medium);
});
it('getPreferences GETs /v1/auth/me/preferences and returns the row', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
await expect(getPreferences()).resolves.toEqual(prefsFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
});
it('getPreferences returns null on 401 (anonymous browsing)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
await expect(getPreferences()).resolves.toBeNull();
});
it('getPreferences re-throws non-401 errors', async () => {
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
await expect(getPreferences()).rejects.toMatchObject({ status: 500 });
});
it('updatePreferences PATCHes JSON and returns the new row', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
const r = await updatePreferences({
reader_mode: 'continuous',
reader_page_gap: 'medium'
});
expect(r).toEqual(prefsFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({
reader_mode: 'continuous',
reader_page_gap: 'medium'
});
});
it('updatePreferences sends only the keys it was given (partial)', async () => {
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
await updatePreferences({ reader_mode: 'single' });
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'single' });
});
it('updatePreferences surfaces 400 invalid_input via ApiError', async () => {
fetchSpy.mockResolvedValueOnce(
envelope(400, 'invalid_input', 'reader_mode must be one of ...')
);
await expect(
updatePreferences({ reader_mode: 'continuous' })
).rejects.toMatchObject({ status: 400, code: 'invalid_input' });
});
});