Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions backend/app/routers/serials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]
91 changes: 76 additions & 15 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,119 @@ 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<Response> {
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(<App />)
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(<App />)
it('renders without crashing', async () => {
await renderApp()
expect(document.body).toBeTruthy()
})

it('shows navigation items (sidebar + bottom nav both render them)', () => {
render(<App />)
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(<App />)
it('shows dashboard page by default', async () => {
await renderApp()
expect(
screen.getByRole('heading', { name: /dashboard/i })
).toBeInTheDocument()
})

it('navigates to library page', async () => {
const user = userEvent.setup()
render(<App />)
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(<App />)
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(<App />)
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()
})
})
30 changes: 15 additions & 15 deletions frontend/src/__tests__/BulkEditModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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[] = [
{
Expand Down Expand Up @@ -91,25 +91,25 @@ function mockFetch() {
})
}

function renderModal(
async function renderModal(
overrides: {
onClose?: () => void
onSuccess?: () => void
} = {}
) {
const selectedIds = new Set(['b1', 'b2'])
return render(
<MemoryRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
render(
<TestMemoryRouter>
<BulkEditModal
selectedIds={selectedIds}
shelves={SHELVES}
onClose={overrides.onClose ?? vi.fn()}
onSuccess={overrides.onSuccess ?? vi.fn()}
/>
</MemoryRouter>
</TestMemoryRouter>
)

await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(4))
}

describe('BulkEditModal', () => {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/__tests__/ChapterList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Loading
Loading