Files
bookorbit/server/test/overlap-repro.e2e-spec.ts
T
neon ddb296cade fix(books): show read status on the book detail discovery shelf
The series, author and similar endpoints never selected a read status, so
every cover in the detail shelf rendered bare. Attach the requesting user's
status in one bulk lookup per lane, and draw the badge in BookCarousel using
the same icons and placement as the library grid, plus an sr-only label.
BookRepository has no user in scope, so it now returns UnscopedBookRecommendation
and the type system forces the status on where the user exists.

Also adds the untracked scanner overlap repro for issue 1094.

Closes #1163
2026-08-27 21:56:17 +00:00

212 lines
9.1 KiB
TypeScript

import { inArray, sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { books, bookFiles } from '../src/db/schema/books';
import { bookSeries, bookSeriesMemberships } from '../src/db/schema/series';
import { ScannerService } from '../src/modules/scanner/scanner.service';
import { createFixtureTree, file, type FixtureTree } from './e2e/scanner/scanner-fixture-builder';
import {
assertNoIntegrityViolations,
closeScannerE2EContext,
createScannerE2EContext,
seedLibrary,
triggerLibraryScan,
waitForScanCompletion,
type ScannerE2EContext,
} from './e2e/scanner/scanner-harness';
// Issue 1094: two libraries whose folders point at the same filesystem path.
// Library "Global" -> /biblioteca/user1
// Library "User1" -> /biblioteca/user1
const BOOK_ONE = 'Ready Player One/Ready Player One.epub';
const BOOK_TWO = 'Ready Player Two/Ready Player Two.epub';
interface BookRow {
id: number;
libraryId: number;
folderPath: string;
status: string;
primaryFileId: number | null;
fileCount: number;
primaryFileOwnedBy: number | null;
}
describe('issue 1094 - overlapping library paths', () => {
let ctx: ScannerE2EContext;
let tree: FixtureTree;
let globalLibraryId: number;
let userLibraryId: number;
const scan = async (libraryId: number) => {
const jobId = await triggerLibraryScan(ctx, libraryId);
await waitForScanCompletion(ctx.db, jobId);
};
const loadBooks = async (): Promise<BookRow[]> => {
const rows = await ctx.db
.select({
id: books.id,
libraryId: books.libraryId,
folderPath: books.folderPath,
status: books.status,
primaryFileId: books.primaryFileId,
})
.from(books)
.where(inArray(books.libraryId, [globalLibraryId, userLibraryId]))
.orderBy(books.libraryId, books.folderPath);
const fileRows = await ctx.db.select({ id: bookFiles.id, bookId: bookFiles.bookId }).from(bookFiles);
const ownerOfFile = new Map(fileRows.map((f) => [f.id, f.bookId]));
const ownFileCount = new Map<number, number>();
for (const f of fileRows) ownFileCount.set(f.bookId, (ownFileCount.get(f.bookId) ?? 0) + 1);
return rows.map((row) => ({
...row,
fileCount: ownFileCount.get(row.id) ?? 0,
primaryFileOwnedBy: row.primaryFileId == null ? null : (ownerOfFile.get(row.primaryFileId) ?? null),
}));
};
const label = (row: BookRow) =>
`library=${(row.libraryId === globalLibraryId ? 'Global' : 'User1').padEnd(6)} book=${String(row.id).padEnd(2)} ` +
`folder="${row.folderPath.split('/').pop()}" status=${row.status} ownFiles=${row.fileCount} ` +
`primaryFileId=${row.primaryFileId ?? 'null'} thatFileBelongsToBook=${row.primaryFileOwnedBy ?? 'null'} ` +
`readable=${row.fileCount > 0 ? 'yes' : 'NO'}`;
beforeAll(async () => {
ctx = await createScannerE2EContext();
tree = await createFixtureTree([file(BOOK_ONE), file(BOOK_TWO)], 'issue-1094-');
// Both libraries are given the exact same root path, as described in the report.
({ libraryId: globalLibraryId } = await seedLibrary(ctx.db, { rootPath: tree.rootPath, mode: 'book_per_folder', name: 'Global' }));
({ libraryId: userLibraryId } = await seedLibrary(ctx.db, { rootPath: tree.rootPath, mode: 'book_per_folder', name: 'User1' }));
}, 120_000);
afterAll(async () => {
await tree?.cleanup();
if (ctx) await closeScannerE2EContext(ctx);
});
it('creates a second, file-less book record for every physical book', async () => {
await scan(globalLibraryId);
await scan(userLibraryId);
const rows = await loadBooks();
console.log('\n--- after scanning Global, then User1 ---');
for (const row of rows) console.log(' ' + label(row));
// Two physical files on disk.
const [{ count: fileRowCount }] = await ctx.db.select({ count: sql<number>`count(*)::int` }).from(bookFiles);
console.log(` book_files rows in the whole database: ${fileRowCount}`);
expect(rows).toHaveLength(4); // 4 book records for 2 physical books
expect(fileRowCount).toBe(2);
const readable = rows.filter((r) => r.fileCount > 0);
const fileLess = rows.filter((r) => r.fileCount === 0);
expect(readable).toHaveLength(2);
expect(fileLess).toHaveLength(2);
// The file-less records are not hidden: they stay 'present' and are served like any other book.
expect(fileLess.every((r) => r.status === 'present')).toBe(true);
// The last library to scan takes the files off the first one.
expect(readable.every((r) => r.libraryId === userLibraryId)).toBe(true);
expect(fileLess.every((r) => r.libraryId === globalLibraryId)).toBe(true);
// Worse than the report describes: the stripped books keep a primary_file_id pointing at a
// file row that now belongs to a different book in a different library.
expect(fileLess.every((r) => r.primaryFileId != null && r.primaryFileOwnedBy !== r.id)).toBe(true);
// BookOrbit's own integrity checker classifies exactly this as a violation.
await expect(assertNoIntegrityViolations(ctx.db)).rejects.toThrow(/invalidPrimaryFileRef/);
}, 120_000);
it('reassigns the files again on a forced full rescan of the other library', async () => {
const scanner = ctx.app.get(ScannerService);
const { jobId } = await scanner.startScan(globalLibraryId, 'manual', true);
await waitForScanCompletion(ctx.db, jobId);
const afterGlobal = await loadBooks();
console.log('\n--- after a FORCED full rescan of Global ---');
for (const row of afterGlobal) console.log(' ' + label(row));
expect(afterGlobal.filter((r) => r.fileCount > 0).every((r) => r.libraryId === globalLibraryId)).toBe(true);
expect(afterGlobal.filter((r) => r.fileCount === 0).every((r) => r.libraryId === userLibraryId)).toBe(true);
const { jobId: userJobId } = await scanner.startScan(userLibraryId, 'manual', true);
await waitForScanCompletion(ctx.db, userJobId);
const afterUser = await loadBooks();
console.log('\n--- after a FORCED full rescan of User1 ---');
for (const row of afterUser) console.log(' ' + label(row));
expect(afterUser.filter((r) => r.fileCount > 0).every((r) => r.libraryId === userLibraryId)).toBe(true);
// Whichever library scanned last owns the files, so reordering libraries is not a workaround.
await expect(assertNoIntegrityViolations(ctx.db)).rejects.toThrow(/invalidPrimaryFileRef/);
}, 120_000);
it('counts and lists every duplicate in the series view', async () => {
const rows = await loadBooks();
const [series] = await ctx.db
.insert(bookSeries)
.values({ name: 'Ready Player', normalizedName: 'ready player', expectedBookCount: 2 })
.returning();
// Metadata extraction is mocked out in the e2e harness, so series membership is written
// directly - exactly the rows a real scan of these two books would produce.
await ctx.db.insert(bookSeriesMemberships).values(
rows.map((row) => ({
bookId: row.id,
seriesId: series.id,
seriesIndex: row.folderPath.includes('Two') ? '2' : '1',
})),
);
const listResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/series',
headers: { authorization: `Bearer ${ctx.adminToken}` },
});
const list = listResponse.json();
const entry = list.items.find((s: { id: number }) => s.id === series.id);
console.log('\n--- GET /api/v1/series ---');
console.log(` name="${entry.name}" bookCount=${entry.bookCount} expectedBookCount=${entry.expectedBookCount}`);
const booksResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/series/${series.id}/books`,
headers: { authorization: `Bearer ${ctx.adminToken}` },
});
const seriesBooks = booksResponse.json();
console.log(`\n--- GET /api/v1/series/${series.id}/books ---`);
for (const b of seriesBooks.items) {
console.log(` #${b.seriesIndex} "${b.title}" bookId=${b.id} files=${b.files.length} readable=${b.files.length > 0 ? 'yes' : 'NO'}`);
}
// The series claims 4 books; only 2 files exist on disk.
expect(entry.bookCount).toBe(4);
expect(entry.expectedBookCount).toBe(2);
expect(seriesBooks.items).toHaveLength(4);
expect(seriesBooks.items.filter((b: { files: unknown[] }) => b.files.length === 0)).toHaveLength(2);
expect(seriesBooks.seriesInfo.bookCount).toBe(4);
}, 120_000);
it('shows the duplicates in the plain Books view too, not only in series views', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: { authorization: `Bearer ${ctx.adminToken}` },
payload: { page: 0, size: 50 },
});
const body = response.json();
console.log('\n--- POST /api/v1/books/query (all accessible libraries) ---');
console.log(` status=${response.statusCode} total=${body.total}`);
for (const b of body.items ?? []) {
console.log(` bookId=${b.id} "${b.title}" files=${b.files.length} readable=${b.files.length > 0 ? 'yes' : 'NO'}`);
}
expect(body.total).toBe(4);
expect(body.items.filter((b: { files: unknown[] }) => b.files.length === 0)).toHaveLength(2);
}, 120_000);
});