import { ScannerService } from '../src/modules/scanner/scanner.service'; import { eq } from 'drizzle-orm'; import { bookMetadata, bookAuthors, bookGenres } from '../src/db/schema'; import { randomUUID } from 'crypto'; import { readFile, rename } from 'fs/promises'; import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { MetadataProviderKey } from '@bookorbit/types'; import { MetadataPreferencesService } from '../src/modules/metadata-preferences/metadata-preferences.service'; import { buildMetadataLockCandidates } from './e2e/metadata-lock/metadata-lock-test-providers'; import { BLUE_PNG_BYTES, COVER_CUSTOM_FILE_PREFIX, GREEN_PNG_BYTES, RED_PNG_BYTES, authHeader, closeMetadataLockE2EContext, createLibraryWithFolder, createMetadataLockE2EContext, findCoverFilePath, locateBookFileByRelPath, readBookMutationState, triggerAndWaitForLibraryScan, uploadBookCover, waitForCondition, waitForScanCompletion, waitForMetadataFetchIdle, type MetadataLockE2EContext, } from './e2e/metadata-lock/metadata-lock-harness'; import { createCbzFixture, createEpubFixture, createEpubWithCoverFixture } from './e2e/metadata-lock/metadata-lock-fixture-builder'; describe('metadata-lock e2e', { timeout: 30_000 }, () => { let ctx: MetadataLockE2EContext; beforeAll(async () => { ctx = await createMetadataLockE2EContext(); // The stand-in Goodreads answers every field, the audiobook ones included, while the default // rules for those name only the audiobook sources that really carry them, and keep a measured // duration. These tests are about locks, so those rules say what the others do: overwrite // whatever is not locked, from the one source that answers. const preferences = ctx.app.get(MetadataPreferencesService); const global = await preferences.getGlobal(); for (const field of ['narrators', 'duration', 'abridged'] as const) { global.fields[field] = { enabled: true, providers: [MetadataProviderKey.GOODREADS], mergeStrategy: 'overwriteIfProvided' }; } await preferences.setGlobal(global); }, 120_000); afterAll(async () => { await closeMetadataLockE2EContext(ctx); }); it.each(['book_per_file', 'book_per_folder'] as const)( 'merges relative regex metadata and preserves unchanged and locked fields (%s)', async (mode) => { const library = await createLibraryWithFolder(ctx, { mode }); const fullScan = async () => { const { jobId } = await ctx.app.get(ScannerService).startScan(library.libraryId, 'manual', true); await waitForScanCompletion(ctx.db, jobId); }; const relPath = 'My.Series/08 - File.Name.epub'; await createEpubFixture(library.folderPath, relPath, { title: 'Embedded title' }); const config = { rules: [{ pattern: '^(?[^/]+)/', flags: '' }] }; const configure = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(ctx.adminToken), payload: { icon: 'BookOpen', regexMetadata: config, metadataPrecedence: ['regex', 'embedded', 'opfFile'] }, }); expect(configure.statusCode).toBe(200); expect(configure.json().regexMetadata).toEqual(config); const preview = await ctx.app.inject({ method: 'POST', url: '/api/v1/libraries/regex-metadata/preview', headers: authHeader(ctx.adminToken), payload: { libraryId: library.libraryId, config, relativePath: relPath }, }); expect(preview.statusCode).toBe(200); expect(preview.json()).toMatchObject({ inputPath: 'My.Series/08 - File.Name', metadata: { seriesName: 'My.Series' } }); await fullScan(); const located = await locateBookFileByRelPath(ctx, library.libraryId, relPath); const read = async () => (await ctx.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, located.bookId)))[0]; expect(await read()).toMatchObject({ title: 'Embedded title', seriesName: 'My.Series' }); const originalAuthors = await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, located.bookId)); const changed = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(ctx.adminToken), payload: { regexMetadata: { rules: [{ pattern: '^(?[^/]+)/(?[^ ]+) - (?.+)$', flags: '' }] } }, }); expect(changed.statusCode).toBe(200); await fullScan(); expect(await read()).toMatchObject({ title: 'Embedded title', seriesIndex: null }); expect((await patchLocks(located.bookId, ['title'])).statusCode).toBe(200); await pause(1100); await createEpubFixture(library.folderPath, relPath, { title: 'Changed embedded title with a different size' }); await fullScan(); expect(await read()).toMatchObject({ title: 'Embedded title', seriesName: 'My.Series', seriesIndex: '08' }); expect(await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, located.bookId))).toEqual(originalAuthors); }, ); it.each(['book_per_file', 'book_per_folder'] as const)('preserves unlocked relations on a partial regex rescan (%s)', async (mode) => { const library = await createLibraryWithFolder(ctx, { mode }); const fullScan = async () => { const { jobId } = await ctx.app.get(ScannerService).startScan(library.libraryId, 'manual', true); await waitForScanCompletion(ctx.db, jobId); }; const relPath = 'Path Series/book.epub'; await createEpubWithCoverFixture(library.folderPath, relPath, { title: 'Original title' }); await fullScan(); const { bookId } = await locateBookFileByRelPath(ctx, library.libraryId, relPath); expect((await patchMetadata(bookId, { authors: ['Existing Author'], genres: ['Existing Genre'] })).statusCode).toBe(200); expect((await patchLocks(bookId, [])).statusCode).toBe(200); const originalAuthors = await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, bookId)); const originalGenres = await ctx.db.select().from(bookGenres).where(eq(bookGenres.bookId, bookId)); expect(originalAuthors).toHaveLength(1); expect(originalGenres).toHaveLength(1); const configure = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(ctx.adminToken), payload: { icon: 'BookOpen', regexMetadata: { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] }, metadataPrecedence: ['regex', 'embedded', 'opfFile'], }, }); expect(configure.statusCode).toBe(200); await pause(1100); await createEpubWithCoverFixture(library.folderPath, relPath, { title: 'Changed embedded title with a different size' }); await fullScan(); expect((await locateBookFileByRelPath(ctx, library.libraryId, relPath)).bookId).toBe(bookId); const [metadata] = await ctx.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)); expect(metadata).toMatchObject({ title: 'Changed embedded title with a different size', seriesName: 'Path Series', lockedFields: [] }); expect(await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, bookId))).toEqual(originalAuthors); expect(await ctx.db.select().from(bookGenres).where(eq(bookGenres.bookId, bookId))).toEqual(originalGenres); }); it('normalizes and persists lock updates and rejects duplicate lock fields', async () => { const scanned = await scanSingleEpub('lock-contract', { title: 'Lock Contract Base' }); const success = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/books/${scanned.bookId}/metadata-locks`, headers: authHeader(ctx.adminToken), payload: { lockedFields: ['cover', 'authors', 'title'] }, }); expect(success.statusCode).toBe(200); expect(success.json()).toMatchObject({ lockedFields: ['title', 'authors', 'cover'], }); const stateAfterSuccess = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterSuccess.metadata?.lockedFields).toEqual(['title', 'authors', 'cover']); const duplicate = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/books/${scanned.bookId}/metadata-locks`, headers: authHeader(ctx.adminToken), payload: { lockedFields: ['title', 'cover', 'title'] }, }); expect(duplicate.statusCode).toBe(400); const stateAfterFailure = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterFailure.metadata?.lockedFields).toEqual(['title', 'authors', 'cover']); }); it('unlocks all fields when an empty array is patched', async () => { const scanned = await scanSingleEpub('unlock-all', { title: 'Unlock All Base' }); const lock = await patchLocks(scanned.bookId, ['title', 'cover']); expect(lock.statusCode).toBe(200); const stateAfterLock = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterLock.metadata?.lockedFields).toEqual(['title', 'cover']); const unlock = await patchLocks(scanned.bookId, []); expect(unlock.statusCode).toBe(200); expect(unlock.json()).toMatchObject({ lockedFields: [] }); const stateAfterUnlock = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterUnlock.metadata?.lockedFields).toEqual([]); }); it('rejects manual metadata writes atomically when any targeted field is locked', async () => { const scanned = await scanSingleEpub('manual-atomic', { title: 'Manual Atomic Base' }); const seed = await patchMetadata(scanned.bookId, { title: 'Manual Atomic Base', publisher: 'Seed Publisher', }); expect(seed.statusCode).toBe(200); const lock = await patchLocks(scanned.bookId, ['title']); expect(lock.statusCode).toBe(200); const blocked = await patchMetadata(scanned.bookId, { title: 'Blocked Manual Title', publisher: 'Blocked Publisher', }); expect(blocked.statusCode).toBe(409); const stateAfterBlocked = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterBlocked.metadata).toMatchObject({ title: 'Manual Atomic Base', publisher: 'Seed Publisher', }); const allowed = await patchMetadata(scanned.bookId, { publisher: 'Allowed Publisher' }); expect(allowed.statusCode).toBe(200); const stateAfterAllowed = await readBookMutationState(ctx, scanned.bookId); expect(stateAfterAllowed.metadata).toMatchObject({ title: 'Manual Atomic Base', publisher: 'Allowed Publisher', lockedFields: ['title'], }); }); it('returns preview metadata without mutating the book or downloading covers', async () => { const scanned = await scanSingleEpub('refresh-preview', { title: 'Preview Base' }); const seed = await patchMetadata(scanned.bookId, { title: 'Preview Base' }); expect(seed.statusCode).toBe(200); const expected = buildMetadataLockCandidates('Preview Base', ctx.coverServer.urlFor); const before = await readBookMutationState(ctx, scanned.bookId); const response = await ctx.app.inject({ method: 'POST', url: `/api/v1/books/${scanned.bookId}/refresh-metadata?preview=true`, headers: authHeader(ctx.adminToken), }); expect(response.statusCode).toBe(201); expect(response.json()).toMatchObject({ metadata: { subtitle: expected.goodreads.subtitle, description: expected.goodreads.description, publisher: expected.goodreads.publisher, language: expected.goodreads.language, pageCount: expected.goodreads.pageCount, isbn10: expected.goodreads.isbn10, isbn13: expected.goodreads.isbn13, goodreadsId: expected.goodreads.providerId, openLibraryId: expected.openLibrary.providerId, coverUrl: expected.goodreads.coverUrl, audioMetadata: { narrators: expected.goodreads.narrators, durationSeconds: expected.goodreads.durationSeconds, abridged: expected.goodreads.abridged, chapters: expected.goodreads.chapters, }, comicMetadata: { issueNumber: expected.goodreads.comicMetadata?.issueNumber, volumeName: expected.goodreads.comicMetadata?.volumeName, storyArcs: expected.goodreads.comicMetadata?.storyArcs, }, }, diagnostics: { reason: null, activeProviders: ['goodreads', 'openLibrary'], candidateProviders: ['goodreads', 'openLibrary'], candidateCount: 2, }, }); const after = await readBookMutationState(ctx, scanned.bookId); expect(after).toEqual(before); await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull(); }); it('refreshes unlocked fields while preserving locked fields from the API through the database', async () => { const scanned = await scanSingleEpub('refresh-persist', { title: 'Refresh Base' }); const expected = buildMetadataLockCandidates('Refresh Base', ctx.coverServer.urlFor); const seed = await patchMetadata(scanned.bookId, { title: 'Refresh Base', subtitle: 'Locked subtitle', description: 'Seed description', publisher: 'Seed publisher', language: 'en', pageCount: 111, audioMetadata: { narrators: ['Locked Narrator'], durationSeconds: 1111, abridged: false, chapters: [{ title: 'Seed Chapter', startMs: 0, durationMs: 60_000 }], }, comicMetadata: { issueNumber: 'LOCK-1', volumeName: 'Seed Volume', storyArcs: ['Seed Arc'], }, }); expect(seed.statusCode).toBe(200); const lock = await patchLocks(scanned.bookId, ['subtitle', 'narrators', 'comicIssueNumber', 'cover', 'openLibraryId', 'isbn13']); expect(lock.statusCode).toBe(200); const refresh = await ctx.app.inject({ method: 'POST', url: `/api/v1/books/${scanned.bookId}/refresh-metadata`, headers: authHeader(ctx.adminToken), }); expect(refresh.statusCode).toBe(201); const state = await readBookMutationState(ctx, scanned.bookId); expect(state.metadata).toMatchObject({ subtitle: 'Locked subtitle', description: expected.goodreads.description, publisher: expected.goodreads.publisher, language: expected.goodreads.language, pageCount: expected.goodreads.pageCount, isbn10: expected.goodreads.isbn10, isbn13: null, goodreadsId: expected.goodreads.providerId, openLibraryId: null, durationSeconds: expected.goodreads.durationSeconds, abridged: expected.goodreads.abridged, chapters: expected.goodreads.chapters, coverSource: null, }); expect(state.narrators).toEqual(['Locked Narrator']); expect(state.comicMetadata).toEqual({ issueNumber: 'LOCK-1', volumeName: expected.goodreads.comicMetadata?.volumeName ?? null, storyArcs: [...(expected.goodreads.comicMetadata?.storyArcs ?? [])].sort(), }); expect(state.metadata?.lastMetadataFetchAt).not.toBeNull(); await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull(); }); it('keeps locked fields untouched during bulk metadata fetch while persisting unlocked fields', async () => { const scanned = await scanSingleEpub('bulk-fetch', { title: 'Bulk Base' }); const expected = buildMetadataLockCandidates('Bulk Base', ctx.coverServer.urlFor); const seed = await patchMetadata(scanned.bookId, { title: 'Bulk Base', description: 'Locked bulk description', publisher: 'Seed bulk publisher', audioMetadata: { narrators: ['Locked Bulk Narrator'], durationSeconds: 777, }, comicMetadata: { volumeName: 'Seed Bulk Volume', }, }); expect(seed.statusCode).toBe(200); const lock = await patchLocks(scanned.bookId, ['description', 'cover', 'openLibraryId', 'narrators']); expect(lock.statusCode).toBe(200); const config = await ctx.app.inject({ method: 'PUT', url: '/api/v1/book-metadata-fetch/config', headers: authHeader(ctx.adminToken), payload: { enabled: true, triggerOnImport: false, conditions: { scoreThreshold: { enabled: false, threshold: 60 }, missingFields: { enabled: false, fields: [] }, neverFetched: { enabled: true }, }, }, }); expect(config.statusCode).toBe(200); const run = await ctx.app.inject({ method: 'POST', url: `/api/v1/book-metadata-fetch/run/${scanned.libraryId}`, headers: authHeader(ctx.adminToken), }); expect(run.statusCode).toBe(201); expect(run.json()).toMatchObject({ queued: 1 }); await waitForMetadataFetchIdle(ctx); const state = await readBookMutationState(ctx, scanned.bookId); expect(state.metadata).toMatchObject({ description: 'Locked bulk description', publisher: expected.goodreads.publisher, language: expected.goodreads.language, pageCount: expected.goodreads.pageCount, goodreadsId: expected.goodreads.providerId, openLibraryId: null, durationSeconds: expected.goodreads.durationSeconds, coverSource: null, }); expect(state.narrators).toEqual(['Locked Bulk Narrator']); expect(state.comicMetadata).toEqual({ issueNumber: expected.goodreads.comicMetadata?.issueNumber ?? null, volumeName: expected.goodreads.comicMetadata?.volumeName ?? null, storyArcs: [...(expected.goodreads.comicMetadata?.storyArcs ?? [])].sort(), }); expect(state.metadata?.lastMetadataFetchAt).not.toBeNull(); await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull(); }); it('blocks locked custom cover mutations after a successful unlocked upload', async () => { const scanned = await scanSingleEpub('custom-cover', { title: 'Custom Cover Base' }); const upload = await uploadBookCover(ctx, scanned.bookId, RED_PNG_BYTES); expect(upload.statusCode).toBe(204); const customPath = await findCoverFilePath(ctx, scanned.bookId, COVER_CUSTOM_FILE_PREFIX); expect(customPath).not.toBeNull(); await expect(readFile(customPath!)).resolves.toEqual(RED_PNG_BYTES); const lock = await patchLocks(scanned.bookId, ['cover']); expect(lock.statusCode).toBe(200); const uploadBlocked = await uploadBookCover(ctx, scanned.bookId, BLUE_PNG_BYTES, 'blocked-cover.png'); expect(uploadBlocked.statusCode).toBe(409); const deleteBlocked = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/books/${scanned.bookId}/cover`, headers: authHeader(ctx.adminToken), }); expect(deleteBlocked.statusCode).toBe(409); const state = await readBookMutationState(ctx, scanned.bookId); expect(state.metadata).toMatchObject({ coverSource: 'custom', lockedFields: ['cover'], }); await expect(readFile(customPath!)).resolves.toEqual(RED_PNG_BYTES); }); it('preserves locked scanner metadata while still updating unlocked extracted fields on rescan', async () => { const cbzRelPath = uniqueRelPath('scanner-rescan', 'book.cbz'); const epubRelPath = cbzRelPath.replace(/\.cbz$/, '.epub'); const library = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', allowedFormats: ['cbz', 'epub'], name: `metadata-lock-scanner-${randomUUID()}`, }); await createCbzFixture(library.folderPath, cbzRelPath, { title: 'Scanner Original Title', publisher: 'Original Publisher', }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const initial = await locateBookFileByRelPath(ctx, library.libraryId, cbzRelPath); const seed = await patchMetadata(initial.bookId, { title: 'Scanner Locked Title', publisher: 'Original Publisher', }); expect(seed.statusCode).toBe(200); const lock = await patchLocks(initial.bookId, ['title']); expect(lock.statusCode).toBe(200); // Filesystem timestamp resolution is 1s on many OSes; wait so the new file gets a newer mtime. await pause(1_100); await createEpubWithCoverFixture(library.folderPath, epubRelPath, { title: 'Scanner EPUB Winner', coverBytes: BLUE_PNG_BYTES, }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const rescanned = await locateBookFileByRelPath(ctx, library.libraryId, epubRelPath); expect(rescanned.bookId).toBe(initial.bookId); const state = await readBookMutationState(ctx, rescanned.bookId); expect(state.metadata).toMatchObject({ title: 'Scanner Locked Title', coverSource: 'extracted', lockedFields: ['title'], }); const extractedCoverPath = await findCoverFilePath(ctx, rescanned.bookId); expect(extractedCoverPath).not.toBeNull(); await expect(readFile(extractedCoverPath!)).resolves.toEqual(BLUE_PNG_BYTES); }, 60_000); it('imports ComicInfo Genre and Tags as separate genres and tags on scan', async () => { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file', allowedFormats: ['cbz'], name: `metadata-lock-comic-tags-${randomUUID()}`, }); const folder = `comic-tags-${randomUUID()}`; await createCbzFixture(library.folderPath, `${folder}/tagged.cbz`, { title: 'Tagged Comic', genre: 'Science Fiction', tags: 'Foo, Bar' }); await createCbzFixture(library.folderPath, `${folder}/tags-only.cbz`, { title: 'Tags Only Comic', tags: 'Ninja, School Life' }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const tagged = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tagged.cbz`); const tagsOnly = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tags-only.cbz`); await expect(readBookMutationState(ctx, tagged.bookId)).resolves.toMatchObject({ genres: ['Science Fiction'], tags: ['Bar', 'Foo'] }); await expect(readBookMutationState(ctx, tagsOnly.bookId)).resolves.toMatchObject({ genres: [], tags: ['Ninja', 'School Life'] }); }); it('replaces comic genres and tags from a re-tagged file on rescan, except locked tags', async () => { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file', allowedFormats: ['cbz'], name: `metadata-lock-comic-retag-${randomUUID()}`, }); const folder = `comic-retag-${randomUUID()}`; const lockedRelPath = `${folder}/locked.cbz`; const unlockedRelPath = `${folder}/unlocked.cbz`; await createCbzFixture(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' }); await createCbzFixture(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const locked = await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath); const unlocked = await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath); expect((await patchLocks(locked.bookId, ['tags'])).statusCode).toBe(200); // Filesystem timestamp resolution is 1s on many OSes; wait so the re-tagged files get a newer mtime. await pause(1_100); await retagComic(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', tags: 'Found Family' }); await retagComic(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', tags: 'Found Family' }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); expect((await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath)).bookId).toBe(locked.bookId); expect((await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath)).bookId).toBe(unlocked.bookId); await expect(readBookMutationState(ctx, locked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Time Travel'] }); await expect(readBookMutationState(ctx, unlocked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Found Family'] }); }, 60_000); it('refreshes extracted covers only for unlocked books during scanner cover refresh', async () => { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file', allowedFormats: ['epub'], name: `metadata-lock-cover-refresh-${randomUUID()}`, }); const unlockedRelPath = uniqueRelPath('cover-refresh', 'unlocked.epub'); const lockedRelPath = uniqueRelPath('cover-refresh', 'locked.epub'); await createEpubWithCoverFixture(library.folderPath, unlockedRelPath, { title: 'Unlocked Cover Book', coverBytes: RED_PNG_BYTES, }); await createEpubWithCoverFixture(library.folderPath, lockedRelPath, { title: 'Locked Cover Book', coverBytes: GREEN_PNG_BYTES, }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const unlocked = await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath); const locked = await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath); const unlockedCoverBefore = await findCoverFilePath(ctx, unlocked.bookId); const lockedCoverBefore = await findCoverFilePath(ctx, locked.bookId); await expect(readFile(unlockedCoverBefore!)).resolves.toEqual(RED_PNG_BYTES); await expect(readFile(lockedCoverBefore!)).resolves.toEqual(GREEN_PNG_BYTES); const lockResponse = await patchLocks(locked.bookId, ['cover']); expect(lockResponse.statusCode).toBe(200); await createEpubWithCoverFixture(library.folderPath, unlockedRelPath, { title: 'Unlocked Cover Book', coverBytes: BLUE_PNG_BYTES, }); await createEpubWithCoverFixture(library.folderPath, lockedRelPath, { title: 'Locked Cover Book', coverBytes: RED_PNG_BYTES, }); const refresh = await ctx.app.inject({ method: 'POST', url: `/api/v1/scanner/libraries/${library.libraryId}/refresh-covers`, headers: authHeader(ctx.adminToken), }); expect(refresh.statusCode).toBe(202); expect(refresh.json()).toMatchObject({ queued: 2 }); await waitForCondition(async () => { const path = await findCoverFilePath(ctx, unlocked.bookId); if (!path) throw new Error('Unlocked extracted cover path missing'); const bytes = await readFile(path); expect(bytes).toEqual(BLUE_PNG_BYTES); }, 15_000); await waitForCondition(async () => { const path = await findCoverFilePath(ctx, locked.bookId); if (!path) throw new Error('Locked extracted cover path missing'); const bytes = await readFile(path); expect(bytes).toEqual(GREEN_PNG_BYTES); }, 15_000); }); async function patchLocks(bookId: number, lockedFields: string[]) { return ctx.app.inject({ method: 'PATCH', url: `/api/v1/books/${bookId}/metadata-locks`, headers: authHeader(ctx.adminToken), payload: { lockedFields }, }); } async function patchMetadata(bookId: number, payload: Record<string, unknown>) { return ctx.app.inject({ method: 'PATCH', url: `/api/v1/books/${bookId}/metadata`, headers: authHeader(ctx.adminToken), payload, }); } async function scanSingleEpub(prefix: string, input: { title: string; language?: string }) { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file', allowedFormats: ['epub'], name: `metadata-lock-${prefix}-${randomUUID()}`, }); const relPath = uniqueRelPath(prefix, 'book.epub'); await createEpubFixture(library.folderPath, relPath, { title: input.title, language: input.language ?? 'en', }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const located = await locateBookFileByRelPath(ctx, library.libraryId, relPath); return { ...library, relPath, bookId: located.bookId, absolutePath: located.absolutePath, }; } // Taggers write a new archive and rename it over the old one. The rename moves the folder's mtime, // which is what makes an incremental scan look inside the folder again. async function retagComic(folderPath: string, relPath: string, input: Parameters<typeof createCbzFixture>[2]) { const stagingRelPath = `${relPath}.retag`; await createCbzFixture(folderPath, stagingRelPath, input); await rename(join(folderPath, stagingRelPath), join(folderPath, relPath)); } function uniqueRelPath(prefix: string, fileName: string): string { return `${prefix}-${randomUUID()}/${fileName}`; } function pause(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } });