Files
bookorbit/server/test/chapter-health.e2e-spec.ts
neon d21c68d13c feat(books): add an audiobook chapter editor
Add a Chapters tab to edit chapter timing and titles, look chapters up on Audible or in the ebook,
snap starts to pauses, keep a version history and write chapters into M4B, M4A and MP3 files. Add a
chapter health tool with a bulk Audible lookup queue. Editing needs Editor access to the book's
library. Chapter edits keep released iOS playback working by accepting a stale manifest revision
while the track layout is unchanged.

Ref #1120
2026-10-22 04:36:49 +00:00

700 lines
33 KiB
TypeScript

import { randomUUID } from 'crypto';
import { HttpException, HttpStatus } from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm';
import {
BOOK_CHAPTERS_ERROR_CODES,
Permission,
type AudiobookChapter,
type AudiobookChapterSource,
type BookChaptersState,
type ChapterHealthPage,
type ChapterHealthSummary,
type ChapterLookupQueueState,
type ChapterLookupResult,
type ChapterLookupReviewSummary,
type EnqueueChapterLookupResult,
} from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { ChapterLookupService } from '../src/modules/chapter-editor/chapter-lookup.service';
import { ChapterHealthRepository } from '../src/modules/chapter-health/chapter-health.repository';
import { ChapterEditorService } from '../src/modules/chapter-editor/chapter-editor.service';
import { ChapterLookupWorker } from '../src/modules/chapter-health/chapter-lookup.worker';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
createUserAndLogin,
grantLibraryAccess,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
interface SeedBook {
title: string;
files?: number;
fileSeconds?: number;
format?: string;
chapters?: AudiobookChapter[] | null;
source?: AudiobookChapterSource | null;
chaptersUpdatedAt?: Date | null;
audibleId?: string | null;
lockedFields?: string[];
status?: string;
library?: CreatedLibrary;
}
function chapterList(titles: readonly string[], spacingMs = 60_000): AudiobookChapter[] {
return titles.map((title, index) => ({ title, startMs: index * spacingMs }));
}
function range(count: number, title: (index: number) => string): string[] {
return Array.from({ length: count }, (_, index) => title(index + 1));
}
const REVIEW_SUMMARY: ChapterLookupReviewSummary = {
provider: 'audible',
chapterCount: 3,
runtimeMs: 1_215_000,
totalDurationMs: 1_200_000,
isAccurate: true,
};
describe('Chapter health (e2e)', { timeout: 60_000 }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let libraryA!: CreatedLibrary;
let libraryB!: CreatedLibrary;
let libraryAName!: string;
let editor!: TestUserSession;
let outsider!: TestUserSession;
let plainUser!: TestUserSession;
const ids: Record<string, number> = {};
async function seedBook(input: SeedBook): Promise<number> {
const library = input.library ?? libraryA;
const slug = `chapter-health-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({
libraryId: library.libraryId,
libraryFolderId: library.libraryFolderId,
folderPath: `${library.folderPath}/${slug}`,
status: input.status ?? 'present',
})
.returning({ id: schema.books.id });
const bookId = book!.id;
const chapters = input.chapters ?? null;
await ctx.db.insert(schema.bookMetadata).values({
bookId,
title: input.title,
chapters,
chaptersSource: input.source ?? (chapters ? 'embedded' : null),
chaptersUpdatedAt: input.chaptersUpdatedAt ?? null,
audibleId: input.audibleId ?? null,
lockedFields: input.lockedFields ?? [],
});
const format = input.format ?? 'mp3';
const parts = Array.from({ length: input.files ?? 1 }, (_, index) => index + 1);
const files = await ctx.db
.insert(schema.bookFiles)
.values(
parts.map((part) => ({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `${library.folderPath}/${slug}/Part ${part}.${format}`,
relPath: `${slug}/Part ${part}.${format}`,
ino: BigInt(part),
sizeBytes: 1_000 * part,
format,
role: 'content',
sortOrder: part,
durationSeconds: input.fileSeconds ?? 600,
})),
)
.returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: files[0]!.id }).where(eq(schema.books.id, bookId));
return bookId;
}
async function request<T>(
user: TestUserSession | 'admin',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
payload?: Record<string, unknown>,
): Promise<{ status: number; body: T }> {
const token = user === 'admin' ? ctx.adminToken : user.accessToken;
const response = await ctx.app.inject({ method, url: `/api/v1${url}`, headers: authHeader(token), ...(payload ? { payload } : {}) });
return { status: response.statusCode, body: (response.body ? response.json() : null) as T };
}
async function summary(user: TestUserSession | 'admin', query = ''): Promise<ChapterHealthSummary> {
const response = await request<ChapterHealthSummary>(user, 'GET', `/chapter-health/summary${query}`);
expect(response.status).toBe(200);
return response.body;
}
async function titlesIn(user: TestUserSession, category: string, query = ''): Promise<string[]> {
const response = await request<ChapterHealthPage>(user, 'GET', `/chapter-health/books?category=${category}${query}`);
expect(response.status).toBe(200);
return response.body.items.map((item) => item.title ?? '');
}
async function queueRow(bookId: number) {
const [row] = await ctx.db.select().from(schema.chapterLookupQueue).where(eq(schema.chapterLookupQueue.bookId, bookId));
return row ?? null;
}
async function metadataRow(bookId: number) {
const [row] = await ctx.db
.select({
chapters: schema.bookMetadata.chapters,
chaptersSource: schema.bookMetadata.chaptersSource,
chaptersUpdatedBy: schema.bookMetadata.chaptersUpdatedBy,
lockedFields: schema.bookMetadata.lockedFields,
})
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
return row!;
}
async function versionsOf(bookId: number) {
return ctx.db.select().from(schema.bookChapterVersions).where(eq(schema.bookChapterVersions.bookId, bookId));
}
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
libraryAName = `chapter-health-a-${randomUUID()}`;
libraryA = await createLibraryWithFolder(ctx, { name: libraryAName });
libraryB = await createLibraryWithFolder(ctx, { name: `chapter-health-b-${randomUUID()}` });
editor = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
outsider = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
plainUser = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, editor.userId, libraryA.libraryId, 'editor');
await grantLibraryAccess(ctx, outsider.userId, libraryB.libraryId, 'editor');
await grantLibraryAccess(ctx, plainUser.userId, libraryA.libraryId);
// Titles sort in seeding order, which is the order every list below is read in.
ids.singleNone = await seedBook({ title: 'Alpha Single No Chapters', audibleId: ' B0SINGLE01 ' });
ids.singleOne = await seedBook({ title: 'Bravo Single One Chapter', chapters: chapterList(['Whole Book']) });
ids.threeNone = await seedBook({ title: 'Charlie Three Files', files: 3, audibleId: 'B0THREE001' });
ids.threeOne = await seedBook({ title: 'Delta Three Files One Chapter', files: 3, chapters: chapterList(['Whole Book']) });
ids.digits = await seedBook({
title: 'Echo Numbered Digits',
chapters: chapterList(range(10, (n) => String(n).padStart(3, '0'))),
audibleId: 'B0DIGITS01',
});
ids.tracks = await seedBook({
title: 'Foxtrot Track Titles',
files: 2,
chapters: chapterList(range(10, (n) => `Track ${String(n).padStart(2, '0')}`)),
});
ids.mostly = await seedBook({
title: 'Golf Mostly Numbered',
chapters: chapterList([...range(8, (n) => String(n).padStart(2, '0')), 'Epilogue', 'Afterword']),
});
ids.partly = await seedBook({
title: 'Hotel Partly Numbered',
chapters: chapterList([...range(7, (n) => String(n).padStart(2, '0')), 'Interlude', 'Epilogue', 'Afterword']),
});
ids.real = await seedBook({ title: 'India Real Chapters', chapters: chapterList(range(10, (n) => `Chapter ${n}`)), audibleId: 'B0REAL0001' });
ids.manual = await seedBook({ title: 'Juliet Hand Edited', files: 2, chapters: chapterList(['Opening', 'Middle'], 300_000), source: 'manual' });
ids.reviewOpen = await seedBook({
title: 'Kilo Review Open',
files: 2,
chapters: chapterList(
range(3, (n) => `Chapter ${n}`),
300_000,
),
chaptersUpdatedAt: new Date('2026-09-01T00:00:00.000Z'),
audibleId: 'B0REVIEW01',
});
ids.reviewSettled = await seedBook({
title: 'Lima Review Settled',
files: 2,
chapters: chapterList(
range(3, (n) => `Chapter ${n}`),
300_000,
),
chaptersUpdatedAt: new Date('2026-09-03T00:00:00.000Z'),
audibleId: 'B0REVIEW02',
});
ids.missing = await seedBook({ title: 'Mike Missing', status: 'missing', audibleId: 'B0MISSING1' });
ids.ebook = await seedBook({ title: 'November Ebook Only', format: 'epub', audibleId: 'B0EBOOK001' });
ids.otherLibrary = await seedBook({ title: 'Oscar Other Library', library: libraryB, audibleId: 'B0OTHER001' });
const finishedAt = new Date('2026-09-02T00:00:00.000Z');
await ctx.db.insert(schema.chapterLookupQueue).values(
[ids.reviewOpen!, ids.reviewSettled!].map((bookId) => ({
bookId,
userId: editor.userId,
status: 'review' as const,
reason: 'runtime_mismatch',
result: REVIEW_SUMMARY,
attemptCount: 1,
finishedAt,
})),
);
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('counts each category over the libraries the user can reach', async () => {
const expected: ChapterHealthSummary = {
counts: { no_chapters: 2, one_per_file: 1, numbered_tracks: 3, needs_review: 1, edited: 1 },
withAsin: { no_chapters: 1, one_per_file: 1, numbered_tracks: 1, needs_review: 1, edited: 0 },
};
await expect(summary(editor)).resolves.toEqual(expected);
await expect(summary(editor, `?libraryId=${libraryA.libraryId}`)).resolves.toEqual(expected);
await expect(summary('admin', `?libraryId=${libraryA.libraryId}`)).resolves.toEqual(expected);
});
it('puts each book in the categories its files and chapters call for', async () => {
await expect(titlesIn(editor, 'no_chapters')).resolves.toEqual(['Alpha Single No Chapters', 'Bravo Single One Chapter']);
await expect(titlesIn(editor, 'one_per_file')).resolves.toEqual(['Charlie Three Files']);
// "Chapter 1".."Chapter 10" are real chapters, and 7 numbered titles out of 10 fall short of 80%.
await expect(titlesIn(editor, 'numbered_tracks')).resolves.toEqual(['Echo Numbered Digits', 'Foxtrot Track Titles', 'Golf Mostly Numbered']);
// A later chapter save settles a review.
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open']);
await expect(titlesIn(editor, 'edited')).resolves.toEqual(['Juliet Hand Edited']);
});
it('describes each listed book, attaching the review only where one waits', async () => {
const numbered = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=numbered_tracks');
expect(numbered.body.items[0]).toEqual({
bookId: ids.digits,
title: 'Echo Numbered Digits',
authors: [],
libraryName: libraryAName,
audioFiles: 1,
chapterCount: 10,
source: 'embedded',
audibleId: 'B0DIGITS01',
review: null,
});
const single = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=no_chapters&pageSize=1');
expect(single.body.items[0]).toMatchObject({ bookId: ids.singleNone, audibleId: 'B0SINGLE01', chapterCount: 0, source: null });
const review = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=needs_review');
expect(review.body).toEqual({
total: 1,
items: [
expect.objectContaining({
bookId: ids.reviewOpen,
audioFiles: 2,
chapterCount: 3,
review: { reason: 'runtime_mismatch', summary: REVIEW_SUMMARY },
}),
],
});
});
it('pages through a category with a stable total', async () => {
const page = (number: number) =>
request<ChapterHealthPage>(editor, 'GET', `/chapter-health/books?category=no_chapters&pageSize=1&page=${number}`);
const [first, second, third] = await Promise.all([page(1), page(2), page(3)]);
expect([first.body.total, second.body.total, third.body.total]).toEqual([2, 2, 2]);
expect(first.body.items.map((item) => item.bookId)).toEqual([ids.singleNone]);
expect(second.body.items.map((item) => item.bookId)).toEqual([ids.singleOne]);
expect(third.body.items).toEqual([]);
});
it('refuses a malformed listing', async () => {
await expect(request(editor, 'GET', '/chapter-health/books?category=bogus')).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'GET', '/chapter-health/books')).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'GET', '/chapter-health/books?category=edited&pageSize=101')).resolves.toMatchObject({ status: 400 });
});
it('covers no library where the user is only a viewer, even with the metadata editing permission', async () => {
const viewer = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
await grantLibraryAccess(ctx, viewer.userId, libraryA.libraryId, 'viewer');
await expect(summary(viewer)).resolves.toEqual({
counts: { no_chapters: 0, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
withAsin: { no_chapters: 0, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
});
await expect(request(viewer, 'GET', `/chapter-health/summary?libraryId=${libraryA.libraryId}`)).resolves.toMatchObject({ status: 403 });
await expect(request(viewer, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone!] })).resolves.toMatchObject({
status: 200,
body: { queued: 0, skipped: 1 },
});
});
it('shows a user only the libraries they can reach, and refuses one they cannot name', async () => {
await expect(summary(outsider)).resolves.toEqual({
counts: { no_chapters: 1, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
withAsin: { no_chapters: 1, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
});
await expect(titlesIn(outsider, 'no_chapters')).resolves.toEqual(['Oscar Other Library']);
await expect(titlesIn(outsider, 'needs_review')).resolves.toEqual([]);
for (const url of [
`/chapter-health/summary?libraryId=${libraryA.libraryId}`,
`/chapter-health/books?category=no_chapters&libraryId=${libraryA.libraryId}`,
]) {
await expect(request(outsider, 'GET', url)).resolves.toMatchObject({ status: 403 });
}
await expect(
request(outsider, 'POST', '/chapter-health/lookups', { category: 'no_chapters', libraryId: libraryA.libraryId }),
).resolves.toMatchObject({
status: 403,
});
await expect(request(editor, 'GET', `/chapter-health/books?category=no_chapters&libraryId=${libraryB.libraryId}`)).resolves.toMatchObject({
status: 403,
});
// A superuser reaches every library.
await expect(summary('admin', `?libraryId=${libraryB.libraryId}`)).resolves.toMatchObject({ counts: { no_chapters: 1 } });
});
it('refuses a user without the metadata editing permission', async () => {
await expect(request(plainUser, 'GET', '/chapter-health/summary')).resolves.toMatchObject({ status: 403 });
await expect(request(plainUser, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone] })).resolves.toMatchObject({ status: 403 });
});
it('queues lookups by book or by category, leaving out books without an ASIN or out of reach', async () => {
const byIds = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', {
bookIds: [ids.singleNone, ids.singleOne, ids.otherLibrary, 999_999, ids.singleNone, ids.missing, ids.ebook],
});
// A missing book and an ebook have ASINs too, but nothing to put chapters on.
expect(byIds).toEqual({ status: 200, body: { queued: 1, skipped: 5 } });
const byCategory = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', { category: 'one_per_file' });
expect(byCategory).toEqual({ status: 200, body: { queued: 1, skipped: 0 } });
const scoped = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', {
category: 'numbered_tracks',
libraryId: libraryA.libraryId,
});
expect(scoped).toEqual({ status: 200, body: { queued: 1, skipped: 0 } });
await expect(request(editor, 'POST', '/chapter-health/lookups', {})).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'POST', '/chapter-health/lookups', { bookIds: [] })).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'POST', '/chapter-health/lookups', { category: 'everything' })).resolves.toMatchObject({ status: 400 });
const outside = await request<EnqueueChapterLookupResult>(outsider, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone] });
expect(outside).toEqual({ status: 200, body: { queued: 0, skipped: 1 } });
const queued = await ctx.db
.select({ bookId: schema.chapterLookupQueue.bookId, userId: schema.chapterLookupQueue.userId, status: schema.chapterLookupQueue.status })
.from(schema.chapterLookupQueue)
.where(eq(schema.chapterLookupQueue.status, 'queued'));
expect(queued.sort((left, right) => left.bookId - right.bookId)).toEqual(
[ids.singleNone!, ids.threeNone!, ids.digits!].map((bookId) => ({ bookId, userId: editor.userId, status: 'queued' })),
);
});
it("reports and cancels only the user's own queue", async () => {
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toEqual({
status: 200,
body: { queued: 3, processing: null, finished: { applied: 0, review: 1, not_found: 0, failed: 0, skipped: 1 } },
});
await expect(request<ChapterLookupQueueState>(outsider, 'GET', '/chapter-health/lookups/state')).resolves.toMatchObject({
body: { queued: 0, finished: { review: 0 } },
});
await expect(request(outsider, 'DELETE', '/chapter-health/lookups/queued')).resolves.toEqual({ status: 200, body: { removed: 0 } });
await expect(request(editor, 'DELETE', '/chapter-health/lookups/queued')).resolves.toEqual({ status: 200, body: { removed: 3 } });
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toMatchObject({
body: { queued: 0, finished: { review: 1, skipped: 1 } },
});
});
it('applies confident lookups and leaves the rest for a person, without reaching Audible', async () => {
const worker = ctx.app.get(ChapterLookupWorker);
const lookupService = ctx.app.get(ChapterLookupService);
const asin = (bookId: number) => `B0WORKER${String(bookId).padStart(2, '0')}`.slice(0, 20);
// One hour of audio; Audible's runtime adds a 2 s lead-in and a 3 s sign-off.
const confident = await seedBook({ title: 'Papa Confident', fileSeconds: 3_600 });
const mismatched = await seedBook({ title: 'Quebec Mismatched', fileSeconds: 3_600 });
const missing = await seedBook({ title: 'Romeo Not Found', fileSeconds: 3_600 });
const lockedChapters = chapterList(['Mine', 'Also Mine'], 1_800_000);
const locked = await seedBook({
title: 'Sierra Locked',
fileSeconds: 3_600,
chapters: lockedChapters,
source: 'manual',
lockedFields: ['chapters'],
});
const flaky = await seedBook({ title: 'Tango Unreachable', fileSeconds: 3_600 });
const lostAsin = await seedBook({ title: 'Uniform Lost ASIN', fileSeconds: 3_600 });
const books = [confident, mismatched, missing, locked, flaky, lostAsin];
for (const bookId of books) {
await ctx.db
.update(schema.bookMetadata)
.set({ audibleId: asin(bookId) })
.where(eq(schema.bookMetadata.bookId, bookId));
}
const found: ChapterLookupResult = {
provider: 'audible',
asin: asin(confident),
region: 'us',
chapters: [
{ title: 'Opening Credits', startMs: 0, lengthMs: 60_000, children: [] },
{ title: 'Chapter 1', startMs: 60_000, lengthMs: 1_740_000, children: [] },
{ title: 'Chapter 2', startMs: 1_800_000, lengthMs: 1_795_000, children: [] },
{ title: 'End Credits', startMs: 3_595_000, lengthMs: 10_000, children: [] },
],
runtimeMs: 3_605_000,
brandIntroMs: 2_000,
brandOutroMs: 3_000,
isAccurate: true,
};
const lookup = vi.spyOn(lookupService, 'lookup').mockImplementation((requested) => {
if (requested === asin(confident) || requested === asin(locked)) return Promise.resolve(found);
if (requested === asin(mismatched)) return Promise.resolve({ ...found, runtimeMs: 3_625_000 });
if (requested === asin(missing)) {
return Promise.reject(
new HttpException(
{ message: 'No chapters were found for that ASIN', errorCode: BOOK_CHAPTERS_ERROR_CODES.lookupNotFound },
HttpStatus.NOT_FOUND,
),
);
}
if (requested === asin(flaky)) {
return Promise.reject(new HttpException({ message: 'The chapter sources could not be reached' }, HttpStatus.BAD_GATEWAY));
}
return Promise.reject(new Error(`unexpected lookup ${requested}`));
});
try {
const enqueued = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', { bookIds: books });
expect(enqueued.body).toEqual({ queued: 6, skipped: 0 });
// The ASIN was cleared after the book was queued.
await ctx.db.update(schema.bookMetadata).set({ audibleId: null }).where(eq(schema.bookMetadata.bookId, lostAsin));
// Tests drive the worker; nothing ticked on its own.
expect((await queueRow(confident))!.status).toBe('queued');
await worker.tick();
// A failed lookup waits before its next try; check the wait, then bring each retry forward.
for (let retry = 0; retry < 2; retry += 1) {
const waiting = await queueRow(flaky);
expect(waiting).toMatchObject({ status: 'queued' });
expect(waiting!.availableAt.getTime()).toBeGreaterThan(Date.now() + 30_000);
await ctx.db
.update(schema.chapterLookupQueue)
.set({ availableAt: new Date(Date.now() - 1_000) })
.where(eq(schema.chapterLookupQueue.bookId, flaky));
await worker.tick();
}
expect(lookup.mock.calls.map(([requested, region]) => [requested, region])).toEqual([
[asin(confident), 'us'],
[asin(mismatched), 'us'],
[asin(missing), 'us'],
[asin(locked), 'us'],
[asin(flaky), 'us'],
[asin(flaky), 'us'],
[asin(flaky), 'us'],
]);
} finally {
lookup.mockRestore();
}
const applied = [
{ title: 'Opening Credits', startMs: 0 },
{ title: 'Chapter 1', startMs: 58_000 },
{ title: 'Chapter 2', startMs: 1_798_000 },
{ title: 'End Credits', startMs: 3_593_000 },
];
const summaryOf = (runtimeMs: number): ChapterLookupReviewSummary => ({
provider: 'audible',
chapterCount: 4,
runtimeMs,
totalDurationMs: 3_600_000,
isAccurate: true,
});
expect(await queueRow(confident)).toMatchObject({
status: 'applied',
reason: null,
result: summaryOf(3_605_000),
attemptCount: 1,
lastError: null,
finishedAt: expect.any(Date),
});
expect(await metadataRow(confident)).toEqual({
chapters: applied,
chaptersSource: 'provider',
chaptersUpdatedBy: null,
lockedFields: ['chapters'],
});
expect((await versionsOf(confident)).map((row) => ({ source: row.source, chapterCount: row.chapterCount, createdBy: row.createdBy }))).toEqual([
{ source: 'provider', chapterCount: 4, createdBy: editor.userId },
]);
const state = await request<BookChaptersState>(editor, 'GET', `/books/${confident}/chapters`);
expect(state.body).toMatchObject({ chapters: applied, source: 'provider', locked: true, versionCount: 1 });
expect(await queueRow(mismatched)).toMatchObject({ status: 'review', reason: 'runtime_mismatch', result: summaryOf(3_625_000) });
expect(await metadataRow(mismatched)).toEqual({ chapters: null, chaptersSource: null, chaptersUpdatedBy: null, lockedFields: [] });
expect(await versionsOf(mismatched)).toEqual([]);
expect(await queueRow(missing)).toMatchObject({ status: 'not_found', reason: null, result: null });
expect(await queueRow(locked)).toMatchObject({ status: 'review', reason: 'locked', result: summaryOf(3_605_000) });
expect(await metadataRow(locked)).toMatchObject({ chapters: lockedChapters, chaptersSource: 'manual', lockedFields: ['chapters'] });
expect(await versionsOf(locked)).toEqual([]);
expect(await queueRow(flaky)).toMatchObject({
status: 'failed',
attemptCount: 3,
lastError: 'The chapter sources could not be reached',
finishedAt: expect.any(Date),
});
expect(await queueRow(lostAsin)).toMatchObject({ status: 'skipped', reason: 'no_asin', result: null });
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toEqual({
status: 200,
body: { queued: 0, processing: null, finished: { applied: 1, review: 3, not_found: 1, failed: 1, skipped: 2 } },
});
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open', 'Quebec Mismatched', 'Sierra Locked']);
await expect(titlesIn(editor, 'no_chapters')).resolves.not.toContain('Papa Confident');
});
it('settles a review once the chapters are saved after it', async () => {
const [mismatched] = await ctx.db
.select({ bookId: schema.bookMetadata.bookId })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.title, 'Quebec Mismatched'));
const bookId = mismatched!.bookId;
const state = await request<BookChaptersState>(editor, 'GET', `/books/${bookId}/chapters`);
const saved = await request(editor, 'PUT', `/books/${bookId}/chapters`, {
baseRevision: state.body.revision,
chapters: [
{ title: 'Opening', startMs: 0 },
{ title: 'Second Half', startMs: 1_800_000 },
],
});
expect(saved.status).toBe(200);
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open', 'Sierra Locked']);
await expect(titlesIn(editor, 'edited')).resolves.toEqual(['Juliet Hand Edited', 'Quebec Mismatched', 'Sierra Locked']);
// The outcome stays queued for the person; only the category drops it.
expect((await queueRow(bookId))!.status).toBe('review');
});
it('clears finished outcomes but keeps the reviews still waiting', async () => {
await expect(request(outsider, 'DELETE', '/chapter-health/lookups/finished')).resolves.toEqual({ status: 200, body: { removed: 0 } });
await expect(request(editor, 'DELETE', '/chapter-health/lookups/finished')).resolves.toEqual({ status: 200, body: { removed: 6 } });
const left = await ctx.db.select({ status: schema.chapterLookupQueue.status }).from(schema.chapterLookupQueue);
expect(left.map((row) => row.status)).toEqual(['review', 'review']);
});
it('dismisses a waiting review only for a user who can reach the book', async () => {
await expect(request(outsider, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toMatchObject({ status: 404 });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.singleNone}`)).resolves.toMatchObject({ status: 404 });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toEqual({ status: 204, body: null });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toMatchObject({ status: 404 });
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Sierra Locked']);
const remaining = await ctx.db
.select({ bookId: schema.chapterLookupQueue.bookId })
.from(schema.chapterLookupQueue)
.where(inArray(schema.chapterLookupQueue.bookId, [ids.reviewOpen!, ids.reviewSettled!]));
expect(remaining).toEqual([]);
});
it('hides content-filtered books from health reads, queue status, enqueue, dismissal and execution', async () => {
const restricted = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
const isolated = await createLibraryWithFolder(ctx, { name: `health-filter-${randomUUID()}` });
await grantLibraryAccess(ctx, restricted.userId, isolated.libraryId, 'editor');
const hidden = await seedBook({ title: 'Hidden chapters', audibleId: 'B0HIDDEN', library: isolated });
const [tag] = await ctx.db
.insert(schema.tags)
.values({ name: `hidden-${randomUUID()}` })
.returning();
await ctx.db.insert(schema.bookTags).values({ bookId: hidden, tagId: tag.id });
await ctx.db.insert(schema.userContentFilterTags).values({ userId: restricted.userId, filterType: 'exclude', tagId: tag.id });
expect((await request<ChapterHealthSummary>(restricted, 'GET', '/chapter-health/summary')).body.counts.no_chapters).toBe(0);
expect((await request<ChapterHealthPage>(restricted, 'GET', '/chapter-health/books?category=no_chapters')).body.items).toEqual([]);
expect((await request<EnqueueChapterLookupResult>(restricted, 'POST', '/chapter-health/lookups', { bookIds: [hidden] })).body).toEqual({
queued: 0,
skipped: 1,
});
await ctx.db.insert(schema.chapterLookupQueue).values({ bookId: hidden, userId: restricted.userId, status: 'review', finishedAt: new Date() });
expect((await request<ChapterLookupQueueState>(restricted, 'GET', '/chapter-health/lookups/state')).body.finished.review).toBe(0);
expect((await request(restricted, 'DELETE', `/chapter-health/lookups/${hidden}`)).status).toBe(404);
expect(await ctx.app.get(ChapterEditorService).bulkLookup(hidden, restricted.userId)).toMatchObject({
status: 'skipped',
reason: 'access_changed',
});
});
it('does not reset a processing claim and ignores late finishes from an older claim', async () => {
const repo = ctx.app.get(ChapterHealthRepository);
const book = await seedBook({ title: 'Claim safety', audibleId: 'B0CLAIM' });
await repo.enqueue([book], editor.userId);
const first = await repo.claimNext();
expect(first?.bookId).toBe(book);
expect(await repo.enqueue([book], outsider.userId)).toBe(0);
await repo.requeueProcessing();
const second = await repo.claimNext();
expect(second?.claimToken).not.toBe(first?.claimToken);
await repo.finish(book, { status: 'applied', reason: null, result: null }, first!.claimToken);
const [waiting] = await ctx.db.select().from(schema.chapterLookupQueue).where(eq(schema.chapterLookupQueue.bookId, book));
expect(waiting.status).toBe('processing');
await repo.finish(book, { status: 'failed', reason: null, result: null }, second!.claimToken);
});
it('advances category batches past 5000 completed candidates in a large library', async () => {
const large = await createLibraryWithFolder(ctx, { name: `large-health-${randomUUID()}` });
const seededIds: number[] = [];
for (let offset = 0; offset < 5100; offset += 500) {
const count = Math.min(500, 5100 - offset);
const books = await ctx.db
.insert(schema.books)
.values(
Array.from({ length: count }, (_, index) => ({
libraryId: large.libraryId,
libraryFolderId: large.libraryFolderId,
folderPath: `${large.folderPath}/book-${offset + index}`,
status: 'present' as const,
})),
)
.returning({ id: schema.books.id });
seededIds.push(...books.map((book) => book.id));
await ctx.db
.insert(schema.bookMetadata)
.values(books.map((book) => ({ bookId: book.id, title: `Book ${book.id}`, audibleId: `B${String(book.id).padStart(9, '0')}` })));
await ctx.db.insert(schema.bookFiles).values(
books.map((book) => ({
bookId: book.id,
libraryFolderId: large.libraryFolderId,
absolutePath: `${large.folderPath}/book-${book.id}/audio.mp3`,
relPath: `book-${book.id}/audio.mp3`,
ino: BigInt(book.id),
sizeBytes: 1000,
format: 'mp3',
role: 'content' as const,
durationSeconds: 60,
})),
);
}
const payload = { category: 'no_chapters', libraryId: large.libraryId };
expect((await request<EnqueueChapterLookupResult>('admin', 'POST', '/chapter-health/lookups', payload)).body.queued).toBe(5000);
await ctx.db
.update(schema.chapterLookupQueue)
.set({ status: 'not_found', finishedAt: new Date() })
.where(inArray(schema.chapterLookupQueue.bookId, seededIds));
expect((await request<EnqueueChapterLookupResult>('admin', 'POST', '/chapter-health/lookups', payload)).body.queued).toBe(100);
const listing = await request<ChapterHealthPage>(
'admin',
'GET',
`/chapter-health/books?category=no_chapters&libraryId=${large.libraryId}&page=102`,
);
expect(listing.body.total).toBe(5100);
expect(listing.body.items).toHaveLength(50);
});
});