feat(authors): collapse series on author pages (#1271)

Reduce large author listings to one card per series while preserving book counts, scoped visibility, and row-based pagination. Compute both totals in the collapsed query so deep pages remain consistent without a second author-wide scan.

Closes #301

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
SergeiAK
2026-09-10 21:39:14 +03:00
committed by GitHub
parent b7e1e21d07
commit 73ac0bb722
23 changed files with 953 additions and 59 deletions
+4 -2
View File
@@ -1,10 +1,10 @@
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { import type {
AuthorsPage, AuthorsPage,
AuthorBooksPage,
AuthorDetail, AuthorDetail,
AuthorMetadataCandidate, AuthorMetadataCandidate,
AuthorMetadataProviderKey, AuthorMetadataProviderKey,
BooksPage,
JumpBucketsResponse, JumpBucketsResponse,
MergeAuthorsResult, MergeAuthorsResult,
} from '@bookorbit/types' } from '@bookorbit/types'
@@ -34,6 +34,7 @@ type ListAuthorBooksParams = {
sort: AuthorBookSort sort: AuthorBookSort
order: SortDirection order: SortDirection
libraryId?: number | null libraryId?: number | null
collapseSeries?: boolean
} }
export type UpdateAuthorPayload = { export type UpdateAuthorPayload = {
@@ -141,13 +142,14 @@ export async function fetchAuthor(id: number): Promise<AuthorDetail | null> {
return res.json() return res.json()
} }
export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<BooksPage> { export async function fetchAuthorBooks(authorId: number, params: ListAuthorBooksParams): Promise<AuthorBooksPage> {
const query = toQuery({ const query = toQuery({
page: params.page, page: params.page,
size: params.size, size: params.size,
sort: params.sort, sort: params.sort,
order: params.order, order: params.order,
libraryId: params.libraryId ?? undefined, libraryId: params.libraryId ?? undefined,
collapseSeries: params.collapseSeries ? 'true' : undefined,
}) })
const res = await api(`/api/v1/authors/${authorId}/books${query}`) const res = await api(`/api/v1/authors/${authorId}/books${query}`)
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import type { BookCard, BooksPage } from '@bookorbit/types' import type { AuthorBooksPage, BookCard } from '@bookorbit/types'
vi.mock('../api/author', () => ({ vi.mock('../api/author', () => ({
fetchAuthorBooks: vi.fn<typeof import('../api/author').fetchAuthorBooks>(), fetchAuthorBooks: vi.fn<typeof import('../api/author').fetchAuthorBooks>(),
@@ -15,8 +15,8 @@ function makeBook(id: number): BookCard {
return { id, title: `Book ${id}` } as BookCard return { id, title: `Book ${id}` } as BookCard
} }
function makePage(items: BookCard[], total = items.length): BooksPage { function makePage(items: BookCard[], total = items.length, bookTotal = total): AuthorBooksPage {
return { items, total, page: 0, size: 50 } return { items, total, bookTotal, page: 0, size: 50 }
} }
describe('useAuthorBooks', () => { describe('useAuthorBooks', () => {
@@ -56,19 +56,19 @@ describe('useAuthorBooks', () => {
expect(mockFetchAuthorBooks).not.toHaveBeenCalled() expect(mockFetchAuthorBooks).not.toHaveBeenCalled()
}) })
it('skips fetch when already loading', async () => { it('does not fire a second request while one is in flight', async () => {
const authorId = ref(1) const authorId = ref(1)
let resolveFirst!: (v: BooksPage) => void let resolveFirst!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks.mockImplementation( mockFetchAuthorBooks.mockImplementation(
() => () =>
new Promise<BooksPage>((resolve) => { new Promise<AuthorBooksPage>((resolve) => {
resolveFirst = resolve resolveFirst = resolve
}), }),
) )
const { load } = useAuthorBooks(authorId) const { load } = useAuthorBooks(authorId)
const firstLoad = load(true) const firstLoad = load(true)
const secondLoad = load(true) const secondLoad = load(false)
resolveFirst(makePage([makeBook(1)])) resolveFirst(makePage([makeBook(1)]))
await Promise.all([firstLoad, secondLoad]) await Promise.all([firstLoad, secondLoad])
@@ -76,6 +76,64 @@ describe('useAuthorBooks', () => {
expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(1) expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(1)
}) })
it('reloads with the new flag when collapseSeries changes during an unresolved load', async () => {
const authorId = ref(1)
let resolveFirst!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks
.mockImplementationOnce(
() =>
new Promise<AuthorBooksPage>((resolve) => {
resolveFirst = resolve
}),
)
.mockResolvedValueOnce(makePage([makeBook(50)], 1, 4))
const { load, items, total, bookTotal, collapseSeries } = useAuthorBooks(authorId)
const flatLoad = load(true)
// The toggle flips while the flat request is still out, so the reset it asks for is the one
// that has to win.
collapseSeries.value = true
void load(true)
resolveFirst(makePage([makeBook(1), makeBook(2), makeBook(3), makeBook(4)], 4, 4))
await flatLoad
await vi.waitFor(() => expect(mockFetchAuthorBooks).toHaveBeenCalledTimes(2))
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true, page: 0 }))
expect(items.value).toHaveLength(1)
expect(items.value[0]!.id).toBe(50)
expect(total.value).toBe(1)
expect(bookTotal.value).toBe(4)
})
it('keeps the error of a stale request off the page when a reset is queued behind it', async () => {
const authorId = ref(1)
let rejectFirst!: (e: unknown) => void
mockFetchAuthorBooks
.mockImplementationOnce(
() =>
new Promise<AuthorBooksPage>((_resolve, reject) => {
rejectFirst = reject
}),
)
.mockResolvedValueOnce(makePage([makeBook(7)], 1))
const { load, error, items, collapseSeries } = useAuthorBooks(authorId)
const flatLoad = load(true)
collapseSeries.value = true
void load(true)
rejectFirst(new Error('stale request failed'))
await flatLoad
await vi.waitFor(() => expect(items.value).toHaveLength(1))
expect(error.value).toBeNull()
expect(items.value[0]!.id).toBe(7)
})
it('skips non-reset load when no more items to load', async () => { it('skips non-reset load when no more items to load', async () => {
const authorId = ref(1) const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1), makeBook(2)], 2)) mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1), makeBook(2)], 2))
@@ -103,6 +161,46 @@ describe('useAuthorBooks', () => {
expect(hasMore.value).toBe(true) expect(hasMore.value).toBe(true)
}) })
it('sends collapseSeries only once it is turned on', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValue(makePage([makeBook(1)]))
const { load, collapseSeries } = useAuthorBooks(authorId)
await load(true)
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(1, 1, expect.objectContaining({ collapseSeries: false }))
collapseSeries.value = true
await load(true)
expect(mockFetchAuthorBooks).toHaveBeenNthCalledWith(2, 1, expect.objectContaining({ collapseSeries: true }))
})
it('paginates on rows while keeping the book count separately', async () => {
const authorId = ref(1)
// 30 collapsed rows standing for 200 books: the row count is what says there is more to load,
// and the book count must not be the thing driving it.
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 30, 200))
const { load, items, total, bookTotal, hasMore } = useAuthorBooks(authorId)
await load(true)
expect(items.value).toHaveLength(2)
expect(total.value).toBe(30)
expect(bookTotal.value).toBe(200)
expect(hasMore.value).toBe(true)
})
it('stops loading once every row has arrived, however many books they stand for', async () => {
const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1), makeBook(2)], 2, 200))
const { load, hasMore, bookTotal } = useAuthorBooks(authorId)
await load(true)
expect(hasMore.value).toBe(false)
expect(bookTotal.value).toBe(200)
})
it('resets items and page on reset=true', async () => { it('resets items and page on reset=true', async () => {
const authorId = ref(1) const authorId = ref(1)
mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1)], 3)).mockResolvedValueOnce(makePage([makeBook(99)], 1)) mockFetchAuthorBooks.mockResolvedValueOnce(makePage([makeBook(1)], 3)).mockResolvedValueOnce(makePage([makeBook(99)], 1))
@@ -199,14 +297,15 @@ describe('useAuthorBooks', () => {
expect(loading.value).toBe(false) expect(loading.value).toBe(false)
}) })
it('passes sort, order, and libraryId to fetchAuthorBooks', async () => { it('passes sort, order, libraryId, and collapseSeries to fetchAuthorBooks', async () => {
const authorId = ref(5) const authorId = ref(5)
mockFetchAuthorBooks.mockResolvedValue(makePage([])) mockFetchAuthorBooks.mockResolvedValue(makePage([]))
const { load, sort, order, libraryId } = useAuthorBooks(authorId) const { load, sort, order, libraryId, collapseSeries } = useAuthorBooks(authorId)
sort.value = 'title' sort.value = 'title'
order.value = 'asc' order.value = 'asc'
libraryId.value = 7 libraryId.value = 7
collapseSeries.value = true
await load(true) await load(true)
@@ -216,6 +315,7 @@ describe('useAuthorBooks', () => {
sort: 'title', sort: 'title',
order: 'asc', order: 'asc',
libraryId: 7, libraryId: 7,
collapseSeries: true,
}) })
}) })
@@ -270,10 +370,10 @@ describe('useAuthorBooks', () => {
await load(true) await load(true)
expect(items.value).toHaveLength(1) expect(items.value).toHaveLength(1)
let resolve!: (v: BooksPage) => void let resolve!: (v: AuthorBooksPage) => void
mockFetchAuthorBooks.mockImplementation( mockFetchAuthorBooks.mockImplementation(
() => () =>
new Promise<BooksPage>((r) => { new Promise<AuthorBooksPage>((r) => {
resolve = r resolve = r
}), }),
) )
@@ -8,20 +8,33 @@ const PAGE_SIZE = 50
export function useAuthorBooks(authorId: Ref<number>) { export function useAuthorBooks(authorId: Ref<number>) {
const items = ref<BookCard[]>([]) const items = ref<BookCard[]>([])
/** Rows, so it keeps matching `items` once a series arrives as a single collapsed card. */
const total = ref(0) const total = ref(0)
/** Books behind those rows. Equal to `total` while the list is flat. */
const bookTotal = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const sort = ref<AuthorBookSort>('addedAt') const sort = ref<AuthorBookSort>('addedAt')
const order = ref<SortDirection>('desc') const order = ref<SortDirection>('desc')
const libraryId = ref<number | null>(null) const libraryId = ref<number | null>(null)
const collapseSeries = ref(false)
const page = ref(0) const page = ref(0)
const hasMore = computed(() => items.value.length < total.value) const hasMore = computed(() => items.value.length < total.value)
/**
* Set when a reset arrives while a request is in flight: the query changed under it, so the
* pending response is stale and the list has to be rebuilt once that request settles.
*/
let resetQueued = false
async function load(reset = false): Promise<void> { async function load(reset = false): Promise<void> {
if (!authorId.value || Number.isNaN(authorId.value)) return if (!authorId.value || Number.isNaN(authorId.value)) return
if (loading.value) return if (loading.value) {
if (reset) resetQueued = true
return
}
if (!reset && !hasMore.value) return if (!reset && !hasMore.value) return
loading.value = true loading.value = true
@@ -39,27 +52,38 @@ export function useAuthorBooks(authorId: Ref<number>) {
sort: sort.value, sort: sort.value,
order: order.value, order: order.value,
libraryId: libraryId.value, libraryId: libraryId.value,
collapseSeries: collapseSeries.value,
}) })
items.value = reset ? data.items : [...items.value, ...data.items] // Dropped on purpose when a reset is queued: these rows answer the previous query.
total.value = data.total if (!resetQueued) {
page.value += 1 items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
bookTotal.value = data.bookTotal
page.value += 1
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load books' if (!resetQueued) error.value = err instanceof Error ? err.message : 'Failed to load books'
} finally { } finally {
loading.value = false loading.value = false
if (resetQueued) {
resetQueued = false
void load(true)
}
} }
} }
return { return {
items, items,
total, total,
bookTotal,
loading, loading,
error, error,
hasMore, hasMore,
sort, sort,
order, order,
libraryId, libraryId,
collapseSeries,
load, load,
} }
} }
@@ -29,12 +29,16 @@ const mocks = vi.hoisted(() => ({
authorNotFound: null as unknown as Ref<boolean>, authorNotFound: null as unknown as Ref<boolean>,
books: null as unknown as Ref<BookCard[]>, books: null as unknown as Ref<BookCard[]>,
total: null as unknown as Ref<number>, total: null as unknown as Ref<number>,
bookTotal: null as unknown as Ref<number>,
loadingBooks: null as unknown as Ref<boolean>, loadingBooks: null as unknown as Ref<boolean>,
booksError: null as unknown as Ref<string | null>, booksError: null as unknown as Ref<string | null>,
hasMore: null as unknown as Ref<boolean>, hasMore: null as unknown as Ref<boolean>,
sort: null as unknown as Ref<'addedAt' | 'title' | 'publishedYear'>, sort: null as unknown as Ref<'addedAt' | 'title' | 'publishedYear'>,
order: null as unknown as Ref<'asc' | 'desc'>, order: null as unknown as Ref<'asc' | 'desc'>,
libraryId: null as unknown as Ref<number | null>, libraryId: null as unknown as Ref<number | null>,
collapseSeries: null as unknown as Ref<boolean>,
collapsePreference: false,
setCollapsePreference: vi.fn<(ctx: unknown, value: boolean | null) => Promise<void>>(),
})) }))
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@@ -102,16 +106,26 @@ vi.mock('../composables/useAuthorBooks', () => ({
useAuthorBooks: () => ({ useAuthorBooks: () => ({
items: mocks.books, items: mocks.books,
total: mocks.total, total: mocks.total,
bookTotal: mocks.bookTotal,
loading: mocks.loadingBooks, loading: mocks.loadingBooks,
error: mocks.booksError, error: mocks.booksError,
hasMore: mocks.hasMore, hasMore: mocks.hasMore,
sort: mocks.sort, sort: mocks.sort,
order: mocks.order, order: mocks.order,
libraryId: mocks.libraryId, libraryId: mocks.libraryId,
collapseSeries: mocks.collapseSeries,
load: mocks.loadBooks, load: mocks.loadBooks,
}), }),
})) }))
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
useSeriesCollapsePreference: () => ({
getEffectivePreference: () => mocks.collapsePreference,
setPreference: mocks.setCollapsePreference,
prefs: ref(undefined),
}),
}))
vi.mock('../composables/useAuthorMetadataPreview', () => ({ vi.mock('../composables/useAuthorMetadataPreview', () => ({
useAuthorMetadataPreview: () => ({ useAuthorMetadataPreview: () => ({
preview: ref(null), preview: ref(null),
@@ -228,12 +242,16 @@ describe('AuthorDetailView', () => {
mocks.authorNotFound = ref(false) mocks.authorNotFound = ref(false)
mocks.books = ref([makeBook(101), makeBook(102)]) mocks.books = ref([makeBook(101), makeBook(102)])
mocks.total = ref(2) mocks.total = ref(2)
mocks.bookTotal = ref(2)
mocks.loadingBooks = ref(false) mocks.loadingBooks = ref(false)
mocks.booksError = ref(null) mocks.booksError = ref(null)
mocks.hasMore = ref(false) mocks.hasMore = ref(false)
mocks.sort = ref('addedAt') mocks.sort = ref('addedAt')
mocks.order = ref('desc') mocks.order = ref('desc')
mocks.libraryId = ref(null) mocks.libraryId = ref(null)
mocks.collapseSeries = ref(false)
mocks.collapsePreference = false
mocks.setCollapsePreference.mockResolvedValue()
}) })
afterEach(() => { afterEach(() => {
@@ -275,6 +293,49 @@ describe('AuthorDetailView', () => {
await flushPromises() await flushPromises()
} }
describe('series collapse toggle', () => {
it('starts from the saved author pages preference', async () => {
mocks.collapsePreference = true
const wrapper = await mountView()
expect(mocks.collapseSeries.value).toBe(true)
expect(wrapper.get('[data-testid="author-collapse-series-toggle"]').attributes('aria-pressed')).toBe('true')
})
it('turns collapsing on and remembers it for every author page', async () => {
const wrapper = await mountView()
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
await flushPromises()
expect(mocks.collapseSeries.value).toBe(true)
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, true)
})
it('turns collapsing back off', async () => {
mocks.collapsePreference = true
const wrapper = await mountView()
await wrapper.get('[data-testid="author-collapse-series-toggle"]').trigger('click')
await flushPromises()
expect(mocks.collapseSeries.value).toBe(false)
expect(mocks.setCollapsePreference).toHaveBeenCalledWith({ authorPages: true }, false)
})
it('counts books rather than rows in the all-loaded label', async () => {
// 2 collapsed rows standing for 200 books: the reader should be told about the books.
mocks.total = ref(2)
mocks.bookTotal = ref(200)
mocks.hasMore = ref(false)
const wrapper = await mountView()
expect(wrapper.text()).toContain(i18n.global.t('author.detail.books.allLoaded', { total: '200' }))
})
})
it('keeps unsaved edit fields when an author image is uploaded', async () => { it('keeps unsaved edit fields when an author image is uploaded', async () => {
vi.mocked(uploadAuthorImage).mockResolvedValue(makeAuthor({ imageUrl: '/api/v1/authors/7/image' })) vi.mocked(uploadAuthorImage).mockResolvedValue(makeAuthor({ imageUrl: '/api/v1/authors/7/image' }))
@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters' import { formatNumber } from '@/i18n/formatters'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, LayoutGrid, List, Upload } from '@lucide/vue' import { ArrowUpDown, ChevronDown, ChevronLeft, ImageMinus, Layers, LayoutGrid, List, Upload } from '@lucide/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import type { AuthorDetail, AuthorSummary, BookCard } from '@bookorbit/types' import type { AuthorDetail, AuthorSummary, BookCard } from '@bookorbit/types'
@@ -12,6 +12,7 @@ import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue' import BookListRow from '@/features/book/components/BookListRow.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue' import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate' import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useDisplaySettings } from '@/composables/useDisplaySettings' import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries' import { useLibraries } from '@/features/library/composables/useLibraries'
import { usePermissions } from '@/features/auth/composables/usePermissions' import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -49,7 +50,30 @@ const authorBooksViewMode = ref<'grid' | 'list'>('grid')
const authorId = computed(() => Number(route.params.id)) const authorId = computed(() => Number(route.params.id))
const { author, loading: loadingAuthor, error: authorError, notFound: authorNotFound, load: loadAuthor } = useAuthorDetail(authorId) const { author, loading: loadingAuthor, error: authorError, notFound: authorNotFound, load: loadAuthor } = useAuthorDetail(authorId)
const { items: books, total, loading: loadingBooks, error: booksError, hasMore, sort, order, libraryId, load: loadBooks } = useAuthorBooks(authorId) const {
items: books,
total,
bookTotal,
loading: loadingBooks,
error: booksError,
hasMore,
sort,
order,
libraryId,
collapseSeries,
load: loadBooks,
} = useAuthorBooks(authorId)
// One flag for every author page, so the state carries from one author to the next rather than
// being re-chosen per author; the toggle below writes it back. Tracked rather than read once,
// because on a cold load of an author URL the signed-in user - and so the preference - can
// arrive after this component is set up.
const { getEffectivePreference, setPreference } = useSeriesCollapsePreference()
watch(
() => getEffectivePreference({ authorPages: true }),
(value) => (collapseSeries.value = value),
{ immediate: true },
)
const authorName = computed(() => author.value?.name ?? '') const authorName = computed(() => author.value?.name ?? '')
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (author.value?.name) return t('author.detail.pageTitleNamed', { name: author.value.name }) if (author.value?.name) return t('author.detail.pageTitleNamed', { name: author.value.name })
@@ -129,7 +153,10 @@ const {
books.value = books.value.filter((b) => b.id !== id) books.value = books.value.filter((b) => b.id !== id)
if (books.value.length === previousLength) return if (books.value.length === previousLength) return
// Only a plain book card carries a delete action - a collapsed series row opens the series
// instead - so the card that just went is worth exactly one row and one book.
total.value = Math.max(0, total.value - 1) total.value = Math.max(0, total.value - 1)
bookTotal.value = Math.max(0, bookTotal.value - 1)
if (author.value) { if (author.value) {
author.value = { author.value = {
...author.value, ...author.value,
@@ -209,6 +236,14 @@ function handleBookUpdate(updated: BookCard) {
if (idx !== -1) books.value = books.value.map((b, i) => (i === idx ? updated : b)) if (idx !== -1) books.value = books.value.map((b, i) => (i === idx ? updated : b))
} }
const collapseToggleLabel = computed(() => (collapseSeries.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
async function handleToggleCollapse() {
const next = !collapseSeries.value
collapseSeries.value = next
await setPreference({ authorPages: true }, next)
}
function loadIfSentinelVisible() { function loadIfSentinelVisible() {
if (loadingBooks.value || !hasMore.value || !sentinel.value) return if (loadingBooks.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) { if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
@@ -448,7 +483,7 @@ watch(authorId, () => {
void Promise.all([loadAuthor(), loadBooks(true)]).then(() => loadMetadataPreview()) void Promise.all([loadAuthor(), loadBooks(true)]).then(() => loadMetadataPreview())
}) })
watch([sort, order, libraryId], () => { watch([sort, order, libraryId, collapseSeries], () => {
void loadBooks(true) void loadBooks(true)
}) })
@@ -618,7 +653,7 @@ defineOptions({ name: 'AuthorDetailView' })
<div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div class="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h2 class="text-sm font-semibold text-foreground">{{ t('author.detail.books.heading') }}</h2> <h2 class="text-sm font-semibold text-foreground">{{ t('author.detail.books.heading') }}</h2>
<div class="w-full space-y-2 sm:hidden"> <div class="w-full space-y-2 sm:hidden">
<div class="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2"> <div class="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-2">
<div class="relative min-w-0"> <div class="relative min-w-0">
<select <select
:value="sort" :value="sort"
@@ -639,6 +674,22 @@ defineOptions({ name: 'AuthorDetailView' })
{{ order === 'asc' ? t('author.detail.books.asc') : t('author.detail.books.desc') }} {{ order === 'asc' ? t('author.detail.books.asc') : t('author.detail.books.desc') }}
</button> </button>
<button
data-testid="author-collapse-series-toggle-mobile"
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
collapseSeries
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseToggleLabel"
:aria-pressed="collapseSeries"
:title="collapseToggleLabel"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
<div class="flex items-center rounded-md border border-input bg-background"> <div class="flex items-center rounded-md border border-input bg-background">
<button <button
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors" class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
@@ -703,6 +754,22 @@ defineOptions({ name: 'AuthorDetailView' })
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option> <option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select> </select>
<button
data-testid="author-collapse-series-toggle"
class="flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
collapseSeries
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseToggleLabel"
:aria-pressed="collapseSeries"
:title="collapseToggleLabel"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
<div class="flex items-center rounded-md border border-input bg-background"> <div class="flex items-center rounded-md border border-input bg-background">
<button <button
class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors" class="flex h-8 w-8 items-center justify-center rounded-l-md transition-colors"
@@ -751,7 +818,7 @@ defineOptions({ name: 'AuthorDetailView' })
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center"> <div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span> <span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">{{ <span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">{{
t('author.detail.books.allLoaded', { total: formatNumber(total) }) t('author.detail.books.allLoaded', { total: formatNumber(bookTotal) })
}}</span> }}</span>
</div> </div>
</section> </section>
@@ -238,7 +238,38 @@ describe('useSeriesCollapsePreference', () => {
await setPreference('global', true) await setPreference('global', true)
expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {} }) expect(prefs.value).toEqual({ global: true, libraries: {}, collections: {}, smartScopes: {}, authorPages: false })
})
it('writes the author pages flag without touching the other scopes', async () => {
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: true, libraries: { '2': true }, collections: {} } }))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
const { setPreference, prefs } = useSeriesCollapsePreference()
await setPreference({ authorPages: true }, true)
expect(prefs.value!.authorPages).toBe(true)
expect(prefs.value!.global).toBe(true)
expect(prefs.value!.libraries).toEqual({ '2': true })
expect(mockApi).toHaveBeenCalledWith(
'/api/v1/users/me/series-collapse-preferences',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ authorPages: true }),
}),
)
})
it('turns the author pages flag back off', async () => {
const userRef = ref(makeUser({ seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, authorPages: true } }))
mockUseAuth.mockReturnValue({ user: userRef } as ReturnType<typeof useAuth>)
const { setPreference, prefs } = useSeriesCollapsePreference()
await setPreference({ authorPages: true }, false)
expect(prefs.value!.authorPages).toBe(false)
}) })
it('preserves existing library overrides when setting global', async () => { it('preserves existing library overrides when setting global', async () => {
@@ -4,6 +4,12 @@ import { resolveCollapsePreference } from '@bookorbit/types'
import { useAuth } from '@/features/auth/composables/useAuth' import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api' import { api } from '@/lib/api'
/**
* A place whose collapse state is remembered. `authorPages` is a single flag rather than an id,
* so every author page shares it - see the type in `@bookorbit/types` for why.
*/
export type CollapseScope = { libraryId?: number; collectionId?: number; smartScopeId?: number; authorPages?: boolean }
export function useSeriesCollapsePreference() { export function useSeriesCollapsePreference() {
const { user } = useAuth() const { user } = useAuth()
@@ -11,28 +17,24 @@ export function useSeriesCollapsePreference() {
return user.value?.settings.seriesCollapsePreferences return user.value?.settings.seriesCollapsePreferences
}) })
function getEffectivePreference(ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number }): boolean { function getEffectivePreference(ctx: CollapseScope): boolean {
return resolveCollapsePreference(prefs.value, ctx) return resolveCollapsePreference(prefs.value, ctx)
} }
let pendingUpdate: Promise<void> = Promise.resolve() let pendingUpdate: Promise<void> = Promise.resolve()
async function setPreference( async function setPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
const op = () => doSetPreference(ctx, value) const op = () => doSetPreference(ctx, value)
pendingUpdate = pendingUpdate.then(op, op) pendingUpdate = pendingUpdate.then(op, op)
return pendingUpdate return pendingUpdate
} }
async function doSetPreference( async function doSetPreference(ctx: CollapseScope | 'global', value: boolean | null): Promise<void> {
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number } | 'global',
value: boolean | null,
): Promise<void> {
let body: Record<string, unknown> let body: Record<string, unknown>
if (ctx === 'global') { if (ctx === 'global') {
body = { global: value === null ? false : value } body = { global: value === null ? false : value }
} else if (ctx.authorPages) {
body = { authorPages: value === null ? false : value }
} else if (ctx.smartScopeId !== undefined) { } else if (ctx.smartScopeId !== undefined) {
body = { smartScopes: { [String(ctx.smartScopeId)]: value } } body = { smartScopes: { [String(ctx.smartScopeId)]: value } }
} else if (ctx.collectionId !== undefined) { } else if (ctx.collectionId !== undefined) {
@@ -64,6 +66,7 @@ export function useSeriesCollapsePreference() {
libraries: nextLibraries as Record<string, boolean>, libraries: nextLibraries as Record<string, boolean>,
collections: nextCollections as Record<string, boolean>, collections: nextCollections as Record<string, boolean>,
smartScopes: nextSmartScopes as Record<string, boolean>, smartScopes: nextSmartScopes as Record<string, boolean>,
authorPages: body.authorPages !== undefined ? (body.authorPages as boolean) : (current.authorPages ?? false),
} }
const previous = user.value?.settings ? { ...user.value.settings } : undefined const previous = user.value?.settings ? { ...user.value.settings } : undefined
+9 -1
View File
@@ -43,7 +43,15 @@ export type AuthorsPage = {
size: number; size: number;
}; };
export type AuthorBooksPage = BooksPage; /**
* `total` counts rows in the page's own unit, so it is the number of cards once
* `collapseSeries` folds a series into one card - that is what paginating and "is there more"
* have to be driven by. `bookTotal` stays the number of books behind those rows, for the
* places that count books rather than cards. The two are equal while the list is flat.
*/
export type AuthorBooksPage = BooksPage & {
bookTotal: number;
};
export type MergeAuthorsResult = { export type MergeAuthorsResult = {
target: AuthorDetail; target: AuthorDetail;
+11 -1
View File
@@ -3,6 +3,13 @@ export type SeriesCollapsePreferences = {
libraries: Record<string, boolean>; libraries: Record<string, boolean>;
collections: Record<string, boolean>; collections: Record<string, boolean>;
smartScopes?: Record<string, boolean>; smartScopes?: Record<string, boolean>;
/**
* Author pages share one flag instead of a per-author bucket: the bucket would grow with every
* author browsed, and "collapsed for King but not for Pratchett" is not a distinction anyone
* asks for. Absent means off, so an upgrade leaves author pages flat even for a user whose
* {@link SeriesCollapsePreferences.global} is on.
*/
authorPages?: boolean;
}; };
export type CollapsedSeriesInfo = { export type CollapsedSeriesInfo = {
@@ -18,9 +25,12 @@ export type CollapsedSeriesInfo = {
export function resolveCollapsePreference( export function resolveCollapsePreference(
prefs: SeriesCollapsePreferences | undefined, prefs: SeriesCollapsePreferences | undefined,
ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number }, ctx: { libraryId?: number; collectionId?: number; smartScopeId?: number; authorPages?: boolean },
): boolean { ): boolean {
if (!prefs) return false; if (!prefs) return false;
// Author pages resolve to their own flag and stop there - they deliberately do not inherit the
// global default, and the library filter on an author page is a filter, not a scope to override.
if (ctx.authorPages) return prefs.authorPages ?? false;
if (ctx.smartScopeId !== undefined) { if (ctx.smartScopeId !== undefined) {
const override = prefs.smartScopes?.[String(ctx.smartScopeId)]; const override = prefs.smartScopes?.[String(ctx.smartScopeId)];
if (override !== undefined) return override; if (override !== undefined) return override;
+21
View File
@@ -351,6 +351,27 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS, ...SHARED_DB_AND_HELPER_PATHS,
], ],
}, },
"author-books-collapse": {
id: "author-books-collapse",
name: "Author Books Collapse",
timeout: 60,
lane: "full",
description: "Author page series collapsing, and the row and book counts it reports",
vitestTarget: "test/author-books-collapse.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/author-books-collapse-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/authors/**",
"server/src/modules/book/book.repository.ts",
"server/src/modules/book/book-read.service.ts",
"server/test/author-books-collapse.e2e-spec.ts",
"server/test/e2e/authorization-matrix/**",
".github/workflows/e2e-runner.yml",
"packages/types/src/author.ts",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"series-next-book": { "series-next-book": {
id: "series-next-book", id: "series-next-book",
name: "Series Next Book", name: "Series Next Book",
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { SQL, and, asc, desc, eq, inArray, isNull, max, or, sql } from 'drizzle-orm'; import { SQL, and, asc, desc, eq, exists, inArray, isNull, max, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules } from '@bookorbit/types'; import type { ContentFilterRules } from '@bookorbit/types';
@@ -290,6 +290,30 @@ export class AuthorsRepository {
return { bookIds: rows.map((row: AuthorBookIdRow) => row.id), total: Number(total), page: params.page, size: params.size }; return { bookIds: rows.map((row: AuthorBookIdRow) => row.id), total: Number(total), page: params.page, size: params.size };
} }
/**
* The same "books of this author the user may see" predicate as {@link findBookIdsPage}, shaped
* for the collapsed book query. That query folds series in SQL over a CTE selecting from books
* alone, so authorship has to arrive as a correlated EXISTS rather than the join used above.
*
* Keyed on the author id, not the author name: merged authors and two writers sharing a name
* stay distinct here, which a name-based filter would silently collapse together.
*/
buildBooksWhere(params: { authorId: number; libraryIds: number[]; contentFilters?: ContentFilterRules }): SQL {
// Never `undefined` for the empty case: an absent predicate reads as "no restriction" to the
// query that consumes this, which is the opposite of what no accessible library means.
if (params.libraryIds.length === 0) return sql`false`;
const byThisAuthor = exists(
this.db
.select({ one: sql`1` })
.from(bookAuthors)
.where(and(sql`${bookAuthors.bookId} = ${books.id}`, eq(bookAuthors.authorId, params.authorId))),
);
const filterClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
return and(byThisAuthor, inArray(books.libraryId, params.libraryIds), ...filterClauses)!;
}
async updateAuthorById( async updateAuthorById(
authorId: number, authorId: number,
values: Partial<{ values: Partial<{
@@ -42,6 +42,7 @@ describe('AuthorsService', () => {
findPage: vi.fn(), findPage: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
findBookIdsPage: vi.fn(), findBookIdsPage: vi.fn(),
buildBooksWhere: vi.fn(),
updateAuthorById: vi.fn(), updateAuthorById: vi.fn(),
findVisibleAuthorIds: vi.fn(), findVisibleAuthorIds: vi.fn(),
countDistinctBooks: vi.fn(), countDistinctBooks: vi.fn(),
@@ -57,6 +58,8 @@ describe('AuthorsService', () => {
const bookReadService = { const bookReadService = {
findCardsByBookIds: vi.fn(), findCardsByBookIds: vi.fn(),
findCardsCollapsed: vi.fn(),
countWhere: vi.fn(),
}; };
const libraryService = { const libraryService = {
@@ -354,7 +357,7 @@ describe('AuthorsService', () => {
const page = await service.findBooks(reqUser(), 99, {}); const page = await service.findBooks(reqUser(), 99, {});
expect(page).toEqual({ items: [], total: 0, page: 0, size: 50 }); expect(page).toEqual({ items: [], total: 0, bookTotal: 0, page: 0, size: 50 });
expect(authorsRepo.findById).not.toHaveBeenCalled(); expect(authorsRepo.findById).not.toHaveBeenCalled();
}); });
@@ -374,10 +377,116 @@ describe('AuthorsService', () => {
const result = await service.findBooks(reqUser(), 10, {}); const result = await service.findBooks(reqUser(), 10, {});
expect(result).toEqual({ items: [], total: 0, page: 0, size: 50 }); expect(result).toEqual({ items: [], total: 0, bookTotal: 0, page: 0, size: 50 });
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled(); expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
}); });
describe('findBooks with collapseSeries', () => {
const AUTHOR_WHERE = { fake: 'where' } as any;
function collapsedRow(id: number, seriesName: string, bookCount: number) {
return {
...makeBookRow(id, `${seriesName} #1`),
seriesId: id * 100,
seriesName,
bookCount,
readCount: 0,
coverBookIds: [id],
coverUpdatedAtByBookId: null,
seriesLatestAddedAt: new Date('2026-08-01T00:00:00.000Z'),
firstVolumeBookId: id,
latestVolumeBookId: id,
firstUnreadBookId: id,
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
};
}
function emptyCollapsedResult(rows: ReturnType<typeof collapsedRow>[], total: number, bookTotal = total) {
return {
rows,
authorRows: [],
fileRows: [],
genreRows: [],
tagRows: [],
progressRows: [],
statusRows: [],
narratorRows: [],
seriesMembershipRows: [],
total,
bookTotal,
};
}
beforeEach(() => {
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 200 });
authorsRepo.buildBooksWhere.mockReturnValue(AUTHOR_WHERE);
});
it('routes to the collapsed query instead of the flat one', async () => {
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([collapsedRow(1, 'Discworld', 41)], 30, 200));
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true });
expect(authorsRepo.findBookIdsPage).not.toHaveBeenCalled();
expect(bookReadService.findCardsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ where: AUTHOR_WHERE, limit: 50, offset: 0, userId: 7 }),
);
expect(result.items[0]!.collapsedSeries).toEqual(expect.objectContaining({ bookCount: 41 }));
});
it('reports rows as total and books as bookTotal', async () => {
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([collapsedRow(1, 'Discworld', 41)], 30, 200));
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true });
expect(result.total).toBe(30);
expect(result.bookTotal).toBe(200);
});
it('uses counts returned with the collapsed rows without a second query', async () => {
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 30, 200));
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true, page: 20 });
expect(result).toEqual({ items: [], total: 30, bookTotal: 200, page: 20, size: 50 });
expect(bookReadService.countWhere).not.toHaveBeenCalled();
});
it('maps the author sort onto the collapsed sort spec', async () => {
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 0));
await service.findBooks(reqUser(), 10, { collapseSeries: true, sort: 'title', order: 'asc', page: 2, size: 20 });
expect(bookReadService.findCardsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ sort: [{ field: 'title', dir: 'asc' }], limit: 20, offset: 40 }),
);
});
it('scopes the collapsed query by author id, libraries, and content filters', async () => {
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 0));
libraryService.findAll.mockResolvedValue([{ id: 3 }]);
await service.findBooks({ ...reqUser(), contentFilters: EMPTY_CONTENT_FILTER_RULES }, 10, { collapseSeries: true });
expect(authorsRepo.buildBooksWhere).toHaveBeenCalledWith({
authorId: 10,
libraryIds: [3],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
});
it('leaves the flat path untouched when the flag is absent', async () => {
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 7, page: 0, size: 50 });
const result = await service.findBooks(reqUser(), 10, {});
expect(bookReadService.findCardsCollapsed).not.toHaveBeenCalled();
// Nothing is folded, so the two counts are the same number.
expect(result.total).toBe(7);
expect(result.bookTotal).toBe(7);
});
});
it('findBooks passes sort, order, size, and libraryId to findBookIdsPage', async () => { it('findBooks passes sort, order, size, and libraryId to findBookIdsPage', async () => {
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 3 }); authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 3 });
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 10 }); authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 10 });
+52 -6
View File
@@ -2,16 +2,16 @@ import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundEx
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import type { import type {
AuthorBooksPage,
AuthorDetail, AuthorDetail,
AuthorMetadataCandidate, AuthorMetadataCandidate,
AuthorMetadataProviderInfo, AuthorMetadataProviderInfo,
AuthorSummary, AuthorSummary,
AuthorsPage, AuthorsPage,
JumpBucketsResponse, JumpBucketsResponse,
BooksPage,
MergeAuthorsResult, MergeAuthorsResult,
} from '@bookorbit/types'; } from '@bookorbit/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards'; import { assembleBookCards, assembleCollapsedBookCards } from '../book/utils/assemble-book-cards';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants'; import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils'; import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils';
@@ -134,16 +134,20 @@ export class AuthorsService {
return this.withAuthorImageUrl(this.mapAuthorDetail(row), 'full'); return this.withAuthorImageUrl(this.mapAuthorDetail(row), 'full');
} }
async findBooks(user: RequestUser, authorId: number, dto: ListAuthorBooksDto): Promise<BooksPage> { async findBooks(user: RequestUser, authorId: number, dto: ListAuthorBooksDto): Promise<AuthorBooksPage> {
this.assertPaginationWindow(dto.page ?? 0, dto.size ?? 50); this.assertPaginationWindow(dto.page ?? 0, dto.size ?? 50);
const libraryIds = await this.resolveLibraryIds(user, dto.libraryId); const libraryIds = await this.resolveLibraryIds(user, dto.libraryId);
if (libraryIds.length === 0) { if (libraryIds.length === 0) {
return { items: [], total: 0, page: dto.page ?? 0, size: dto.size ?? 50 }; return { items: [], total: 0, bookTotal: 0, page: dto.page ?? 0, size: dto.size ?? 50 };
} }
const author = await this.authorsRepo.findById(authorId, libraryIds, user.isSuperuser ? undefined : user.contentFilters); const author = await this.authorsRepo.findById(authorId, libraryIds, user.isSuperuser ? undefined : user.contentFilters);
if (!author) throw new NotFoundException('Author not found'); if (!author) throw new NotFoundException('Author not found');
if (dto.collapseSeries === true) {
return this.findBooksCollapsed(user, authorId, dto, libraryIds);
}
const page = await this.authorsRepo.findBookIdsPage({ const page = await this.authorsRepo.findBookIdsPage({
authorId, authorId,
page: dto.page ?? 0, page: dto.page ?? 0,
@@ -155,7 +159,7 @@ export class AuthorsService {
}); });
if (page.bookIds.length === 0) { if (page.bookIds.length === 0) {
return { items: [], total: page.total, page: page.page, size: page.size }; return { items: [], total: page.total, bookTotal: page.total, page: page.page, size: page.size };
} }
const orderMap = new Map(page.bookIds.map((id, index) => [id, index])); const orderMap = new Map(page.bookIds.map((id, index) => [id, index]));
@@ -172,7 +176,49 @@ export class AuthorsService {
cardData.seriesMembershipRows, cardData.seriesMembershipRows,
).sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); ).sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
return { items, total: page.total, page: page.page, size: page.size }; return { items, total: page.total, bookTotal: page.total, page: page.page, size: page.size };
}
/**
* The collapsed half of {@link findBooks}: the same author scope, run through the book module's
* collapsed query so a series arrives as one card carrying its `collapsedSeries` payload. The
* per-series counts come out of that query scoped to the rows it saw, so they say "books by this
* author in the series" - which is the number an author page should show.
*
* `total` is the row count the collapsed query reports, so paging still walks the list it is
* actually rendering; `bookTotal` counts the books behind those rows for the callers that speak
* in books. Counting both under one visibility rule keeps them from disagreeing.
*/
private async findBooksCollapsed(user: RequestUser, authorId: number, dto: ListAuthorBooksDto, libraryIds: number[]): Promise<AuthorBooksPage> {
const page = dto.page ?? 0;
const size = dto.size ?? 50;
const where = this.authorsRepo.buildBooksWhere({
authorId,
libraryIds,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
});
const collapsed = await this.bookReadService.findCardsCollapsed({
where,
sort: [{ field: dto.sort ?? 'addedAt', dir: dto.order ?? 'desc' }],
limit: size,
offset: page * size,
userId: user.id,
});
const items = assembleCollapsedBookCards(
collapsed.rows,
collapsed.authorRows,
collapsed.fileRows,
collapsed.genreRows,
collapsed.progressRows,
collapsed.statusRows,
collapsed.narratorRows,
collapsed.tagRows,
collapsed.seriesMembershipRows,
);
return { items, total: collapsed.total, bookTotal: collapsed.bookTotal, page, size };
} }
listMetadataProviders(): AuthorMetadataProviderInfo[] { listMetadataProviders(): AuthorMetadataProviderInfo[] {
@@ -65,6 +65,19 @@ describe('Authors DTO validation', () => {
expect((await errorsFor(ListAuthorBooksDto, { size: '101' })).length).toBeGreaterThan(0); expect((await errorsFor(ListAuthorBooksDto, { size: '101' })).length).toBeGreaterThan(0);
}); });
it('reads collapseSeries out of the query string', async () => {
const dto = plainToInstance(ListAuthorBooksDto, { collapseSeries: 'true' });
expect(await validate(dto)).toHaveLength(0);
expect(dto.collapseSeries).toBe(true);
// Anything that is not the string "true" means flat, so a stray value cannot switch it on.
const off = plainToInstance(ListAuthorBooksDto, { collapseSeries: 'false' });
expect(await validate(off)).toHaveLength(0);
expect(off.collapseSeries).toBe(false);
expect(plainToInstance(ListAuthorBooksDto, {}).collapseSeries).toBeUndefined();
});
it('normalizes metadata provider query values and rejects unknown providers', async () => { it('normalizes metadata provider query values and rejects unknown providers', async () => {
const dto = plainToInstance(ListAuthorMetadataDto, { const dto = plainToInstance(ListAuthorMetadataDto, {
q: 'Jane Doe', q: 'Jane Doe',
@@ -1,5 +1,5 @@
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; import { IsBoolean, IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { SORT_DIRECTIONS, type SortDirection } from './list-authors.dto'; import { SORT_DIRECTIONS, type SortDirection } from './list-authors.dto';
@@ -33,4 +33,10 @@ export class ListAuthorBooksDto {
@IsInt() @IsInt()
@Min(1) @Min(1)
libraryId?: number; libraryId?: number;
/** Fold each series into a single card, the way the library and collection listings do. */
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
collapseSeries?: boolean;
} }
@@ -31,6 +31,10 @@ export class BookReadService {
return this.bookRepo.findCardsByBookIds(bookIds, userId); return this.bookRepo.findCardsByBookIds(bookIds, userId);
} }
findCardsCollapsed(opts: Parameters<BookRepository['findCardsCollapsed']>[0]) {
return this.bookRepo.findCardsCollapsed(opts);
}
countWhere(where: SQL | undefined): Promise<number> { countWhere(where: SQL | undefined): Promise<number> {
return this.bookRepo.countWhere(where); return this.bookRepo.countWhere(where);
} }
@@ -348,6 +348,64 @@ describe('BookRepository', () => {
expect(query.sql).not.toContain('FROM "collection_books"'); expect(query.sql).not.toContain('FROM "collection_books"');
}); });
it('computes row and book totals before paging collapsed cards', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRepository({ execute } as never);
await repo.findCardsCollapsed({
where: undefined,
sort: [{ field: 'title', dir: 'asc' }],
limit: 20,
offset: 40,
userId: 7,
});
const query = new PgDialect().sqlToQuery(execute.mock.calls[0]![0]);
expect(query.sql).toContain('COUNT(*) AS total_count');
expect(query.sql).toContain('COALESCE(SUM(COALESCE(book_count, 1)), 0) AS book_total');
expect(query.sql).toContain('LEFT JOIN LATERAL');
expect(query.sql).toMatch(/LIMIT \$\d+ OFFSET \$\d+/);
expect(query.params).toEqual(expect.arrayContaining([20, 40]));
});
it('returns totals from the sentinel row when a collapsed page is empty', async () => {
const execute = vi.fn().mockResolvedValue({
rows: [{ id: null, total_count: '30', book_total: '200' }],
});
const repo = new BookRepository({ execute } as never);
const result = await repo.findCardsCollapsed({
where: undefined,
sort: [{ field: 'title', dir: 'asc' }],
limit: 50,
offset: 1_000,
userId: 7,
});
expect(result.rows).toEqual([]);
expect(result.total).toBe(30);
expect(result.bookTotal).toBe(200);
});
it('returns zero totals when the collapsed scope has no books', async () => {
const execute = vi.fn().mockResolvedValue({
rows: [{ id: null, total_count: '0', book_total: '0' }],
});
const repo = new BookRepository({ execute } as never);
const result = await repo.findCardsCollapsed({
where: undefined,
sort: [{ field: 'title', dir: 'asc' }],
limit: 50,
offset: 0,
userId: 7,
});
expect(result.rows).toEqual([]);
expect(result.total).toBe(0);
expect(result.bookTotal).toBe(0);
});
it('rejects an unusable collection id on the collapsed path', async () => { it('rejects an unusable collection id on the collapsed path', async () => {
const execute = vi.fn().mockResolvedValue({ rows: [] }); const execute = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRepository({ execute } as never); const repo = new BookRepository({ execute } as never);
+22 -8
View File
@@ -67,7 +67,7 @@ type JsonObj = Record<string, unknown>;
type BookRepositoryTx = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0]; type BookRepositoryTx = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0];
type CollapsedRawRow = { type CollapsedRawRow = {
id: number; id: number | null;
status: string; status: string;
cover_aspect_ratio: string; cover_aspect_ratio: string;
primary_file_id: number | null; primary_file_id: number | null;
@@ -101,6 +101,7 @@ type CollapsedRawRow = {
latest_volume_book_id: number | null; latest_volume_book_id: number | null;
first_unread_book_id: number | null; first_unread_book_id: number | null;
total_count: string; total_count: string;
book_total: string;
}; };
type PatternMetadataRow = { type PatternMetadataRow = {
bookId: number; bookId: number;
@@ -822,6 +823,7 @@ export class BookRepository {
expectedBookCount: number | null; expectedBookCount: number | null;
}[]; }[];
total: number; total: number;
bookTotal: number;
}> { }> {
const { where, sort, limit, offset, userId, defaultCollectionId } = opts; const { where, sort, limit, offset, userId, defaultCollectionId } = opts;
if (defaultCollectionId !== undefined && (!Number.isSafeInteger(defaultCollectionId) || defaultCollectionId <= 0)) { if (defaultCollectionId !== undefined && (!Number.isSafeInteger(defaultCollectionId) || defaultCollectionId <= 0)) {
@@ -1041,16 +1043,28 @@ export class BookRepository {
ON sfu2.series_id = base.series_id ON sfu2.series_id = base.series_id
AND sfu2.library_id = base.library_id AND sfu2.library_id = base.library_id
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)} ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
totals AS (
SELECT
COUNT(*) AS total_count,
COALESCE(SUM(COALESCE(book_count, 1)), 0) AS book_total
FROM representatives
) )
SELECT r.*, SELECT r.*, totals.total_count, totals.book_total
COUNT(*) OVER () AS total_count FROM totals
FROM representatives r LEFT JOIN LATERAL (
SELECT r.*
FROM representatives r
ORDER BY ${sql.raw(orderBy)}
LIMIT ${limit} OFFSET ${offset}
) r ON true
ORDER BY ${sql.raw(orderBy)} ORDER BY ${sql.raw(orderBy)}
LIMIT ${limit} OFFSET ${offset}
`); `);
const rawRows = result.rows as CollapsedRawRow[]; const queryRows = result.rows as CollapsedRawRow[];
const total = rawRows.length > 0 ? Number(rawRows[0].total_count) : 0; const total = Number(queryRows[0]?.total_count ?? 0);
const bookTotal = Number(queryRows[0]?.book_total ?? 0);
const rawRows = queryRows.filter((row): row is CollapsedRawRow & { id: number } => row.id !== null);
const mappedRows = rawRows.map((r) => ({ const mappedRows = rawRows.map((r) => ({
id: r.id, id: r.id,
@@ -1090,7 +1104,7 @@ export class BookRepository {
const bookRefs = mappedRows.map((row) => ({ id: row.id, primaryFileId: row.primaryFileId ?? null })); const bookRefs = mappedRows.map((row) => ({ id: row.id, primaryFileId: row.primaryFileId ?? null }));
const enrichment = await this.enrichBookIds(bookRefs, userId); const enrichment = await this.enrichBookIds(bookRefs, userId);
return { rows: mappedRows, ...enrichment, total }; return { rows: mappedRows, ...enrichment, total, bookTotal };
} }
async findJumpBuckets(opts: { async findJumpBuckets(opts: {
@@ -66,4 +66,35 @@ describe('resolveCollapsePreference', () => {
const prefs: SeriesCollapsePreferences = { global: false, libraries: { '42': true }, collections: {} }; const prefs: SeriesCollapsePreferences = { global: false, libraries: { '42': true }, collections: {} };
expect(resolveCollapsePreference(prefs, { libraryId: 42 })).toBe(true); expect(resolveCollapsePreference(prefs, { libraryId: 42 })).toBe(true);
}); });
describe('author pages', () => {
it('returns the author pages flag when it is set', () => {
const prefs: SeriesCollapsePreferences = { global: false, libraries: {}, collections: {}, authorPages: true };
expect(resolveCollapsePreference(prefs, { authorPages: true })).toBe(true);
});
it('stays off for prefs saved before the flag existed, whatever global says', () => {
const prefs: SeriesCollapsePreferences = { global: true, libraries: {}, collections: {} };
expect(resolveCollapsePreference(prefs, { authorPages: true })).toBe(false);
});
it('does not inherit global, in either direction', () => {
const globalOn: SeriesCollapsePreferences = { global: true, libraries: {}, collections: {}, authorPages: false };
expect(resolveCollapsePreference(globalOn, { authorPages: true })).toBe(false);
const globalOff: SeriesCollapsePreferences = { global: false, libraries: {}, collections: {}, authorPages: true };
expect(resolveCollapsePreference(globalOff, { authorPages: true })).toBe(true);
});
it('ignores library overrides, since the author page filter is not a scope', () => {
const prefs: SeriesCollapsePreferences = { global: false, libraries: { '3': true }, collections: {}, authorPages: false };
expect(resolveCollapsePreference(prefs, { authorPages: true, libraryId: 3 })).toBe(false);
});
it('leaves the other scopes untouched when the flag is on', () => {
const prefs: SeriesCollapsePreferences = { global: false, libraries: { '3': true }, collections: {}, authorPages: true };
expect(resolveCollapsePreference(prefs, { libraryId: 3 })).toBe(true);
expect(resolveCollapsePreference(prefs, {})).toBe(false);
});
});
}); });
@@ -36,4 +36,9 @@ export class UpdateSeriesCollapsePreferencesDto {
@IsOptional() @IsOptional()
@IsBooleanOrNullRecord() @IsBooleanOrNullRecord()
smartScopes?: Record<string, boolean | null>; smartScopes?: Record<string, boolean | null>;
/** One flag for every author page; there is no per-author bucket to override it. */
@IsOptional()
@IsBoolean()
authorPages?: boolean;
} }
+42 -5
View File
@@ -809,7 +809,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, { expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: { settings: {
seriesCollapsePreferences: { global: true, libraries: {}, collections: {}, smartScopes: {} }, seriesCollapsePreferences: { global: true, libraries: {}, collections: {}, smartScopes: {}, authorPages: false },
}, },
}); });
}); });
@@ -826,7 +826,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, { expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: { settings: {
seriesCollapsePreferences: { global: true, libraries: { '3': true }, collections: { '7': false }, smartScopes: {} }, seriesCollapsePreferences: { global: true, libraries: { '3': true }, collections: { '7': false }, smartScopes: {}, authorPages: false },
}, },
}); });
}); });
@@ -843,7 +843,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, { expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: { settings: {
seriesCollapsePreferences: { global: false, libraries: { '1': true, '2': false }, collections: {}, smartScopes: {} }, seriesCollapsePreferences: { global: false, libraries: { '1': true, '2': false }, collections: {}, smartScopes: {}, authorPages: false },
}, },
}); });
}); });
@@ -860,7 +860,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, { expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: { settings: {
seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false, '9': true }, smartScopes: {} }, seriesCollapsePreferences: { global: true, libraries: {}, collections: { '5': false, '9': true }, smartScopes: {}, authorPages: false },
}, },
}); });
}); });
@@ -897,8 +897,45 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
expect(userRepo.update).toHaveBeenCalledWith(1, { expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: { settings: {
seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, smartScopes: { '3': true } }, seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, smartScopes: { '3': true }, authorPages: false },
}, },
}); });
}); });
it('sets the author pages flag on its own', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({
id: 1,
settings: {
seriesCollapsePreferences: { global: false, libraries: { '3': true }, collections: {} },
},
});
await service.updateSeriesCollapsePreferences(1, { authorPages: true });
expect(userRepo.update).toHaveBeenCalledWith(1, {
settings: {
seriesCollapsePreferences: { global: false, libraries: { '3': true }, collections: {}, smartScopes: {}, authorPages: true },
},
});
});
it('leaves the author pages flag alone when another scope is written', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({
id: 1,
settings: {
seriesCollapsePreferences: { global: false, libraries: {}, collections: {}, authorPages: true },
},
});
await service.updateSeriesCollapsePreferences(1, { global: true });
expect(userRepo.update).toHaveBeenCalledWith(
1,
expect.objectContaining({
settings: expect.objectContaining({
seriesCollapsePreferences: expect.objectContaining({ authorPages: true }),
}),
}),
);
});
}); });
+1
View File
@@ -504,6 +504,7 @@ export class UserService {
libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) }, libraries: { ...currentPrefs.libraries, ...(dto.libraries ?? {}) },
collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) }, collections: { ...currentPrefs.collections, ...(dto.collections ?? {}) },
smartScopes: { ...(currentPrefs.smartScopes ?? {}), ...(dto.smartScopes ?? {}) }, smartScopes: { ...(currentPrefs.smartScopes ?? {}), ...(dto.smartScopes ?? {}) },
authorPages: dto.authorPages !== undefined ? dto.authorPages : (currentPrefs.authorPages ?? false),
}; };
// Remove entries set to null (deletion of overrides) // Remove entries set to null (deletion of overrides)
@@ -0,0 +1,219 @@
import { randomUUID } from 'crypto';
import type { BookCard } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
createUserAndLogin,
grantLibraryAccess,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
const SCENARIO_TIMEOUT_MS = 60_000;
let inoSequence = 960_000;
type SeededSeries = { id: number; name: string };
async function createAuthor(ctx: AuthorizationMatrixE2EContext, name: string): Promise<number> {
const [author] = await ctx.db
.insert(schema.authors)
.values({ name: `${name} ${randomUUID()}` })
.returning({ id: schema.authors.id });
return author!.id;
}
async function createSeries(ctx: AuthorizationMatrixE2EContext, name: string): Promise<SeededSeries> {
const [series] = await ctx.db
.insert(schema.bookSeries)
.values({ name, normalizedName: `${name.toLowerCase()} ${randomUUID()}` })
.returning({ id: schema.bookSeries.id });
return { id: series!.id, name };
}
async function seedBook(
ctx: AuthorizationMatrixE2EContext,
library: CreatedLibrary,
options: { title: string; authorIds: number[]; series?: SeededSeries; seriesIndex?: string },
): Promise<number> {
const slug = `${options.title.toLowerCase().replaceAll(' ', '-')}-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({
libraryId: library.libraryId,
libraryFolderId: library.libraryFolderId,
folderPath: `${library.folderPath}/${slug}`,
status: 'present',
})
.returning({ id: schema.books.id });
const bookId = book!.id;
await ctx.db.insert(schema.bookMetadata).values({
bookId,
title: options.title,
seriesId: options.series?.id ?? null,
seriesName: options.series?.name ?? null,
seriesIndex: options.seriesIndex ?? null,
});
inoSequence += 1;
await ctx.db.insert(schema.bookFiles).values({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `${library.folderPath}/${slug}/${slug}.epub`,
relPath: `${slug}/${slug}.epub`,
ino: BigInt(inoSequence),
sizeBytes: 2048,
format: 'epub',
role: 'content',
});
for (const [displayOrder, authorId] of options.authorIds.entries()) {
await ctx.db.insert(schema.bookAuthors).values({ bookId, authorId, displayOrder });
}
if (options.series) {
await ctx.db.insert(schema.bookSeriesMemberships).values({
bookId,
seriesId: options.series.id,
seriesIndex: options.seriesIndex ?? null,
});
}
return bookId;
}
/**
* Exercises the collapsed author listing against a real database, which is the only place the
* correlated EXISTS that carries authorship into the collapsed query actually runs.
*/
describe('Author books collapsed by series (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let reader!: TestUserSession;
let prolificAuthorId!: number;
let otherAuthorId!: number;
let standaloneBookId!: number;
async function listBooks(authorId: number, query = '') {
return ctx.app.inject({
method: 'GET',
url: `/api/v1/authors/${authorId}/books${query}`,
headers: authHeader(reader.accessToken),
});
}
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
const library = await createLibraryWithFolder(ctx, { name: `author-collapse-${randomUUID()}` });
reader = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, reader.userId, library.libraryId);
prolificAuthorId = await createAuthor(ctx, 'Prolific Writer');
otherAuthorId = await createAuthor(ctx, 'Other Writer');
const discworld = await createSeries(ctx, 'Discworld');
const longEarth = await createSeries(ctx, 'The Long Earth');
// Five books over two series plus one standalone: seven rows flat, three collapsed.
await seedBook(ctx, library, { title: 'Guards Guards', authorIds: [prolificAuthorId], series: discworld, seriesIndex: '1' });
await seedBook(ctx, library, { title: 'Men At Arms', authorIds: [prolificAuthorId], series: discworld, seriesIndex: '2' });
await seedBook(ctx, library, { title: 'Feet Of Clay', authorIds: [prolificAuthorId], series: discworld, seriesIndex: '3' });
await seedBook(ctx, library, { title: 'The Long Earth', authorIds: [prolificAuthorId], series: longEarth, seriesIndex: '1' });
await seedBook(ctx, library, { title: 'The Long War', authorIds: [prolificAuthorId], series: longEarth, seriesIndex: '2' });
standaloneBookId = await seedBook(ctx, library, { title: 'Nation', authorIds: [prolificAuthorId] });
// Same series, a different author: it must not be counted into the prolific author's card.
await seedBook(ctx, library, { title: 'Foreign Volume', authorIds: [otherAuthorId], series: discworld, seriesIndex: '4' });
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('lists every book when collapsing is off', async () => {
const response = await listBooks(prolificAuthorId);
expect(response.statusCode).toBe(200);
const body = response.json() as { items: BookCard[]; total: number; bookTotal: number };
expect(body.items).toHaveLength(6);
expect(body.total).toBe(6);
// Flat, so the two counts say the same thing.
expect(body.bookTotal).toBe(6);
expect(body.items.every((item) => item.collapsedSeries === undefined)).toBe(true);
});
it('folds each series into one card and leaves the standalone book alone', async () => {
const response = await listBooks(prolificAuthorId, '?collapseSeries=true');
expect(response.statusCode).toBe(200);
const body = response.json() as { items: BookCard[]; total: number; bookTotal: number };
expect(body.items).toHaveLength(3);
const collapsed = body.items.filter((item) => item.collapsedSeries);
expect(collapsed).toHaveLength(2);
expect(collapsed.map((item) => item.collapsedSeries!.bookCount).sort()).toEqual([2, 3]);
const standalone = body.items.find((item) => item.id === standaloneBookId);
expect(standalone).toBeDefined();
expect(standalone!.collapsedSeries).toBeUndefined();
});
it('counts rows in total and books in bookTotal', async () => {
const response = await listBooks(prolificAuthorId, '?collapseSeries=true');
const body = response.json() as { total: number; bookTotal: number };
expect(body.total).toBe(3);
expect(body.bookTotal).toBe(6);
});
it('counts only this author in a series shared with another writer', async () => {
const response = await listBooks(prolificAuthorId, '?collapseSeries=true');
const body = response.json() as { items: BookCard[] };
const discworld = body.items.find((item) => item.seriesName === 'Discworld');
expect(discworld).toBeDefined();
// The fourth Discworld volume belongs to the other writer, so it stays out of this count.
expect(discworld!.collapsedSeries!.bookCount).toBe(3);
});
it('keeps another author to their own books', async () => {
const response = await listBooks(otherAuthorId, '?collapseSeries=true');
const body = response.json() as { items: BookCard[]; total: number; bookTotal: number };
expect(body.total).toBe(1);
expect(body.bookTotal).toBe(1);
expect(body.items[0]!.collapsedSeries!.bookCount).toBe(1);
});
it('pages over rows, so a page holds collapsed cards rather than books', async () => {
const first = await listBooks(prolificAuthorId, '?collapseSeries=true&size=2&page=0');
const second = await listBooks(prolificAuthorId, '?collapseSeries=true&size=2&page=1');
const firstBody = first.json() as { items: BookCard[]; total: number };
const secondBody = second.json() as { items: BookCard[]; total: number };
expect(firstBody.items).toHaveLength(2);
expect(secondBody.items).toHaveLength(1);
expect(firstBody.total).toBe(3);
// Walking the pages must reach the end of the list rather than looping on a book count.
expect(firstBody.items.length + secondBody.items.length).toBe(firstBody.total);
});
it('preserves both totals when the requested page is beyond the last row', async () => {
const response = await listBooks(prolificAuthorId, '?collapseSeries=true&size=2&page=20');
expect(response.statusCode).toBe(200);
const body = response.json() as { items: BookCard[]; total: number; bookTotal: number };
expect(body.items).toEqual([]);
expect(body.total).toBe(3);
expect(body.bookTotal).toBe(6);
});
});