mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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
This commit is contained in:
@@ -13,7 +13,7 @@ import type {
|
||||
TemporalJumpBucketPrecision,
|
||||
TemporalJumpBucketUnit,
|
||||
} from '@bookorbit/types';
|
||||
import type { BookRecommendation } from '@bookorbit/types';
|
||||
import type { UnscopedBookRecommendation } from '@bookorbit/types';
|
||||
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
@@ -1716,7 +1716,7 @@ export class BookRepository {
|
||||
.where(inArray(books.id, bookIds));
|
||||
}
|
||||
|
||||
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<BookRecommendation[]> {
|
||||
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<UnscopedBookRecommendation[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
|
||||
const [rows, authorRows] = await Promise.all([
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { EmbeddingModule } from '../embedding/embedding.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
|
||||
import { RecommendationController } from './recommendation.controller';
|
||||
import { RecommendationRepository } from './recommendation.repository';
|
||||
import { RecommendationService } from './recommendation.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule, LibraryModule, EmbeddingModule],
|
||||
imports: [BookModule, LibraryModule, EmbeddingModule, UserBookStatusModule],
|
||||
controllers: [RecommendationController],
|
||||
providers: [RecommendationService, RecommendationRepository],
|
||||
exports: [RecommendationService],
|
||||
|
||||
@@ -44,10 +44,19 @@ function makeService() {
|
||||
const embedder = {
|
||||
embedBook: vi.fn(),
|
||||
};
|
||||
const userBookStatusService = {
|
||||
findByBookIds: vi.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
|
||||
const service = new RecommendationService(recRepo as never, bookRepo as never, libraryService as never, embedder as never);
|
||||
const service = new RecommendationService(
|
||||
recRepo as never,
|
||||
bookRepo as never,
|
||||
libraryService as never,
|
||||
embedder as never,
|
||||
userBookStatusService as never,
|
||||
);
|
||||
|
||||
return { service, recRepo, bookRepo, libraryService, embedder };
|
||||
return { service, recRepo, bookRepo, libraryService, embedder, userBookStatusService };
|
||||
}
|
||||
|
||||
describe('RecommendationService', () => {
|
||||
@@ -88,7 +97,16 @@ describe('RecommendationService', () => {
|
||||
]);
|
||||
|
||||
await expect(service.getRecommendations(55, makeUser())).resolves.toEqual([
|
||||
{ id: 91, title: 'Fallback Match', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
|
||||
{
|
||||
id: 91,
|
||||
title: 'Fallback Match',
|
||||
coverAspectRatio: '1/1',
|
||||
updatedAt: null,
|
||||
hasCover: false,
|
||||
authors: [],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
},
|
||||
]);
|
||||
expect(embedder.embedBook).toHaveBeenCalledWith(55);
|
||||
expect(recRepo.findAnnCandidates).toHaveBeenCalledWith([0.4, 0.6], 55, [7, 9], EMPTY_CONTENT_FILTER_RULES);
|
||||
@@ -159,6 +177,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: false,
|
||||
authors: ['Frank Herbert'],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: 200,
|
||||
@@ -168,6 +187,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: true,
|
||||
authors: [],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +227,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: true,
|
||||
authors: ['frank herbert'],
|
||||
isAudiobook: true,
|
||||
readStatus: null,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: 2,
|
||||
@@ -216,6 +237,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: false,
|
||||
authors: [],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,6 +276,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: true,
|
||||
authors: [],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +418,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: true,
|
||||
authors: ['Brandon Sanderson'],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -405,6 +429,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: false,
|
||||
authors: [],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
@@ -415,6 +440,7 @@ describe('RecommendationService', () => {
|
||||
hasCover: true,
|
||||
authors: ['Brandon Sanderson'],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
},
|
||||
]);
|
||||
expect(recRepo.findSeriesBooks).toHaveBeenCalledWith(42, [5, 6], EMPTY_CONTENT_FILTER_RULES);
|
||||
@@ -472,12 +498,48 @@ describe('RecommendationService', () => {
|
||||
const result = await service.getAuthorBooks(1, makeUser());
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 10, title: 'Other Book A', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: ['Jane Austen'], isAudiobook: false },
|
||||
{ id: 20, title: 'Other Book B', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: true },
|
||||
{
|
||||
id: 10,
|
||||
title: 'Other Book A',
|
||||
coverAspectRatio: '2/3',
|
||||
updatedAt: null,
|
||||
hasCover: true,
|
||||
authors: ['Jane Austen'],
|
||||
isAudiobook: false,
|
||||
readStatus: null,
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: 'Other Book B',
|
||||
coverAspectRatio: '1/1',
|
||||
updatedAt: null,
|
||||
hasCover: false,
|
||||
authors: [],
|
||||
isAudiobook: true,
|
||||
readStatus: null,
|
||||
},
|
||||
]);
|
||||
expect(recRepo.findAuthorBooks).toHaveBeenCalledWith(1, [5], EMPTY_CONTENT_FILTER_RULES);
|
||||
});
|
||||
|
||||
it("attaches the requesting user's read status to each book", async () => {
|
||||
const { service, bookRepo, recRepo, libraryService, userBookStatusService } = makeService();
|
||||
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([5]);
|
||||
recRepo.findAuthorBooks.mockResolvedValue([
|
||||
{ bookId: 10, title: 'Read One', coverAspectRatio: '2/3', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: false },
|
||||
{ bookId: 20, title: 'Untracked', coverAspectRatio: '2/3', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: false },
|
||||
]);
|
||||
const status = { status: 'reading', source: 'auto', startedAt: null, finishedAt: null, updatedAt: '2026-01-01T00:00:00.000Z' };
|
||||
userBookStatusService.findByBookIds.mockResolvedValue(new Map([[10, status]]));
|
||||
|
||||
const result = await service.getAuthorBooks(1, makeUser());
|
||||
|
||||
expect(userBookStatusService.findByBookIds).toHaveBeenCalledWith(12, [10, 20]);
|
||||
expect(result[0].readStatus).toEqual(status);
|
||||
expect(result[1].readStatus).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array when author has no other books', async () => {
|
||||
const { service, bookRepo, recRepo, libraryService } = makeService();
|
||||
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { BookRecommendation, SeriesBookRecommendation } from '@bookorbit/types';
|
||||
import type { BookRecommendation, SeriesBookRecommendation, UnscopedBookRecommendation, UserBookStatus } from '@bookorbit/types';
|
||||
import { normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import { AnnCandidate, CandidateMetadata, RecommendationRepository, TargetBookData } from './recommendation.repository';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
@@ -32,8 +33,17 @@ export class RecommendationService {
|
||||
private readonly bookReadService: BookReadService,
|
||||
private readonly libraryService: LibraryService,
|
||||
private readonly embedder: BookEmbedderService,
|
||||
private readonly userBookStatusService: UserBookStatusService,
|
||||
) {}
|
||||
|
||||
private async withReadStatus<T extends { id: number }>(rows: T[], userId: number): Promise<(T & { readStatus: UserBookStatus | null })[]> {
|
||||
const statuses = await this.userBookStatusService.findByBookIds(
|
||||
userId,
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
return rows.map((row) => ({ ...row, readStatus: statuses.get(row.id) ?? null }));
|
||||
}
|
||||
|
||||
async getRecommendations(bookId: number, user: RequestUser): Promise<BookRecommendation[]> {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(
|
||||
@@ -91,9 +101,10 @@ export class RecommendationService {
|
||||
const topIds = rescored.map((row) => row.bookId);
|
||||
const rows = await this.bookReadService.findRecommendationTitlesByBookIds(topIds);
|
||||
const rowMap = new Map(rows.map((row) => [row.id, row]));
|
||||
const recommendations = rescored
|
||||
const ordered = rescored
|
||||
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
|
||||
.filter((row): row is BookRecommendation => row != null);
|
||||
.filter((row): row is UnscopedBookRecommendation => row != null);
|
||||
const recommendations = await this.withReadStatus(ordered, user.id);
|
||||
|
||||
this.logger.log(
|
||||
`[${RECOMMENDATION_EVENT}] [end] bookId=${bookId} userId=${user.id} libraryId=${libraryId} durationMs=${Date.now() - startedAt} accessibleLibraryCount=${accessibleLibraryIds.length} candidateCount=${candidates.length} rescoredCount=${rescored.length} resultCount=${recommendations.length} - recommendation lookup completed`,
|
||||
@@ -133,17 +144,20 @@ export class RecommendationService {
|
||||
`[${SERIES_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} seriesId=${series.id} seriesName="${sanitizeLogValue(series.name ?? '')}" resultCount=${rows.length} - series books lookup completed`,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.bookId,
|
||||
title: r.title,
|
||||
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
|
||||
updatedAt: r.updatedAt?.toISOString() ?? null,
|
||||
seriesIndex: r.seriesIndex,
|
||||
hasCover: r.coverSource !== null,
|
||||
authors: r.authorNames,
|
||||
isAudiobook: r.isAudiobook,
|
||||
isComic: r.isComic,
|
||||
}));
|
||||
return this.withReadStatus(
|
||||
rows.map((r) => ({
|
||||
id: r.bookId,
|
||||
title: r.title,
|
||||
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
|
||||
updatedAt: r.updatedAt?.toISOString() ?? null,
|
||||
seriesIndex: r.seriesIndex,
|
||||
hasCover: r.coverSource !== null,
|
||||
authors: r.authorNames,
|
||||
isAudiobook: r.isAudiobook,
|
||||
isComic: r.isComic,
|
||||
})),
|
||||
user.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const { errorClass, errorMessage } = this.parseError(err);
|
||||
this.logger.error(
|
||||
@@ -169,16 +183,19 @@ export class RecommendationService {
|
||||
`[${AUTHOR_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} resultCount=${rows.length} - author books lookup completed`,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.bookId,
|
||||
title: r.title,
|
||||
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
|
||||
updatedAt: r.updatedAt?.toISOString() ?? null,
|
||||
hasCover: r.coverSource !== null,
|
||||
authors: r.authorNames,
|
||||
isAudiobook: r.isAudiobook,
|
||||
isComic: r.isComic,
|
||||
}));
|
||||
return this.withReadStatus(
|
||||
rows.map((r) => ({
|
||||
id: r.bookId,
|
||||
title: r.title,
|
||||
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
|
||||
updatedAt: r.updatedAt?.toISOString() ?? null,
|
||||
hasCover: r.coverSource !== null,
|
||||
authors: r.authorNames,
|
||||
isAudiobook: r.isAudiobook,
|
||||
isComic: r.isComic,
|
||||
})),
|
||||
user.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const { errorClass, errorMessage } = this.parseError(err);
|
||||
this.logger.error(
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user