mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
39ac2f1efd
Link two ordinary requests as companions, filed into one book, with a Both search, grouped list rows, a tabbed release picker and a book-page request for the missing format. Retire comic requests, keeping old rows readable. File Book Dock covers by the field rules with a shape check, and re-pick a moved book's primary by its new library.
841 lines
32 KiB
TypeScript
841 lines
32 KiB
TypeScript
/**
|
|
* 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, copyFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
|
|
import { and, eq } from 'drizzle-orm';
|
|
|
|
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, createM4bFixture, 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';
|
|
|
|
const SCENARIO_TIMEOUT_MS = 120_000;
|
|
|
|
describe('Cross-library book move (e2e)', () => {
|
|
let ctx: MetadataWriteE2EContext;
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createMetadataWriteE2EContext();
|
|
}, SCENARIO_TIMEOUT_MS);
|
|
|
|
afterAll(async () => {
|
|
await closeMetadataWriteE2EContext(ctx);
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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/preview',
|
|
headers: authHeader(token),
|
|
payload,
|
|
});
|
|
}
|
|
|
|
function executeMove(payload: Record<string, unknown>, token = ctx.adminToken) {
|
|
return ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/books/move',
|
|
headers: authHeader(token),
|
|
payload,
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
return { events, summary };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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);
|
|
|
|
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.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,
|
|
);
|
|
|
|
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)}`);
|
|
|
|
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.alreadyInTargetCount).toBe(1);
|
|
expect(preview.readyCount).toBe(0);
|
|
},
|
|
SCENARIO_TIMEOUT_MS,
|
|
);
|
|
|
|
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 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(
|
|
'picks the primary file by the format priority of the library the book moved into',
|
|
async () => {
|
|
const src = await createSource({ mode: 'book_per_folder' });
|
|
const dst = await createTarget({ mode: 'book_per_folder' });
|
|
await ctx.db
|
|
.update(libraries)
|
|
.set({ formatPriority: ['m4b', 'epub'] })
|
|
.where(eq(libraries.id, dst.libraryId));
|
|
const title = `Both formats ${randomUUID().slice(0, 8)}`;
|
|
await createM4bFixture(src.folderPath, `${title}/${title}.m4b`, { title });
|
|
const book = await seedEpub(src, `${title}/${title}.epub`, title);
|
|
|
|
const formatOf = async (fileId: number | null) => (await readFileRows(book.bookId)).find((file) => file.id === fileId)?.relPath ?? null;
|
|
const primaryOf = async () =>
|
|
(await ctx.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, book.bookId)))[0]?.primaryFileId ?? null;
|
|
expect(await formatOf(await primaryOf())).toMatch(/\.epub$/);
|
|
|
|
const summary = await moveAndSummarize({
|
|
selection: { bookIds: [book.bookId] },
|
|
targetLibraryId: dst.libraryId,
|
|
targetFolderId: dst.libraryFolderId,
|
|
collisionPolicy: 'suggested',
|
|
});
|
|
|
|
expect(summary.succeeded).toBe(1);
|
|
expect(await formatOf(await primaryOf())).toMatch(/\.m4b$/);
|
|
},
|
|
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,
|
|
);
|
|
});
|
|
});
|