mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
security(metadata): scope catalog suggestions to visible books (#1366)
Derive catalog typeahead values only from books in libraries the requester can access, and enforce their content filters. Preserve superuser visibility, exact-match ranking, and user-scoped collection lookup.
This commit is contained in:
@@ -266,6 +266,26 @@ export const E2E_SUITES = Object.freeze({
|
|||||||
...SHARED_DB_AND_HELPER_PATHS,
|
...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": {
|
"dashboard-random": {
|
||||||
id: "dashboard-random",
|
id: "dashboard-random",
|
||||||
name: "Dashboard Random Scroller",
|
name: "Dashboard Random Scroller",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const SEARCH_ENTRY_POINTS = [
|
|||||||
'modules/book-dock/book-dock.repository.ts',
|
'modules/book-dock/book-dock.repository.ts',
|
||||||
'modules/book/book-query-builder.service.ts',
|
'modules/book/book-query-builder.service.ts',
|
||||||
'modules/book/book.repository.ts',
|
'modules/book/book.repository.ts',
|
||||||
'modules/catalog/catalog.service.ts',
|
'modules/catalog/catalog.repository.ts',
|
||||||
'modules/custom-icon/custom-icon.repository.ts',
|
'modules/custom-icon/custom-icon.repository.ts',
|
||||||
'modules/entity-manager/strategies/author.strategy.ts',
|
'modules/entity-manager/strategies/author.strategy.ts',
|
||||||
'modules/entity-manager/strategies/inline-entity.strategy.ts',
|
'modules/entity-manager/strategies/inline-entity.strategy.ts',
|
||||||
@@ -24,7 +24,7 @@ const SEARCH_ENTRY_POINTS = [
|
|||||||
const FREE_TEXT_SEARCH_ENTRY_POINTS = [
|
const FREE_TEXT_SEARCH_ENTRY_POINTS = [
|
||||||
'modules/authors/authors.repository.ts',
|
'modules/authors/authors.repository.ts',
|
||||||
'modules/book/book-query-builder.service.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/opds/opds-book.service.ts',
|
||||||
'modules/series/series.repository.ts',
|
'modules/series/series.repository.ts',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe('Architecture boundaries', () => {
|
|||||||
'src/modules/authors/author-metadata-preferences.service.ts',
|
'src/modules/authors/author-metadata-preferences.service.ts',
|
||||||
'src/modules/book/book-query-builder.service.ts',
|
'src/modules/book/book-query-builder.service.ts',
|
||||||
'src/modules/book-metadata-fetch/book-metadata-fetch-config.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/cover/cover.service.ts',
|
||||||
'src/modules/entity-manager/duplicate-compute.service.ts',
|
'src/modules/entity-manager/duplicate-compute.service.ts',
|
||||||
'src/modules/kobo/services/kobo-analytics-resolver.service.ts',
|
'src/modules/kobo/services/kobo-analytics-resolver.service.ts',
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { CatalogController } from './catalog.controller';
|
|||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
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() {
|
function makeController() {
|
||||||
const service = {
|
const service = {
|
||||||
searchAuthors: vi.fn(),
|
searchAuthors: vi.fn(),
|
||||||
@@ -31,9 +35,9 @@ describe('CatalogController', () => {
|
|||||||
const expected = [{ name: 'Frank Herbert' }];
|
const expected = [{ name: 'Frank Herbert' }];
|
||||||
service.searchAuthors.mockResolvedValue(expected);
|
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);
|
expect(result).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,57 +45,57 @@ describe('CatalogController', () => {
|
|||||||
const { controller, service } = makeController();
|
const { controller, service } = makeController();
|
||||||
const query = new SearchCatalogQueryDto();
|
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 () => {
|
it('delegates genre queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('delegates tag queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('delegates narrator queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('delegates publisher queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('delegates series queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('delegates language queries to the catalog service', async () => {
|
||||||
const { controller, service } = makeController();
|
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 () => {
|
it('passes current user id to collection search', async () => {
|
||||||
|
|||||||
@@ -10,38 +10,38 @@ export class CatalogController {
|
|||||||
constructor(private readonly catalogService: CatalogService) {}
|
constructor(private readonly catalogService: CatalogService) {}
|
||||||
|
|
||||||
@Get('authors')
|
@Get('authors')
|
||||||
searchAuthors(@Query() query: SearchCatalogQueryDto) {
|
searchAuthors(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchAuthors(query.q);
|
return this.catalogService.searchAuthors(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('genres')
|
@Get('genres')
|
||||||
searchGenres(@Query() query: SearchCatalogQueryDto) {
|
searchGenres(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchGenres(query.q);
|
return this.catalogService.searchGenres(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tags')
|
@Get('tags')
|
||||||
searchTags(@Query() query: SearchCatalogQueryDto) {
|
searchTags(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchTags(query.q);
|
return this.catalogService.searchTags(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('narrators')
|
@Get('narrators')
|
||||||
searchNarrators(@Query() query: SearchCatalogQueryDto) {
|
searchNarrators(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchNarrators(query.q);
|
return this.catalogService.searchNarrators(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('publishers')
|
@Get('publishers')
|
||||||
searchPublishers(@Query() query: SearchCatalogQueryDto) {
|
searchPublishers(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchPublishers(query.q);
|
return this.catalogService.searchPublishers(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('series')
|
@Get('series')
|
||||||
searchSeries(@Query() query: SearchCatalogQueryDto) {
|
searchSeries(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchSeries(query.q);
|
return this.catalogService.searchSeries(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('languages')
|
@Get('languages')
|
||||||
searchLanguages(@Query() query: SearchCatalogQueryDto) {
|
searchLanguages(@CurrentUser() user: RequestUser, @Query() query: SearchCatalogQueryDto) {
|
||||||
return this.catalogService.searchLanguages(query.q);
|
return this.catalogService.searchLanguages(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('collections')
|
@Get('collections')
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LibraryModule } from '../library/library.module';
|
||||||
import { CatalogController } from './catalog.controller';
|
import { CatalogController } from './catalog.controller';
|
||||||
|
import { CatalogRepository } from './catalog.repository';
|
||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [LibraryModule],
|
||||||
controllers: [CatalogController],
|
controllers: [CatalogController],
|
||||||
providers: [CatalogService],
|
providers: [CatalogService, CatalogRepository],
|
||||||
})
|
})
|
||||||
export class CatalogModule {}
|
export class CatalogModule {}
|
||||||
|
|||||||
@@ -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<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
innerJoin: ReturnType<typeof vi.fn>;
|
||||||
|
where: ReturnType<typeof vi.fn>;
|
||||||
|
groupBy: ReturnType<typeof vi.fn>;
|
||||||
|
orderBy: ReturnType<typeof vi.fn>;
|
||||||
|
limit: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof schema>;
|
||||||
|
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<SearchResult[]> {
|
||||||
|
return this.searchByName(q, authors, AUTHOR_JUNCTION, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchGenres(q: string, scope: CatalogSearchScope): Promise<SearchResultWithId[]> {
|
||||||
|
return this.searchByNameWithId(q, genres, GENRE_JUNCTION, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTags(q: string, scope: CatalogSearchScope): Promise<SearchResultWithId[]> {
|
||||||
|
return this.searchByNameWithId(q, tags, TAG_JUNCTION, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchNarrators(q: string, scope: CatalogSearchScope): Promise<SearchResult[]> {
|
||||||
|
return this.searchByName(q, narrators, NARRATOR_JUNCTION, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPublishers(q: string, scope: CatalogSearchScope): Promise<SearchResult[]> {
|
||||||
|
return this.searchDistinctMetadataField(q, bookMetadata.publisher, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchSeries(q: string, scope: CatalogSearchScope): Promise<SearchResult[]> {
|
||||||
|
return this.searchByName(q, bookSeries, SERIES_JUNCTION, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLanguages(q: string, scope: CatalogSearchScope): Promise<SearchResult[]> {
|
||||||
|
return this.searchDistinctMetadataField(q, bookMetadata.language, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCollections(userId: number, q: string): Promise<SearchResult[]> {
|
||||||
|
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<SearchResult[]> {
|
||||||
|
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<SearchResultWithId[]> {
|
||||||
|
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<SearchResult[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +1,38 @@
|
|||||||
vi.mock('drizzle-orm', () => ({
|
import type { Mocked } from 'vitest';
|
||||||
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 })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({
|
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
||||||
...(await importOriginal<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
accentInsensitiveExactMatchRank: vi.fn((left: unknown, term: string) => ({ type: 'sql', left, term })),
|
import { CatalogRepository } from './catalog.repository';
|
||||||
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 { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
interface QueryChain<T> {
|
function requestUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||||
from: ReturnType<typeof vi.fn>;
|
return {
|
||||||
where: ReturnType<typeof vi.fn>;
|
id: 7,
|
||||||
orderBy: ReturnType<typeof vi.fn>;
|
isSuperuser: false,
|
||||||
limit: ReturnType<typeof vi.fn>;
|
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||||
rows: T[];
|
...overrides,
|
||||||
|
} as RequestUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createQueryChain<T>(rows: T[]): QueryChain<T> {
|
function makeService(libraryIds: number[] = [1, 2]) {
|
||||||
const chain: QueryChain<T> = {
|
const catalogRepository = {
|
||||||
from: vi.fn(),
|
searchAuthors: vi.fn(),
|
||||||
where: vi.fn(),
|
searchGenres: vi.fn(),
|
||||||
orderBy: vi.fn(),
|
searchTags: vi.fn(),
|
||||||
limit: vi.fn().mockResolvedValue(rows),
|
searchNarrators: vi.fn(),
|
||||||
rows,
|
searchPublishers: vi.fn(),
|
||||||
|
searchSeries: vi.fn(),
|
||||||
|
searchLanguages: vi.fn(),
|
||||||
|
searchCollections: vi.fn(),
|
||||||
|
} as unknown as Mocked<CatalogRepository>;
|
||||||
|
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 {
|
return {
|
||||||
service,
|
service: new CatalogService(catalogRepository, libraryService as never),
|
||||||
db,
|
catalogRepository,
|
||||||
selectChains,
|
libraryService,
|
||||||
selectDistinctChains,
|
|
||||||
setSelectRows: (rows: { name: string }[]) => {
|
|
||||||
selectRows = rows;
|
|
||||||
},
|
|
||||||
setSelectDistinctRows: (rows: { name: string | null }[]) => {
|
|
||||||
selectDistinctRows = rows;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,106 +41,80 @@ describe('CatalogService', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty result without querying the database for blank author terms', async () => {
|
it('does not resolve library access or query the repository for blank terms', async () => {
|
||||||
const { service, db } = makeService();
|
const { service, catalogRepository, libraryService } = makeService();
|
||||||
|
|
||||||
await expect(service.searchAuthors(' ')).resolves.toEqual([]);
|
await expect(service.searchAuthors(requestUser(), ' ')).resolves.toEqual([]);
|
||||||
expect(db.select).not.toHaveBeenCalled();
|
|
||||||
|
expect(libraryService.findAccessibleLibraryIds).not.toHaveBeenCalled();
|
||||||
|
expect(catalogRepository.searchAuthors).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims and escapes wildcard characters for name searches', async () => {
|
it('returns no suggestions without an accessible library', async () => {
|
||||||
const { service, setSelectRows, selectChains } = makeService();
|
const { service, catalogRepository, libraryService } = makeService([]);
|
||||||
setSelectRows([{ name: 'A%_\\B' }]);
|
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(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user);
|
||||||
expect(accentInsensitiveIlike).toHaveBeenCalledWith(authors.name, '%A\\%\\_\\\\B%');
|
expect(catalogRepository.searchTags).not.toHaveBeenCalled();
|
||||||
expect(selectChains[0]?.from).toHaveBeenCalledWith(authors);
|
|
||||||
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(authors.name);
|
|
||||||
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the shared name-search helper for narrators', async () => {
|
it('passes accessible libraries and content filters for a regular user', async () => {
|
||||||
const { service, setSelectRows, selectChains } = makeService();
|
const { service, catalogRepository } = makeService([4, 9]);
|
||||||
setSelectRows([{ name: 'Ray Porter' }]);
|
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(catalogRepository.searchGenres).toHaveBeenCalledWith('history', {
|
||||||
expect(selectChains[0]?.from).toHaveBeenCalledWith(narrators);
|
libraryIds: [4, 9],
|
||||||
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(narrators.name);
|
contentFilters,
|
||||||
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
it.each([
|
||||||
['genres', genres, (service: CatalogService, q: string) => service.searchGenres(q)],
|
['authors', 'searchAuthors'],
|
||||||
['tags', tags, (service: CatalogService, q: string) => service.searchTags(q)],
|
['tags', 'searchTags'],
|
||||||
] as const)('ranks an exact %s match before alphabetical partial matches', async (_label, table, search) => {
|
['narrators', 'searchNarrators'],
|
||||||
const { service, selectChains } = makeService();
|
['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(catalogRepository[method]).toHaveBeenCalledWith('term', {
|
||||||
expect(accentInsensitiveExactMatchRank).toHaveBeenCalledWith(table.name, 'Young Adult');
|
libraryIds: [12],
|
||||||
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'sql' }), table.name);
|
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||||
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses distinct metadata lookup for publishers and filters null rows defensively', async () => {
|
it('keeps collection searches scoped directly by owner id', async () => {
|
||||||
const { service, setSelectDistinctRows, selectDistinctChains } = makeService();
|
const { service, catalogRepository, libraryService } = makeService();
|
||||||
setSelectDistinctRows([{ name: 'Orbit' }, { name: null }, { name: 'Tor' }]);
|
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(catalogRepository.searchCollections).toHaveBeenCalledWith(42, 'favorites');
|
||||||
expect(isNotNull).toHaveBeenCalledWith(bookMetadata.publisher);
|
expect(libraryService.findAccessibleLibraryIds).not.toHaveBeenCalled();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,109 +1,57 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { and, eq, isNotNull } from 'drizzle-orm';
|
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
||||||
|
|
||||||
import { DB } from '../../db';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
import { LibraryService } from '../library/library.service';
|
||||||
import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils';
|
import { CatalogRepository, type CatalogSearchScope } from './catalog.repository';
|
||||||
import * as schema from '../../db/schema';
|
|
||||||
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
|
|
||||||
|
|
||||||
type Db = NodePgDatabase<typeof schema>;
|
|
||||||
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;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
constructor(
|
||||||
|
private readonly catalogRepository: CatalogRepository,
|
||||||
|
private readonly libraryService: LibraryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
searchAuthors(q: string): Promise<SearchResult[]> {
|
searchAuthors(user: RequestUser, q: string) {
|
||||||
return this.searchByName(q, authors);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchAuthors(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchGenres(q: string): Promise<SearchResultWithId[]> {
|
searchGenres(user: RequestUser, q: string) {
|
||||||
return this.searchByNameWithId(q, genres);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchGenres(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTags(q: string): Promise<SearchResultWithId[]> {
|
searchTags(user: RequestUser, q: string) {
|
||||||
return this.searchByNameWithId(q, tags);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchTags(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchNarrators(q: string): Promise<SearchResult[]> {
|
searchNarrators(user: RequestUser, q: string) {
|
||||||
return this.searchByName(q, narrators);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchNarrators(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchPublishers(q: string): Promise<SearchResult[]> {
|
searchPublishers(user: RequestUser, q: string) {
|
||||||
return this.searchDistinctMetadataField(q, bookMetadata.publisher);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchPublishers(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchSeries(q: string): Promise<SearchResult[]> {
|
searchSeries(user: RequestUser, q: string) {
|
||||||
return this.searchByName(q, bookSeries);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchSeries(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchLanguages(q: string): Promise<SearchResult[]> {
|
searchLanguages(user: RequestUser, q: string) {
|
||||||
return this.searchDistinctMetadataField(q, bookMetadata.language);
|
return this.searchVisible(user, q, (scope) => this.catalogRepository.searchLanguages(q, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchCollections(userId: number, q: string): Promise<SearchResult[]> {
|
searchCollections(userId: number, q: string) {
|
||||||
const pattern = this.toContainsPattern(q);
|
return this.catalogRepository.searchCollections(userId, 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): Promise<SearchResult[]> {
|
private async searchVisible<T>(user: RequestUser, q: string, search: (scope: CatalogSearchScope) => Promise<T[]>): Promise<T[]> {
|
||||||
const pattern = this.toContainsPattern(q);
|
if (!q.trim()) return [];
|
||||||
if (!pattern) return Promise.resolve([]);
|
|
||||||
|
|
||||||
return this.db
|
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
||||||
.select({ name: table.name })
|
if (libraryIds.length === 0) return [];
|
||||||
.from(table)
|
|
||||||
.where(accentInsensitiveIlike(table.name, pattern))
|
|
||||||
.orderBy(table.name)
|
|
||||||
.limit(DEFAULT_SEARCH_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private searchByNameWithId(q: string, table: NamedTableWithId): Promise<SearchResultWithId[]> {
|
return search({
|
||||||
const term = normalizeMetadataText(q);
|
libraryIds,
|
||||||
if (!term) return Promise.resolve([]);
|
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
|
||||||
|
});
|
||||||
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<SearchResult[]> {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SeededCatalogBook> {
|
||||||
|
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<Array<{ id?: number; name: string }>> {
|
||||||
|
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<keyof CatalogNames>) {
|
||||||
|
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<keyof CatalogNames>) {
|
||||||
|
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<keyof CatalogNames>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user