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; 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' }); }); });