fix(metadata): write finalized metadata back to the imported file (#1499)

* fix(metadata): write finalized metadata back to the imported file

Finalizing a Book Dock file only moved the bytes, so a library with file
writes enabled kept the metadata the file was uploaded with rather than
the metadata the dock settled on. Schedule a write-back for every book a
finalize produces once all of them have their metadata, attributed to
the uploader.

Fixes #1385

* fix(metadata): schedule dock file writes after cleanup

Prevent a failed Book Dock cleanup from leaving a pending file write. Keep the integration assertion synchronized with the debounced writer and restore the missing directory stability mock for end-to-end coverage.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
vishnu
2026-09-26 10:11:48 -05:00
committed by GitHub
parent bc12143015
commit d4bc62f8a9
7 changed files with 156 additions and 1 deletions
@@ -12,6 +12,8 @@ import {
} from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser';
import { waitForCondition } from './e2e/app-harness';
import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder';
import { createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
import {
@@ -525,6 +527,57 @@ describe('Book Dock ingest + finalize (e2e)', () => {
});
});
it('finalize writes the dock metadata into the file when the library writes metadata to files', async () => {
const destination = await createLibraryWithFolder(context, {
fileWriteEnabled: true,
fileWriteFb2Enabled: true,
});
const uploader = await createUserAndLogin(context, { permissions: [Permission.ManageBookDock] });
const bookDockRow = await createBookDockRow(context, {
fileName: 'write-back.fb2',
content: buildFb2Fixture({ title: 'Uploaded File Title', authors: ['Uploaded File Author'] }),
embeddedMetadata: { title: 'Uploaded File Title', authors: ['Uploaded File Author'] },
selectedMetadata: { title: 'Dock Edited Title', authors: ['Dock Edited Author'] },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
uploadedBy: uploader.userId,
});
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(context.adminToken),
payload: { fileIds: [bookDockRow.id] },
});
expect(response.statusCode).toBe(201);
const body = response.json() as BookDockFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
const finalizedBookId = body.results[0]!.bookId!;
const [bookFile] = await context.db
.select({ absolutePath: schema.bookFiles.absolutePath })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.bookId, finalizedBookId))
.limit(1);
expect(bookFile).toBeDefined();
// The write is debounced, so the file is polled rather than drained: draining cancels a timer
// that has not fired yet.
await waitForCondition(async () => {
const parsed = await parseFb2File(bookFile!.absolutePath);
expect(parsed?.title).toBe('Dock Edited Title');
expect(parsed?.authors.map((author) => author.name)).toEqual(['Dock Edited Author']);
const [writeLog] = await context.db
.select({ status: schema.fileWriteLog.status, triggeredBy: schema.fileWriteLog.triggeredBy, userId: schema.fileWriteLog.userId })
.from(schema.fileWriteLog)
.where(eq(schema.fileWriteLog.bookId, finalizedBookId))
.limit(1);
expect(writeLog).toMatchObject({ status: 'success', triggeredBy: 'auto', userId: uploader.userId });
});
});
it('finalize returns partial success with duplicate and destination conflicts', async () => {
const destination = await createLibraryWithFolder(context);
+1
View File
@@ -2,4 +2,5 @@ import { vi } from 'vitest';
vi.mock('../src/common/utils/fs-stability.utils', () => ({
waitForStability: vi.fn().mockResolvedValue(undefined),
waitForDirectoryStability: vi.fn().mockResolvedValue(undefined),
}));
@@ -27,6 +27,7 @@ const ADMIN_SETUP_DTO = {
interface EnvSnapshot {
appDataPath: string | undefined;
fileWriteDebounceMs: string | undefined;
}
export interface BookDockE2EContext {
@@ -81,9 +82,13 @@ export async function createBookDockE2EContext(): Promise<BookDockE2EContext> {
const fixture = await createBookDockFixtureRoot();
const envSnapshot: EnvSnapshot = {
appDataPath: process.env.APP_DATA_PATH,
fileWriteDebounceMs: process.env.FILE_WRITE_DEBOUNCE_MS,
};
process.env.APP_DATA_PATH = fixture.booksPath;
// Read at config-factory time and cached in the FileWriteService constructor, so it has to be set
// before the module compiles.
process.env.FILE_WRITE_DEBOUNCE_MS = '25';
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -137,6 +142,8 @@ export async function createLibraryWithFolder(
mode?: 'book_per_file' | 'book_per_folder';
allowedFormats?: string[];
name?: string;
fileWriteEnabled?: boolean;
fileWriteFb2Enabled?: boolean;
} = {},
): Promise<CreatedLibrary> {
const folderPath = join(ctx.fixture.booksPath, `library-${randomUUID()}`);
@@ -150,6 +157,17 @@ export async function createLibraryWithFolder(
name: options.name,
});
// FB2 writes have their own toggle which is off by default, so `fileWriteEnabled` alone still skips them.
if (options.fileWriteEnabled !== undefined || options.fileWriteFb2Enabled !== undefined) {
await ctx.db
.update(schema.libraries)
.set({
...(options.fileWriteEnabled !== undefined ? { fileWriteEnabled: options.fileWriteEnabled } : {}),
...(options.fileWriteFb2Enabled !== undefined ? { fileWriteFb2Enabled: options.fileWriteFb2Enabled } : {}),
})
.where(eq(schema.libraries.id, libraryId));
}
return {
libraryId,
libraryFolderId,
@@ -391,4 +409,6 @@ async function setSetting(db: Db, key: string, value: string): Promise<void> {
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
if (snapshot.fileWriteDebounceMs === undefined) delete process.env.FILE_WRITE_DEBOUNCE_MS;
else process.env.FILE_WRITE_DEBOUNCE_MS = snapshot.fileWriteDebounceMs;
}