mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(books): join metadata table when resolving query-based book ids
Query-based book selections failed with a 500 error when filtering by metadata fields because findIdsByWhere queried the bare books table without joining book_metadata. Left join book_metadata so WHERE clause predicates referencing metadata fields resolve correctly. Closes #732
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { BookRepository } from './book.repository';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
import { bookMetadata, books } from '../../db/schema';
|
||||
|
||||
function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
|
||||
const chain: Record<string, vi.Mock> = {
|
||||
@@ -210,6 +211,26 @@ describe('BookRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('findIdsByWhere joins metadata before applying selection predicates', async () => {
|
||||
const rows = [{ id: 10 }, { id: 11 }];
|
||||
const chain = makeSelectChain('where', rows);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
const result = await repo.findIdsByWhere(sql`${bookMetadata.title} is not null`);
|
||||
|
||||
expect(chain.from).toHaveBeenCalledWith(books);
|
||||
expect(chain.leftJoin).toHaveBeenCalledWith(bookMetadata, expect.anything());
|
||||
|
||||
const dialect = new PgDialect();
|
||||
const joinSql = dialect.sqlToQuery(chain.leftJoin.mock.calls[0]![1]).sql;
|
||||
const whereSql = dialect.sqlToQuery(chain.where.mock.calls[0]![0]).sql;
|
||||
expect(joinSql).toBe('"book_metadata"."book_id" = "books"."id"');
|
||||
expect(whereSql).toContain('"book_metadata"."title" is not null');
|
||||
expect(whereSql).toContain('"books"."status" <>');
|
||||
expect(result).toEqual([10, 11]);
|
||||
});
|
||||
|
||||
it('fetches per-book and per-file progress helpers with null-safe fallbacks', async () => {
|
||||
const findCollectionsChain = makeSelectChain('orderBy', [{ id: 1, name: 'Favorites' }]);
|
||||
const libraryIdChain = makeSelectChain('limit', [{ libraryId: 5 }]);
|
||||
|
||||
@@ -1729,7 +1729,11 @@ export class BookRepository {
|
||||
}
|
||||
|
||||
async findIdsByWhere(where: SQL | undefined): Promise<number[]> {
|
||||
const rows = await this.db.select({ id: books.id }).from(books).where(this.visibleWhere(where));
|
||||
const rows = await this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(this.visibleWhere(where));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,82 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk query selections', () => {
|
||||
it('resolves metadata filters and search without regressing subquery or explicit selections', async () => {
|
||||
const metadataCollectionResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/collections',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: { name: `Metadata selection ${randomUUID()}`, icon: 'FolderOpen' },
|
||||
});
|
||||
expect(metadataCollectionResponse.statusCode).toBe(201);
|
||||
const metadataCollection = metadataCollectionResponse.json() as { id: number };
|
||||
|
||||
const metadataAdd = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${metadataCollection.id}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: {
|
||||
query: {
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
filter: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Contract' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(metadataAdd.statusCode).toBe(201);
|
||||
expect(metadataAdd.json()).toMatchObject({ id: metadataCollection.id, bookCount: 3 });
|
||||
|
||||
const subqueryRemove = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/collections/${metadataCollection.id}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: {
|
||||
query: {
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
filter: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(subqueryRemove.statusCode).toBe(200);
|
||||
expect(subqueryRemove.json()).toMatchObject({ id: metadataCollection.id, bookCount: 2 });
|
||||
|
||||
const explicitAdd = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${metadataCollection.id}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: { bookIds: [visiblePdf.bookId] },
|
||||
});
|
||||
expect(explicitAdd.statusCode).toBe(201);
|
||||
expect(explicitAdd.json()).toMatchObject({ id: metadataCollection.id, bookCount: 3 });
|
||||
|
||||
const searchCollectionResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/collections',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: { name: `Search selection ${randomUUID()}`, icon: 'FolderSearch' },
|
||||
});
|
||||
expect(searchCollectionResponse.statusCode).toBe(201);
|
||||
const searchCollection = searchCollectionResponse.json() as { id: number };
|
||||
|
||||
const scopedSearchAdd = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${searchCollection.id}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: { query: { q: 'EPUB' } },
|
||||
});
|
||||
expect(scopedSearchAdd.statusCode).toBe(201);
|
||||
expect(scopedSearchAdd.json()).toMatchObject({ id: searchCollection.id, bookCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail and reader state', () => {
|
||||
it('returns detail payload fields and keeps progress and status isolated per user', async () => {
|
||||
const saveProgress = await ctx.app.inject({
|
||||
|
||||
Reference in New Issue
Block a user