Files
ReadMeABook/tests/app/bookdate.page.test.tsx
T
kikootwo a97979358f Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
2026-01-28 11:42:00 -05:00

367 lines
12 KiB
TypeScript

/**
* Component: BookDate Page Tests
* Documentation: documentation/features/bookdate.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockRouter, routerMock } from '../helpers/mock-next-navigation';
vi.mock('@/components/layout/Header', () => ({
Header: () => <div data-testid="header" />,
}));
vi.mock('@/components/bookdate/LoadingScreen', () => ({
LoadingScreen: () => <div data-testid="loading" />,
}));
vi.mock('@/components/bookdate/SettingsWidget', () => ({
SettingsWidget: ({
isOpen,
isOnboarding,
onOnboardingComplete,
}: {
isOpen: boolean;
isOnboarding: boolean;
onOnboardingComplete: () => void;
}) => (
<div data-testid="settings-widget" data-open={String(isOpen)} data-onboarding={String(isOnboarding)}>
<button type="button" onClick={onOnboardingComplete}>
Finish Onboarding
</button>
</div>
),
}));
vi.mock('@/components/bookdate/CardStack', () => ({
CardStack: ({
recommendations,
onSwipe,
onSwipeComplete,
}: {
recommendations: any[];
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onSwipeComplete: () => void;
}) => (
<div>
<div data-testid="card-count">{recommendations.length}</div>
<button
type="button"
onClick={() => {
onSwipe('left');
onSwipeComplete();
}}
>
Swipe Left
</button>
<button
type="button"
onClick={() => {
onSwipe('right');
onSwipeComplete();
}}
>
Swipe Right
</button>
</div>
),
}));
const makeJsonResponse = (body: any, ok: boolean = true) => ({
ok,
status: ok ? 200 : 500,
json: async () => body,
});
describe('BookDatePage', () => {
beforeEach(() => {
resetMockRouter();
localStorage.clear();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('redirects to login when no access token is available', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await waitFor(() => {
expect(routerMock.push).toHaveBeenCalledWith('/login');
});
});
it('shows onboarding settings when onboarding is incomplete', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: false });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByText('Welcome to BookDate!')).toBeInTheDocument();
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
});
it('loads recommendations after completing onboarding', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: false });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await screen.findByText('Welcome to BookDate!');
fireEvent.click(screen.getByRole('button', { name: 'Finish Onboarding' }));
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
});
it('loads recommendations when onboarding status check fails', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ error: 'fail' }, false);
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
});
it('renders an error state when recommendations fetch fails', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ error: 'bad' }, false);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByText(/Could not load recommendations/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
await waitFor(() => {
const recCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/bookdate/recommendations'));
expect(recCalls.length).toBeGreaterThan(1);
});
});
it('navigates to settings from the error state', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ error: 'bad' }, false);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
await screen.findByText(/Could not load recommendations/);
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
expect(routerMock.push).toHaveBeenCalledWith('/settings');
});
it('shows empty state and triggers recommendation generation', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [] });
}
if (url === '/api/bookdate/generate') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
const generateButton = await screen.findByRole('button', { name: 'Get More Recommendations' });
fireEvent.click(generateButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/generate',
expect.objectContaining({ method: 'POST' })
);
});
});
it('posts swipes and shows undo option', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
}
if (url === '/api/bookdate/swipe') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/swipe',
expect.objectContaining({ method: 'POST' })
);
});
expect(await screen.findByRole('button', { name: /Undo/i })).toBeInTheDocument();
});
it('opens settings when the settings button is clicked', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'false');
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
});
it('undoes a swipe and reloads recommendations', async () => {
localStorage.setItem('accessToken', 'token');
let recommendationsCall = 0;
const fetchMock = vi.fn(async (input: RequestInfo) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/bookdate/preferences') {
return makeJsonResponse({ onboardingComplete: true });
}
if (url === '/api/bookdate/recommendations') {
recommendationsCall += 1;
if (recommendationsCall === 1) {
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
}
return makeJsonResponse({ recommendations: [{ id: 'rec-restored' }] });
}
if (url === '/api/bookdate/swipe') {
return makeJsonResponse({ success: true });
}
if (url === '/api/bookdate/undo') {
return makeJsonResponse({ success: true });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { default: BookDatePage } = await import('@/app/bookdate/page');
render(<BookDatePage />);
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
const undoButton = await screen.findByRole('button', { name: /Undo/i });
fireEvent.click(undoButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/bookdate/undo',
expect.objectContaining({ method: 'POST' })
);
});
await waitFor(() => {
expect(screen.getByTestId('card-count')).toHaveTextContent('1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Undo/i })).toBeNull();
});
});
});