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:
neon
2026-07-22 06:31:31 +00:00
parent 8c287e9b42
commit a38288b1a2
3 changed files with 102 additions and 1 deletions
+76
View File
@@ -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({