import { mkdir, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { eq } from 'drizzle-orm'; import { Permission, type LibraryFileSyncProgressEvent } from '@projectx/types'; import * as schema from '../src/db/schema'; import { BookService, type MetadataUpdateFailpoint } from '../src/modules/book/book.service'; import { FileWriteService } from '../src/modules/file-write/file-write.service'; import { extractCb7Metadata, extractCbzMetadata } from '../src/modules/metadata/lib/cbz-metadata'; import { extractEpubMetadata } from '../src/modules/metadata/lib/epub'; import { parsePdfFile } from '../src/modules/metadata/lib/pdf-parser'; import { authHeader, closeMetadataWriteE2EContext, createLibraryWithFolder, createMetadataWriteE2EContext, createUserAndLogin, getLatestWriteLogEntry, grantLibraryAccess, locateBookFileByRelPath, setFileWriteSettings, triggerAndWaitForLibraryScan, waitForWriteLogEntry, type LocatedBookFile, type MetadataWriteE2EContext, } from './e2e/metadata-write/metadata-write-harness'; import { createCb7Fixture, createCbzFixture, createEpubFixture, createPdfFixture, writeFixtureFile, } from './e2e/metadata-write/metadata-write-fixture-builder'; type SupportedFormat = 'pdf' | 'epub' | 'cbz' | 'cb7'; const SCENARIO_TIMEOUT_MS = 120_000; interface ScenarioRunResult { id: string; status: 'passed' | 'failed'; durationMs: number; error?: string; } interface PreparedLibraryResult { libraryId: number; books: Record; } interface ExpectedWritePayload { title: string; subtitle: string; description: string; publisher: string; publishedYear: number; language: string; pageCount: number; seriesName: string; seriesIndex: number; isbn10: string; isbn13: string; rating: number; authors: string[]; genres: string[]; tags: string[]; googleBooksId: string; goodreadsId: string; amazonId: string; hardcoverId: string; openLibraryId: string; itunesId: string; } const MATRIX_SET_PAYLOAD = { title: 'Matrix Title', subtitle: 'Matrix Subtitle', description: 'Matrix Description', publisher: 'Matrix Publisher', publishedYear: 2007, language: 'en', pageCount: 321, seriesName: 'Matrix Series', seriesIndex: 3.5, isbn10: '123456789X', isbn13: '9781234567890', rating: 5, authors: ['Matrix Author A', 'Matrix Author B'], genres: ['Matrix Genre A', 'Matrix Genre B'], tags: ['MatrixTagA', 'MatrixTagB'], googleBooksId: 'gbook-123', goodreadsId: 'goodreads-123', amazonId: 'B00TEST123', hardcoverId: 'hardcover-123', openLibraryId: 'OL123W', itunesId: 'itunes-123', } as const; const MATRIX_CLEAR_PAYLOAD = { title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, authors: [], genres: [], tags: [], googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, } as const; const METADATA_UPDATE_FAILPOINT_SEQUENCE: MetadataUpdateFailpoint[] = [ 'afterScalarUpdate', 'afterComicMetadataUpsert', 'afterAuthorsReplace', 'afterNarratorsReplace', 'afterGenresReplace', 'afterTagsReplace', 'beforeTransactionCommit', ]; const ATOMICITY_BASELINE_PAYLOAD = { title: 'Atomic Baseline Title', subtitle: 'Atomic Baseline Subtitle', description: 'Atomic Baseline Description', publisher: 'Atomic Baseline Publisher', publishedYear: 2001, language: 'en', pageCount: 111, seriesName: 'Atomic Baseline Series', seriesIndex: 1.5, isbn10: '1111111111', isbn13: '9781111111111', rating: 4, authors: ['Atomic Baseline Author A', 'Atomic Baseline Author B'], genres: ['Atomic Baseline Genre'], tags: ['AtomicBaselineTag'], googleBooksId: 'atomic-baseline-google', goodreadsId: 'atomic-baseline-goodreads', amazonId: 'ATOMICBASE01', hardcoverId: 'atomic-baseline-hardcover', openLibraryId: 'ATOMICBASE-OL', itunesId: 'atomic-baseline-itunes', audibleId: 'ATOMICBASEAUD', comicvineId: 'atomic-baseline-comicvine', audioMetadata: { narrators: ['Atomic Baseline Narrator'], durationSeconds: 4200, abridged: true, chapters: [{ title: 'Atomic Baseline Chapter', startMs: 0 }], }, comicMetadata: { issueNumber: '1', volumeName: 'Atomic Baseline Volume', pencillers: ['Atomic Baseline Penciller'], inkers: ['Atomic Baseline Inker'], colorists: ['Atomic Baseline Colorist'], letterers: ['Atomic Baseline Letterer'], coverArtists: ['Atomic Baseline CoverArtist'], characters: ['Atomic Baseline Character'], teams: ['Atomic Baseline Team'], locations: ['Atomic Baseline Location'], storyArcs: ['Atomic Baseline StoryArc'], }, } as const; async function writeScenarioReport(results: ScenarioRunResult[]): Promise { const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); await mkdir(reportDir, { recursive: true }); const reportPath = join(reportDir, 'metadata-write-e2e-scenarios.json'); await writeFile( reportPath, JSON.stringify( { generatedAt: new Date().toISOString(), total: results.length, passed: results.filter((result) => result.status === 'passed').length, failed: results.filter((result) => result.status === 'failed').length, results, }, null, 2, ), ); } describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { let context!: MetadataWriteE2EContext; const scenarioResults: ScenarioRunResult[] = []; let scenarioStartedAt = 0; beforeAll(async () => { context = await createMetadataWriteE2EContext(); }); afterEach(async (taskContext) => { if (context) { context.app.get(BookService).clearMetadataUpdateFailpointForTests(); await drainScheduledFileWrites(context); } const result = taskContext.task.result; if (!result) return; const state = result.state === 'pass' ? 'passed' : 'failed'; const error = result.errors?.[0]?.message; scenarioResults.push({ id: taskContext.task.name, status: state, durationMs: Math.max(0, Date.now() - scenarioStartedAt), ...(error ? { error } : {}), }); }); afterAll(async () => { await writeScenarioReport(scenarioResults); if (context) { await closeMetadataWriteE2EContext(context); } }); beforeEach(async () => { scenarioStartedAt = Date.now(); await setFileWriteSettings(context.db, { enabled: false, writeCover: false, epub: { enabled: true }, pdf: { enabled: true }, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); }); describe('database metadata writes', () => { it('persists scalar and relational metadata through PATCH /books/:id/metadata', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'db-write/book.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'db-write/book.pdf'); const response = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: 'DB Write Title', subtitle: 'DB Write Subtitle', description: 'DB Write Description', publisher: 'DB Write Publisher', publishedYear: 2005, language: 'en', seriesName: 'DB Series', seriesIndex: 2, isbn13: '9781234567890', rating: 4, authors: ['DB Author A', 'DB Author B'], genres: ['DB Genre'], tags: ['DB Tag'], }, }); expect(response.statusCode).toBe(200); const [metadata] = await context.db .select({ title: schema.bookMetadata.title, subtitle: schema.bookMetadata.subtitle, description: schema.bookMetadata.description, publisher: schema.bookMetadata.publisher, publishedYear: schema.bookMetadata.publishedYear, language: schema.bookMetadata.language, seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, isbn13: schema.bookMetadata.isbn13, rating: schema.bookMetadata.rating, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata).toMatchObject({ title: 'DB Write Title', subtitle: 'DB Write Subtitle', description: 'DB Write Description', publisher: 'DB Write Publisher', publishedYear: 2005, language: 'en', seriesName: 'DB Series', seriesIndex: 2, isbn13: '9781234567890', rating: 4, }); const [authorRows, genreRows, tagRows] = await Promise.all([ context.db .select({ name: schema.authors.name }) .from(schema.bookAuthors) .innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId)) .where(eq(schema.bookAuthors.bookId, book.bookId)) .orderBy(schema.bookAuthors.displayOrder), context.db .select({ name: schema.genres.name }) .from(schema.bookGenres) .innerJoin(schema.genres, eq(schema.genres.id, schema.bookGenres.genreId)) .where(eq(schema.bookGenres.bookId, book.bookId)), context.db .select({ name: schema.tags.name }) .from(schema.bookTags) .innerJoin(schema.tags, eq(schema.tags.id, schema.bookTags.tagId)) .where(eq(schema.bookTags.bookId, book.bookId)), ]); expect(authorRows.map((row) => row.name)).toEqual(['DB Author A', 'DB Author B']); expect(genreRows.map((row) => row.name)).toEqual(['DB Genre']); expect(tagRows.map((row) => row.name)).toEqual(['DB Tag']); }); it('applies clear semantics for scalar and relational metadata through PATCH /books/:id/metadata', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'db-clear/book.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'db-clear/book.pdf'); const setResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_SET_PAYLOAD, }); expect(setResponse.statusCode).toBe(200); const clearResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_CLEAR_PAYLOAD, }); expect(clearResponse.statusCode).toBe(200); const [metadata] = await context.db .select({ title: schema.bookMetadata.title, subtitle: schema.bookMetadata.subtitle, description: schema.bookMetadata.description, publisher: schema.bookMetadata.publisher, publishedYear: schema.bookMetadata.publishedYear, language: schema.bookMetadata.language, pageCount: schema.bookMetadata.pageCount, seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, isbn10: schema.bookMetadata.isbn10, isbn13: schema.bookMetadata.isbn13, rating: schema.bookMetadata.rating, googleBooksId: schema.bookMetadata.googleBooksId, goodreadsId: schema.bookMetadata.goodreadsId, amazonId: schema.bookMetadata.amazonId, hardcoverId: schema.bookMetadata.hardcoverId, openLibraryId: schema.bookMetadata.openLibraryId, itunesId: schema.bookMetadata.itunesId, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); const [authorRows, genreRows, tagRows] = await Promise.all([ context.db.select({ bookId: schema.bookAuthors.bookId }).from(schema.bookAuthors).where(eq(schema.bookAuthors.bookId, book.bookId)), context.db.select({ bookId: schema.bookGenres.bookId }).from(schema.bookGenres).where(eq(schema.bookGenres.bookId, book.bookId)), context.db.select({ bookId: schema.bookTags.bookId }).from(schema.bookTags).where(eq(schema.bookTags.bookId, book.bookId)), ]); expect(authorRows).toHaveLength(0); expect(genreRows).toHaveLength(0); expect(tagRows).toHaveLength(0); }); }); describe('atomicity failpoints', () => { it('rolls back metadata mutations for each failpoint stage', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'atomicity/book.pdf', 'Atomicity Seed PDF'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'atomicity/book.pdf'); await setFileWriteSettings(context.db, { enabled: false }); const baselineResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: ATOMICITY_BASELINE_PAYLOAD, }); expect(baselineResponse.statusCode).toBe(200); const baselineState = await readMetadataMutationState(context, book.bookId); const bookService = context.app.get(BookService); for (const failpoint of METADATA_UPDATE_FAILPOINT_SEQUENCE) { bookService.setMetadataUpdateFailpointForTests(failpoint); const failureResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: buildAtomicityFailurePayload(failpoint), }); expect(failureResponse.statusCode).toBe(500); const afterFailureState = await readMetadataMutationState(context, book.bookId); expect(afterFailureState).toEqual(baselineState); } }); }); describe('auto-write on metadata patch', () => { it('writes PDF metadata to disk and records auto write-log entry', async () => { await setFileWriteSettings(context.db, { enabled: true }); const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'auto-write/book.pdf', 'Auto Seed PDF'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'auto-write/book.pdf'); const response = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: 'Auto PDF Title', authors: ['Auto PDF Author'], description: 'Auto PDF Description', publisher: 'Auto PDF Publisher', tags: ['AutoTag'], }, }); expect(response.statusCode).toBe(200); const logEntry = await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'auto', status: 'success', }); expect(logEntry.format).toBe('pdf'); const parsedPdf = await parsePdfFile(book.absolutePath, { extractCover: false }); expect(parsedPdf?.title).toBe('Auto PDF Title'); expect(parsedPdf?.authors.map((author) => author.name)).toContain('Auto PDF Author'); expect(parsedPdf?.description).toBe('Auto PDF Description'); const [metadata] = await context.db .select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata?.lastWrittenAt).toBeInstanceOf(Date); }); it('writes EPUB metadata to disk and records auto write-log entry', async () => { await setFileWriteSettings(context.db, { enabled: true }); const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createEpubFixture(library.folderPath, 'auto-write/book.epub', { title: 'Auto Seed EPUB' }); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'auto-write/book.epub'); const response = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: 'Auto EPUB Title', authors: ['Auto EPUB Author'], language: 'fr', publishedYear: 2012, genres: ['Sci-Fi'], tags: ['AutoEpubTag'], }, }); expect(response.statusCode).toBe(200); const logEntry = await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'auto', status: 'success', }); expect(logEntry.format).toBe('epub'); const parsedEpub = await extractEpubMetadata(book.absolutePath); expect(parsedEpub?.title).toBe('Auto EPUB Title'); expect(parsedEpub?.language).toBe('fr'); expect(parsedEpub?.publishedYear).toBe(2012); expect(parsedEpub?.authors.map((author) => author.name)).toContain('Auto EPUB Author'); const [metadata] = await context.db .select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata?.lastWrittenAt).toBeInstanceOf(Date); }); }); describe('library sync write flow', () => { it('writes metadata to PDF/EPUB/CBZ/CB7 and emits SSE summary', async () => { const prepared = await prepareLibraryWithAllFormats(context); await setFileWriteSettings(context.db, { enabled: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_SET_PAYLOAD, }); expect(patchResponse.statusCode).toBe(200); } await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true, writeCover: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); const syncResponse = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${prepared.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }); expect(syncResponse.statusCode).toBe(200); const events = parseSseEvents(syncResponse.body); const doneEvent = events.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 }); expect(events.filter((event) => !('done' in event))).toHaveLength(4); for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const logEntry = await getLatestWriteLogEntry(context.db, book.bookId, 'sync'); expect(logEntry?.status).toBe('success'); if (format === 'cbz' || format === 'cb7') { expect(logEntry?.fieldsWritten ?? []).not.toContain('itunesId'); } else { expect(logEntry?.fieldsWritten ?? []).toContain('itunesId'); } } const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath, { extractCover: false }); expect(parsedPdf).toMatchObject({ title: MATRIX_SET_PAYLOAD.title, subtitle: MATRIX_SET_PAYLOAD.subtitle, description: MATRIX_SET_PAYLOAD.description, publisher: MATRIX_SET_PAYLOAD.publisher, publishedYear: MATRIX_SET_PAYLOAD.publishedYear, language: MATRIX_SET_PAYLOAD.language, pageCount: MATRIX_SET_PAYLOAD.pageCount, seriesName: MATRIX_SET_PAYLOAD.seriesName, seriesIndex: MATRIX_SET_PAYLOAD.seriesIndex, isbn10: MATRIX_SET_PAYLOAD.isbn10, isbn13: MATRIX_SET_PAYLOAD.isbn13, rating: MATRIX_SET_PAYLOAD.rating, googleBooksId: MATRIX_SET_PAYLOAD.googleBooksId, goodreadsId: MATRIX_SET_PAYLOAD.goodreadsId, amazonId: MATRIX_SET_PAYLOAD.amazonId, hardcoverId: MATRIX_SET_PAYLOAD.hardcoverId, openLibraryId: MATRIX_SET_PAYLOAD.openLibraryId, itunesId: MATRIX_SET_PAYLOAD.itunesId, }); expect(parsedPdf?.authors.map((author) => author.name)).toEqual(MATRIX_SET_PAYLOAD.authors); expect(parsedPdf?.genres).toEqual(MATRIX_SET_PAYLOAD.genres); expect(parsedPdf?.tags).toEqual(MATRIX_SET_PAYLOAD.tags); const parsedEpub = await extractEpubMetadata(prepared.books.epub.absolutePath); expect(parsedEpub).toMatchObject({ title: MATRIX_SET_PAYLOAD.title, subtitle: MATRIX_SET_PAYLOAD.subtitle, description: MATRIX_SET_PAYLOAD.description, publisher: MATRIX_SET_PAYLOAD.publisher, publishedYear: MATRIX_SET_PAYLOAD.publishedYear, language: MATRIX_SET_PAYLOAD.language, pageCount: MATRIX_SET_PAYLOAD.pageCount, seriesName: MATRIX_SET_PAYLOAD.seriesName, seriesIndex: MATRIX_SET_PAYLOAD.seriesIndex, isbn10: MATRIX_SET_PAYLOAD.isbn10, isbn13: MATRIX_SET_PAYLOAD.isbn13, rating: MATRIX_SET_PAYLOAD.rating, googleBooksId: MATRIX_SET_PAYLOAD.googleBooksId, goodreadsId: MATRIX_SET_PAYLOAD.goodreadsId, amazonId: MATRIX_SET_PAYLOAD.amazonId, hardcoverId: MATRIX_SET_PAYLOAD.hardcoverId, openLibraryId: MATRIX_SET_PAYLOAD.openLibraryId, itunesId: MATRIX_SET_PAYLOAD.itunesId, }); expect(parsedEpub?.authors.map((author) => author.name)).toEqual(MATRIX_SET_PAYLOAD.authors); expect(parsedEpub?.genres).toEqual(MATRIX_SET_PAYLOAD.genres); expect(parsedEpub?.tags).toEqual(MATRIX_SET_PAYLOAD.tags); const parsedCbz = await extractCbzMetadata(prepared.books.cbz.absolutePath); expect(parsedCbz).toMatchObject({ title: MATRIX_SET_PAYLOAD.title, subtitle: MATRIX_SET_PAYLOAD.subtitle, description: MATRIX_SET_PAYLOAD.description, publisher: MATRIX_SET_PAYLOAD.publisher, publishedYear: MATRIX_SET_PAYLOAD.publishedYear, language: MATRIX_SET_PAYLOAD.language, pageCount: MATRIX_SET_PAYLOAD.pageCount, seriesName: MATRIX_SET_PAYLOAD.seriesName, seriesIndex: MATRIX_SET_PAYLOAD.seriesIndex, isbn10: MATRIX_SET_PAYLOAD.isbn10, isbn13: MATRIX_SET_PAYLOAD.isbn13, rating: MATRIX_SET_PAYLOAD.rating, googleBooksId: MATRIX_SET_PAYLOAD.googleBooksId, goodreadsId: MATRIX_SET_PAYLOAD.goodreadsId, amazonId: MATRIX_SET_PAYLOAD.amazonId, hardcoverId: MATRIX_SET_PAYLOAD.hardcoverId, openLibraryId: MATRIX_SET_PAYLOAD.openLibraryId, itunesId: null, }); expect(parsedCbz?.authors.map((author) => author.name)).toEqual(MATRIX_SET_PAYLOAD.authors); expect(parsedCbz?.genres).toEqual(MATRIX_SET_PAYLOAD.genres); expect(parsedCbz?.tags).toEqual(MATRIX_SET_PAYLOAD.tags); const parsedCb7 = await extractCb7Metadata(prepared.books.cb7.absolutePath); expect(parsedCb7).toMatchObject({ title: MATRIX_SET_PAYLOAD.title, subtitle: MATRIX_SET_PAYLOAD.subtitle, description: MATRIX_SET_PAYLOAD.description, publisher: MATRIX_SET_PAYLOAD.publisher, publishedYear: MATRIX_SET_PAYLOAD.publishedYear, language: MATRIX_SET_PAYLOAD.language, pageCount: MATRIX_SET_PAYLOAD.pageCount, seriesName: MATRIX_SET_PAYLOAD.seriesName, seriesIndex: MATRIX_SET_PAYLOAD.seriesIndex, isbn10: MATRIX_SET_PAYLOAD.isbn10, isbn13: MATRIX_SET_PAYLOAD.isbn13, rating: MATRIX_SET_PAYLOAD.rating, googleBooksId: MATRIX_SET_PAYLOAD.googleBooksId, goodreadsId: MATRIX_SET_PAYLOAD.goodreadsId, amazonId: MATRIX_SET_PAYLOAD.amazonId, hardcoverId: MATRIX_SET_PAYLOAD.hardcoverId, openLibraryId: MATRIX_SET_PAYLOAD.openLibraryId, itunesId: null, }); expect(parsedCb7?.authors.map((author) => author.name)).toEqual(MATRIX_SET_PAYLOAD.authors); expect(parsedCb7?.genres).toEqual(MATRIX_SET_PAYLOAD.genres); expect(parsedCb7?.tags).toEqual(MATRIX_SET_PAYLOAD.tags); }); it('clears supported metadata in DB and files across all formats', async () => { const prepared = await prepareLibraryWithAllFormats(context); await setFileWriteSettings(context.db, { enabled: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const setResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_SET_PAYLOAD, }); expect(setResponse.statusCode).toBe(200); const clearResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_CLEAR_PAYLOAD, }); expect(clearResponse.statusCode).toBe(200); } for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const [metadata] = await context.db .select({ title: schema.bookMetadata.title, subtitle: schema.bookMetadata.subtitle, description: schema.bookMetadata.description, publisher: schema.bookMetadata.publisher, publishedYear: schema.bookMetadata.publishedYear, language: schema.bookMetadata.language, pageCount: schema.bookMetadata.pageCount, seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, isbn10: schema.bookMetadata.isbn10, isbn13: schema.bookMetadata.isbn13, rating: schema.bookMetadata.rating, googleBooksId: schema.bookMetadata.googleBooksId, goodreadsId: schema.bookMetadata.goodreadsId, amazonId: schema.bookMetadata.amazonId, hardcoverId: schema.bookMetadata.hardcoverId, openLibraryId: schema.bookMetadata.openLibraryId, itunesId: schema.bookMetadata.itunesId, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); const [authorRows, genreRows, tagRows] = await Promise.all([ context.db.select({ bookId: schema.bookAuthors.bookId }).from(schema.bookAuthors).where(eq(schema.bookAuthors.bookId, book.bookId)), context.db.select({ bookId: schema.bookGenres.bookId }).from(schema.bookGenres).where(eq(schema.bookGenres.bookId, book.bookId)), context.db.select({ bookId: schema.bookTags.bookId }).from(schema.bookTags).where(eq(schema.bookTags.bookId, book.bookId)), ]); expect(authorRows).toHaveLength(0); expect(genreRows).toHaveLength(0); expect(tagRows).toHaveLength(0); } await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true, writeCover: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); const syncResponse = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${prepared.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }); expect(syncResponse.statusCode).toBe(200); const events = parseSseEvents(syncResponse.body); const doneEvent = events.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 }); const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath, { extractCover: false }); expect(parsedPdf).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); expect(parsedPdf?.authors).toEqual([]); expect(parsedPdf?.genres).toEqual([]); expect(parsedPdf?.tags).toEqual([]); const parsedEpub = await extractEpubMetadata(prepared.books.epub.absolutePath); expect(parsedEpub).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); expect(parsedEpub?.authors).toEqual([]); expect(parsedEpub?.genres).toEqual([]); expect(parsedEpub?.tags).toEqual([]); const parsedCbz = await extractCbzMetadata(prepared.books.cbz.absolutePath); expect(parsedCbz).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); expect(parsedCbz?.authors).toEqual([]); expect(parsedCbz?.genres).toEqual([]); expect(parsedCbz?.tags).toEqual([]); const parsedCb7 = await extractCb7Metadata(prepared.books.cb7.absolutePath); expect(parsedCb7).toMatchObject({ title: null, subtitle: null, description: null, publisher: null, publishedYear: null, language: null, pageCount: null, seriesName: null, seriesIndex: null, isbn10: null, isbn13: null, rating: null, googleBooksId: null, goodreadsId: null, amazonId: null, hardcoverId: null, openLibraryId: null, itunesId: null, }); expect(parsedCb7?.authors).toEqual([]); expect(parsedCb7?.genres).toEqual([]); expect(parsedCb7?.tags).toEqual([]); }); it('supports dry-run sync and records skipped write-log entries', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'dry-run/book.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'dry-run/book.pdf'); await setFileWriteSettings(context.db, { enabled: false }); const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: 'Dry Run Title', authors: ['Dry Run Author'], }, }); expect(patchResponse.statusCode).toBe(200); await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true }); const dryRunResponse = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files?dryRun=true`, headers: authHeader(context.adminToken), }); expect(dryRunResponse.statusCode).toBe(200); const events = parseSseEvents(dryRunResponse.body); const doneEvent = events.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: 1, succeeded: 0, failed: 0, skipped: 1 }); const logEntry = await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'sync', status: 'skipped', }); expect(logEntry.errorMessage).toBe('dry-run'); const [metadata] = await context.db .select({ lastWrittenAt: schema.bookMetadata.lastWrittenAt }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, book.bookId)) .limit(1); expect(metadata?.lastWrittenAt ?? null).toBeNull(); }); it('rejects sync writes when file-write settings are disabled', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'disabled-sync/book.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); await setFileWriteSettings(context.db, { enabled: false }); const response = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ message: expect.stringContaining('Metadata file write is not enabled'), }); }); }); describe('failure isolation', () => { it('continues processing other books when one file write fails', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'failure-isolation/ok.pdf'); await writeFixtureFile(library.folderPath, 'failure-isolation/broken.epub', 'not a valid epub archive'); await triggerAndWaitForLibraryScan(context, library.libraryId); const okBook = await locateBookFileByRelPath(context, library.libraryId, 'failure-isolation/ok.pdf'); const brokenBook = await locateBookFileByRelPath(context, library.libraryId, 'failure-isolation/broken.epub'); await setFileWriteSettings(context.db, { enabled: false }); for (const target of [okBook, brokenBook]) { const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${target.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: target.bookId === okBook.bookId ? 'Failure Isolation Success' : 'Failure Isolation Broken', authors: ['Failure Isolation Author'], }, }); expect(patchResponse.statusCode).toBe(200); } await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true, cbx: { enabled: true, formats: ['cbz', 'cb7'] } }); const syncResponse = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }); expect(syncResponse.statusCode).toBe(200); const events = parseSseEvents(syncResponse.body); const doneEvent = events.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: 2, succeeded: 1, failed: 1, skipped: 0 }); const successLog = await waitForWriteLogEntry(context.db, okBook.bookId, { triggeredBy: 'sync', status: 'success' }); const failureLog = await waitForWriteLogEntry(context.db, brokenBook.bookId, { triggeredBy: 'sync', status: 'failed' }); expect(successLog.errorMessage ?? null).toBeNull(); expect(failureLog.errorMessage).toBeTruthy(); const parsedPdf = await parsePdfFile(okBook.absolutePath, { extractCover: false }); expect(parsedPdf?.title).toBe('Failure Isolation Success'); }); }); describe('concurrency hardening', () => { it('resolves parallel PATCH conflicts to one complete payload shape', async () => { await setFileWriteSettings(context.db, { enabled: true }); const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'concurrency/parallel-patch.pdf', 'Parallel Patch Seed'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'concurrency/parallel-patch.pdf'); const payloadA = { title: 'Parallel Patch Title A', subtitle: 'Parallel Patch Subtitle A', description: 'Parallel Patch Description A', publisher: 'Parallel Patch Publisher A', publishedYear: 2003, language: 'en', pageCount: 203, seriesName: 'Parallel Patch Series A', seriesIndex: 1.1, isbn10: '1234500001', isbn13: '9781234500001', rating: 4, authors: ['Parallel Patch Author A1', 'Parallel Patch Author A2'], genres: ['Parallel Patch Genre A'], tags: ['ParallelPatchTagA'], googleBooksId: 'parallel-a-google', goodreadsId: 'parallel-a-goodreads', amazonId: 'PARALLELA01', hardcoverId: 'parallel-a-hardcover', openLibraryId: 'PARALLELA-OL', itunesId: 'parallel-a-itunes', } as const; const payloadB = { title: 'Parallel Patch Title B', subtitle: 'Parallel Patch Subtitle B', description: 'Parallel Patch Description B', publisher: 'Parallel Patch Publisher B', publishedYear: 2004, language: 'fr', pageCount: 204, seriesName: 'Parallel Patch Series B', seriesIndex: 2.2, isbn10: '1234500002', isbn13: '9781234500002', rating: 5, authors: ['Parallel Patch Author B1', 'Parallel Patch Author B2'], genres: ['Parallel Patch Genre B'], tags: ['ParallelPatchTagB'], googleBooksId: 'parallel-b-google', goodreadsId: 'parallel-b-goodreads', amazonId: 'PARALLELB02', hardcoverId: 'parallel-b-hardcover', openLibraryId: 'PARALLELB-OL', itunesId: 'parallel-b-itunes', } as const; const [responseA, responseB] = await Promise.all([ context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: payloadA, }), context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: payloadB, }), ]); expect(responseA.statusCode).toBe(200); expect(responseB.statusCode).toBe(200); await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'auto', status: 'success', timeoutMs: 20_000, }); const dbState = await readBookWriteState(context, book.bookId); const winningPayload = matchesBookWriteState(dbState, payloadA) ? payloadA : matchesBookWriteState(dbState, payloadB) ? payloadB : null; expect(winningPayload).not.toBeNull(); const parsedPdf = await parsePdfFile(book.absolutePath, { extractCover: false }); expect(parsedPdf).toMatchObject({ title: winningPayload!.title, subtitle: winningPayload!.subtitle, description: winningPayload!.description, publisher: winningPayload!.publisher, publishedYear: winningPayload!.publishedYear, language: winningPayload!.language, pageCount: winningPayload!.pageCount, seriesName: winningPayload!.seriesName, seriesIndex: winningPayload!.seriesIndex, isbn10: winningPayload!.isbn10, isbn13: winningPayload!.isbn13, rating: winningPayload!.rating, googleBooksId: winningPayload!.googleBooksId, goodreadsId: winningPayload!.goodreadsId, amazonId: winningPayload!.amazonId, hardcoverId: winningPayload!.hardcoverId, openLibraryId: winningPayload!.openLibraryId, itunesId: winningPayload!.itunesId, }); expect(parsedPdf?.authors.map((author) => author.name)).toEqual(winningPayload!.authors); expect(parsedPdf?.genres).toEqual(winningPayload!.genres); expect(parsedPdf?.tags).toEqual(winningPayload!.tags); }); it('converges to latest PATCH state when PATCH overlaps with sync run', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); const relPaths = ['concurrency/overlap/a.pdf', 'concurrency/overlap/b.pdf', 'concurrency/overlap/c.pdf', 'concurrency/overlap/d.pdf']; await Promise.all(relPaths.map((relPath) => createPdfFixture(library.folderPath, relPath, `Overlap Seed ${relPath}`))); await triggerAndWaitForLibraryScan(context, library.libraryId); const books = await Promise.all(relPaths.map((relPath) => locateBookFileByRelPath(context, library.libraryId, relPath))); const targetBook = books[0]!; await setFileWriteSettings(context.db, { enabled: false }); for (const located of books) { const seedResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${located.bookId}/metadata`, headers: authHeader(context.adminToken), payload: { title: `Overlap Seed ${located.bookId}`, authors: ['Overlap Seed Author'], tags: ['OverlapSeedTag'], }, }); expect(seedResponse.statusCode).toBe(200); } await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true, writeCover: false, epub: { enabled: true }, pdf: { enabled: true }, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); const overlapPayload = { title: 'Overlap Final Title', subtitle: 'Overlap Final Subtitle', description: 'Overlap Final Description', publisher: 'Overlap Final Publisher', publishedYear: 2017, language: 'es', pageCount: 417, seriesName: 'Overlap Final Series', seriesIndex: 7.7, isbn10: '7654321098', isbn13: '9787654321098', rating: 5, authors: ['Overlap Final Author A', 'Overlap Final Author B'], genres: ['Overlap Final Genre'], tags: ['OverlapFinalTag'], googleBooksId: 'overlap-google', goodreadsId: 'overlap-goodreads', amazonId: 'OVERLAP017', hardcoverId: 'overlap-hardcover', openLibraryId: 'OVERLAP-OL', itunesId: 'overlap-itunes', } as const; const fileWriteService = context.app.get(FileWriteService); const originalWriteToFile = fileWriteService.writeToFile.bind(fileWriteService); let releaseFirstSyncWrite!: () => void; let resolveFirstSyncWriteStarted!: () => void; const firstSyncWriteStarted = new Promise((resolve) => { resolveFirstSyncWriteStarted = resolve; }); const releaseFirstSyncWritePromise = new Promise((resolve) => { releaseFirstSyncWrite = resolve; }); let blockedFirstSyncWrite = false; let releasedFirstSyncWrite = false; const writeSpy = vi .spyOn(fileWriteService, 'writeToFile') .mockImplementation(async (bookId: number, triggeredBy: 'auto' | 'sync', userId?: number, dryRun?: boolean) => { if (triggeredBy === 'sync' && !blockedFirstSyncWrite) { blockedFirstSyncWrite = true; resolveFirstSyncWriteStarted(); await releaseFirstSyncWritePromise; } return originalWriteToFile(bookId, triggeredBy, userId, dryRun); }); let syncResponse!: Awaited>; try { const syncPromise = context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }); await firstSyncWriteStarted; const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${targetBook.bookId}/metadata`, headers: authHeader(context.adminToken), payload: overlapPayload, }); expect(patchResponse.statusCode).toBe(200); releasedFirstSyncWrite = true; releaseFirstSyncWrite(); syncResponse = await syncPromise; } finally { if (blockedFirstSyncWrite && !releasedFirstSyncWrite) { releaseFirstSyncWrite(); } writeSpy.mockRestore(); } expect(syncResponse.statusCode).toBe(200); const syncEvents = parseSseEvents(syncResponse.body); const doneEvent = syncEvents.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: books.length, failed: 0 }); await waitForWriteLogEntry(context.db, targetBook.bookId, { triggeredBy: 'auto', status: 'success', timeoutMs: 20_000, }); const targetState = await readBookWriteState(context, targetBook.bookId); expect(matchesBookWriteState(targetState, overlapPayload)).toBe(true); const parsedPdf = await parsePdfFile(targetBook.absolutePath, { extractCover: false }); expect(parsedPdf).toMatchObject({ title: overlapPayload.title, subtitle: overlapPayload.subtitle, description: overlapPayload.description, publisher: overlapPayload.publisher, publishedYear: overlapPayload.publishedYear, language: overlapPayload.language, pageCount: overlapPayload.pageCount, seriesName: overlapPayload.seriesName, seriesIndex: overlapPayload.seriesIndex, isbn10: overlapPayload.isbn10, isbn13: overlapPayload.isbn13, rating: overlapPayload.rating, googleBooksId: overlapPayload.googleBooksId, goodreadsId: overlapPayload.goodreadsId, amazonId: overlapPayload.amazonId, hardcoverId: overlapPayload.hardcoverId, openLibraryId: overlapPayload.openLibraryId, itunesId: overlapPayload.itunesId, }); expect(parsedPdf?.authors.map((author) => author.name)).toEqual(overlapPayload.authors); expect(parsedPdf?.genres).toEqual(overlapPayload.genres); expect(parsedPdf?.tags).toEqual(overlapPayload.tags); }); it('allows concurrent sync runs on the same library with consistent outcomes', async () => { const prepared = await prepareLibraryWithAllFormats(context); await setFileWriteSettings(context.db, { enabled: false, writeCover: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(context.adminToken), payload: MATRIX_SET_PAYLOAD, }); expect(patchResponse.statusCode).toBe(200); } await drainScheduledFileWrites(context); await setFileWriteSettings(context.db, { enabled: true, writeCover: false, cbx: { enabled: true, formats: ['cbz', 'cb7'] }, }); const [syncA, syncB] = await Promise.all([ context.app.inject({ method: 'POST', url: `/api/v1/libraries/${prepared.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }), context.app.inject({ method: 'POST', url: `/api/v1/libraries/${prepared.libraryId}/write-metadata-to-files`, headers: authHeader(context.adminToken), }), ]); expect(syncA.statusCode).toBe(200); expect(syncB.statusCode).toBe(200); const doneA = parseSseEvents(syncA.body).find( (event): event is Extract => 'done' in event && event.done === true, ); const doneB = parseSseEvents(syncB.body).find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneA).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 }); expect(doneB).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 }); for (const format of Object.keys(prepared.books) as SupportedFormat[]) { const book = prepared.books[format]; const syncSuccessLogs = await countSyncSuccessLogs(context, book.bookId); expect(syncSuccessLogs).toBe(2); } }); }); describe('access control', () => { it('enforces permission and library access for PATCH /books/:id/metadata', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'access/patch.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await locateBookFileByRelPath(context, library.libraryId, 'access/patch.pdf'); const noPermissionUser = await createUserAndLogin(context); const withPermissionUser = await createUserAndLogin(context, { permissions: [Permission.LibraryEditMetadata], }); await grantLibraryAccess(context, noPermissionUser.userId, library.libraryId, 'editor'); const missingPermission = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(noPermissionUser.accessToken), payload: { title: 'Should Fail' }, }); expect(missingPermission.statusCode).toBe(403); const missingAccess = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(withPermissionUser.accessToken), payload: { title: 'Should Fail Access' }, }); expect(missingAccess.statusCode).toBe(403); await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'editor'); const success = await context.app.inject({ method: 'PATCH', url: `/api/v1/books/${book.bookId}/metadata`, headers: authHeader(withPermissionUser.accessToken), payload: { title: 'Patch Access Success' }, }); expect(success.statusCode).toBe(200); }); it('returns 404 for sync requests targeting a missing library', async () => { await setFileWriteSettings(context.db, { enabled: true }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/libraries/999999/write-metadata-to-files', headers: authHeader(context.adminToken), }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ message: 'Library not found' }); }); it('enforces permission and editor-level library access for sync endpoint', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file' }); await createPdfFixture(library.folderPath, 'access/sync.pdf'); await triggerAndWaitForLibraryScan(context, library.libraryId); const noPermissionUser = await createUserAndLogin(context); const withPermissionUser = await createUserAndLogin(context, { permissions: [Permission.LibraryEditMetadata], }); await grantLibraryAccess(context, noPermissionUser.userId, library.libraryId, 'editor'); const missingPermission = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(noPermissionUser.accessToken), }); expect(missingPermission.statusCode).toBe(403); const noLibraryAccess = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(withPermissionUser.accessToken), }); expect(noLibraryAccess.statusCode).toBe(403); await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'viewer'); const viewerAccess = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(withPermissionUser.accessToken), }); expect(viewerAccess.statusCode).toBe(403); await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'editor'); await setFileWriteSettings(context.db, { enabled: true }); const editorAccess = await context.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, headers: authHeader(withPermissionUser.accessToken), }); expect(editorAccess.statusCode).toBe(200); const events = parseSseEvents(editorAccess.body); const doneEvent = events.find( (event): event is Extract => 'done' in event && event.done === true, ); expect(doneEvent).toMatchObject({ processed: 1 }); }); }); }); async function prepareLibraryWithAllFormats(ctx: MetadataWriteE2EContext): Promise { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file' }); await Promise.all([ createPdfFixture(library.folderPath, 'all-formats/book.pdf', 'All Formats PDF Seed'), createEpubFixture(library.folderPath, 'all-formats/book.epub', { title: 'All Formats EPUB Seed' }), createCbzFixture(library.folderPath, 'all-formats/book.cbz', { title: 'All Formats CBZ Seed' }), createCb7Fixture(library.folderPath, 'all-formats/book.cb7', { title: 'All Formats CB7 Seed' }), ]); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const [pdf, epub, cbz, cb7] = await Promise.all([ locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.pdf'), locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.epub'), locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.cbz'), locateBookFileByRelPath(ctx, library.libraryId, 'all-formats/book.cb7'), ]); return { libraryId: library.libraryId, books: { pdf, epub, cbz, cb7 }, }; } function parseSseEvents(rawBody: string): LibraryFileSyncProgressEvent[] { const events: LibraryFileSyncProgressEvent[] = []; for (const chunk of rawBody.split('\n\n')) { const line = chunk .split('\n') .map((item) => item.trim()) .find((item) => item.startsWith('data:')); if (!line) continue; const payload = line.slice('data:'.length).trim(); if (!payload) continue; events.push(JSON.parse(payload) as LibraryFileSyncProgressEvent); } return events; } async function drainScheduledFileWrites(context: MetadataWriteE2EContext): Promise { await context.app.get(FileWriteService).drainScheduledWritesForTests(); } function buildAtomicityFailurePayload(failpoint: MetadataUpdateFailpoint) { const token = failpoint .replace(/[^a-z]/gi, '') .toLowerCase() .slice(0, 10); return { title: `Atomic Failure Title ${token}`, subtitle: `Atomic Failure Subtitle ${token}`, description: `Atomic Failure Description ${token}`, publisher: `Atomic Failure Publisher ${token}`, publishedYear: 2020, language: 'de', pageCount: 520, seriesName: `Atomic Failure Series ${token}`, seriesIndex: 9.9, isbn10: '9999999999', isbn13: '9789999999999', rating: 2, authors: [`Atomic Failure Author ${token}`], genres: [`Atomic Failure Genre ${token}`], tags: [`AtomicFailureTag${token}`], googleBooksId: `atomic-fail-g-${token}`, goodreadsId: `atomic-fail-gr-${token}`, amazonId: `ATFAIL${token.slice(0, 5).toUpperCase()}`, hardcoverId: `atomic-fail-hc-${token}`, openLibraryId: `ATFAIL-${token.toUpperCase()}`, itunesId: `atomic-fail-it-${token}`, audibleId: `ATF${token.slice(0, 8).toUpperCase()}`, comicvineId: `atomic-fail-cv-${token}`, audioMetadata: { narrators: [`Atomic Failure Narrator ${token}`], durationSeconds: 777, abridged: false, chapters: [{ title: `Atomic Failure Chapter ${token}`, startMs: 1000 }], }, comicMetadata: { issueNumber: '9', volumeName: `Atomic Failure Volume ${token}`, pencillers: [`Atomic Failure Penciller ${token}`], inkers: [`Atomic Failure Inker ${token}`], colorists: [`Atomic Failure Colorist ${token}`], letterers: [`Atomic Failure Letterer ${token}`], coverArtists: [`Atomic Failure CoverArtist ${token}`], characters: [`Atomic Failure Character ${token}`], teams: [`Atomic Failure Team ${token}`], locations: [`Atomic Failure Location ${token}`], storyArcs: [`Atomic Failure StoryArc ${token}`], }, }; } async function readMetadataMutationState(context: MetadataWriteE2EContext, bookId: number) { const [metadata, authors, genres, tags, narrators, comicRows] = await Promise.all([ context.db .select({ title: schema.bookMetadata.title, subtitle: schema.bookMetadata.subtitle, description: schema.bookMetadata.description, publisher: schema.bookMetadata.publisher, publishedYear: schema.bookMetadata.publishedYear, language: schema.bookMetadata.language, pageCount: schema.bookMetadata.pageCount, seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, isbn10: schema.bookMetadata.isbn10, isbn13: schema.bookMetadata.isbn13, rating: schema.bookMetadata.rating, googleBooksId: schema.bookMetadata.googleBooksId, goodreadsId: schema.bookMetadata.goodreadsId, amazonId: schema.bookMetadata.amazonId, hardcoverId: schema.bookMetadata.hardcoverId, openLibraryId: schema.bookMetadata.openLibraryId, itunesId: schema.bookMetadata.itunesId, audibleId: schema.bookMetadata.audibleId, comicvineId: schema.bookMetadata.comicvineId, durationSeconds: schema.bookMetadata.durationSeconds, abridged: schema.bookMetadata.abridged, chapters: schema.bookMetadata.chapters, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, bookId)) .limit(1), context.db .select({ name: schema.authors.name }) .from(schema.bookAuthors) .innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId)) .where(eq(schema.bookAuthors.bookId, bookId)) .orderBy(schema.bookAuthors.displayOrder), context.db .select({ name: schema.genres.name }) .from(schema.bookGenres) .innerJoin(schema.genres, eq(schema.genres.id, schema.bookGenres.genreId)) .where(eq(schema.bookGenres.bookId, bookId)), context.db .select({ name: schema.tags.name }) .from(schema.bookTags) .innerJoin(schema.tags, eq(schema.tags.id, schema.bookTags.tagId)) .where(eq(schema.bookTags.bookId, bookId)), context.db .select({ name: schema.narrators.name }) .from(schema.bookNarrators) .innerJoin(schema.narrators, eq(schema.narrators.id, schema.bookNarrators.narratorId)) .where(eq(schema.bookNarrators.bookId, bookId)) .orderBy(schema.bookNarrators.displayOrder), context.db.select().from(schema.comicMetadata).where(eq(schema.comicMetadata.bookId, bookId)).limit(1), ]); const comic = comicRows[0] ?? null; return { metadata: metadata[0] ?? null, authors: authors.map((row) => row.name), genres: genres.map((row) => row.name).sort(), tags: tags.map((row) => row.name).sort(), narrators: narrators.map((row) => row.name), comicMetadata: comic ? { issueNumber: comic.issueNumber ?? null, volumeName: comic.volumeName ?? null, pencillers: [...(comic.pencillers ?? [])].sort(), inkers: [...(comic.inkers ?? [])].sort(), colorists: [...(comic.colorists ?? [])].sort(), letterers: [...(comic.letterers ?? [])].sort(), coverArtists: [...(comic.coverArtists ?? [])].sort(), characters: [...(comic.characters ?? [])].sort(), teams: [...(comic.teams ?? [])].sort(), locations: [...(comic.locations ?? [])].sort(), storyArcs: [...(comic.storyArcs ?? [])].sort(), } : null, }; } async function readBookWriteState(context: MetadataWriteE2EContext, bookId: number) { const [metadata, authors, genres, tags] = await Promise.all([ context.db .select({ title: schema.bookMetadata.title, subtitle: schema.bookMetadata.subtitle, description: schema.bookMetadata.description, publisher: schema.bookMetadata.publisher, publishedYear: schema.bookMetadata.publishedYear, language: schema.bookMetadata.language, pageCount: schema.bookMetadata.pageCount, seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, isbn10: schema.bookMetadata.isbn10, isbn13: schema.bookMetadata.isbn13, rating: schema.bookMetadata.rating, googleBooksId: schema.bookMetadata.googleBooksId, goodreadsId: schema.bookMetadata.goodreadsId, amazonId: schema.bookMetadata.amazonId, hardcoverId: schema.bookMetadata.hardcoverId, openLibraryId: schema.bookMetadata.openLibraryId, itunesId: schema.bookMetadata.itunesId, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, bookId)) .limit(1), context.db .select({ name: schema.authors.name }) .from(schema.bookAuthors) .innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId)) .where(eq(schema.bookAuthors.bookId, bookId)) .orderBy(schema.bookAuthors.displayOrder), context.db .select({ name: schema.genres.name }) .from(schema.bookGenres) .innerJoin(schema.genres, eq(schema.genres.id, schema.bookGenres.genreId)) .where(eq(schema.bookGenres.bookId, bookId)), context.db .select({ name: schema.tags.name }) .from(schema.bookTags) .innerJoin(schema.tags, eq(schema.tags.id, schema.bookTags.tagId)) .where(eq(schema.bookTags.bookId, bookId)), ]); return { ...(metadata[0] ?? {}), authors: authors.map((row) => row.name), genres: genres.map((row) => row.name).sort(), tags: tags.map((row) => row.name).sort(), }; } function matchesBookWriteState(state: Awaited>, payload: ExpectedWritePayload): boolean { return ( state.title === payload.title && state.subtitle === payload.subtitle && state.description === payload.description && state.publisher === payload.publisher && state.publishedYear === payload.publishedYear && state.language === payload.language && state.pageCount === payload.pageCount && state.seriesName === payload.seriesName && state.seriesIndex === payload.seriesIndex && state.isbn10 === payload.isbn10 && state.isbn13 === payload.isbn13 && state.rating === payload.rating && state.googleBooksId === payload.googleBooksId && state.goodreadsId === payload.goodreadsId && state.amazonId === payload.amazonId && state.hardcoverId === payload.hardcoverId && state.openLibraryId === payload.openLibraryId && state.itunesId === payload.itunesId && JSON.stringify(state.authors) === JSON.stringify(payload.authors) && JSON.stringify(state.genres) === JSON.stringify([...payload.genres].sort()) && JSON.stringify(state.tags) === JSON.stringify([...payload.tags].sort()) ); } async function countSyncSuccessLogs(context: MetadataWriteE2EContext, bookId: number): Promise { const rows = await context.db .select({ triggeredBy: schema.fileWriteLog.triggeredBy, status: schema.fileWriteLog.status }) .from(schema.fileWriteLog) .where(eq(schema.fileWriteLog.bookId, bookId)); return rows.filter((row) => row.triggeredBy === 'sync' && row.status === 'success').length; }