mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a97979358f
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.
367 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|