diff --git a/scripts/e2e/suite-registry.mjs b/scripts/e2e/suite-registry.mjs index fcc15d26c..18fe318e5 100644 --- a/scripts/e2e/suite-registry.mjs +++ b/scripts/e2e/suite-registry.mjs @@ -266,6 +266,26 @@ export const E2E_SUITES = Object.freeze({ ...SHARED_DB_AND_HELPER_PATHS, ], }, + "catalog-visibility": { + id: "catalog-visibility", + name: "Catalog Visibility", + timeout: 60, + lane: "full", + description: "Catalog metadata library and content-filter visibility suite", + vitestTarget: "test/catalog-visibility.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/catalog-visibility-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + changedPaths: [ + "server/src/modules/catalog/**", + "server/src/modules/library/**", + "server/src/common/utils/content-filter-sql.utils.ts", + "server/test/catalog-visibility.e2e-spec.ts", + "server/test/e2e/authorization-matrix/**", + ".github/workflows/e2e-runner.yml", + ...SHARED_DB_AND_HELPER_PATHS, + ], + }, "dashboard-random": { id: "dashboard-random", name: "Dashboard Random Scroller", diff --git a/server/src/common/utils/accent-insensitive-search-entry-points.test.ts b/server/src/common/utils/accent-insensitive-search-entry-points.test.ts index d702211c0..d36b67c61 100644 --- a/server/src/common/utils/accent-insensitive-search-entry-points.test.ts +++ b/server/src/common/utils/accent-insensitive-search-entry-points.test.ts @@ -7,7 +7,7 @@ const SEARCH_ENTRY_POINTS = [ 'modules/book-dock/book-dock.repository.ts', 'modules/book/book-query-builder.service.ts', 'modules/book/book.repository.ts', - 'modules/catalog/catalog.service.ts', + 'modules/catalog/catalog.repository.ts', 'modules/custom-icon/custom-icon.repository.ts', 'modules/entity-manager/strategies/author.strategy.ts', 'modules/entity-manager/strategies/inline-entity.strategy.ts', @@ -24,7 +24,7 @@ const SEARCH_ENTRY_POINTS = [ const FREE_TEXT_SEARCH_ENTRY_POINTS = [ 'modules/authors/authors.repository.ts', 'modules/book/book-query-builder.service.ts', - 'modules/catalog/catalog.service.ts', + 'modules/catalog/catalog.repository.ts', 'modules/opds/opds-book.service.ts', 'modules/series/series.repository.ts', ] as const; diff --git a/server/src/modules/architecture/architecture-boundaries.test.ts b/server/src/modules/architecture/architecture-boundaries.test.ts index 0eb2b8236..3020f171b 100644 --- a/server/src/modules/architecture/architecture-boundaries.test.ts +++ b/server/src/modules/architecture/architecture-boundaries.test.ts @@ -43,7 +43,6 @@ describe('Architecture boundaries', () => { 'src/modules/authors/author-metadata-preferences.service.ts', 'src/modules/book/book-query-builder.service.ts', 'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts', - 'src/modules/catalog/catalog.service.ts', 'src/modules/cover/cover.service.ts', 'src/modules/entity-manager/duplicate-compute.service.ts', 'src/modules/kobo/services/kobo-analytics-resolver.service.ts', diff --git a/server/src/modules/catalog/catalog.controller.test.ts b/server/src/modules/catalog/catalog.controller.test.ts index 3c9581222..152349f5f 100644 --- a/server/src/modules/catalog/catalog.controller.test.ts +++ b/server/src/modules/catalog/catalog.controller.test.ts @@ -6,6 +6,10 @@ import { CatalogController } from './catalog.controller'; import { CatalogService } from './catalog.service'; import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; +function requestUser(): RequestUser { + return { id: 7, isSuperuser: false, contentFilters: EMPTY_CONTENT_FILTER_RULES } as RequestUser; +} + function makeController() { const service = { searchAuthors: vi.fn(), @@ -31,9 +35,9 @@ describe('CatalogController', () => { const expected = [{ name: 'Frank Herbert' }]; service.searchAuthors.mockResolvedValue(expected); - const result = await controller.searchAuthors({ q: 'Frank' }); + const result = await controller.searchAuthors(requestUser(), { q: 'Frank' }); - expect(service.searchAuthors).toHaveBeenCalledWith('Frank'); + expect(service.searchAuthors).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Frank'); expect(result).toEqual(expected); }); @@ -41,57 +45,57 @@ describe('CatalogController', () => { const { controller, service } = makeController(); const query = new SearchCatalogQueryDto(); - await controller.searchAuthors(query); + await controller.searchAuthors(requestUser(), query); - expect(service.searchAuthors).toHaveBeenCalledWith(''); + expect(service.searchAuthors).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), ''); }); it('delegates genre queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchGenres({ q: 'Fantasy' }); + await controller.searchGenres(requestUser(), { q: 'Fantasy' }); - expect(service.searchGenres).toHaveBeenCalledWith('Fantasy'); + expect(service.searchGenres).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Fantasy'); }); it('delegates tag queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchTags({ q: 'Space Opera' }); + await controller.searchTags(requestUser(), { q: 'Space Opera' }); - expect(service.searchTags).toHaveBeenCalledWith('Space Opera'); + expect(service.searchTags).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Space Opera'); }); it('delegates narrator queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchNarrators({ q: 'Ray Porter' }); + await controller.searchNarrators(requestUser(), { q: 'Ray Porter' }); - expect(service.searchNarrators).toHaveBeenCalledWith('Ray Porter'); + expect(service.searchNarrators).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Ray Porter'); }); it('delegates publisher queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchPublishers({ q: 'Orbit' }); + await controller.searchPublishers(requestUser(), { q: 'Orbit' }); - expect(service.searchPublishers).toHaveBeenCalledWith('Orbit'); + expect(service.searchPublishers).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Orbit'); }); it('delegates series queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchSeries({ q: 'Expanse' }); + await controller.searchSeries(requestUser(), { q: 'Expanse' }); - expect(service.searchSeries).toHaveBeenCalledWith('Expanse'); + expect(service.searchSeries).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'Expanse'); }); it('delegates language queries to the catalog service', async () => { const { controller, service } = makeController(); - await controller.searchLanguages({ q: 'English' }); + await controller.searchLanguages(requestUser(), { q: 'English' }); - expect(service.searchLanguages).toHaveBeenCalledWith('English'); + expect(service.searchLanguages).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }), 'English'); }); it('passes current user id to collection search', async () => { diff --git a/server/src/modules/catalog/catalog.controller.ts b/server/src/modules/catalog/catalog.controller.ts index edda26814..7a014fc27 100644 --- a/server/src/modules/catalog/catalog.controller.ts +++ b/server/src/modules/catalog/catalog.controller.ts @@ -10,38 +10,38 @@ export class CatalogController { constructor(private readonly catalogService: CatalogService) {} @Get('authors') - searchAuthors(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchAuthors(query.q); + searchAuthors(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchAuthors(user, query.q); } @Get('genres') - searchGenres(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchGenres(query.q); + searchGenres(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchGenres(user, query.q); } @Get('tags') - searchTags(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchTags(query.q); + searchTags(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchTags(user, query.q); } @Get('narrators') - searchNarrators(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchNarrators(query.q); + searchNarrators(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchNarrators(user, query.q); } @Get('publishers') - searchPublishers(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchPublishers(query.q); + searchPublishers(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchPublishers(user, query.q); } @Get('series') - searchSeries(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchSeries(query.q); + searchSeries(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchSeries(user, query.q); } @Get('languages') - searchLanguages(@Query() query: SearchCatalogQueryDto) { - return this.catalogService.searchLanguages(query.q); + searchLanguages(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) { + return this.catalogService.searchLanguages(user, query.q); } @Get('collections') diff --git a/server/src/modules/catalog/catalog.module.ts b/server/src/modules/catalog/catalog.module.ts index bba6e3d8f..fe7071435 100644 --- a/server/src/modules/catalog/catalog.module.ts +++ b/server/src/modules/catalog/catalog.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; +import { LibraryModule } from '../library/library.module'; import { CatalogController } from './catalog.controller'; +import { CatalogRepository } from './catalog.repository'; import { CatalogService } from './catalog.service'; @Module({ + imports: [LibraryModule], controllers: [CatalogController], - providers: [CatalogService], + providers: [CatalogService, CatalogRepository], }) export class CatalogModule {} diff --git a/server/src/modules/catalog/catalog.repository.test.ts b/server/src/modules/catalog/catalog.repository.test.ts new file mode 100644 index 000000000..6c5f036fb --- /dev/null +++ b/server/src/modules/catalog/catalog.repository.test.ts @@ -0,0 +1,168 @@ +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...clauses: unknown[]) => ({ type: 'and', clauses })), + eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), + inArray: vi.fn((column: unknown, values: unknown[]) => ({ type: 'inArray', column, values })), + isNotNull: vi.fn((value: unknown) => ({ type: 'isNotNull', value })), + sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', parts, values })), +})); + +vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({ + ...(await importOriginal()), + accentInsensitiveExactMatchRank: vi.fn((left: unknown, term: string) => ({ type: 'rank', left, term })), + accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'ilike', left, pattern })), +})); + +vi.mock('../../common/utils/content-filter-sql.utils', () => ({ + buildContentFilterClauses: vi.fn(() => [{ type: 'contentFilter' }]), +})); + +import { and, eq, inArray, isNotNull } from 'drizzle-orm'; + +import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; +import { accentInsensitiveExactMatchRank, accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils'; +import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils'; +import { + authors, + bookAuthors, + bookGenres, + bookMetadata, + bookNarrators, + bookSeries, + bookSeriesMemberships, + bookTags, + books, + collections, + genres, + narrators, + tags, +} from '../../db/schema'; +import { CatalogRepository, type CatalogSearchScope } from './catalog.repository'; + +interface QueryChain { + from: ReturnType; + innerJoin: ReturnType; + where: ReturnType; + groupBy: ReturnType; + orderBy: ReturnType; + limit: ReturnType; +} + +function createQueryChain(rows: unknown[]): QueryChain { + const chain: QueryChain = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + groupBy: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn().mockResolvedValue(rows), + }; + + chain.from.mockReturnValue(chain); + chain.innerJoin.mockReturnValue(chain); + chain.where.mockReturnValue(chain); + chain.groupBy.mockReturnValue(chain); + chain.orderBy.mockReturnValue(chain); + return chain; +} + +function makeRepository(rows: unknown[] = []) { + const selectChain = createQueryChain(rows); + const selectDistinctChain = createQueryChain(rows); + const db = { + select: vi.fn(() => selectChain), + selectDistinct: vi.fn(() => selectDistinctChain), + }; + + return { + repository: new CatalogRepository(db as never), + db, + selectChain, + selectDistinctChain, + }; +} + +const filteredScope: CatalogSearchScope = { + libraryIds: [4, 9], + contentFilters: { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [3] }, +}; + +describe('CatalogRepository', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns no author results without building a query for a blank term', async () => { + const { repository, db } = makeRepository(); + + await expect(repository.searchAuthors(' ', filteredScope)).resolves.toEqual([]); + + expect(db.selectDistinct).not.toHaveBeenCalled(); + }); + + it.each([ + ['authors', authors, bookAuthors, (repository: CatalogRepository) => repository.searchAuthors('scope', filteredScope)], + ['narrators', narrators, bookNarrators, (repository: CatalogRepository) => repository.searchNarrators('scope', filteredScope)], + ['series', bookSeries, bookSeriesMemberships, (repository: CatalogRepository) => repository.searchSeries('scope', filteredScope)], + ] as const)('joins %s through its book relation and applies the complete visibility scope', async (_label, table, junction, search) => { + const { repository, selectDistinctChain } = makeRepository(); + + await search(repository); + + expect(selectDistinctChain.from).toHaveBeenCalledWith(table); + expect(selectDistinctChain.innerJoin).toHaveBeenCalledWith(junction, expect.anything()); + expect(selectDistinctChain.innerJoin).toHaveBeenCalledWith(books, expect.anything()); + expect(inArray).toHaveBeenCalledWith(books.libraryId, [4, 9]); + expect(buildContentFilterClauses).toHaveBeenCalledWith(filteredScope.contentFilters, expect.anything()); + }); + + it.each([ + ['genres', genres, bookGenres, (repository: CatalogRepository) => repository.searchGenres(' Young\u00a0 Adult ', filteredScope)], + ['tags', tags, bookTags, (repository: CatalogRepository) => repository.searchTags(' Young\u00a0 Adult ', filteredScope)], + ] as const)('deduplicates %s with grouping while retaining normalized exact-match ranking', async (_label, table, junction, search) => { + const { repository, selectChain } = makeRepository(); + + await search(repository); + + expect(selectChain.from).toHaveBeenCalledWith(table); + expect(selectChain.innerJoin).toHaveBeenCalledWith(junction, expect.anything()); + expect(selectChain.innerJoin).toHaveBeenCalledWith(books, expect.anything()); + expect(accentInsensitiveIlike).toHaveBeenCalledWith(table.name, '%Young Adult%'); + expect(selectChain.groupBy).toHaveBeenCalledWith(table.id, table.name); + expect(accentInsensitiveExactMatchRank).toHaveBeenCalledWith(table.name, 'Young Adult'); + expect(selectChain.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'rank' }), table.name); + }); + + it.each([ + ['publishers', bookMetadata.publisher, (repository: CatalogRepository) => repository.searchPublishers('scope', filteredScope)], + ['languages', bookMetadata.language, (repository: CatalogRepository) => repository.searchLanguages('scope', filteredScope)], + ] as const)('joins %s metadata to books before applying visibility', async (_label, column, search) => { + const { repository, selectDistinctChain } = makeRepository([{ name: 'scope value' }, { name: null }]); + + await expect(search(repository)).resolves.toEqual([{ name: 'scope value' }]); + + expect(selectDistinctChain.from).toHaveBeenCalledWith(bookMetadata); + expect(selectDistinctChain.innerJoin).toHaveBeenCalledWith(books, expect.anything()); + expect(isNotNull).toHaveBeenCalledWith(column); + expect(inArray).toHaveBeenCalledWith(books.libraryId, [4, 9]); + }); + + it('does not build content-filter SQL for a superuser scope', async () => { + const { repository } = makeRepository(); + + await repository.searchTags('history', { libraryIds: [1, 2] }); + + expect(buildContentFilterClauses).not.toHaveBeenCalled(); + }); + + it('keeps collection lookup user-scoped and wildcard-safe', async () => { + const { repository, selectChain } = makeRepository([{ name: 'Favorites' }]); + + await expect(repository.searchCollections(42, ' sci_fi% ')).resolves.toEqual([{ name: 'Favorites' }]); + + expect(eq).toHaveBeenCalledWith(collections.userId, 42); + expect(accentInsensitiveIlike).toHaveBeenCalledWith(collections.name, '%sci\\_fi\\%%'); + expect(and).toHaveBeenCalled(); + expect(selectChain.from).toHaveBeenCalledWith(collections); + expect(selectChain.limit).toHaveBeenCalledWith(20); + }); +}); diff --git a/server/src/modules/catalog/catalog.repository.ts b/server/src/modules/catalog/catalog.repository.ts new file mode 100644 index 000000000..05fcbb6c2 --- /dev/null +++ b/server/src/modules/catalog/catalog.repository.ts @@ -0,0 +1,153 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'; + +import type { ContentFilterRules } from '@bookorbit/types'; +import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils'; +import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils'; +import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils'; +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { + authors, + bookAuthors, + bookGenres, + bookMetadata, + bookNarrators, + bookSeries, + bookSeriesMemberships, + bookTags, + books, + collections, + genres, + narrators, + tags, +} from '../../db/schema'; + +type Db = NodePgDatabase; +type SearchResult = { name: string }; +type SearchResultWithId = { id: number; name: string }; +type NamedTable = typeof authors | typeof narrators | typeof bookSeries; +type NamedTableWithId = typeof genres | typeof tags; +type MetadataTextColumn = typeof bookMetadata.publisher | typeof bookMetadata.language; +type Junction = { table: PgTable; entityId: PgColumn; bookId: PgColumn }; + +const AUTHOR_JUNCTION: Junction = { table: bookAuthors, entityId: bookAuthors.authorId, bookId: bookAuthors.bookId }; +const GENRE_JUNCTION: Junction = { table: bookGenres, entityId: bookGenres.genreId, bookId: bookGenres.bookId }; +const TAG_JUNCTION: Junction = { table: bookTags, entityId: bookTags.tagId, bookId: bookTags.bookId }; +const NARRATOR_JUNCTION: Junction = { table: bookNarrators, entityId: bookNarrators.narratorId, bookId: bookNarrators.bookId }; +const SERIES_JUNCTION: Junction = { + table: bookSeriesMemberships, + entityId: bookSeriesMemberships.seriesId, + bookId: bookSeriesMemberships.bookId, +}; + +export interface CatalogSearchScope { + libraryIds: number[]; + contentFilters?: ContentFilterRules; +} + +const DEFAULT_SEARCH_LIMIT = 15; +const COLLECTION_SEARCH_LIMIT = 20; + +@Injectable() +export class CatalogRepository { + constructor(@Inject(DB) private readonly db: Db) {} + + searchAuthors(q: string, scope: CatalogSearchScope): Promise { + return this.searchByName(q, authors, AUTHOR_JUNCTION, scope); + } + + searchGenres(q: string, scope: CatalogSearchScope): Promise { + return this.searchByNameWithId(q, genres, GENRE_JUNCTION, scope); + } + + searchTags(q: string, scope: CatalogSearchScope): Promise { + return this.searchByNameWithId(q, tags, TAG_JUNCTION, scope); + } + + searchNarrators(q: string, scope: CatalogSearchScope): Promise { + return this.searchByName(q, narrators, NARRATOR_JUNCTION, scope); + } + + searchPublishers(q: string, scope: CatalogSearchScope): Promise { + return this.searchDistinctMetadataField(q, bookMetadata.publisher, scope); + } + + searchSeries(q: string, scope: CatalogSearchScope): Promise { + return this.searchByName(q, bookSeries, SERIES_JUNCTION, scope); + } + + searchLanguages(q: string, scope: CatalogSearchScope): Promise { + return this.searchDistinctMetadataField(q, bookMetadata.language, scope); + } + + searchCollections(userId: number, q: string): Promise { + const pattern = this.toContainsPattern(q); + if (!pattern) return Promise.resolve([]); + + return this.db + .select({ name: collections.name }) + .from(collections) + .where(and(eq(collections.userId, userId), accentInsensitiveIlike(collections.name, pattern))) + .orderBy(collections.name) + .limit(COLLECTION_SEARCH_LIMIT); + } + + private searchByName(q: string, table: NamedTable, junction: Junction, scope: CatalogSearchScope): Promise { + const pattern = this.toContainsPattern(q); + if (!pattern) return Promise.resolve([]); + + return this.db + .selectDistinct({ name: table.name }) + .from(table) + .innerJoin(junction.table, eq(junction.entityId, table.id)) + .innerJoin(books, eq(books.id, junction.bookId)) + .where(and(accentInsensitiveIlike(table.name, pattern), ...this.visibilityClauses(scope))) + .orderBy(table.name) + .limit(DEFAULT_SEARCH_LIMIT); + } + + private searchByNameWithId(q: string, table: NamedTableWithId, junction: Junction, scope: CatalogSearchScope): Promise { + const term = normalizeMetadataText(q); + if (!term) return Promise.resolve([]); + + return this.db + .select({ id: table.id, name: table.name }) + .from(table) + .innerJoin(junction.table, eq(junction.entityId, table.id)) + .innerJoin(books, eq(books.id, junction.bookId)) + .where(and(accentInsensitiveIlike(table.name, buildSearchPattern(term)), ...this.visibilityClauses(scope))) + .groupBy(table.id, table.name) + .orderBy(accentInsensitiveExactMatchRank(table.name, term), table.name) + .limit(DEFAULT_SEARCH_LIMIT); + } + + private async searchDistinctMetadataField(q: string, column: MetadataTextColumn, scope: CatalogSearchScope): Promise { + const pattern = this.toContainsPattern(q); + if (!pattern) return []; + + const rows = await this.db + .selectDistinct({ name: column }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(column), accentInsensitiveIlike(column, pattern), ...this.visibilityClauses(scope))) + .orderBy(column) + .limit(DEFAULT_SEARCH_LIMIT); + + return rows.filter((row): row is SearchResult => row.name !== null); + } + + private visibilityClauses(scope: CatalogSearchScope): SQL[] { + return [inArray(books.libraryId, scope.libraryIds), ...(scope.contentFilters ? buildContentFilterClauses(scope.contentFilters, this.db) : [])]; + } + + private toContainsPattern(q: string): string | null { + const term = q.trim(); + if (!term) return null; + + return buildSearchPattern(term); + } +} diff --git a/server/src/modules/catalog/catalog.service.test.ts b/server/src/modules/catalog/catalog.service.test.ts index 119d76670..521cd1506 100644 --- a/server/src/modules/catalog/catalog.service.test.ts +++ b/server/src/modules/catalog/catalog.service.test.ts @@ -1,80 +1,38 @@ -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...clauses: unknown[]) => ({ type: 'and', clauses })), - eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - ilike: vi.fn((left: unknown, pattern: string) => ({ type: 'ilike', left, pattern })), - isNotNull: vi.fn((value: unknown) => ({ type: 'isNotNull', value })), - sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', parts, values })), -})); +import type { Mocked } from 'vitest'; -vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({ - ...(await importOriginal()), - accentInsensitiveExactMatchRank: vi.fn((left: unknown, term: string) => ({ type: 'sql', left, term })), - accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'accentInsensitiveIlike', left, pattern })), -})); - -import { and, eq, isNotNull } from 'drizzle-orm'; - -import { accentInsensitiveExactMatchRank, accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils'; -import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema'; +import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; +import type { RequestUser } from '../../common/types/request-user'; +import { CatalogRepository } from './catalog.repository'; import { CatalogService } from './catalog.service'; -interface QueryChain { - from: ReturnType; - where: ReturnType; - orderBy: ReturnType; - limit: ReturnType; - rows: T[]; +function requestUser(overrides: Partial = {}): RequestUser { + return { + id: 7, + isSuperuser: false, + contentFilters: EMPTY_CONTENT_FILTER_RULES, + ...overrides, + } as RequestUser; } -function createQueryChain(rows: T[]): QueryChain { - const chain: QueryChain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - limit: vi.fn().mockResolvedValue(rows), - rows, +function makeService(libraryIds: number[] = [1, 2]) { + const catalogRepository = { + searchAuthors: vi.fn(), + searchGenres: vi.fn(), + searchTags: vi.fn(), + searchNarrators: vi.fn(), + searchPublishers: vi.fn(), + searchSeries: vi.fn(), + searchLanguages: vi.fn(), + searchCollections: vi.fn(), + } as unknown as Mocked; + const libraryService = { + findAccessibleLibraryIds: vi.fn().mockResolvedValue(libraryIds), }; - chain.from.mockReturnValue(chain); - chain.where.mockReturnValue(chain); - chain.orderBy.mockReturnValue(chain); - - return chain; -} - -function makeService() { - let selectRows: { name: string }[] = []; - let selectDistinctRows: { name: string | null }[] = []; - - const selectChains: QueryChain<{ name: string }>[] = []; - const selectDistinctChains: QueryChain<{ name: string | null }>[] = []; - - const db = { - select: vi.fn(() => { - const chain = createQueryChain(selectRows); - selectChains.push(chain); - return chain; - }), - selectDistinct: vi.fn(() => { - const chain = createQueryChain(selectDistinctRows); - selectDistinctChains.push(chain); - return chain; - }), - } as const; - - const service = new CatalogService(db as never); - return { - service, - db, - selectChains, - selectDistinctChains, - setSelectRows: (rows: { name: string }[]) => { - selectRows = rows; - }, - setSelectDistinctRows: (rows: { name: string | null }[]) => { - selectDistinctRows = rows; - }, + service: new CatalogService(catalogRepository, libraryService as never), + catalogRepository, + libraryService, }; } @@ -83,106 +41,80 @@ describe('CatalogService', () => { vi.clearAllMocks(); }); - it('returns an empty result without querying the database for blank author terms', async () => { - const { service, db } = makeService(); + it('does not resolve library access or query the repository for blank terms', async () => { + const { service, catalogRepository, libraryService } = makeService(); - await expect(service.searchAuthors(' ')).resolves.toEqual([]); - expect(db.select).not.toHaveBeenCalled(); + await expect(service.searchAuthors(requestUser(), ' ')).resolves.toEqual([]); + + expect(libraryService.findAccessibleLibraryIds).not.toHaveBeenCalled(); + expect(catalogRepository.searchAuthors).not.toHaveBeenCalled(); }); - it('trims and escapes wildcard characters for name searches', async () => { - const { service, setSelectRows, selectChains } = makeService(); - setSelectRows([{ name: 'A%_\\B' }]); + it('returns no suggestions without an accessible library', async () => { + const { service, catalogRepository, libraryService } = makeService([]); + const user = requestUser(); - const result = await service.searchAuthors(' A%_\\B '); + await expect(service.searchTags(user, 'history')).resolves.toEqual([]); - expect(result).toEqual([{ name: 'A%_\\B' }]); - expect(accentInsensitiveIlike).toHaveBeenCalledWith(authors.name, '%A\\%\\_\\\\B%'); - expect(selectChains[0]?.from).toHaveBeenCalledWith(authors); - expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(authors.name); - expect(selectChains[0]?.limit).toHaveBeenCalledWith(15); + expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user); + expect(catalogRepository.searchTags).not.toHaveBeenCalled(); }); - it('uses the shared name-search helper for narrators', async () => { - const { service, setSelectRows, selectChains } = makeService(); - setSelectRows([{ name: 'Ray Porter' }]); + it('passes accessible libraries and content filters for a regular user', async () => { + const { service, catalogRepository } = makeService([4, 9]); + const contentFilters = { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [3] }; + const user = requestUser({ contentFilters }); + catalogRepository.searchGenres.mockResolvedValue([{ id: 11, name: 'History' }]); - const result = await service.searchNarrators('Ray'); + await expect(service.searchGenres(user, 'history')).resolves.toEqual([{ id: 11, name: 'History' }]); - expect(result).toEqual([{ name: 'Ray Porter' }]); - expect(selectChains[0]?.from).toHaveBeenCalledWith(narrators); - expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(narrators.name); - expect(selectChains[0]?.limit).toHaveBeenCalledWith(15); + expect(catalogRepository.searchGenres).toHaveBeenCalledWith('history', { + libraryIds: [4, 9], + contentFilters, + }); + }); + + it('omits content filters for a superuser', async () => { + const { service, catalogRepository } = makeService([1, 2]); + const user = requestUser({ + isSuperuser: true, + contentFilters: { ...EMPTY_CONTENT_FILTER_RULES, excludeGenreIds: [5] }, + }); + + await service.searchPublishers(user, 'orbit'); + + expect(catalogRepository.searchPublishers).toHaveBeenCalledWith('orbit', { + libraryIds: [1, 2], + contentFilters: undefined, + }); }); it.each([ - ['genres', genres, (service: CatalogService, q: string) => service.searchGenres(q)], - ['tags', tags, (service: CatalogService, q: string) => service.searchTags(q)], - ] as const)('ranks an exact %s match before alphabetical partial matches', async (_label, table, search) => { - const { service, selectChains } = makeService(); + ['authors', 'searchAuthors'], + ['tags', 'searchTags'], + ['narrators', 'searchNarrators'], + ['publishers', 'searchPublishers'], + ['series', 'searchSeries'], + ['languages', 'searchLanguages'], + ] as const)('scopes %s searches before delegating', async (_label, method) => { + const { service, catalogRepository } = makeService([12]); + const user = requestUser(); - await search(service, ' Young\u00a0 Adult '); + await service[method](user, 'term'); - expect(accentInsensitiveIlike).toHaveBeenCalledWith(table.name, '%Young Adult%'); - expect(accentInsensitiveExactMatchRank).toHaveBeenCalledWith(table.name, 'Young Adult'); - expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'sql' }), table.name); - expect(selectChains[0]?.limit).toHaveBeenCalledWith(15); + expect(catalogRepository[method]).toHaveBeenCalledWith('term', { + libraryIds: [12], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); }); - it('uses distinct metadata lookup for publishers and filters null rows defensively', async () => { - const { service, setSelectDistinctRows, selectDistinctChains } = makeService(); - setSelectDistinctRows([{ name: 'Orbit' }, { name: null }, { name: 'Tor' }]); + it('keeps collection searches scoped directly by owner id', async () => { + const { service, catalogRepository, libraryService } = makeService(); + catalogRepository.searchCollections.mockResolvedValue([{ name: 'Favorites' }]); - const result = await service.searchPublishers(' or '); + await expect(service.searchCollections(42, 'favorites')).resolves.toEqual([{ name: 'Favorites' }]); - expect(result).toEqual([{ name: 'Orbit' }, { name: 'Tor' }]); - expect(isNotNull).toHaveBeenCalledWith(bookMetadata.publisher); - expect(accentInsensitiveIlike).toHaveBeenCalledWith(bookMetadata.publisher, '%or%'); - expect(selectDistinctChains[0]?.from).toHaveBeenCalledWith(bookMetadata); - expect(selectDistinctChains[0]?.limit).toHaveBeenCalledWith(15); - }); - - it('queries the expected metadata column for series search', async () => { - const { service, setSelectRows, selectChains } = makeService(); - setSelectRows([{ name: 'The Expanse' }]); - - await service.searchSeries('Expanse'); - - expect(accentInsensitiveIlike).toHaveBeenCalledWith(bookSeries.name, '%Expanse%'); - expect(selectChains[0]?.from).toHaveBeenCalledWith(bookSeries); - expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(bookSeries.name); - }); - - it('queries the expected metadata column for language search', async () => { - const { service, setSelectDistinctRows } = makeService(); - setSelectDistinctRows([{ name: 'English' }]); - - await service.searchLanguages('English'); - - expect(isNotNull).toHaveBeenCalledWith(bookMetadata.language); - expect(accentInsensitiveIlike).toHaveBeenCalledWith(bookMetadata.language, '%English%'); - }); - - it('returns an empty result for blank collection terms to match other search endpoints', async () => { - const { service, db } = makeService(); - - await expect(service.searchCollections(7, ' ')).resolves.toEqual([]); - expect(db.select).not.toHaveBeenCalled(); - }); - - it('enforces user scoping and collection limit for collection searches', async () => { - const { service, setSelectRows, selectChains } = makeService(); - setSelectRows([{ name: 'Sci-Fi Favorites' }]); - - const result = await service.searchCollections(42, ' sci_fi% '); - - expect(result).toEqual([{ name: 'Sci-Fi Favorites' }]); - expect(eq).toHaveBeenCalledWith(collections.userId, 42); - expect(accentInsensitiveIlike).toHaveBeenCalledWith(collections.name, '%sci\\_fi\\%%'); - expect(and).toHaveBeenCalledTimes(1); - expect(selectChains[0]?.from).toHaveBeenCalledWith(collections); - expect(selectChains[0]?.where).toHaveBeenCalledWith(expect.objectContaining({ type: 'and' })); - expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(collections.name); - expect(selectChains[0]?.limit).toHaveBeenCalledWith(20); + expect(catalogRepository.searchCollections).toHaveBeenCalledWith(42, 'favorites'); + expect(libraryService.findAccessibleLibraryIds).not.toHaveBeenCalled(); }); }); diff --git a/server/src/modules/catalog/catalog.service.ts b/server/src/modules/catalog/catalog.service.ts index 8ddd0a488..988faeee4 100644 --- a/server/src/modules/catalog/catalog.service.ts +++ b/server/src/modules/catalog/catalog.service.ts @@ -1,109 +1,57 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, isNotNull } from 'drizzle-orm'; -import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Injectable } from '@nestjs/common'; -import { DB } from '../../db'; -import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils'; -import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils'; -import * as schema from '../../db/schema'; -import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema'; - -type Db = NodePgDatabase; -type SearchResult = { name: string }; -type SearchResultWithId = { id: number; name: string }; -type NamedTable = typeof authors | typeof genres | typeof tags | typeof narrators | typeof bookSeries; -type NamedTableWithId = typeof genres | typeof tags; -type MetadataTextColumn = typeof bookMetadata.publisher | typeof bookMetadata.language; - -const DEFAULT_SEARCH_LIMIT = 15; -const COLLECTION_SEARCH_LIMIT = 20; +import type { RequestUser } from '../../common/types/request-user'; +import { LibraryService } from '../library/library.service'; +import { CatalogRepository, type CatalogSearchScope } from './catalog.repository'; @Injectable() export class CatalogService { - constructor(@Inject(DB) private readonly db: Db) {} + constructor( + private readonly catalogRepository: CatalogRepository, + private readonly libraryService: LibraryService, + ) {} - searchAuthors(q: string): Promise { - return this.searchByName(q, authors); + searchAuthors(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchAuthors(q, scope)); } - searchGenres(q: string): Promise { - return this.searchByNameWithId(q, genres); + searchGenres(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchGenres(q, scope)); } - searchTags(q: string): Promise { - return this.searchByNameWithId(q, tags); + searchTags(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchTags(q, scope)); } - searchNarrators(q: string): Promise { - return this.searchByName(q, narrators); + searchNarrators(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchNarrators(q, scope)); } - searchPublishers(q: string): Promise { - return this.searchDistinctMetadataField(q, bookMetadata.publisher); + searchPublishers(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchPublishers(q, scope)); } - searchSeries(q: string): Promise { - return this.searchByName(q, bookSeries); + searchSeries(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchSeries(q, scope)); } - searchLanguages(q: string): Promise { - return this.searchDistinctMetadataField(q, bookMetadata.language); + searchLanguages(user: RequestUser, q: string) { + return this.searchVisible(user, q, (scope) => this.catalogRepository.searchLanguages(q, scope)); } - searchCollections(userId: number, q: string): Promise { - const pattern = this.toContainsPattern(q); - if (!pattern) return Promise.resolve([]); - - return this.db - .select({ name: collections.name }) - .from(collections) - .where(and(eq(collections.userId, userId), accentInsensitiveIlike(collections.name, pattern))) - .orderBy(collections.name) - .limit(COLLECTION_SEARCH_LIMIT); + searchCollections(userId: number, q: string) { + return this.catalogRepository.searchCollections(userId, q); } - private searchByName(q: string, table: NamedTable): Promise { - const pattern = this.toContainsPattern(q); - if (!pattern) return Promise.resolve([]); + private async searchVisible(user: RequestUser, q: string, search: (scope: CatalogSearchScope) => Promise): Promise { + if (!q.trim()) return []; - return this.db - .select({ name: table.name }) - .from(table) - .where(accentInsensitiveIlike(table.name, pattern)) - .orderBy(table.name) - .limit(DEFAULT_SEARCH_LIMIT); - } + const libraryIds = await this.libraryService.findAccessibleLibraryIds(user); + if (libraryIds.length === 0) return []; - private searchByNameWithId(q: string, table: NamedTableWithId): Promise { - const term = normalizeMetadataText(q); - if (!term) return Promise.resolve([]); - - return this.db - .select({ id: table.id, name: table.name }) - .from(table) - .where(accentInsensitiveIlike(table.name, buildSearchPattern(term))) - .orderBy(accentInsensitiveExactMatchRank(table.name, term), table.name) - .limit(DEFAULT_SEARCH_LIMIT); - } - - private async searchDistinctMetadataField(q: string, column: MetadataTextColumn): Promise { - const pattern = this.toContainsPattern(q); - if (!pattern) return []; - - const rows = await this.db - .selectDistinct({ name: column }) - .from(bookMetadata) - .where(and(isNotNull(column), accentInsensitiveIlike(column, pattern))) - .orderBy(column) - .limit(DEFAULT_SEARCH_LIMIT); - - return rows.filter((row): row is SearchResult => row.name !== null); - } - - private toContainsPattern(q: string): string | null { - const term = q.trim(); - if (!term) return null; - - return buildSearchPattern(term); + return search({ + libraryIds, + contentFilters: user.isSuperuser ? undefined : user.contentFilters, + }); } } diff --git a/server/test/catalog-visibility.e2e-spec.ts b/server/test/catalog-visibility.e2e-spec.ts new file mode 100644 index 000000000..8016c1722 --- /dev/null +++ b/server/test/catalog-visibility.e2e-spec.ts @@ -0,0 +1,206 @@ +import { randomUUID } from 'crypto'; + +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; + +interface CatalogNames { + authors: string; + genres: string; + tags: string; + narrators: string; + publishers: string; + series: string; + languages: string; +} + +interface SeededCatalogBook { + bookId: number; + tagId: number; + genreId: number; + names: CatalogNames; +} + +async function seedCatalogBook( + ctx: AuthorizationMatrixE2EContext, + library: CreatedLibrary, + queryToken: string, + label: string, +): Promise { + const names: CatalogNames = { + authors: `${queryToken} ${label} Author`, + genres: `${queryToken} ${label} Genre`, + tags: `${queryToken} ${label} Tag`, + narrators: `${queryToken} ${label} Narrator`, + publishers: `${queryToken} ${label} Publisher`, + series: `${queryToken} ${label} Series`, + languages: `${queryToken} ${label} Language`, + }; + const uniquePath = `${label.toLowerCase()}-${randomUUID()}`; + + const [book] = await ctx.db + .insert(schema.books) + .values({ + libraryId: library.libraryId, + libraryFolderId: library.libraryFolderId, + folderPath: `${library.folderPath}/${uniquePath}`, + status: 'present', + }) + .returning({ id: schema.books.id }); + const [author] = await ctx.db.insert(schema.authors).values({ name: names.authors }).returning({ id: schema.authors.id }); + const [genre] = await ctx.db.insert(schema.genres).values({ name: names.genres }).returning({ id: schema.genres.id }); + const [tag] = await ctx.db.insert(schema.tags).values({ name: names.tags }).returning({ id: schema.tags.id }); + const [narrator] = await ctx.db.insert(schema.narrators).values({ name: names.narrators }).returning({ id: schema.narrators.id }); + const [series] = await ctx.db + .insert(schema.bookSeries) + .values({ name: names.series, normalizedName: names.series.toLowerCase() }) + .returning({ id: schema.bookSeries.id }); + + await ctx.db.insert(schema.bookMetadata).values({ + bookId: book!.id, + title: `${queryToken} ${label} Book`, + publisher: names.publishers, + language: names.languages, + }); + await Promise.all([ + ctx.db.insert(schema.bookAuthors).values({ bookId: book!.id, authorId: author!.id }), + ctx.db.insert(schema.bookGenres).values({ bookId: book!.id, genreId: genre!.id }), + ctx.db.insert(schema.bookTags).values({ bookId: book!.id, tagId: tag!.id }), + ctx.db.insert(schema.bookNarrators).values({ bookId: book!.id, narratorId: narrator!.id }), + ctx.db.insert(schema.bookSeriesMemberships).values({ bookId: book!.id, seriesId: series!.id }), + ]); + + return { bookId: book!.id, tagId: tag!.id, genreId: genre!.id, names }; +} + +describe('Catalog visibility (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { + let ctx!: AuthorizationMatrixE2EContext; + let scopedReader!: TestUserSession; + let noLibraryReader!: TestUserSession; + let superuser!: TestUserSession; + let queryToken!: string; + let visible!: SeededCatalogBook; + let contentFiltered!: SeededCatalogBook; + let hiddenLibrary!: SeededCatalogBook; + + async function search(endpoint: keyof CatalogNames, token: string, q = queryToken): Promise> { + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/metadata/${endpoint}?q=${encodeURIComponent(q)}`, + headers: authHeader(token), + }); + + expect(response.statusCode).toBe(200); + return response.json() as Array<{ id?: number; name: string }>; + } + + beforeAll(async () => { + ctx = await createAuthorizationMatrixE2EContext(); + queryToken = `CatalogScope${randomUUID().replaceAll('-', '').slice(0, 10)}`; + + const accessibleLibrary = await createLibraryWithFolder(ctx, { name: `${queryToken} Accessible Library` }); + const inaccessibleLibrary = await createLibraryWithFolder(ctx, { name: `${queryToken} Inaccessible Library` }); + + visible = await seedCatalogBook(ctx, accessibleLibrary, queryToken, 'Visible'); + contentFiltered = await seedCatalogBook(ctx, accessibleLibrary, queryToken, 'Filtered'); + hiddenLibrary = await seedCatalogBook(ctx, inaccessibleLibrary, queryToken, 'Hidden'); + + await Promise.all([ + ctx.db.insert(schema.authors).values({ name: `${queryToken} Orphan Author` }), + ctx.db.insert(schema.genres).values({ name: `${queryToken} Orphan Genre` }), + ctx.db.insert(schema.tags).values({ name: `${queryToken} Orphan Tag` }), + ctx.db.insert(schema.narrators).values({ name: `${queryToken} Orphan Narrator` }), + ctx.db.insert(schema.bookSeries).values({ + name: `${queryToken} Orphan Series`, + normalizedName: `${queryToken} Orphan Series`.toLowerCase(), + }), + ]); + + scopedReader = await createUserAndLogin(ctx); + noLibraryReader = await createUserAndLogin(ctx); + superuser = await createUserAndLogin(ctx, { isSuperuser: true }); + await grantLibraryAccess(ctx, scopedReader.userId, accessibleLibrary.libraryId); + + await ctx.db.insert(schema.userContentFilterTags).values([ + { userId: scopedReader.userId, filterType: 'exclude', tagId: contentFiltered.tagId }, + { userId: superuser.userId, filterType: 'exclude', tagId: contentFiltered.tagId }, + ]); + }); + + afterAll(async () => { + if (ctx) await closeAuthorizationMatrixE2EContext(ctx); + }); + + it('returns only metadata attached to books the reader can access and that pass content filters', async () => { + for (const endpoint of Object.keys(visible.names) as Array) { + const results = await search(endpoint, scopedReader.accessToken); + expect(results.map(({ name }) => name)).toEqual([visible.names[endpoint]]); + } + }); + + it('returns no suggestions when the reader cannot access a library', async () => { + for (const endpoint of Object.keys(visible.names) as Array) { + await expect(search(endpoint, noLibraryReader.accessToken)).resolves.toEqual([]); + } + }); + + it('lets superusers search every library while bypassing their content filters', async () => { + for (const endpoint of Object.keys(visible.names) as Array) { + const results = await search(endpoint, superuser.accessToken); + expect(results.map(({ name }) => name)).toEqual( + [contentFiltered.names[endpoint], hiddenLibrary.names[endpoint], visible.names[endpoint]].sort(), + ); + } + }); + + it.each([ + ['genres', 'Genre'], + ['tags', 'Tag'], + ] as const)('preserves normalized exact-match ordering for %s after visibility joins', async (endpoint, label) => { + const exactName = `${queryToken} Exact ${label}`; + const partialName = `A ${exactName}`; + + if (endpoint === 'genres') { + const [exact, partial] = await ctx.db + .insert(schema.genres) + .values([{ name: exactName }, { name: partialName }]) + .returning({ id: schema.genres.id }); + await ctx.db.insert(schema.bookGenres).values([ + { bookId: visible.bookId, genreId: exact!.id }, + { bookId: visible.bookId, genreId: partial!.id }, + ]); + } else { + const [exact, partial] = await ctx.db + .insert(schema.tags) + .values([{ name: exactName }, { name: partialName }]) + .returning({ id: schema.tags.id }); + await ctx.db.insert(schema.bookTags).values([ + { bookId: visible.bookId, tagId: exact!.id }, + { bookId: visible.bookId, tagId: partial!.id }, + ]); + } + + const results = await search(endpoint, scopedReader.accessToken, ` ${queryToken}\u00a0 Exact ${label} `); + expect(results.map(({ name }) => name)).toEqual([exactName, partialName]); + }); + + it('still requires authentication', async () => { + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/metadata/tags?q=${encodeURIComponent(queryToken)}`, + }); + + expect(response.statusCode).toBe(401); + }); +});