/**
* Component: BookDate Settings Widget Tests
* Documentation: documentation/features/bookdate.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/components/bookdate/BookPickerModal', () => ({
BookPickerModal: ({ isOpen, onConfirm }: { isOpen: boolean; onConfirm: (ids: string[]) => void }) =>
isOpen ? (
) : null,
}));
describe('SettingsWidget', () => {
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
it('loads preferences and populates the form', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
libraryScope: 'rated',
favoriteBookIds: ['book-1'],
customPrompt: 'Custom prompt',
backendCapabilities: { supportsRatings: true },
}),
});
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-789');
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/preferences', {
headers: { Authorization: 'Bearer token-789' },
});
});
const ratedRadio = await screen.findByRole('radio', { name: /Rated Books Only/ });
expect(ratedRadio).toBeChecked();
expect(screen.getByDisplayValue('Custom prompt')).toBeInTheDocument();
});
it('requires favorites selection before saving', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
libraryScope: 'full',
favoriteBookIds: [],
customPrompt: '',
backendCapabilities: { supportsRatings: true },
}),
});
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-000');
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render();
const favoritesRadio = await screen.findByRole('radio', { name: /Pick my favorites/ });
fireEvent.click(favoritesRadio);
fireEvent.click(screen.getByRole('button', { name: /Save Preferences/ }));
expect(await screen.findByText('Please select at least 1 favorite book')).toBeInTheDocument();
});
it('saves onboarding preferences and calls completion handlers', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
libraryScope: 'full',
favoriteBookIds: [],
customPrompt: '',
backendCapabilities: { supportsRatings: true },
}),
})
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-onboarding');
const onClose = vi.fn();
const onOnboardingComplete = vi.fn();
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render(
);
const letsGoButton = await screen.findByRole('button', { name: "Let's Go!" });
vi.useFakeTimers();
fireEvent.click(letsGoButton);
await act(async () => {
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
expect(requestBody.onboardingComplete).toBe(true);
expect(requestBody.customPrompt).toBeNull();
expect(requestBody.libraryScope).toBe('full');
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(onOnboardingComplete).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
vi.useRealTimers();
});
it('hides rated scope when backend does not support ratings', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
libraryScope: 'full',
favoriteBookIds: [],
customPrompt: '',
backendCapabilities: { supportsRatings: false },
}),
});
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-no-ratings');
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
expect(screen.queryByRole('radio', { name: /Rated Books Only/ })).toBeNull();
});
it('shows an error when loading preferences fails', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
});
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-fail');
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render();
expect(await screen.findByText('Failed to load preferences')).toBeInTheDocument();
});
it('saves preferences and clears success message after delay', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
libraryScope: 'full',
favoriteBookIds: [],
customPrompt: '',
backendCapabilities: { supportsRatings: true },
}),
})
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', fetchMock);
localStorage.setItem('accessToken', 'token-save');
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
render();
const promptInput = await screen.findByLabelText(/Special Requests/);
fireEvent.change(promptInput, { target: { value: ' trimmed ' } });
vi.useFakeTimers();
fireEvent.click(screen.getByRole('button', { name: 'Save Preferences' }));
await act(async () => {
await Promise.resolve();
});
const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
expect(requestBody.customPrompt).toBe('trimmed');
expect(requestBody.onboardingComplete).toBeUndefined();
expect(screen.getByText('Preferences saved successfully!')).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(screen.queryByText('Preferences saved successfully!')).toBeNull();
vi.useRealTimers();
});
});