diff --git a/backend/app/routers/serials.py b/backend/app/routers/serials.py index 9359400..693b227 100644 --- a/backend/app/routers/serials.py +++ b/backend/app/routers/serials.py @@ -109,7 +109,7 @@ async def add_serial_endpoint(body: SerialCreate, session: AsyncSession = Depend except SerialAlreadyExists as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) except ScrapingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) return SerialResponse.model_validate(serial) @@ -285,7 +285,7 @@ async def fetch_chapters_endpoint( except SerialNotFound as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) except ScrapingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) return job @@ -329,7 +329,7 @@ async def update_from_source_endpoint(serial_id: int, session: AsyncSession = De except SerialNotFound as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) except ScrapingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) return result @@ -450,7 +450,7 @@ async def generate_volume_endpoint( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) except VolumeGenerationError as exc: log.error("Volume generation error: %s", exc) - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) except Exception as exc: log.exception("Unexpected error generating volume %d for serial %d", volume_id, serial_id) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) @@ -483,6 +483,6 @@ async def rebuild_volume_endpoint( except SerialNotFound as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) except VolumeGenerationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) metrics = await get_volume_metrics(session, serial_id) return _enrich_volumes([vol], metrics)[0] diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 7e8e98a..6df7f5d 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -3,32 +3,89 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import App from '../App' +const APP_OVERVIEW = { + books_owned: 0, + books_read: 0, + total_reading_time_seconds: 0, + total_pages_read: 0, + current_streak_days: 0, +} + +const APP_DISTRIBUTION = { + by_hour: Array.from({ length: 24 }, (_, hour) => ({ hour, seconds: 0 })), + by_weekday: Array.from({ length: 7 }, (_, weekday) => ({ + weekday, + seconds: 0, + })), +} + +const EMPTY_BOOKS = { + items: [], + total: 0, + page: 1, + per_page: 200, + pages: 0, +} + +function mockFetch(url: string): Promise { + let data: unknown = null + + if (url.includes('/api/shelves')) data = [{ id: 1, name: 'Library' }] + else if (url.includes('/api/books?status=reading')) data = EMPTY_BOOKS + else if (url.includes('/api/stats/overview')) data = APP_OVERVIEW + else if (url.includes('/api/stats/heatmap')) data = [] + else if (url.includes('/api/stats/reading-time')) data = [] + else if (url.includes('/api/stats/pages')) data = [] + else if (url.includes('/api/stats/streaks')) + data = { current: 0, longest: 0, last_read_date: null, history: [] } + else if (url.includes('/api/stats/distribution')) data = APP_DISTRIBUTION + else if (url.includes('/api/stats/by-author')) data = [] + else if (url.includes('/api/stats/by-tag')) data = [] + else if (url.includes('/api/stats/calendar')) data = [] + else if (url.includes('/api/stats/recent-sessions')) data = [] + else if (url.includes('/api/stats/books-completed')) data = [] + else if (url.includes('/api/serials/dashboard')) data = [] + else data = [] + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + } as Response) +} + +async function renderApp() { + render() + await screen.findByRole('heading', { name: /dashboard/i }) + await screen.findByText('0 books in library') +} + describe('App', () => { - // Silence fetch errors from useApi calls in jsdom (no network available) beforeEach(() => { - vi.spyOn(globalThis, 'fetch').mockRejectedValue( - new Error('No network in tests') + window.history.pushState({}, '', '/') + vi.spyOn(globalThis, 'fetch').mockImplementation( + (url: string | URL | Request) => mockFetch(url.toString()) ) }) afterEach(() => { vi.restoreAllMocks() }) - it('renders without crashing', () => { - render() + it('renders without crashing', async () => { + await renderApp() expect(document.body).toBeTruthy() }) - it('shows navigation items (sidebar + bottom nav both render them)', () => { - render() + it('shows navigation items (sidebar + bottom nav both render them)', async () => { + await renderApp() // Both Sidebar and BottomNav render the same labels — getAllByText asserts ≥1 expect(screen.getAllByText('Library').length).toBeGreaterThan(0) expect(screen.getAllByText('Stats').length).toBeGreaterThan(0) expect(screen.getAllByText('Series').length).toBeGreaterThan(0) }) - it('shows dashboard page by default', () => { - render() + it('shows dashboard page by default', async () => { + await renderApp() expect( screen.getByRole('heading', { name: /dashboard/i }) ).toBeInTheDocument() @@ -36,25 +93,29 @@ describe('App', () => { it('navigates to library page', async () => { const user = userEvent.setup() - render() + await renderApp() // Click the first matching nav link (sidebar or bottom nav) await user.click(screen.getAllByText('Library')[0]) expect( - screen.getByRole('heading', { name: /library/i }) + await screen.findByRole('heading', { name: /library/i }) ).toBeInTheDocument() }) it('navigates to stats page', async () => { const user = userEvent.setup() - render() + await renderApp() await user.click(screen.getAllByText('Stats')[0]) - expect(screen.getByRole('heading', { name: /stats/i })).toBeInTheDocument() + expect( + await screen.findByRole('heading', { name: /stats/i }) + ).toBeInTheDocument() }) it('navigates to series page', async () => { const user = userEvent.setup() - render() + await renderApp() await user.click(screen.getAllByText('Series')[0]) - expect(screen.getByRole('heading', { name: /series/i })).toBeInTheDocument() + expect( + await screen.findByRole('heading', { name: /series/i }) + ).toBeInTheDocument() }) }) diff --git a/frontend/src/__tests__/BulkEditModal.test.tsx b/frontend/src/__tests__/BulkEditModal.test.tsx index f9d5b74..3a51880 100644 --- a/frontend/src/__tests__/BulkEditModal.test.tsx +++ b/frontend/src/__tests__/BulkEditModal.test.tsx @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import BulkEditModal from '../components/library/BulkEditModal' import type { Shelf } from '../types/api' +import { TestMemoryRouter } from '../test-utils/router' const SHELVES: Shelf[] = [ { @@ -91,25 +91,25 @@ function mockFetch() { }) } -function renderModal( +async function renderModal( overrides: { onClose?: () => void onSuccess?: () => void } = {} ) { const selectedIds = new Set(['b1', 'b2']) - return render( - + render( + - + ) + + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(4)) } describe('BulkEditModal', () => { @@ -124,21 +124,21 @@ describe('BulkEditModal', () => { fetchSpy.mockRestore() }) - it('renders the modal with section headers', () => { - renderModal() + it('renders the modal with section headers', async () => { + await renderModal() expect(screen.getByText('Edit 2 Books')).toBeInTheDocument() expect(screen.getByText('01 Metadata')).toBeInTheDocument() expect(screen.getByText('02 Move to Shelf')).toBeInTheDocument() }) - it('apply button is disabled when no changes are made', () => { - renderModal() + it('apply button is disabled when no changes are made', async () => { + await renderModal() const btn = screen.getByTestId('bulk-apply-btn') expect(btn).toBeDisabled() }) - it('shows shelf options in the move dropdown', () => { - renderModal() + it('shows shelf options in the move dropdown', async () => { + await renderModal() const select = screen.getByTestId( 'bulk-move-shelf-select' ) as HTMLSelectElement @@ -147,7 +147,7 @@ describe('BulkEditModal', () => { it('requires confirmation checkbox before move is enabled', async () => { const user = userEvent.setup() - renderModal() + await renderModal() await user.selectOptions(screen.getByTestId('bulk-move-shelf-select'), '2') // Apply still disabled — need confirmation @@ -160,7 +160,7 @@ describe('BulkEditModal', () => { it('shows result summary after applying metadata changes', async () => { const user = userEvent.setup() const onSuccess = vi.fn() - renderModal({ onSuccess }) + await renderModal({ onSuccess }) // Type a genre name and press enter to add it const genreInput = screen.getAllByPlaceholderText( diff --git a/frontend/src/__tests__/ChapterList.test.tsx b/frontend/src/__tests__/ChapterList.test.tsx index 089cb38..812dde4 100644 --- a/frontend/src/__tests__/ChapterList.test.tsx +++ b/frontend/src/__tests__/ChapterList.test.tsx @@ -225,6 +225,10 @@ describe('ChapterList', () => { chapter_end: 1, generated_at: null, is_stale: false, + chapter_count: 1, + fetched_chapter_count: 1, + is_partial: false, + stubbed_missing_count: 0, estimated_pages: 1, total_words: 280, }, diff --git a/frontend/src/__tests__/DataManagement.test.tsx b/frontend/src/__tests__/DataManagement.test.tsx index 9b5ed76..94a3647 100644 --- a/frontend/src/__tests__/DataManagement.test.tsx +++ b/frontend/src/__tests__/DataManagement.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import DataManagement from '../pages/DataManagement' +import { TestMemoryRouter } from '../test-utils/router' // ── Fixtures ─────────────────────────────────────────────────────────────────── @@ -118,12 +118,19 @@ function mockFetch(overrides: Record = {}) { }) as unknown as typeof globalThis.fetch } -function renderPage() { - return render( - +async function renderPage() { + render( + - + ) + + await waitFor(() => { + expect( + screen.queryByTestId('duplicate-group-book-1') ?? + screen.queryByTestId('no-duplicates') + ).not.toBeNull() + }) } // ── Tests ────────────────────────────────────────────────────────────────────── @@ -133,12 +140,12 @@ describe('DataManagement page', () => { afterEach(() => vi.restoreAllMocks()) it('renders the page title', async () => { - renderPage() + await renderPage() expect(screen.getByText('Data Management')).toBeInTheDocument() }) it('shows four tabs', async () => { - renderPage() + await renderPage() expect(screen.getByText('Duplicate Sessions')).toBeInTheDocument() expect(screen.getByText('Unmatched Data')).toBeInTheDocument() expect(screen.getByText('Duplicate Books')).toBeInTheDocument() @@ -147,14 +154,14 @@ describe('DataManagement page', () => { describe('Duplicate Sessions tab', () => { it('shows a duplicate group with book title', async () => { - renderPage() + await renderPage() await waitFor(() => { expect(screen.getByText('Dune')).toBeInTheDocument() }) }) it('shows dismissed and active session labels', async () => { - renderPage() + await renderPage() await waitFor(() => { expect(screen.getByTestId('dismissed-session')).toBeInTheDocument() expect(screen.getByTestId('active-session')).toBeInTheDocument() @@ -183,7 +190,7 @@ describe('DataManagement page', () => { return { ok: true, status: 200, json: async () => [] } as Response }) as unknown as typeof globalThis.fetch - renderPage() + await renderPage() await waitFor(() => screen.getByText('Restore dismissed')) await user.click(screen.getByText('Restore dismissed')) @@ -195,7 +202,7 @@ describe('DataManagement page', () => { it('shows empty state when no duplicates', async () => { mockFetch({ '/api/data-mgmt/duplicate-sessions': [] }) - renderPage() + await renderPage() await waitFor(() => { expect(screen.getByTestId('no-duplicates')).toBeInTheDocument() }) @@ -205,7 +212,7 @@ describe('DataManagement page', () => { describe('Unmatched Data tab', () => { it('shows unmatched entries after switching tab', async () => { const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Unmatched Data')) await waitFor(() => { @@ -216,7 +223,7 @@ describe('DataManagement page', () => { it('shows empty state when no unmatched', async () => { mockFetch({ '/api/data-mgmt/unmatched': [] }) const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Unmatched Data')) await waitFor(() => { expect(screen.getByTestId('no-unmatched')).toBeInTheDocument() @@ -246,7 +253,7 @@ describe('DataManagement page', () => { }) as unknown as typeof globalThis.fetch const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Unmatched Data')) await waitFor(() => screen.getByText('Unknown KO Book')) await user.click(screen.getByLabelText('Dismiss')) @@ -258,7 +265,7 @@ describe('DataManagement page', () => { describe('Duplicate Books tab', () => { it('shows duplicate book groups', async () => { const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Duplicate Books')) await waitFor(() => { @@ -271,7 +278,7 @@ describe('DataManagement page', () => { it('shows empty state when no duplicates', async () => { mockFetch({ '/api/data-mgmt/duplicate-books': [] }) const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Duplicate Books')) await waitFor(() => { expect(screen.getByTestId('no-duplicate-books')).toBeInTheDocument() @@ -282,7 +289,7 @@ describe('DataManagement page', () => { describe('Import Log tab', () => { it('shows import log entries', async () => { const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Import Log')) await waitFor(() => { @@ -301,7 +308,7 @@ describe('DataManagement page', () => { }, }) const user = userEvent.setup() - renderPage() + await renderPage() await user.click(screen.getByText('Import Log')) await waitFor(() => { expect(screen.getByTestId('no-import-log')).toBeInTheDocument() @@ -324,9 +331,9 @@ describe('Settings links to DataManagement', () => { const { default: Settings } = await import('../pages/Settings') render( - + - + ) await waitFor(() => { diff --git a/frontend/src/__tests__/FilterDrawer.test.tsx b/frontend/src/__tests__/FilterDrawer.test.tsx index f8bdf20..171c66f 100644 --- a/frontend/src/__tests__/FilterDrawer.test.tsx +++ b/frontend/src/__tests__/FilterDrawer.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import FilterDrawer from '../components/library/FilterDrawer' import type { FilterState, FilterLabels } from '../types/api' @@ -111,6 +111,11 @@ const defaultProps = { onStatusChange: vi.fn(), } +async function renderDrawer(props: Partial = {}) { + render() + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(4)) +} + describe('FilterDrawer', () => { beforeEach(() => { setupMockFetch() @@ -122,7 +127,7 @@ describe('FilterDrawer', () => { }) it('renders all accordion sections when open', async () => { - render() + await renderDrawer() expect(screen.getByTestId('filter-drawer')).toBeInTheDocument() expect(screen.getByTestId('accordion-shelves')).toBeInTheDocument() @@ -141,7 +146,7 @@ describe('FilterDrawer', () => { it('expands accordion on click to show items', async () => { const user = userEvent.setup() - render() + await renderDrawer() // Genre section should be collapsed by default const genreAccordion = screen.getByTestId('accordion-genre') @@ -155,7 +160,7 @@ describe('FilterDrawer', () => { it('toggles AND/OR mode', async () => { const user = userEvent.setup() - render() + await renderDrawer() const andBtn = screen.getByTestId('filter-mode-and') const orBtn = screen.getByTestId('filter-mode-or') @@ -170,7 +175,7 @@ describe('FilterDrawer', () => { it('calls onApply with selected filters', async () => { const user = userEvent.setup() const onApply = vi.fn() - render() + await renderDrawer({ onApply }) // Expand format section and select EPUB await user.click(screen.getByTestId('accordion-format')) @@ -188,13 +193,10 @@ describe('FilterDrawer', () => { it('Clear All resets all draft filters', async () => { const user = userEvent.setup() const onApply = vi.fn() - render( - - ) + await renderDrawer({ + onApply, + filters: { ...EMPTY_FILTERS, formats: ['epub'] }, + }) await user.click(screen.getByTestId('filter-clear-all')) await user.click(screen.getByTestId('filter-apply')) @@ -207,7 +209,7 @@ describe('FilterDrawer', () => { it('closes on backdrop click', async () => { const user = userEvent.setup() const onClose = vi.fn() - render() + await renderDrawer({ onClose }) await user.click(screen.getByTestId('filter-drawer-backdrop')) expect(onClose).toHaveBeenCalled() @@ -216,7 +218,7 @@ describe('FilterDrawer', () => { it('closes on Escape key', async () => { const user = userEvent.setup() const onClose = vi.fn() - render() + await renderDrawer({ onClose }) await user.keyboard('{Escape}') expect(onClose).toHaveBeenCalled() @@ -224,7 +226,7 @@ describe('FilterDrawer', () => { it('search filters the checkbox list', async () => { const user = userEvent.setup() - render() + await renderDrawer() // Expand genre section await user.click(screen.getByTestId('accordion-genre')) @@ -239,26 +241,23 @@ describe('FilterDrawer', () => { expect(screen.queryByText('Mystery')).not.toBeInTheDocument() }) - it('shows Save as Lens button in footer', () => { - render() + it('shows Save as Lens button in footer', async () => { + await renderDrawer() expect(screen.getByTestId('save-as-lens-btn')).toBeInTheDocument() }) it('opens SaveLensModal when Save as Lens is clicked', async () => { const user = userEvent.setup() - render() + await renderDrawer() await user.click(screen.getByTestId('save-as-lens-btn')) expect(screen.getByTestId('save-lens-modal')).toBeInTheDocument() }) it('shows selected count in accordion header', async () => { - render( - - ) + await renderDrawer({ + filters: { ...EMPTY_FILTERS, formats: ['epub', 'pdf'] }, + }) // Format section header should show count of 2 const formatSection = screen.getByTestId('accordion-format') diff --git a/frontend/src/__tests__/Home.test.tsx b/frontend/src/__tests__/Home.test.tsx index f3a8454..fd73983 100644 --- a/frontend/src/__tests__/Home.test.tsx +++ b/frontend/src/__tests__/Home.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' import Home from '../pages/Home' +import { TestMemoryRouter } from '../test-utils/router' const MOCK_BOOKS_RESPONSE = { items: [ @@ -87,12 +87,21 @@ function mockFetch(url: string): Promise { } as Response) } -function renderHome() { - return render( - +async function renderHome() { + render( + - + ) + + await screen.findByText('25 books in library') + await screen.findByText('1h 30m') + await waitFor(() => { + expect( + screen.queryByTestId('currently-reading-card') ?? + screen.queryByText(/nothing in progress/i) + ).not.toBeNull() + }) } describe('Home', () => { @@ -109,15 +118,15 @@ describe('Home', () => { globalThis.fetch = originalFetch }) - it('renders the dashboard heading', () => { - renderHome() + it('renders the dashboard heading', async () => { + await renderHome() expect( screen.getByRole('heading', { name: /dashboard/i }) ).toBeInTheDocument() }) it('shows currently reading card when book is in progress', async () => { - renderHome() + await renderHome() await waitFor(() => { expect(screen.getByTestId('currently-reading-card')).toBeInTheDocument() expect(screen.getAllByText('The Way of Kings').length).toBeGreaterThan(0) @@ -145,21 +154,21 @@ describe('Home', () => { return mockFetch(String(url)) }) - renderHome() + await renderHome() await waitFor(() => expect(screen.getByText(/nothing in progress/i)).toBeInTheDocument() ) }) it('shows streak from overview', async () => { - renderHome() + await renderHome() await waitFor(() => expect(screen.getByText('7 Day Streak')).toBeInTheDocument() ) }) it('shows library totals in status row', async () => { - renderHome() + await renderHome() await waitFor(() => { expect(screen.getByText('25 books in library')).toBeInTheDocument() expect(screen.getByText('8 completed')).toBeInTheDocument() @@ -167,7 +176,7 @@ describe('Home', () => { }) it('shows recent activity feed', async () => { - renderHome() + await renderHome() await waitFor(() => { expect(screen.getAllByTestId('activity-item').length).toBeGreaterThan(0) expect(screen.getByText('Mistborn')).toBeInTheDocument() @@ -175,7 +184,7 @@ describe('Home', () => { }) it('shows this week stat cards', async () => { - renderHome() + await renderHome() await waitFor(() => expect(screen.getAllByText('This Week').length).toBeGreaterThan(0) ) diff --git a/frontend/src/__tests__/LensDetail.test.tsx b/frontend/src/__tests__/LensDetail.test.tsx index 41df4b7..e31e047 100644 --- a/frontend/src/__tests__/LensDetail.test.tsx +++ b/frontend/src/__tests__/LensDetail.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { Routes, Route } from 'react-router-dom' import LensDetail from '../pages/LensDetail' +import { TestMemoryRouter } from '../test-utils/router' const MOCK_LENS = { id: 1, @@ -86,11 +87,11 @@ afterEach(() => { function renderDetail(id = '1') { return render( - + } /> - + ) } diff --git a/frontend/src/__tests__/Lenses.test.tsx b/frontend/src/__tests__/Lenses.test.tsx index 0a36fd1..1cfe64d 100644 --- a/frontend/src/__tests__/Lenses.test.tsx +++ b/frontend/src/__tests__/Lenses.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import Lenses from '../pages/Lenses' +import { TestMemoryRouter } from '../test-utils/router' const MOCK_LENSES = [ { @@ -67,9 +67,9 @@ afterEach(() => { function renderLenses() { return render( - + - + ) } diff --git a/frontend/src/__tests__/Library.test.tsx b/frontend/src/__tests__/Library.test.tsx index 702ce75..374b737 100644 --- a/frontend/src/__tests__/Library.test.tsx +++ b/frontend/src/__tests__/Library.test.tsx @@ -220,8 +220,11 @@ describe('Library', () => { }) afterEach(() => fetchSpy.mockRestore()) - it('renders the library heading', () => { + it('renders the library heading', async () => { renderLibrary() + await waitFor(() => + expect(screen.getAllByTestId('book-card')).toHaveLength(3) + ) expect( screen.getByRole('heading', { name: /library/i }) ).toBeInTheDocument() diff --git a/frontend/src/__tests__/MoreMenu.test.tsx b/frontend/src/__tests__/MoreMenu.test.tsx index 123d6bc..21d4a71 100644 --- a/frontend/src/__tests__/MoreMenu.test.tsx +++ b/frontend/src/__tests__/MoreMenu.test.tsx @@ -1,14 +1,14 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import MoreMenu from '../components/nav/MoreMenu' +import { TestMemoryRouter } from '../test-utils/router' function renderMenu(open: boolean, onClose = vi.fn()) { return render( - + - + ) } diff --git a/frontend/src/__tests__/SerialDetail.test.tsx b/frontend/src/__tests__/SerialDetail.test.tsx index 8274aab..45e3506 100644 --- a/frontend/src/__tests__/SerialDetail.test.tsx +++ b/frontend/src/__tests__/SerialDetail.test.tsx @@ -156,7 +156,9 @@ describe('SerialDetail', () => { }, }) renderDetail() - await waitFor(() => screen.getByRole('heading', { level: 1 })) + await waitFor(() => + expect(screen.getByAltText('Test Story')).toBeInTheDocument() + ) const cover = screen.getByAltText('Test Story') expect(cover.getAttribute('src')).toBe( 'https://example.com/remote-cover.jpg' diff --git a/frontend/src/__tests__/SetupWizard.test.tsx b/frontend/src/__tests__/SetupWizard.test.tsx index 0d46a3a..ad911e0 100644 --- a/frontend/src/__tests__/SetupWizard.test.tsx +++ b/frontend/src/__tests__/SetupWizard.test.tsx @@ -1,8 +1,8 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' import SetupWizard from '../components/SetupWizard' +import { TestMemoryRouter } from '../test-utils/router' // ── mocks ────────────────────────────────────────────────────────────────────── @@ -92,9 +92,9 @@ function mockFetch( function renderWizard(onComplete = vi.fn()) { return render( - + - + ) } diff --git a/frontend/src/__tests__/Stats.test.tsx b/frontend/src/__tests__/Stats.test.tsx index ff940d7..b528fc7 100644 --- a/frontend/src/__tests__/Stats.test.tsx +++ b/frontend/src/__tests__/Stats.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' import Stats from '../pages/Stats' +import { TestMemoryRouter } from '../test-utils/router' // --------------------------------------------------------------------------- // Mock data @@ -94,11 +94,17 @@ function mockFetch(url: string): Promise { } as Response) } -function renderStats() { - return render( - +async function renderStats() { + render( + - + + ) + + await waitFor(() => + expect(screen.getAllByTestId('metric-card').length).toBeGreaterThanOrEqual( + 4 + ) ) } @@ -120,8 +126,8 @@ describe('Stats', () => { globalThis.fetch = originalFetch }) - it('renders heading and all tab buttons', () => { - renderStats() + it('renders heading and all tab buttons', async () => { + await renderStats() expect(screen.getByTestId('stats-heading')).toBeInTheDocument() expect(screen.getByTestId('tab-overview')).toBeInTheDocument() expect(screen.getByTestId('tab-reading-time')).toBeInTheDocument() @@ -131,7 +137,7 @@ describe('Stats', () => { }) it('shows metric cards when overview data loads', async () => { - renderStats() + await renderStats() await waitFor(() => { expect( screen.getAllByTestId('metric-card').length @@ -182,7 +188,7 @@ describe('Stats', () => { } as Response) }) - renderStats() + await renderStats() await waitFor(() => expect(screen.getByText('No data for this period')).toBeInTheDocument() ) @@ -190,7 +196,7 @@ describe('Stats', () => { it('date range preset changes the API call', async () => { const user = userEvent.setup() - renderStats() + await renderStats() // Wait for initial load await waitFor(() => screen.getByTestId('preset-30d')) @@ -210,7 +216,7 @@ describe('Stats', () => { it('granularity toggle updates bar chart title', async () => { const user = userEvent.setup() - renderStats() + await renderStats() await waitFor(() => screen.getByTestId('gran-day')) @@ -231,7 +237,7 @@ describe('Stats', () => { it('switching to calendar tab shows the calendar grid', async () => { const user = userEvent.setup() - renderStats() + await renderStats() await user.click(screen.getByTestId('tab-calendar')) @@ -243,7 +249,7 @@ describe('Stats', () => { it('switching to books-authors tab shows author bars', async () => { const user = userEvent.setup() - renderStats() + await renderStats() await user.click(screen.getByTestId('tab-books-authors')) diff --git a/frontend/src/__tests__/VolumeList.test.tsx b/frontend/src/__tests__/VolumeList.test.tsx index 014cccf..bafcc7e 100644 --- a/frontend/src/__tests__/VolumeList.test.tsx +++ b/frontend/src/__tests__/VolumeList.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import VolumeList from '../components/serials/VolumeList' +import { TestMemoryRouter } from '../test-utils/router' describe('VolumeList', () => { let fetchSpy: { mockRestore: () => void } @@ -59,7 +59,7 @@ describe('VolumeList', () => { it('renders preview estimates for valid custom ranges', async () => { render( - + { shelves={[]} onRefresh={vi.fn()} /> - + ) fireEvent.change(screen.getByPlaceholderText('End'), { diff --git a/frontend/src/components/book-detail/EditBookModal.tsx b/frontend/src/components/book-detail/EditBookModal.tsx index 0ec536c..323c6aa 100644 --- a/frontend/src/components/book-detail/EditBookModal.tsx +++ b/frontend/src/components/book-detail/EditBookModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { X, Search, Plus, Trash2, Check } from 'lucide-react' import { api } from '../../api/client' import type { BookDetail } from '../../types' @@ -53,7 +53,7 @@ function GenreCombobox({ }) }, []) - const assignedIds = new Set(genres.map((g) => g.id)) + const assignedIds = useMemo(() => new Set(genres.map((g) => g.id)), [genres]) const suggestions = allGenres.filter( (g) => g.name.toLowerCase().includes(input.toLowerCase()) && @@ -225,7 +225,7 @@ function TagCombobox({ }) }, []) - const assignedIds = new Set(tags.map((t) => t.id)) + const assignedIds = useMemo(() => new Set(tags.map((t) => t.id)), [tags]) const suggestions = allTags.filter( (t) => t.name.toLowerCase().includes(input.toLowerCase()) && diff --git a/frontend/src/test-utils/router.tsx b/frontend/src/test-utils/router.tsx new file mode 100644 index 0000000..f68aede --- /dev/null +++ b/frontend/src/test-utils/router.tsx @@ -0,0 +1,13 @@ +import type { MemoryRouterProps } from 'react-router-dom' +import { MemoryRouter } from 'react-router-dom' + +export const routerFuture = { + v7_startTransition: true, + v7_relativeSplatPath: true, +} as const + +type TestMemoryRouterProps = Omit + +export function TestMemoryRouter(props: TestMemoryRouterProps) { + return +}