diff --git a/server/src/modules/koreader/koreader-catalog.service.test.ts b/server/src/modules/koreader/koreader-catalog.service.test.ts index 6e874aeda..3976d35e7 100644 --- a/server/src/modules/koreader/koreader-catalog.service.test.ts +++ b/server/src/modules/koreader/koreader-catalog.service.test.ts @@ -646,6 +646,30 @@ describe('KoreaderCatalogService', () => { ); }); + it('uses viewer-scoped aggregate collection counts without recounting each collection', async () => { + const { service, opdsBookService } = makeService(); + const user = makeUser({ id: 7 }); + opdsBookService.getUserCollections.mockResolvedValue([ + { id: 2, name: 'Favorites', bookCount: 3 }, + { id: 9, name: 'Public empty', bookCount: 0 }, + ]); + + const section = await service.getSectionEntries(user, 'collections'); + + expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(7, false, user.contentFilters); + expect(opdsBookService.countBooks).not.toHaveBeenCalled(); + expect(section.items).toEqual([ + { id: '2', title: 'Favorites', section: 'collections', count: 3, booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&collectionId=2' }, + { + id: '9', + title: 'Public empty', + section: 'collections', + count: 0, + booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&collectionId=9', + }, + ]); + }); + it('rejects unknown section names', async () => { const { service } = makeService(); await expect(service.getSectionEntries(makeUser(), 'bogus')).rejects.toThrow(BadRequestException); diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index 21062f4e8..10bab8fbf 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -559,19 +559,14 @@ export class KoreaderCatalogService { } private async getCollectionEntries(user: RequestUser): Promise { - const rows = await this.opdsBookService.getUserCollections(user.id); - return Promise.all( - rows.map(async (row) => { - const count = await this.countBooks(user, { collectionId: row.id }); - return { - id: String(row.id), - title: row.name, - section: 'collections', - count, - booksHref: this.booksHref({ collectionId: row.id, sort: 'title' }), - }; - }), - ); + const rows = await this.opdsBookService.getUserCollections(user.id, user.isSuperuser, user.contentFilters); + return rows.map((row) => ({ + id: String(row.id), + title: row.name, + section: 'collections', + count: row.bookCount, + booksHref: this.booksHref({ collectionId: row.id, sort: 'title' }), + })); } private async getSmartScopeEntries(user: RequestUser): Promise { diff --git a/server/src/modules/opds/__tests__/opds-book.service.test.ts b/server/src/modules/opds/__tests__/opds-book.service.test.ts index 587378e59..6bfdd8931 100644 --- a/server/src/modules/opds/__tests__/opds-book.service.test.ts +++ b/server/src/modules/opds/__tests__/opds-book.service.test.ts @@ -1,7 +1,9 @@ import { ForbiddenException } from '@nestjs/common'; +import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; import { bookSeries, bookSeriesMemberships } from '../../../db/schema'; import { OpdsBookService } from '../opds-book.service'; +import { OpdsCollectionRepository } from '../opds-collection.repository'; type BookPageResult = { entries: unknown[]; total: number }; @@ -49,8 +51,9 @@ function makeService(selectQueue: unknown[] = [], queryBuilderOverrides: Record< buildWhere: vi.fn().mockReturnValue(undefined), ...queryBuilderOverrides, }; - const service = new OpdsBookService(db as never, queryBuilder as never); - return { service, db, queryBuilder }; + const collectionRepository = new OpdsCollectionRepository(db as never); + const service = new OpdsBookService(db as never, queryBuilder as never, collectionRepository); + return { service, db, queryBuilder, collectionRepository }; } function collectValues(value: unknown, seen = new WeakSet()): unknown[] { @@ -69,16 +72,16 @@ function collectValues(value: unknown, seen = new WeakSet()): unknown[] describe('OpdsBookService', () => { it('returns accessible library ids for superusers and regular users', async () => { const superDb = makeDb([[{ id: 1 }, { id: 4 }]]); - const superService = new OpdsBookService(superDb as never, {} as never); + const superService = new OpdsBookService(superDb as never, {} as never, new OpdsCollectionRepository(superDb as never)); await expect(superService.getAccessibleLibraryIds(7, true)).resolves.toEqual([1, 4]); const userDb = makeDb([[{ libraryId: 2 }, { libraryId: 3 }]]); - const userService = new OpdsBookService(userDb as never, {} as never); + const userService = new OpdsBookService(userDb as never, {} as never, new OpdsCollectionRepository(userDb as never)); await expect(userService.getAccessibleLibraryIds(7, false)).resolves.toEqual([2, 3]); }); it('handles getBooksPage access checks and smartScope delegation', async () => { - const { service } = makeService([[{ userId: 999 }], [{ userId: 7 }]]); + const { service } = makeService([[{ userId: 999, isPublic: false, mediaType: 'books' }], [{ userId: 7, isPublic: false, mediaType: 'books' }]]); const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); const privateService = testable(service); const smartScopeSpy = vi.spyOn(privateService, 'buildSmartScopeWhere'); @@ -227,14 +230,47 @@ describe('OpdsBookService', () => { expect(seriesChain.innerJoin).toHaveBeenCalledWith(bookSeriesMemberships, expect.anything()); }); - it('returns user collections and smartScopes', async () => { - const { service, db } = makeService([[{ id: 4, name: 'Favorites', bookCount: 1 }], [{ id: 7, name: 'Unread', icon: 'sparkles' }]]); + it.each([ + { userId: 7, isPublic: false, mediaType: 'books', allowed: true }, + { userId: 999, isPublic: true, mediaType: 'books', allowed: true }, + { userId: 999, isPublic: false, mediaType: 'books', allowed: false }, + { userId: 7, isPublic: true, mediaType: 'podcasts', allowed: false }, + undefined, + ])('checks collection access for both catalog pages and counts: %j', async (collection) => { + const { service, collectionRepository } = makeService(); + vi.spyOn(service, 'getAccessibleLibraryIds').mockResolvedValue([1]); + vi.spyOn(collectionRepository, 'findById').mockResolvedValue(collection ? [collection] : []); + const pageSpy = vi.spyOn(testable(service), 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 }); + + if (collection?.allowed) { + await expect(service.getBooksPage(7, 'recent', 1, 20, { collectionId: 11 })).resolves.toEqual({ entries: [], total: 0 }); + await expect(service.countBooks(7, { collectionId: 11 })).resolves.toBe(0); + expect(pageSpy).toHaveBeenCalledOnce(); + } else { + await expect(service.getBooksPage(7, 'recent', 1, 20, { collectionId: 11 })).rejects.toThrow(ForbiddenException); + await expect(service.countBooks(7, { collectionId: 11 })).rejects.toThrow(ForbiddenException); + expect(pageSpy).not.toHaveBeenCalled(); + } + }); + + it('passes viewer permissions to collection counts', async () => { + const { service, collectionRepository } = makeService(); + const filters = { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [12] }; + const listSpy = vi.spyOn(collectionRepository, 'findVisibleForUser').mockResolvedValue([{ id: 4, name: 'Favorites', bookCount: 1 }]); + const countSpy = vi.spyOn(collectionRepository, 'countVisibleForUser').mockResolvedValue(3); + await expect(service.getUserCollections(8, false, filters)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]); + expect(listSpy).toHaveBeenCalledWith(8, false, filters); + await expect(service.countUserCollections(8)).resolves.toBe(3); + expect(countSpy).toHaveBeenCalledWith(8); + }); + + it('returns user smartScopes', async () => { + const { service, db } = makeService([[{ id: 7, name: 'Unread', icon: 'sparkles' }]]); - await expect(service.getUserCollections(8)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]); await expect(service.getUserSmartScopes(8)).resolves.toEqual([{ id: 7, name: 'Unread', icon: 'sparkles' }]); const chains = (db.select as ReturnType).mock.results.map((r) => r.value as Record); - const smartScopeChain = chains[1]!; + const smartScopeChain = chains[0]!; const whereClause = (smartScopeChain.where as ReturnType).mock.calls[0]?.[0]; expect(collectValues(whereClause)).toContain(8); expect(collectValues(whereClause)).toContain(true); diff --git a/server/src/modules/opds/opds-book.service.ts b/server/src/modules/opds/opds-book.service.ts index 6f8d8d8ff..36bdfaa6d 100644 --- a/server/src/modules/opds/opds-book.service.ts +++ b/server/src/modules/opds/opds-book.service.ts @@ -13,7 +13,6 @@ import { bookSeries, bookSeriesMemberships, books, - collections, collectionBooks, smartScopes, libraries, @@ -25,6 +24,7 @@ import { isAudioFormat, type ContentFilterRules, type GroupRule } from '@bookorb import { rankFileRowsByBook, rankFilesByFormatPriority } from '../../common/utils/primary-file-selection.utils'; import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils'; import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils'; +import { OpdsCollectionRepository } from './opds-collection.repository'; type Db = NodePgDatabase; @@ -153,6 +153,7 @@ export class OpdsBookService { constructor( @Inject(DB) private readonly db: Db, private readonly queryBuilder: BookQueryBuilder, + private readonly collectionRepository: OpdsCollectionRepository, ) {} async getAccessibleLibraryIds(userId: number, isSuperuser = false): Promise { @@ -223,12 +224,8 @@ export class OpdsBookService { } if (filters?.collectionId) { - const [collection] = await this.db - .select({ userId: collections.userId }) - .from(collections) - .where(eq(collections.id, filters.collectionId)) - .limit(1); - if (!collection || collection.userId !== userId) { + const [collection] = await this.collectionRepository.findById(filters.collectionId); + if (!collection || collection.mediaType !== 'books' || (collection.userId !== userId && !collection.isPublic)) { throw new ForbiddenException('No access to this collection'); } } @@ -597,29 +594,15 @@ export class OpdsBookService { return { items: hasNext ? rows.slice(0, opts.limit) : rows, hasNext }; } - async getUserCollections(userId: number) { - return this.db - .select({ - id: collections.id, - name: collections.name, - bookCount: sql`count(${collectionBooks.bookId})::int`, - }) - .from(collections) - .leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id)) - .where(and(eq(collections.userId, userId), eq(collections.mediaType, 'books'))) - .groupBy(collections.id) - .orderBy(collections.name); + async getUserCollections(userId: number, isSuperuser = false, contentFilters?: ContentFilterRules) { + return this.collectionRepository.findVisibleForUser(userId, isSuperuser, contentFilters); } // Badge counts only need the totals, so these skip the per-entity book counts // their list siblings compute. Counting collections through getUserCollections // would aggregate over collection_books just to read the row count back. async countUserCollections(userId: number): Promise { - const [row] = await this.db - .select({ total: count() }) - .from(collections) - .where(and(eq(collections.userId, userId), eq(collections.mediaType, 'books'))); - return Number(row?.total ?? 0); + return this.collectionRepository.countVisibleForUser(userId); } async countUserSmartScopes(userId: number): Promise { diff --git a/server/src/modules/opds/opds-collection.repository.test.ts b/server/src/modules/opds/opds-collection.repository.test.ts new file mode 100644 index 000000000..36e00b4b6 --- /dev/null +++ b/server/src/modules/opds/opds-collection.repository.test.ts @@ -0,0 +1,89 @@ +import { Test } from '@nestjs/testing'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { OpdsCollectionRepository } from './opds-collection.repository'; + +describe('OpdsCollectionRepository', () => { + let repository: OpdsCollectionRepository; + const query = vi.fn(); + + beforeEach(async () => { + query.mockReset().mockResolvedValue({ rows: [] }); + const db = drizzle({ client: { query } as never, schema }); + const module = await Test.createTestingModule({ + providers: [OpdsCollectionRepository, { provide: DB, useValue: db }], + }).compile(); + repository = module.get(OpdsCollectionRepository); + }); + + it('counts only present books in the viewer libraries in one aggregate query', async () => { + query.mockResolvedValueOnce({ + rows: [ + [4, 'Favorites', 1], + [9, 'Empty public', 0], + ], + }); + + await expect(repository.findVisibleForUser(7, false)).resolves.toEqual([ + { id: 4, name: 'Favorites', bookCount: 1 }, + { id: 9, name: 'Empty public', bookCount: 0 }, + ]); + + expect(query).toHaveBeenCalledOnce(); + const [{ text }, params] = query.mock.calls[0] as [{ text: string }, unknown[]]; + expect(text).toContain('count("books"."id")::int'); + expect(text).toContain('left join "books" on'); + expect(text).toContain('"books"."status" = $1'); + expect(text).toContain('exists (select 1 from "user_library_access"'); + expect(text).toContain('"user_library_access"."library_id" = "books"."library_id"'); + expect(text).toContain('("collections"."user_id" = $3 or "collections"."is_public" = $4)'); + expect(params).toEqual(['present', 7, 7, true, 'books']); + }); + + it('applies include and exclude content filters inside the book join', () => { + const filters = { + ...EMPTY_CONTENT_FILTER_RULES, + includeTagIds: [11], + includeGenreIds: [12], + excludeTagIds: [13], + excludeGenreIds: [14], + }; + const { sql, params } = repository.findVisibleForUser(7, false, filters).toSQL(); + const bookJoin = sql.slice(sql.indexOf('left join "books"'), sql.indexOf(' where ((')); + + expect(bookJoin).toContain('"books"."status"'); + expect(bookJoin).toContain('exists (select 1 from "book_tags"'); + expect(bookJoin).toContain('exists (select 1 from "book_genres"'); + expect(bookJoin).toContain(' or exists'); + expect(bookJoin).toContain('not exists (select 1 from "book_tags"'); + expect(bookJoin).toContain('not exists (select 1 from "book_genres"'); + expect(params).toEqual(['present', 7, 11, 12, 13, 14, 7, true, 'books']); + }); + + it('lets superusers count every library but still excludes foreign private and podcast collections', () => { + const filters = { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [13] }; + const { sql, params } = repository.findVisibleForUser(7, true, filters).toSQL(); + + expect(sql).not.toContain('user_library_access'); + expect(sql).not.toContain('book_tags'); + expect(sql).toContain('"books"."status"'); + expect(sql).toContain('"collections"."user_id"'); + expect(sql).toContain('"collections"."is_public"'); + expect(sql).toContain('"collections"."media_type"'); + expect(params).toEqual(['present', 7, true, 'books']); + }); + + it('uses the same visibility for dashboard totals without joining memberships', async () => { + query.mockResolvedValueOnce({ rows: [['3']] }); + + await expect(repository.countVisibleForUser(7)).resolves.toBe(3); + + const [{ text }, params] = query.mock.calls[0] as [{ text: string }, unknown[]]; + expect(text).toContain('("collections"."user_id" = $1 or "collections"."is_public" = $2)'); + expect(text).not.toContain('join'); + expect(params).toEqual([7, true, 'books']); + }); +}); diff --git a/server/src/modules/opds/opds-collection.repository.ts b/server/src/modules/opds/opds-collection.repository.ts new file mode 100644 index 000000000..785f6e1a6 --- /dev/null +++ b/server/src/modules/opds/opds-collection.repository.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, count, eq, exists, or, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import type { ContentFilterRules } from '@bookorbit/types'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { books, collectionBooks, collections, userLibraryAccess } from '../../db/schema'; +import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils'; + +type Db = NodePgDatabase; + +@Injectable() +export class OpdsCollectionRepository { + constructor(@Inject(DB) private readonly db: Db) {} + + findById(id: number) { + return this.db + .select({ userId: collections.userId, isPublic: collections.isPublic, mediaType: collections.mediaType }) + .from(collections) + .where(eq(collections.id, id)) + .limit(1); + } + + findVisibleForUser(userId: number, isSuperuser: boolean, contentFilters?: ContentFilterRules) { + const libraryAccess = isSuperuser + ? undefined + : exists( + this.db + .select({ one: sql`1` }) + .from(userLibraryAccess) + .where(and(eq(userLibraryAccess.userId, userId), eq(userLibraryAccess.libraryId, books.libraryId))), + ); + const filterClauses = !isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []; + + return this.db + .select({ id: collections.id, name: collections.name, bookCount: sql`count(${books.id})::int` }) + .from(collections) + .leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id)) + .leftJoin(books, and(eq(books.id, collectionBooks.bookId), eq(books.status, 'present'), libraryAccess, ...filterClauses)) + .where(this.visibleForUser(userId)) + .groupBy(collections.id) + .orderBy(collections.name); + } + + async countVisibleForUser(userId: number): Promise { + const [row] = await this.db.select({ total: count() }).from(collections).where(this.visibleForUser(userId)); + return Number(row?.total ?? 0); + } + + private visibleForUser(userId: number) { + return and(or(eq(collections.userId, userId), eq(collections.isPublic, true)), eq(collections.mediaType, 'books')); + } +} diff --git a/server/src/modules/opds/opds.controller.test.ts b/server/src/modules/opds/opds.controller.test.ts index a9c697ef2..0163d5804 100644 --- a/server/src/modules/opds/opds.controller.test.ts +++ b/server/src/modules/opds/opds.controller.test.ts @@ -105,7 +105,7 @@ describe('OpdsController', () => { await controller.series(user, makeReply()); expect(opdsBookService.getAccessibleLibraries).toHaveBeenCalledWith(8, false); - expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(8); + expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(8, false, undefined); expect(opdsBookService.getUserSmartScopes).toHaveBeenCalledWith(8); expect(opdsBookService.getDistinctAuthors).toHaveBeenCalledWith(8, false, undefined); expect(opdsBookService.getDistinctSeries).toHaveBeenCalledWith(8, false, undefined); diff --git a/server/src/modules/opds/opds.controller.ts b/server/src/modules/opds/opds.controller.ts index daa831c79..22eb28fa7 100644 --- a/server/src/modules/opds/opds.controller.ts +++ b/server/src/modules/opds/opds.controller.ts @@ -70,7 +70,7 @@ export class OpdsController { @Get('collections') async collections(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) { - const cols = await this.opdsBookService.getUserCollections(user.userId); + const cols = await this.opdsBookService.getUserCollections(user.userId, user.isSuperuser, user.contentFilters); const xml = this.opdsService.generateCollectionsNavigation(cols); this.sendXml(reply, xml, OPDS_MIME_NAV); } diff --git a/server/src/modules/opds/opds.module.test.ts b/server/src/modules/opds/opds.module.test.ts index 92cd532c0..dd9356331 100644 --- a/server/src/modules/opds/opds.module.test.ts +++ b/server/src/modules/opds/opds.module.test.ts @@ -9,6 +9,7 @@ import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.modul import { UserModule } from '../user/user.module'; import { OpdsAuthGuard } from './opds-auth.guard'; import { OpdsBookService } from './opds-book.service'; +import { OpdsCollectionRepository } from './opds-collection.repository'; import { OpdsController } from './opds.controller'; import { OpdsEnabledGuard } from './opds-enabled.guard'; import { OpdsModule } from './opds.module'; @@ -29,6 +30,7 @@ describe('OpdsModule', () => { expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, OpdsModule)).toEqual([ OpdsService, OpdsBookService, + OpdsCollectionRepository, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard, diff --git a/server/src/modules/opds/opds.module.ts b/server/src/modules/opds/opds.module.ts index 008497a01..de88cb884 100644 --- a/server/src/modules/opds/opds.module.ts +++ b/server/src/modules/opds/opds.module.ts @@ -7,6 +7,7 @@ import { UserModule } from '../user/user.module'; import { CommonModule } from '../../common/common.module'; import { OpdsAuthGuard } from './opds-auth.guard'; import { OpdsBookService } from './opds-book.service'; +import { OpdsCollectionRepository } from './opds-collection.repository'; import { OpdsController } from './opds.controller'; import { OpdsEnabledGuard } from './opds-enabled.guard'; import { OpdsService } from './opds.service'; @@ -16,7 +17,7 @@ import { OpdsUserService } from './opds-user.service'; @Module({ imports: [AppSettingsModule, BookModule, BookCoverStoreModule, UserModule, CommonModule], controllers: [OpdsController, OpdsUserController], - providers: [OpdsService, OpdsBookService, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard], + providers: [OpdsService, OpdsBookService, OpdsCollectionRepository, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard], exports: [OpdsBookService], }) export class OpdsModule {} diff --git a/server/test/opds-auth-catalog.e2e-spec.ts b/server/test/opds-auth-catalog.e2e-spec.ts index 1872ae99b..1d38c380c 100644 --- a/server/test/opds-auth-catalog.e2e-spec.ts +++ b/server/test/opds-auth-catalog.e2e-spec.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { mkdir, readFile, stat, writeFile } from 'fs/promises'; import { dirname, join, relative } from 'path'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { Permission } from '@bookorbit/types'; import { ConfigService } from '@nestjs/config'; @@ -488,6 +488,128 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => { expect(seriesResponse.body).not.toContain(hiddenSeriesName); }); + it('exposes public collections across OPDS and KOReader with viewer-scoped counts and access', async () => { + const reader = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess, Permission.KoreaderSync] }); + const noLibraryReader = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] }); + const readerOpds = await createOpdsUserCredential(ctx, { userId: reader.userId }); + const noLibraryOpds = await createOpdsUserCredential(ctx, { userId: noLibraryReader.userId }); + const credentials = { username: readerOpds.row.username, password: readerOpds.password }; + const koreaderHeaders = { 'x-auth-user': reader.username, 'x-auth-key': reader.password }; + await grantLibraryAccess(ctx, reader.userId, visibleLibrary.libraryId); + const credentialResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/koreader/credentials', + headers: authHeader(reader.accessToken), + payload: { username: reader.username, password: reader.password }, + }); + expect([200, 201]).toContain(credentialResponse.statusCode); + + const [excludedTag] = await ctx.db + .insert(schema.tags) + .values({ name: `opds-excluded-${randomUUID()}` }) + .returning(); + await ctx.db.insert(schema.bookTags).values({ bookId: visibleBookBeta.bookId, tagId: excludedTag.id }); + await ctx.db.insert(schema.userContentFilterTags).values({ userId: reader.userId, tagId: excludedTag.id, filterType: 'exclude' }); + const unavailableBooks = await ctx.db + .insert(schema.books) + .values( + (['missing', 'processing'] as const).map((status) => ({ + libraryId: visibleLibrary.libraryId, + libraryFolderId: visibleLibrary.libraryFolderId, + folderPath: join(visibleLibrary.folderPath, `unavailable-${status}-${randomUUID()}`), + status, + })), + ) + .returning({ id: schema.books.id }); + const [publicCollection, ownCollection, emptyCollection, podcastCollection] = await ctx.db + .insert(schema.collections) + .values([ + { userId: intruder.userId, name: `public-collection-${randomUUID()}`, isPublic: true }, + { userId: reader.userId, name: `private-own-${randomUUID()}` }, + { userId: intruder.userId, name: `public-empty-${randomUUID()}`, isPublic: true }, + { userId: intruder.userId, name: `public-podcast-${randomUUID()}`, isPublic: true, mediaType: 'podcasts' as const }, + ]) + .returning(); + await ctx.db.insert(schema.collectionBooks).values( + [visibleBookAlpha.bookId, visibleBookBeta.bookId, hiddenBook.bookId, ...unavailableBooks.map((book) => book.id)].map((bookId) => ({ + collectionId: publicCollection.id, + bookId, + })), + ); + + const koreaderGet = (url: string) => ctx.app.inject({ method: 'GET', url, headers: koreaderHeaders }); + try { + const web = await ctx.app.inject({ method: 'GET', url: '/api/v1/collections', headers: authHeader(reader.accessToken) }); + expect(web.statusCode).toBe(200); + expect(web.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: publicCollection.id })])); + + const navigation = await opdsGet('/api/v1/opds/collections', credentials); + expect(navigation.statusCode).toBe(200); + expect(navigation.body).toContain(`collectionId=${publicCollection.id}`); + expect(navigation.body).toContain('1 books'); + expect(navigation.body).not.toContain(`collectionId=${foreignCollectionId}`); + expect(navigation.body).not.toContain(`collectionId=${podcastCollection.id}`); + + const section = await koreaderGet('/api/v1/koreader/plugin/catalog/sections/collections'); + expect(section.statusCode).toBe(200); + expect(section.json().items).toEqual([ + expect.objectContaining({ id: String(ownCollection.id), count: 0 }), + expect.objectContaining({ id: String(publicCollection.id), count: 1 }), + expect.objectContaining({ id: String(emptyCollection.id), count: 0 }), + ]); + const dashboard = await koreaderGet('/api/v1/koreader/plugin/catalog/dashboard'); + expect(dashboard.statusCode).toBe(200); + expect(dashboard.json().browseCounts.collections).toBe(3); + + const catalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${publicCollection.id}`, credentials); + expect(catalog.statusCode).toBe(200); + expect(catalog.body).toContain('1'); + expect(catalog.body).toContain('Visible Alpha'); + expect(catalog.body).not.toContain('Visible Beta'); + expect(catalog.body).not.toContain('Hidden Gamma'); + for (const route of ['books', 'manifest']) { + const response = await koreaderGet(`/api/v1/koreader/plugin/catalog/${route}?collectionId=${publicCollection.id}`); + expect(response.statusCode).toBe(200); + expect(response.json().items.map((book: { id: number }) => book.id)).toEqual([visibleBookAlpha.bookId]); + } + const privateCatalog = await koreaderGet(`/api/v1/koreader/plugin/catalog/books?collectionId=${foreignCollectionId}`); + expect(privateCatalog.statusCode).toBe(403); + const podcastCatalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${podcastCollection.id}`, credentials); + expect(podcastCatalog.statusCode).toBe(403); + + const emptyNavigation = await opdsGet('/api/v1/opds/collections', { + username: noLibraryOpds.row.username, + password: noLibraryOpds.password, + }); + expect(emptyNavigation.statusCode).toBe(200); + expect(emptyNavigation.body).toContain(`collectionId=${publicCollection.id}`); + expect(emptyNavigation.body).toContain('0 books'); + expect(emptyNavigation.body).not.toMatch(/[1-9]\d* books<\/content>/); + + await ctx.db.update(schema.collections).set({ isPublic: false }).where(eq(schema.collections.id, publicCollection.id)); + const revokedNavigation = await opdsGet('/api/v1/opds/collections', credentials); + expect(revokedNavigation.body).not.toContain(`collectionId=${publicCollection.id}`); + const revokedCatalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${publicCollection.id}`, credentials); + expect(revokedCatalog.statusCode).toBe(403); + const revokedManifest = await koreaderGet(`/api/v1/koreader/plugin/catalog/manifest?collectionId=${publicCollection.id}`); + expect(revokedManifest.statusCode).toBe(403); + const revokedDashboard = await koreaderGet('/api/v1/koreader/plugin/catalog/dashboard'); + expect(revokedDashboard.json().browseCounts.collections).toBe(2); + } finally { + await ctx.db + .delete(schema.collections) + .where(inArray(schema.collections.id, [publicCollection.id, ownCollection.id, emptyCollection.id, podcastCollection.id])); + await ctx.db.delete(schema.books).where( + inArray( + schema.books.id, + unavailableBooks.map((book) => book.id), + ), + ); + await ctx.db.delete(schema.tags).where(eq(schema.tags.id, excludedTag.id)); + await ctx.db.delete(schema.users).where(inArray(schema.users.id, [reader.userId, noLibraryReader.userId])); + } + }); + it('enforces catalog pagination, filters, and strict query validation', async () => { const page1Response = await opdsGet('/api/v1/opds/catalog?page=1&size=1', ownerCredentials); expect(page1Response.statusCode).toBe(200);