Files
bookorbit/server/test/book-api-contract.e2e-spec.ts
T
neon 0b2df6e3d3 fix(books): include subtitles in search
Match subtitle metadata in book searches and index it for large libraries. Add regression coverage for accent matching and library visibility.

+ add open collective

Closes #1552
2026-09-26 04:57:32 +00:00

950 lines
36 KiB
TypeScript

import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import { readdir, rm, stat } from 'fs/promises';
import { join } from 'path';
import * as unzipper from 'unzipper';
import { Permission } from '@bookorbit/types';
import { appSettings } from '../src/db/schema/auth';
import { bookCovers } from '../src/db/schema/book-covers';
import { bookFiles, books } from '../src/db/schema/books';
import { bookMetadata } from '../src/db/schema/metadata';
import { CoverSlotBackfillService } from '../src/modules/cover/cover-slot-backfill.service';
import {
authHeader,
closeMetadataWriteE2EContext,
createLibraryWithFolder,
createMetadataWriteE2EContext,
createUserAndLogin,
grantLibraryAccess,
locateBookFileByRelPath,
triggerAndWaitForLibraryScan,
type LocatedBookFile,
type MetadataWriteE2EContext,
type TestUserSession,
} from './e2e/metadata-write/metadata-write-harness';
import { createCbzFixture, createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
import { createSlotCoverArtifacts } from './e2e/slot-cover-artifacts';
type InjectResponse = Awaited<ReturnType<MetadataWriteE2EContext['app']['inject']>>;
const SCENARIO_TIMEOUT_MS = 120_000;
function responseMessage(response: { message?: string | string[] }): string {
if (Array.isArray(response.message)) return response.message.join(' ');
return String(response.message ?? '');
}
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
expect(response.statusCode).toBe(status);
if (!messageFragment) return;
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
}
function zipBuffer(response: InjectResponse): Buffer {
const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload;
if (Buffer.isBuffer(rawPayload)) return rawPayload;
return Buffer.from(response.body, 'binary');
}
async function listZipEntries(response: InjectResponse): Promise<string[]> {
const zip = await unzipper.Open.buffer(zipBuffer(response));
return zip.files.map((file) => file.path).sort();
}
describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
let visibleLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
let hiddenLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
let visibleLibraryName!: string;
let hiddenLibraryName!: string;
let limitedUser!: TestUserSession;
let crossLibraryUser!: TestUserSession;
let downloadUser!: TestUserSession;
let visibleEpub!: LocatedBookFile;
let visiblePdf!: LocatedBookFile;
let visibleCbz!: LocatedBookFile;
let hiddenEpub!: LocatedBookFile;
beforeAll(async () => {
ctx = await createMetadataWriteE2EContext();
visibleLibraryName = `book-api-visible-${randomUUID()}`;
hiddenLibraryName = `book-api-hidden-${randomUUID()}`;
visibleLibrary = await createLibraryWithFolder(ctx, {
name: visibleLibraryName,
});
hiddenLibrary = await createLibraryWithFolder(ctx, {
name: hiddenLibraryName,
});
await createEpubFixture(visibleLibrary.folderPath, 'contracts/alpha-contract.epub', {
title: 'Alpha Contract EPUB',
});
await createPdfFixture(visibleLibrary.folderPath, 'contracts/beta-contract.pdf', 'Beta Contract PDF');
await createCbzFixture(visibleLibrary.folderPath, 'contracts/gamma-contract.cbz', {
title: 'Gamma Contract Comic',
author: 'Gamma Cartoonist',
});
await createEpubFixture(hiddenLibrary.folderPath, 'restricted/omega-hidden.epub', {
title: 'Omega Hidden EPUB',
});
await triggerAndWaitForLibraryScan(ctx, visibleLibrary.libraryId);
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
visibleEpub = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/alpha-contract.epub');
visiblePdf = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/beta-contract.pdf');
visibleCbz = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/gamma-contract.cbz');
hiddenEpub = await locateBookFileByRelPath(ctx, hiddenLibrary.libraryId, 'restricted/omega-hidden.epub');
limitedUser = await createUserAndLogin(ctx);
crossLibraryUser = await createUserAndLogin(ctx);
downloadUser = await createUserAndLogin(ctx, {
permissions: [Permission.LibraryDownload],
});
await grantLibraryAccess(ctx, limitedUser.userId, visibleLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, crossLibraryUser.userId, visibleLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, downloadUser.userId, visibleLibrary.libraryId, 'viewer');
await createSlotCoverArtifacts(ctx, visibleEpub.bookId, {
coverExtension: 'png',
coverContent: Buffer.from('cover-png', 'utf8'),
thumbnailContent: Buffer.from('thumbnail-jpg', 'utf8'),
});
}, 60_000);
afterAll(async () => {
await closeMetadataWriteE2EContext(ctx);
});
describe('query and search', () => {
it('returns scoped global and library query results with filters, sorting, and pagination', async () => {
const filteredQuery = {
filter: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Contract' }],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 2 },
};
const limitedGlobalQuery = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: filteredQuery,
});
expect(limitedGlobalQuery.statusCode).toBe(201);
expect(limitedGlobalQuery.json()).toMatchObject({
total: 3,
page: 0,
size: 2,
items: [
{
id: visibleEpub.bookId,
title: 'Alpha Contract EPUB',
files: [{ id: visibleEpub.bookFileId, format: 'epub', role: 'primary' }],
},
{
id: visiblePdf.bookId,
title: 'Beta Contract PDF',
files: [{ id: visiblePdf.bookFileId, format: 'pdf', role: 'primary' }],
},
],
});
const libraryQuery = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`,
headers: authHeader(limitedUser.accessToken),
payload: {
filter: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['cbz'] }],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 10 },
},
});
expect(libraryQuery.statusCode).toBe(201);
expect(libraryQuery.json()).toMatchObject({
total: 1,
items: [{ id: visibleCbz.bookId, title: 'Gamma Contract Comic' }],
});
const inaccessibleLibraryQuery = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${hiddenLibrary.libraryId}/books`,
headers: authHeader(limitedUser.accessToken),
payload: filteredQuery,
});
expect(inaccessibleLibraryQuery.statusCode).toBe(403);
const crossLibraryQuery = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(crossLibraryUser.accessToken),
payload: {
filter: {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'EPUB' }],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 10 },
},
});
expect(crossLibraryQuery.statusCode).toBe(201);
expect(crossLibraryQuery.json()).toMatchObject({
total: 2,
items: [
{ id: visibleEpub.bookId, title: 'Alpha Contract EPUB' },
{ id: hiddenEpub.bookId, title: 'Omega Hidden EPUB' },
],
});
});
it('returns scoped search results and validates search params', async () => {
const limitedSearch = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Contract&limit=20',
headers: authHeader(limitedUser.accessToken),
});
expect(limitedSearch.statusCode).toBe(200);
expect(limitedSearch.json()).toEqual([
{
id: visibleEpub.bookId,
title: 'Alpha Contract EPUB',
seriesName: null,
authors: [],
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['epub'],
updatedAt: expect.any(String),
},
{
id: visiblePdf.bookId,
title: 'Beta Contract PDF',
seriesName: null,
authors: [],
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['pdf'],
updatedAt: expect.any(String),
},
{
id: visibleCbz.bookId,
title: 'Gamma Contract Comic',
seriesName: null,
authors: ['Gamma Cartoonist'],
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['cbz'],
updatedAt: expect.any(String),
},
]);
const authorSearch = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Cartoonist&limit=20',
headers: authHeader(limitedUser.accessToken),
});
expect(authorSearch.statusCode).toBe(200);
expect(authorSearch.json()).toEqual([
expect.objectContaining({
id: visibleCbz.bookId,
title: 'Gamma Contract Comic',
authors: ['Gamma Cartoonist'],
}),
]);
const crossLibrarySearch = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Omega&limit=20',
headers: authHeader(crossLibraryUser.accessToken),
});
expect(crossLibrarySearch.statusCode).toBe(200);
expect(crossLibrarySearch.json()).toEqual([
expect.objectContaining({
id: hiddenEpub.bookId,
title: 'Omega Hidden EPUB',
libraryId: hiddenLibrary.libraryId,
libraryName: hiddenLibraryName,
}),
]);
const invalidLimit = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Contract&limit=25',
headers: authHeader(limitedUser.accessToken),
});
expectError(invalidLimit, 400, 'limit must not be greater than 20');
});
it('finds subtitle-only matches without exposing books in inaccessible libraries', async () => {
const [visibleMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleEpub.bookId));
const [hiddenMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, hiddenEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'The Singapôre Story' }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'A Singapôre Chronicle' }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
try {
const query = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(query.statusCode).toBe(201);
expect(query.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const libraryQuery = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`,
headers: authHeader(crossLibraryUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(libraryQuery.statusCode).toBe(201);
expect(libraryQuery.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const search = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Singapore&limit=10',
headers: authHeader(limitedUser.accessToken),
});
expect(search.statusCode).toBe(200);
expect(search.json()).toEqual([expect.objectContaining({ id: visibleEpub.bookId })]);
} finally {
await ctx.db.update(bookMetadata).set({ subtitle: visibleMetadata!.subtitle }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: hiddenMetadata!.subtitle }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
}
});
it('filters books by the selected primary file size', async () => {
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: {
filter: {
type: 'group',
join: 'AND',
rules: [
{ type: 'rule', field: 'title', operator: 'eq', value: 'Alpha Contract EPUB' },
{ type: 'rule', field: 'fileSize', operator: 'eq', value: primaryFileSize },
],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 10 },
},
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
total: 1,
items: [{ id: visibleEpub.bookId, files: [{ id: visibleEpub.bookFileId, role: 'primary', sizeBytes: primaryFileSize }] }],
});
});
it('filters books with audiobook media by whether their audio cover slot is filled', async () => {
const [pdfFile] = await ctx.db
.select({ libraryFolderId: bookFiles.libraryFolderId })
.from(bookFiles)
.where(eq(bookFiles.id, visiblePdf.bookFileId));
const [audioFile] = await ctx.db
.insert(bookFiles)
.values({
bookId: visiblePdf.bookId,
libraryFolderId: pdfFile!.libraryFolderId,
absolutePath: join(visibleLibrary.folderPath, 'contracts/beta-contract.m4b'),
relPath: 'contracts/beta-contract.m4b',
ino: 987_654_321n,
format: 'm4b',
role: 'content',
})
.returning({ id: bookFiles.id });
const audioCoverBookIds = async (operator: 'isMissing' | 'isPresent') => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: {
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'audioCover', operator }] },
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 20 },
},
});
expect(response.statusCode).toBe(201);
return (response.json() as { items: { id: number }[] }).items.map((item) => item.id);
};
try {
expect(await audioCoverBookIds('isMissing')).toEqual([visiblePdf.bookId]);
expect(await audioCoverBookIds('isPresent')).toEqual([]);
await ctx.db.insert(bookCovers).values({ bookId: visiblePdf.bookId, medium: 'audio', source: 'custom', origin: 'upload' });
expect(await audioCoverBookIds('isMissing')).toEqual([]);
expect(await audioCoverBookIds('isPresent')).toEqual([visiblePdf.bookId]);
await ctx.db
.update(bookCovers)
.set({ dormantSince: new Date() })
.where(and(eq(bookCovers.bookId, visiblePdf.bookId), eq(bookCovers.medium, 'audio')));
expect(await audioCoverBookIds('isMissing')).toEqual([visiblePdf.bookId]);
expect(await audioCoverBookIds('isPresent')).toEqual([]);
} finally {
await ctx.db.delete(bookCovers).where(and(eq(bookCovers.bookId, visiblePdf.bookId), eq(bookCovers.medium, 'audio')));
await ctx.db.delete(bookFiles).where(eq(bookFiles.id, audioFile!.id));
}
});
});
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({
method: 'POST',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
headers: authHeader(limitedUser.accessToken),
payload: {
cfi: 'epubcfi(/6/2!/4/1:0)',
percentage: 42.5,
positionSeconds: 90,
},
});
expect(saveProgress.statusCode).toBe(201);
const setStatus = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${visibleEpub.bookId}/status`,
headers: authHeader(limitedUser.accessToken),
payload: { status: 'reading' },
});
expect(setStatus.statusCode).toBe(200);
expect(setStatus.json()).toMatchObject({
status: 'reading',
source: 'manual',
startedAt: expect.any(String),
finishedAt: null,
updatedAt: expect.any(String),
});
const getFileProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
headers: authHeader(limitedUser.accessToken),
});
expect(getFileProgress.statusCode).toBe(200);
expect(getFileProgress.json()).toMatchObject({
cfi: 'epubcfi(/6/2!/4/1:0)',
pageNumber: null,
percentage: 42.5,
});
const otherUserFileProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(otherUserFileProgress.statusCode).toBe(200);
expect(otherUserFileProgress.json()).toEqual({
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
positionSeconds: null,
textUpdatedAt: null,
narrationPercentage: null,
narrationUpdatedAt: null,
mediaOverlaySectionIndex: null,
mediaOverlayFragment: null,
});
const limitedBookProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/progress`,
headers: authHeader(limitedUser.accessToken),
});
expect(limitedBookProgress.statusCode).toBe(200);
expect(limitedBookProgress.json()).toEqual([
expect.objectContaining({
fileId: visibleEpub.bookFileId,
cfi: 'epubcfi(/6/2!/4/1:0)',
pageNumber: null,
percentage: 42.5,
}),
]);
const otherUserBookProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/progress`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(otherUserBookProgress.statusCode).toBe(200);
expect(otherUserBookProgress.json()).toEqual([
{
fileId: visibleEpub.bookFileId,
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
positionSeconds: null,
mediaOverlaySectionIndex: null,
mediaOverlayFragment: null,
updatedAt: null,
},
]);
const limitedDetail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}`,
headers: authHeader(limitedUser.accessToken),
});
expect(limitedDetail.statusCode).toBe(200);
expect(limitedDetail.json()).toMatchObject({
id: visibleEpub.bookId,
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
title: 'Alpha Contract EPUB',
coverSource: 'custom',
coverMedia: ['ebook'],
covers: { ebook: { source: 'custom', updatedAt: expect.any(String), width: null, height: null }, audio: null },
coverVersion: expect.stringMatching(/^ebook:/),
files: [
{
id: visibleEpub.bookFileId,
format: 'epub',
role: 'primary',
filename: 'alpha-contract.epub',
},
],
collections: expect.any(Array),
readStatus: {
status: 'reading',
source: 'manual',
},
});
const crossLibraryDetail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(crossLibraryDetail.statusCode).toBe(200);
expect(crossLibraryDetail.json()).toMatchObject({
id: visibleEpub.bookId,
title: 'Alpha Contract EPUB',
readStatus: null,
});
const inaccessibleDetail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${hiddenEpub.bookId}`,
headers: authHeader(limitedUser.accessToken),
});
expect(inaccessibleDetail.statusCode).toBe(404);
});
});
describe('file delivery and export', () => {
it('serves files inline and as downloads, enforces range semantics, and rejects inaccessible files', async () => {
const inlinePdf = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
headers: authHeader(limitedUser.accessToken),
});
expect(inlinePdf.statusCode).toBe(200);
expect(inlinePdf.headers['content-type']).toContain('application/pdf');
expect(inlinePdf.headers['accept-ranges']).toBe('bytes');
expect(Number(inlinePdf.headers['content-length'])).toBeGreaterThan(0);
expect(String(inlinePdf.headers['content-disposition'])).toContain('inline;');
const rangedPdf = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
headers: {
...authHeader(limitedUser.accessToken),
range: 'bytes=0-9',
},
});
expect(rangedPdf.statusCode).toBe(206);
expect(rangedPdf.headers['content-range']).toBe(`bytes 0-9/${inlinePdf.headers['content-length']}`);
expect(rangedPdf.headers['content-length']).toBe('10');
const invalidRange = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
headers: {
...authHeader(limitedUser.accessToken),
range: `bytes=${Number(inlinePdf.headers['content-length']) + 10}-${Number(inlinePdf.headers['content-length']) + 20}`,
},
});
expect(invalidRange.statusCode).toBe(416);
expect(invalidRange.headers['content-range']).toBe(`bytes */${inlinePdf.headers['content-length']}`);
const forbiddenDownloadEpub = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/download`,
headers: authHeader(limitedUser.accessToken),
});
expect(forbiddenDownloadEpub.statusCode).toBe(403);
const downloadEpub = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/download`,
headers: authHeader(downloadUser.accessToken),
});
expect(downloadEpub.statusCode).toBe(200);
expect(downloadEpub.headers['content-type']).toContain('application/epub+zip');
expect(String(downloadEpub.headers['content-disposition'])).toContain('attachment;');
expect(String(downloadEpub.headers['content-disposition'])).toContain('alpha-contract.epub');
const inaccessibleServe = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${hiddenEpub.bookFileId}/serve`,
headers: authHeader(limitedUser.accessToken),
});
expect(inaccessibleServe.statusCode).toBe(403);
});
it('exports selected books as a zip and rejects unauthorized selections', async () => {
const exportResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/export',
headers: authHeader(downloadUser.accessToken),
payload: {
bookIds: [visibleEpub.bookId, visiblePdf.bookId],
allFormats: false,
},
});
expect(exportResponse.statusCode).toBe(201);
expect(exportResponse.headers['content-type']).toContain('application/zip');
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"; filename*=UTF-8\'\'books.zip');
const zipEntries = await listZipEntries(exportResponse);
expect(zipEntries).toEqual(['alpha-contract.epub', 'beta-contract.pdf']);
const unauthorizedExport = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/export',
headers: authHeader(downloadUser.accessToken),
payload: {
bookIds: [visibleEpub.bookId, hiddenEpub.bookId],
allFormats: false,
},
});
expect(unauthorizedExport.statusCode).toBe(403);
});
});
describe('cover and thumbnail caching', () => {
it('serves cover and thumbnail with ETags and returns 404 when artifacts are missing', async () => {
const cover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
headers: authHeader(limitedUser.accessToken),
});
expect(cover.statusCode).toBe(200);
expect(cover.headers['content-type']).toContain('image/png');
expect(cover.headers.etag).toBeTruthy();
expect(cover.headers['cache-control']).toBe('private, max-age=86400');
const invalidMedium = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover?medium=print`,
headers: authHeader(limitedUser.accessToken),
});
expectError(invalidMedium, 400);
const invalidStrict = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover?strict=yes`,
headers: authHeader(limitedUser.accessToken),
});
expectError(invalidStrict, 400);
const cachedCover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
headers: {
...authHeader(limitedUser.accessToken),
'if-none-match': String(cover.headers.etag),
},
});
expect(cachedCover.statusCode).toBe(304);
expect(cachedCover.headers.etag).toBe(cover.headers.etag);
const thumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
headers: authHeader(limitedUser.accessToken),
});
expect(thumbnail.statusCode).toBe(200);
expect(thumbnail.headers['content-type']).toContain('image/jpeg');
expect(thumbnail.headers.etag).toBeTruthy();
expect(thumbnail.headers['cache-control']).toBe('private, max-age=86400');
const cachedThumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
headers: {
...authHeader(limitedUser.accessToken),
'if-none-match': String(thumbnail.headers.etag),
},
});
expect(cachedThumbnail.statusCode).toBe(304);
expect(cachedThumbnail.headers.etag).toBe(thumbnail.headers.etag);
await rm(join(ctx.fixture.booksPath, 'covers', String(visibleEpub.bookId)), {
recursive: true,
force: true,
});
const missingCover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
headers: authHeader(limitedUser.accessToken),
});
expectError(missingCover, 404, `No cover for book ${visibleEpub.bookId}`);
const missingThumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
headers: authHeader(limitedUser.accessToken),
});
expectError(missingThumbnail, 404, `No thumbnail for book ${visibleEpub.bookId}`);
});
it('restores a cover summary that disagrees with the slots when the upgrade runs, without moving updated_at', async () => {
await ctx.db.insert(bookCovers).values({ bookId: visibleCbz.bookId, medium: 'ebook', source: 'custom', origin: 'upload' });
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
const [before] = await ctx.db
.select({ updatedAt: bookMetadata.updatedAt })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleCbz.bookId));
const marker = JSON.stringify({ version: 3, stage: 'c', lastBookId: 0 });
await ctx.db
.insert(appSettings)
.values({ key: 'cover_slots_backfill', value: marker })
.onConflictDoUpdate({ target: appSettings.key, set: { value: marker } });
try {
const result = await ctx.app.get(CoverSlotBackfillService).run();
expect(result.repaired).toBeGreaterThanOrEqual(1);
const [after] = await ctx.db
.select({ coverSource: bookMetadata.coverSource, updatedAt: bookMetadata.updatedAt })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleCbz.bookId));
expect(after).toEqual({ coverSource: 'custom', updatedAt: before!.updatedAt });
await expect(ctx.app.get(CoverSlotBackfillService).run()).resolves.toMatchObject({ repaired: 0 });
} finally {
await ctx.db.delete(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
}
});
it('moves a cover left in the old root layout into its slot on first read, with no coverSource needed', async () => {
const coverDir = join(ctx.fixture.booksPath, 'covers', String(visibleCbz.bookId));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${visibleCbz.bookId}/cover_custom.jpg`, Buffer.from('legacy-cover', 'utf8'));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${visibleCbz.bookId}/thumbnail.jpg`, Buffer.from('legacy-thumbnail', 'utf8'));
try {
const thumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleCbz.bookId}/thumbnail`,
headers: authHeader(limitedUser.accessToken),
});
expect(thumbnail.statusCode).toBe(200);
expect(thumbnail.body).toBe('legacy-thumbnail');
const slots = await ctx.db.select().from(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
expect(slots).toEqual([expect.objectContaining({ medium: 'ebook', source: 'custom', origin: 'legacy', dormantSince: null })]);
expect(await readdir(coverDir)).toEqual(['ebook']);
expect((await readdir(join(coverDir, 'ebook'))).sort()).toEqual(['cover_custom.jpg', 'thumbnail.jpg']);
const cover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleCbz.bookId}/cover`,
headers: authHeader(limitedUser.accessToken),
});
expect(cover.statusCode).toBe(200);
expect(cover.body).toBe('legacy-cover');
} finally {
await ctx.db.delete(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
await rm(coverDir, { recursive: true, force: true });
}
});
});
describe('user-supplied added dates', () => {
// Issue #1143: an added date the user typed, such as year 0025, came back from Postgres
// as an Invalid Date, so serializing the card threw and every listing holding the book
// answered 500. These are the year bands the driver's own decoder gets wrong.
it.each(['0025-08-25', '0013-01-01', '0031-12-31', '0050-06-15', '0001-01-01'])(
'round-trips an added date of %s through storage and back into a listing',
async (addedAt) => {
const bookId = hiddenEpub.bookId;
const [original] = await ctx.db.select({ addedAt: books.addedAt }).from(books).where(eq(books.id, bookId));
const expected = `${addedAt}T00:00:00.000Z`;
try {
const update = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/added-at`,
headers: authHeader(ctx.adminToken),
payload: { addedAt },
});
expect(update.statusCode).toBe(200);
expect((update.json() as { addedAt: string | null }).addedAt).toBe(expected);
const listing = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(ctx.adminToken),
payload: { pagination: { page: 0, size: 200 } },
});
expect(listing.statusCode).toBe(201);
const items = (listing.json() as { items: { id: number; addedAt: string }[] }).items;
expect(items.find((item) => item.id === bookId)?.addedAt).toBe(expected);
} finally {
await ctx.db.update(books).set({ addedAt: original!.addedAt }).where(eq(books.id, bookId));
}
},
);
});
});