openreader/tests/unit/user-preferences-sync.vitest.spec.ts
Richard R 925c995274 refactor(config): centralize provider default resolution and simplify user preference inheritance
Move provider normalization logic into a dedicated module to ensure consistent
handling of provider defaults and user preference inheritance across client and
server. Update layout components to mount ConfigProvider only at the shared
layout level, preventing unnecessary remounts and hydration issues during
navigation. Adjust app config defaults so user provider settings are empty by
default, always inheriting the admin-configured provider unless explicitly set.
Add tests for normalization and sync logic to ensure robust provider resolution.
2026-06-06 15:25:10 -06:00

81 lines
2.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
cancelPendingPreferenceSync,
flushUserPreferencesSync,
scheduleUserPreferencesSync,
} from '../../src/lib/client/api/user-state';
type FetchMock = ReturnType<typeof vi.fn> & { calls: Array<{ url: string; init?: RequestInit }> };
function installFetchMock(responder: () => Response): FetchMock {
const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
mock.calls.push({ url: String(input), init });
return responder();
}) as unknown as FetchMock;
mock.calls = [];
global.fetch = mock as unknown as typeof fetch;
return mock;
}
function okResponse(): Response {
return new Response(
JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('flushUserPreferencesSync', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers();
cancelPendingPreferenceSync();
});
afterEach(() => {
cancelPendingPreferenceSync();
vi.useRealTimers();
global.fetch = originalFetch;
});
test('sends a pending debounced patch immediately instead of waiting for the timer', async () => {
const fetchMock = installFetchMock(okResponse);
scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1');
// Nothing should have fired yet — it's still debounced.
expect(fetchMock.calls).toHaveLength(0);
await flushUserPreferencesSync();
expect(fetchMock.calls).toHaveLength(1);
const { url, init } = fetchMock.calls[0];
expect(url).toContain('/api/user/state/preferences');
expect(init?.method).toBe('PUT');
const body = JSON.parse(String(init?.body));
expect(body.patch.providerRef).toBe('shared-b');
expect(body.patch.providerType).toBe('openai');
// The debounce timer must not also fire a duplicate request afterwards.
await vi.runAllTimersAsync();
expect(fetchMock.calls).toHaveLength(1);
});
test('is a no-op when there is no pending patch', async () => {
const fetchMock = installFetchMock(okResponse);
await flushUserPreferencesSync();
expect(fetchMock.calls).toHaveLength(0);
});
test('rejects when the server write fails so callers can surface the error', async () => {
installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
}));
scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1');
await expect(flushUserPreferencesSync()).rejects.toThrow();
});
});