feat(books): rework cross-library moves with preview and progress

Replaces the implementation from #818 with a job-backed flow. Moves are
planned up front and surfaced as a preview the user confirms, then run
through a planner/executor split against a book_move_jobs table, so
progress is trackable and partial failures roll back.

The dialog-based flow from #818 is superseded: its module, service, and
repository are removed here in favour of the job-based equivalents. Its
e2e coverage is replaced by a book-move suite that exercises preview
classification, the collision policies, file relocation, preserved user
state, and the books-before-book_files update ordering that the
composite folder foreign key constrains.

Also adds dashboard shelf layout configuration.

Ref #257
This commit is contained in:
neon
2026-08-02 17:03:14 +00:00
parent 41fd6f1881
commit f4cfc3ca5d
93 changed files with 23743 additions and 2871 deletions
+751 -130
View File
@@ -1,37 +1,54 @@
/**
* Cross-library book move end-to-end suite.
*
* Unit tests for this feature mock the database, so they cannot catch ordering
* bugs against real constraints: `book_files_book_folder_consistency_fk`
* references `books(id, library_folder_id)`, so rewriting a file's folder id
* before the book row has moved violates it. This suite drives the real
* endpoints against a real Postgres instance and a real filesystem.
*
* Covers:
*
* - Preview classification: ready / already-in-target / collision / ineligible
* - Collision kinds: hash duplicate (suggests merge), name clash (suggests keep_both)
* - Ineligibility: multi-file book into a book_per_file target
* - Warnings: format mismatch, layout change, users losing access
* - Execute: row re-parenting, file relocation, preserved user state
* - Job bookkeeping in book_move_jobs
* - Collision policies: skip / keep_both / merge, and per-book overrides
* - Layout transition into a book_per_folder target
* - SSE progress framing
* - Editor access enforcement on both source and target
*/
import { randomUUID } from 'crypto';
import { access, readFile } from 'fs/promises';
import { access, copyFile } from 'fs/promises';
import { join } from 'path';
import { and, eq } from 'drizzle-orm';
import type { MoveBookOutcome } from '@bookorbit/types';
import { bookFiles, books, userBookStatus } from '../src/db/schema';
import type { BookMovePreviewResult, BookMoveProgressEvent, BookMoveSummary } from '@bookorbit/types';
import { Permission } from '@bookorbit/types';
import { annotations, bookFiles, bookMoveJobs, books, collectionBooks, collections, libraries, userBookStatus } from '../src/db/schema';
import { createEpubFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
import {
authHeader,
closeMetadataWriteE2EContext,
createLibraryWithFolder,
createMetadataWriteE2EContext,
createUserAndLogin,
grantLibraryAccess,
locateBookFileByRelPath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type MetadataWriteE2EContext,
} from './e2e/metadata-write/metadata-write-harness';
import { createEpubFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
const SCENARIO_TIMEOUT_MS = 120_000;
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
describe('Book move between libraries (e2e)', () => {
describe('Cross-library book move (e2e)', () => {
let ctx: MetadataWriteE2EContext;
beforeAll(async () => {
@@ -42,147 +59,751 @@ describe('Book move between libraries (e2e)', () => {
await closeMetadataWriteE2EContext(ctx);
});
async function seedBookInLibrary(library: CreatedLibrary, relPath: string): Promise<LocatedBookFile> {
await createEpubFixture(library.folderPath, relPath);
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Pin the destination naming pattern so expected paths are exact rather than
* dependent on whatever the instance-wide upload pattern happens to be.
*/
async function setNamingPattern(libraryId: number, pattern: string): Promise<void> {
await ctx.db.update(libraries).set({ fileNamingPattern: pattern }).where(eq(libraries.id, libraryId));
}
async function createSource(options: { mode?: 'book_per_file' | 'book_per_folder' } = {}): Promise<CreatedLibrary> {
return createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}`, mode: options.mode ?? 'book_per_file' });
}
async function createTarget(
options: { mode?: 'book_per_file' | 'book_per_folder'; pattern?: string; allowedFormats?: string[] } = {},
): Promise<CreatedLibrary> {
const mode = options.mode ?? 'book_per_file';
const library = await createLibraryWithFolder(ctx, {
name: `move-dst-${randomUUID()}`,
mode,
allowedFormats: options.allowedFormats ?? [],
});
await setNamingPattern(library.libraryId, options.pattern ?? (mode === 'book_per_folder' ? '{title}/{title}' : '{title}'));
return library;
}
async function seedEpub(library: CreatedLibrary, relPath: string, title: string, uid?: string): Promise<LocatedBookFile> {
await createEpubFixture(library.folderPath, relPath, { title, uid: uid ?? title });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
return locateBookFileByRelPath(ctx, library.libraryId, relPath);
}
async function moveBooks(payload: Record<string, unknown>) {
/**
* Seeds a book already sitting on the exact path an incoming book of the same
* title would resolve to, which is what makes the destination collide. A distinct
* uid keeps the content hash different so this reads as a name clash rather than
* a duplicate copy.
*/
async function seedIncumbent(target: CreatedLibrary, title: string): Promise<LocatedBookFile> {
return seedEpub(target, `${title}.epub`, title, `${title}-incumbent`);
}
/**
* Places byte-identical copies of one book in both libraries so they share a
* content hash. The bytes are copied rather than generated twice because the epub
* archive embeds timestamps, which makes a second generation only usually equal.
*/
async function seedIdenticalPair(
source: CreatedLibrary,
target: CreatedLibrary,
title: string,
): Promise<{ existing: LocatedBookFile; incoming: LocatedBookFile }> {
const existingPath = await createEpubFixture(target.folderPath, 'existing.epub', { title, uid: title });
await copyFile(existingPath, join(source.folderPath, 'incoming.epub'));
await triggerAndWaitForLibraryScan(ctx, target.libraryId);
await triggerAndWaitForLibraryScan(ctx, source.libraryId);
return {
existing: await locateBookFileByRelPath(ctx, target.libraryId, 'existing.epub'),
incoming: await locateBookFileByRelPath(ctx, source.libraryId, 'incoming.epub'),
};
}
function previewMove(payload: Record<string, unknown>, token = ctx.adminToken) {
return ctx.app.inject({
method: 'POST',
url: '/api/v1/books/move',
headers: authHeader(ctx.adminToken),
url: '/api/v1/books/move/preview',
headers: authHeader(token),
payload,
});
}
// The endpoint streams SSE frames: one outcome event per book plus a final
// done summary. Parse the raw body back into structured results.
function parseMoveStream(body: string): { results: MoveBookOutcome[]; summary: Record<string, unknown> | null } {
const results: MoveBookOutcome[] = [];
let summary: Record<string, unknown> | null = null;
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6)) as Record<string, unknown>;
if (event.done === true) summary = event;
else results.push(event as unknown as MoveBookOutcome);
}
return { results, summary };
function executeMove(payload: Record<string, unknown>, token = ctx.adminToken) {
return ctx.app.inject({
method: 'POST',
url: '/api/v1/books/move',
headers: authHeader(token),
payload,
});
}
it(
'moves a book into another library, re-parenting rows, relocating files, and preserving user state',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'moving/book.epub');
/** The execute endpoint streams one frame per book plus a final done summary. */
function parseMoveStream(body: string): { events: BookMoveProgressEvent[]; summary: BookMoveSummary | null } {
const events: BookMoveProgressEvent[] = [];
let summary: BookMoveSummary | null = null;
const reader = await createUserAndLogin(ctx);
await ctx.db.insert(userBookStatus).values({ userId: reader.userId, bookId: book.bookId, status: 'reading', source: 'manual' });
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('text/event-stream');
const { results, summary } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'moved' }]);
expect(summary).toMatchObject({ done: true, total: 1, moved: 1, skipped: 0, failed: 0, cancelled: false });
// Book row re-parented; this is the regression guard for
// book_files_book_folder_consistency_fk (books must be updated before files).
const [movedBook] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(movedBook.libraryId).toBe(dst.libraryId);
expect(movedBook.libraryFolderId).toBe(dst.libraryFolderId);
expect(movedBook.folderPath.startsWith(dst.folderPath)).toBe(true);
const movedFiles = await ctx.db.select().from(bookFiles).where(eq(bookFiles.bookId, book.bookId));
expect(movedFiles.length).toBeGreaterThan(0);
for (const file of movedFiles) {
expect(file.libraryFolderId).toBe(dst.libraryFolderId);
expect(file.absolutePath.startsWith(dst.folderPath)).toBe(true);
await expect(pathExists(file.absolutePath)).resolves.toBe(true);
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6)) as BookMoveProgressEvent;
events.push(event);
if ('done' in event && event.done === true) {
const { processed, succeeded, merged, failed, skipped, cancelled } = event;
summary = { processed, succeeded, merged, failed, skipped, cancelled };
}
await expect(pathExists(join(src.folderPath, 'moving/book.epub'))).resolves.toBe(false);
}
// User state stays attached because the book id is preserved.
const [status] = await ctx.db
.select()
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, reader.userId), eq(userBookStatus.bookId, book.bookId)));
expect(status?.status).toBe('reading');
},
SCENARIO_TIMEOUT_MS,
);
return { events, summary };
}
it(
'skips the move when the target path already exists on disk and leaves the book untouched',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'dupe/book.epub');
await writeFixtureFile(dst.folderPath, 'dupe/book.epub', 'already here');
async function moveAndSummarize(payload: Record<string, unknown>, token = ctx.adminToken): Promise<BookMoveSummary> {
const response = await executeMove(payload, token);
expect(response.statusCode).toBe(200);
const { summary } = parseMoveStream(response.body);
if (!summary) throw new Error(`Move stream carried no done summary: ${response.body}`);
return summary;
}
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
async function readBookRow(bookId: number) {
const [row] = await ctx.db
.select({ id: books.id, libraryId: books.libraryId, libraryFolderId: books.libraryFolderId, status: books.status })
.from(books)
.where(eq(books.id, bookId))
.limit(1);
return row ?? null;
}
expect(response.statusCode).toBe(200);
const { results } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'skipped', reason: 'target_path_exists' }]);
async function readFileRows(bookId: number) {
return ctx.db
.select({
id: bookFiles.id,
libraryFolderId: bookFiles.libraryFolderId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId));
}
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'dupe/book.epub'))).resolves.toBe(true);
// The colliding file in the target must not have been overwritten.
await expect(readFile(join(dst.folderPath, 'dupe/book.epub'), 'utf8')).resolves.toBe('already here');
},
SCENARIO_TIMEOUT_MS,
);
describe('preview', () => {
it(
'classifies a movable book as ready and resolves its destination path',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Ready ${randomUUID().slice(0, 8)}`;
const book = await seedEpub(src, 'inbox/ready.epub', title);
it(
'rejects a target folder that belongs to a different library and leaves the book untouched',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'wrong-folder/book.epub');
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
const response = await moveBooks({
bookIds: [book.bookId],
targetLibraryId: dst.libraryId,
targetFolderId: src.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ statusCode: 400, message: expect.stringContaining('does not belong to library') });
expect(preview.totalSelected).toBe(1);
expect(preview.readyCount).toBe(1);
expect(preview.collisionCount).toBe(0);
expect(preview.ineligibleCount).toBe(0);
expect(preview.alreadyInTargetCount).toBe(0);
expect(preview.targetOrganizationMode).toBe('book_per_file');
expect(preview.ready[0]).toMatchObject({
bookId: book.bookId,
currentPath: book.absolutePath,
targetPath: join(dst.folderPath, `${title}.epub`),
layoutChange: null,
});
expect(preview.requiresReview).toBe(false);
},
SCENARIO_TIMEOUT_MS,
);
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'wrong-folder/book.epub'))).resolves.toBe(true);
await expect(pathExists(join(dst.folderPath, 'wrong-folder/book.epub'))).resolves.toBe(false);
},
SCENARIO_TIMEOUT_MS,
);
it(
'reports books that already live in the target folder',
async () => {
const dst = await createTarget();
const book = await seedEpub(dst, 'resident.epub', `Resident ${randomUUID().slice(0, 8)}`);
it(
'skips books whose format is not allowed in the target library and leaves files in place',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}`, allowedFormats: ['pdf'] });
const book = await seedBookInLibrary(src, 'format/book.epub');
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.alreadyInTargetCount).toBe(1);
expect(preview.readyCount).toBe(0);
},
SCENARIO_TIMEOUT_MS,
);
expect(response.statusCode).toBe(200);
const { results } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'skipped', reason: 'format_not_allowed' }]);
it(
'flags an identical copy in the target as a hash duplicate and suggests merge',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Duplicate ${randomUUID().slice(0, 8)}`;
const { existing, incoming } = await seedIdenticalPair(src, dst, title);
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'format/book.epub'))).resolves.toBe(true);
await expect(pathExists(join(dst.folderPath, 'format/book.epub'))).resolves.toBe(false);
},
SCENARIO_TIMEOUT_MS,
);
const response = await previewMove({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.collisionCount).toBe(1);
expect(preview.collisions[0]).toMatchObject({
bookId: incoming.bookId,
kind: 'hash_duplicate',
existingBookId: existing.bookId,
suggestedPolicy: 'merge',
});
expect(preview.requiresReview).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'flags a name-only clash as a path collision and offers a keep-both destination',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Clash ${randomUUID().slice(0, 8)}`;
// Same title (so the resolved destination name matches) but different uid,
// so the content hashes differ and this is a name clash, not a duplicate.
const incumbent = await seedIncumbent(dst, title);
const incoming = await seedEpub(src, 'incoming.epub', title, `${title}-incoming`);
const response = await previewMove({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.collisionCount).toBe(1);
const collision = preview.collisions[0];
expect(collision.bookId).toBe(incoming.bookId);
// For a book_per_file target the plan's folder key is the destination file
// path itself, so the folder-owner check reports the clash before the
// per-file pass gets to it. Both describe the same occupied path.
expect(collision.kind).toBe('folder_path');
expect(collision.existingBookId).toBe(incumbent.bookId);
expect(collision.suggestedPolicy).toBe('keep_both');
expect(collision.targetPath).toBe(join(dst.folderPath, `${title}.epub`));
expect(collision.keepBothPath).not.toBe(collision.targetPath);
expect(collision.keepBothPath.startsWith(dst.folderPath)).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'marks a multi-file book ineligible for a book_per_file target',
async () => {
const src = await createSource({ mode: 'book_per_folder' });
const dst = await createTarget({ mode: 'book_per_file' });
const title = `Multi ${randomUUID().slice(0, 8)}`;
await createEpubFixture(src.folderPath, `${title}/book.epub`, { title, uid: title });
await createPdfFixture(src.folderPath, `${title}/book.pdf`, title);
await triggerAndWaitForLibraryScan(ctx, src.libraryId);
const seeded = await locateBookFileByRelPath(ctx, src.libraryId, `${title}/book.epub`);
const files = await readFileRows(seeded.bookId);
expect(files.length).toBeGreaterThan(1);
const response = await previewMove({
selection: { bookIds: [seeded.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.ineligibleCount).toBe(1);
expect(preview.ineligible[0]).toMatchObject({
bookId: seeded.bookId,
reason: 'multi_file_target_per_file',
});
expect(preview.readyCount).toBe(0);
expect(preview.requiresReview).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'warns when a moved format is outside the target library allow-list',
async () => {
const src = await createSource();
const dst = await createTarget({ allowedFormats: ['pdf'] });
const book = await seedEpub(src, 'mismatch.epub', `Mismatch ${randomUUID().slice(0, 8)}`);
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.warnings.formatMismatches).toHaveLength(1);
expect(preview.warnings.formatMismatches[0]).toMatchObject({ bookId: book.bookId, format: 'epub' });
expect(preview.requiresReview).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'warns about the layout change when the target organizes book-per-folder',
async () => {
const src = await createSource({ mode: 'book_per_file' });
const dst = await createTarget({ mode: 'book_per_folder' });
const book = await seedEpub(src, 'flat.epub', `Layout ${randomUUID().slice(0, 8)}`);
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.readyCount).toBe(1);
expect(preview.ready[0].layoutChange).toBe('wrap_into_folder');
expect(preview.warnings.layout).toMatchObject({ change: 'wrap_into_folder', bookCount: 1 });
expect(preview.requiresReview).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'warns about users who lose sight of the books after the move',
async () => {
const src = await createSource();
const dst = await createTarget();
const book = await seedEpub(src, 'watched.epub', `Access ${randomUUID().slice(0, 8)}`);
const viewer = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, viewer.userId, src.libraryId, 'viewer');
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(201);
const preview = response.json() as BookMovePreviewResult;
expect(preview.warnings.accessLosers).toContainEqual({
userId: viewer.userId,
username: viewer.username,
bookCount: 1,
});
expect(preview.requiresReview).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'rejects a target folder that does not exist',
async () => {
const src = await createSource();
const book = await seedEpub(src, 'orphan.epub', `Orphan ${randomUUID().slice(0, 8)}`);
const response = await previewMove({
selection: { bookIds: [book.bookId] },
targetLibraryId: 999_999,
targetFolderId: 999_999,
});
expect(response.statusCode).toBe(404);
},
SCENARIO_TIMEOUT_MS,
);
it(
'rejects a query selection that matches no books',
async () => {
const empty = await createSource();
const dst = await createTarget();
const response = await previewMove({
selection: { query: { libraryId: empty.libraryId } },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(400);
},
SCENARIO_TIMEOUT_MS,
);
it(
'rejects a selection naming a book that does not exist',
async () => {
const dst = await createTarget();
const response = await previewMove({
selection: { bookIds: [9_999_999] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
expect(response.statusCode).toBe(404);
},
SCENARIO_TIMEOUT_MS,
);
});
describe('execute', () => {
it(
're-parents the book, relocates the file, and preserves user state',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Moved ${randomUUID().slice(0, 8)}`;
const book = await seedEpub(src, 'nested/moved.epub', title);
const originalPath = book.absolutePath;
const reader = await createUserAndLogin(ctx);
await ctx.db.insert(userBookStatus).values({ userId: reader.userId, bookId: book.bookId, status: 'reading', source: 'manual' });
const [annotation] = await ctx.db
.insert(annotations)
.values({ userId: reader.userId, bookId: book.bookId, text: 'preserved highlight' })
.returning({ id: annotations.id });
const [collection] = await ctx.db
.insert(collections)
.values({ userId: reader.userId, name: `collection-${randomUUID()}` })
.returning({ id: collections.id });
await ctx.db.insert(collectionBooks).values({ collectionId: collection.id, bookId: book.bookId });
const summary = await moveAndSummarize({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
});
expect(summary).toMatchObject({ processed: 1, succeeded: 1, merged: 0, failed: 0, skipped: 0, cancelled: false });
// The book row and its file rows must both land in the target folder. If the
// file row were rewritten first, the composite FK back to books would reject it.
const row = await readBookRow(book.bookId);
expect(row).toMatchObject({ libraryId: dst.libraryId, libraryFolderId: dst.libraryFolderId, status: 'present' });
const expectedPath = join(dst.folderPath, `${title}.epub`);
const files = await readFileRows(book.bookId);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({ libraryFolderId: dst.libraryFolderId, absolutePath: expectedPath, relPath: `${title}.epub` });
expect(await pathExists(expectedPath)).toBe(true);
expect(await pathExists(originalPath)).toBe(false);
const [status] = await ctx.db
.select({ status: userBookStatus.status })
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, reader.userId), eq(userBookStatus.bookId, book.bookId)));
expect(status?.status).toBe('reading');
const keptAnnotations = await ctx.db.select({ id: annotations.id }).from(annotations).where(eq(annotations.bookId, book.bookId));
expect(keptAnnotations).toContainEqual({ id: annotation.id });
const keptCollection = await ctx.db
.select({ bookId: collectionBooks.bookId })
.from(collectionBooks)
.where(and(eq(collectionBooks.collectionId, collection.id), eq(collectionBooks.bookId, book.bookId)));
expect(keptCollection).toHaveLength(1);
},
SCENARIO_TIMEOUT_MS,
);
it(
'records a completed job row carrying the per-status counts',
async () => {
const src = await createSource();
const dst = await createTarget();
const book = await seedEpub(src, 'job.epub', `Job ${randomUUID().slice(0, 8)}`);
await moveAndSummarize({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
});
const [job] = await ctx.db
.select()
.from(bookMoveJobs)
.where(and(eq(bookMoveJobs.targetLibraryId, dst.libraryId), eq(bookMoveJobs.targetFolderId, dst.libraryFolderId)));
expect(job).toBeDefined();
expect(job.status).toBe('completed');
expect(job.totalBooks).toBe(1);
expect(job.succeeded).toBe(1);
expect(job.failed).toBe(0);
expect(job.sourceLibraryIds).toEqual([src.libraryId]);
expect(job.finishedAt).not.toBeNull();
},
SCENARIO_TIMEOUT_MS,
);
it(
'leaves a colliding book untouched under the skip policy',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Skipped ${randomUUID().slice(0, 8)}`;
await seedIncumbent(dst, title);
const incoming = await seedEpub(src, 'incoming.epub', title, `${title}-incoming`);
const originalPath = incoming.absolutePath;
const summary = await moveAndSummarize({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'skip',
});
// "skip" drops the book from the work list entirely, so nothing is processed.
expect(summary.succeeded).toBe(0);
expect(summary.merged).toBe(0);
const row = await readBookRow(incoming.bookId);
expect(row?.libraryId).toBe(src.libraryId);
expect(await pathExists(originalPath)).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'lands a colliding book beside the incumbent under the keep_both policy',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `KeepBoth ${randomUUID().slice(0, 8)}`;
const existing = await seedIncumbent(dst, title);
const incoming = await seedEpub(src, 'incoming.epub', title, `${title}-incoming`);
const previewResponse = await previewMove({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
});
const keepBothPath = (previewResponse.json() as BookMovePreviewResult).collisions[0].keepBothPath;
const summary = await moveAndSummarize({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'keep_both',
});
expect(summary.succeeded).toBe(1);
const files = await readFileRows(incoming.bookId);
expect(files[0].absolutePath).toBe(keepBothPath);
expect(await pathExists(keepBothPath)).toBe(true);
// The incumbent is untouched.
expect(await pathExists(existing.absolutePath)).toBe(true);
const incumbent = await readBookRow(existing.bookId);
expect(incumbent?.libraryId).toBe(dst.libraryId);
},
SCENARIO_TIMEOUT_MS,
);
it(
'replaces the incumbent copy under the merge policy',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Merge ${randomUUID().slice(0, 8)}`;
const { existing, incoming } = await seedIdenticalPair(src, dst, title);
const summary = await moveAndSummarize({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'merge',
});
expect(summary.merged).toBe(1);
expect(summary.failed).toBe(0);
const moved = await readBookRow(incoming.bookId);
expect(moved?.libraryId).toBe(dst.libraryId);
// The duplicate row it replaced is gone.
expect(await readBookRow(existing.bookId)).toBeNull();
},
SCENARIO_TIMEOUT_MS,
);
it(
'lets a per-book override win over the job-level policy',
async () => {
const src = await createSource();
const dst = await createTarget();
const title = `Override ${randomUUID().slice(0, 8)}`;
await seedIncumbent(dst, title);
const incoming = await seedEpub(src, 'incoming.epub', title, `${title}-incoming`);
// Job policy says skip; the override says keep_both, so the book still moves.
const summary = await moveAndSummarize({
selection: { bookIds: [incoming.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'skip',
overrides: [{ bookId: incoming.bookId, policy: 'keep_both' }],
});
expect(summary.succeeded).toBe(1);
const row = await readBookRow(incoming.bookId);
expect(row?.libraryId).toBe(dst.libraryId);
},
SCENARIO_TIMEOUT_MS,
);
it(
'wraps a flat book into its own folder for a book_per_folder target',
async () => {
const src = await createSource({ mode: 'book_per_file' });
const dst = await createTarget({ mode: 'book_per_folder' });
const title = `Wrapped ${randomUUID().slice(0, 8)}`;
const book = await seedEpub(src, 'flat.epub', title);
const summary = await moveAndSummarize({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
});
expect(summary.succeeded).toBe(1);
const expectedPath = join(dst.folderPath, title, `${title}.epub`);
const files = await readFileRows(book.bookId);
expect(files[0].absolutePath).toBe(expectedPath);
expect(await pathExists(expectedPath)).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
it(
'streams one progress frame per book before the done summary',
async () => {
const src = await createSource();
const dst = await createTarget();
const first = await seedEpub(src, 'first.epub', `Stream A ${randomUUID().slice(0, 8)}`);
const second = await seedEpub(src, 'second.epub', `Stream B ${randomUUID().slice(0, 8)}`);
const response = await executeMove({
selection: { bookIds: [first.bookId, second.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
const { events, summary } = parseMoveStream(response.body);
const perBook = events.filter((event) => !('done' in event)) as { bookId: number; status: string }[];
expect(perBook).toHaveLength(2);
expect(perBook.map((event) => event.bookId).sort()).toEqual([first.bookId, second.bookId].sort());
expect(perBook.every((event) => event.status === 'success')).toBe(true);
expect(summary).toMatchObject({ processed: 2, succeeded: 2 });
// The done frame is last.
expect('done' in events[events.length - 1]).toBe(true);
},
SCENARIO_TIMEOUT_MS,
);
});
describe('access control', () => {
it(
'refuses a mover without editor access to the source library',
async () => {
const src = await createSource();
const dst = await createTarget();
const book = await seedEpub(src, 'guarded.epub', `Guarded ${randomUUID().slice(0, 8)}`);
const mover = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
await grantLibraryAccess(ctx, mover.userId, src.libraryId, 'viewer');
await grantLibraryAccess(ctx, mover.userId, dst.libraryId, 'editor');
const response = await executeMove(
{
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
},
mover.accessToken,
);
expect(response.statusCode).toBe(403);
const row = await readBookRow(book.bookId);
expect(row?.libraryId).toBe(src.libraryId);
},
SCENARIO_TIMEOUT_MS,
);
it(
'refuses a mover without editor access to the target library',
async () => {
const src = await createSource();
const dst = await createTarget();
const book = await seedEpub(src, 'guarded-target.epub', `GuardedTarget ${randomUUID().slice(0, 8)}`);
const mover = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
await grantLibraryAccess(ctx, mover.userId, src.libraryId, 'editor');
await grantLibraryAccess(ctx, mover.userId, dst.libraryId, 'viewer');
const response = await executeMove(
{
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
},
mover.accessToken,
);
expect(response.statusCode).toBe(403);
const row = await readBookRow(book.bookId);
expect(row?.libraryId).toBe(src.libraryId);
},
SCENARIO_TIMEOUT_MS,
);
});
});
@@ -1,10 +1,10 @@
{
"totalRoutes": 368,
"totalRoutes": 370,
"byPermission": {
"(none)": 165,
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 31,
"Permission.LibraryEditMetadata": 33,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 22,
"Permission.ManageLibraries": 14,
@@ -166,5 +166,29 @@
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-move/book-move.controller.ts",
"className": "BookMoveController",
"methodName": "preview",
"httpMethod": "POST",
"path": "/books/move/preview",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-move/book-move.controller.ts",
"className": "BookMoveController",
"methodName": "move",
"httpMethod": "POST",
"path": "/books/move",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
}
]