mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: request an ebook and audiobook as a pair
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.
This commit is contained in:
@@ -31,7 +31,7 @@ import type { BookMovePreviewResult, BookMoveProgressEvent, BookMoveSummary } fr
|
||||
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 { createEpubFixture, createM4bFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
|
||||
import {
|
||||
authHeader,
|
||||
closeMetadataWriteE2EContext,
|
||||
@@ -716,6 +716,37 @@ describe('Cross-library book move (e2e)', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { BookRequestBulkResult, BookRequestItem, BookRequestPage, BookRequestSubmitResult, IndexerItem } from '@bookorbit/types';
|
||||
import type {
|
||||
BookRequestBulkResult,
|
||||
BookRequestItem,
|
||||
BookRequestPage,
|
||||
BookRequestPairSubmitResult,
|
||||
BookRequestSubmitResult,
|
||||
IndexerItem,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import {
|
||||
@@ -40,7 +47,7 @@ const SELF_FULFIL_PERMISSIONS = [Permission.BookRequestAccess, Permission.BookRe
|
||||
|
||||
interface SubmitOptions {
|
||||
title?: string;
|
||||
mediaKind?: 'ebook' | 'audiobook' | 'comic';
|
||||
mediaKind?: 'ebook' | 'audiobook';
|
||||
targetLibraryId?: number | null;
|
||||
selfServe?: boolean;
|
||||
userId?: number;
|
||||
@@ -517,6 +524,224 @@ describe('Book requests (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('ebook and audiobook pairs', () => {
|
||||
function submitPair(session: TestUserSession, title: string, overrides: Record<string, unknown> = {}) {
|
||||
return inject('POST', '/book-requests/pairs', session, {
|
||||
title,
|
||||
authors: ['Fixture Author'],
|
||||
ebook: { targetLibraryId: library.libraryId },
|
||||
audiobook: { targetLibraryId: library.libraryId },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitPairOk(
|
||||
session: TestUserSession,
|
||||
title: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<BookRequestPairSubmitResult> {
|
||||
const response = await submitPair(session, title, overrides);
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.json() as BookRequestPairSubmitResult;
|
||||
}
|
||||
|
||||
/** A book holding one ebook file, in a library that keeps one book per folder. */
|
||||
async function seedEbookBook(target: CreatedLibrary): Promise<number> {
|
||||
const folderPath = `${target.folderPath}/${randomUUID()}`;
|
||||
const [book] = await ctx.db
|
||||
.insert(schema.books)
|
||||
.values({ libraryId: target.libraryId, libraryFolderId: target.libraryFolderId, folderPath })
|
||||
.returning({ id: schema.books.id });
|
||||
await ctx.db.insert(schema.bookFiles).values({
|
||||
bookId: book!.id,
|
||||
libraryFolderId: target.libraryFolderId,
|
||||
absolutePath: `${folderPath}/book.epub`,
|
||||
ino: BigInt(Math.floor(Math.random() * 1_000_000_000)),
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
});
|
||||
return book!.id;
|
||||
}
|
||||
|
||||
it(
|
||||
'files both halves as requests of their own that name each other',
|
||||
async () => {
|
||||
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
|
||||
|
||||
expect(ebook.subscribed).toBe(false);
|
||||
expect(audiobook.subscribed).toBe(false);
|
||||
expect(ebook.request.mediaKind).toBe('ebook');
|
||||
expect(audiobook.request.mediaKind).toBe('audiobook');
|
||||
expect(ebook.request.status).toBe('pending');
|
||||
expect(audiobook.request.status).toBe('pending');
|
||||
expect(ebook.request.companionRequestId).toBe(audiobook.request.id);
|
||||
expect(audiobook.request.companionRequestId).toBe(ebook.request.id);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'lists a pair as one entry led by its ebook when asked to group, and as two entries otherwise',
|
||||
async () => {
|
||||
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
|
||||
|
||||
const grouped = (await inject('GET', '/book-requests?groupPairs=true&limit=100', requester)).json() as BookRequestPage;
|
||||
const lead = grouped.items.find((item) => item.id === ebook.request.id);
|
||||
expect(lead?.companion?.id).toBe(audiobook.request.id);
|
||||
expect(grouped.items.some((item) => item.id === audiobook.request.id)).toBe(false);
|
||||
|
||||
const flat = (await inject('GET', '/book-requests?limit=100', requester)).json() as BookRequestPage;
|
||||
const ids = flat.items.map((item) => item.id);
|
||||
expect(ids).toEqual(expect.arrayContaining([ebook.request.id, audiobook.request.id]));
|
||||
// A client that never asked to group must not be handed the nested shape.
|
||||
expect(flat.items.find((item) => item.id === ebook.request.id)).not.toHaveProperty('companion');
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'keeps a grouped pair on a filtered queue when only one half matches the filter',
|
||||
async () => {
|
||||
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
|
||||
const approved = await inject('POST', `/admin/book-requests/${audiobook.request.id}/approve`, moderator, {});
|
||||
expect(approved.statusCode).toBe(200);
|
||||
|
||||
const queue = (await inject('GET', '/admin/book-requests?groupPairs=true&status=approved&limit=100', moderator)).json() as BookRequestPage;
|
||||
const lead = queue.items.find((item) => item.id === ebook.request.id);
|
||||
expect(lead?.status).toBe('pending');
|
||||
expect(lead?.companion?.status).toBe('approved');
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'joins a half somebody already asked for and links it to the half it created',
|
||||
async () => {
|
||||
const title = `Pair ${randomUUID()}`;
|
||||
const existing = await submitOk(secondRequester, { title });
|
||||
|
||||
const { ebook, audiobook } = await submitPairOk(requester, title);
|
||||
|
||||
expect(ebook.subscribed).toBe(true);
|
||||
expect(ebook.request.id).toBe(existing.request.id);
|
||||
expect(audiobook.subscribed).toBe(false);
|
||||
expect(ebook.request.companionRequestId).toBe(audiobook.request.id);
|
||||
expect(audiobook.request.companionRequestId).toBe(existing.request.id);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'refuses the whole pair, writing neither half, when the rules refuse one of them',
|
||||
async () => {
|
||||
const title = `Pair ${randomUUID()}`;
|
||||
const response = await submitPair(requester, title, { audiobook: { targetLibraryId: otherLibrary.libraryId } });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect((response.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_LIBRARY_FORBIDDEN');
|
||||
const rows = await ctx.db.select({ id: schema.bookRequests.id }).from(schema.bookRequests).where(eq(schema.bookRequests.title, title));
|
||||
expect(rows).toHaveLength(0);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'files a missing format against the book it was asked from, and refuses a format the book already has',
|
||||
async () => {
|
||||
const folderLibrary = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `book-requests-folder-${randomUUID()}` });
|
||||
await grantLibraryAccess(ctx, requester.userId, folderLibrary.libraryId, 'viewer');
|
||||
const bookId = await seedEbookBook(folderLibrary);
|
||||
|
||||
const added = await inject('POST', '/book-requests', requester, {
|
||||
title: `Joined ${randomUUID()}`,
|
||||
mediaKind: 'audiobook',
|
||||
authors: ['Fixture Author'],
|
||||
targetBookId: bookId,
|
||||
targetLibraryId: library.libraryId,
|
||||
});
|
||||
expect(added.statusCode).toBe(201);
|
||||
const { request } = added.json() as BookRequestSubmitResult;
|
||||
expect(request.targetBookId).toBe(bookId);
|
||||
// The book decides where the format lands, whatever library the form had selected.
|
||||
expect(request.targetLibraryId).toBe(folderLibrary.libraryId);
|
||||
|
||||
const duplicate = await inject('POST', '/book-requests', requester, {
|
||||
title: `Joined ${randomUUID()}`,
|
||||
mediaKind: 'ebook',
|
||||
targetBookId: bookId,
|
||||
});
|
||||
expect(duplicate.statusCode).toBe(400);
|
||||
expect((duplicate.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_TARGET_BOOK_HAS_MEDIUM');
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'treats a book in a library the requester cannot reach as one that does not exist',
|
||||
async () => {
|
||||
const hidden = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `book-requests-hidden-${randomUUID()}` });
|
||||
const bookId = await seedEbookBook(hidden);
|
||||
|
||||
const response = await inject('POST', '/book-requests', requester, {
|
||||
title: `Joined ${randomUUID()}`,
|
||||
mediaKind: 'audiobook',
|
||||
targetBookId: bookId,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_TARGET_BOOK_NOT_FOUND');
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('the retired comic medium', () => {
|
||||
it(
|
||||
'refuses a new comic request, writing nothing',
|
||||
async () => {
|
||||
const title = `Retired ${randomUUID()}`;
|
||||
const response = await inject('POST', '/book-requests', requester, { title, mediaKind: 'comic', targetLibraryId: library.libraryId });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const rows = await ctx.db.select({ id: schema.bookRequests.id }).from(schema.bookRequests).where(eq(schema.bookRequests.title, title));
|
||||
expect(rows).toHaveLength(0);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
/** Released iOS builds decode `comic` here as required; leaving it out breaks their whole request screen. */
|
||||
it(
|
||||
'still answers comic, with no destination, among the default destinations',
|
||||
async () => {
|
||||
const response = await inject('GET', '/book-requests/default-destinations', requester);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ comic: { libraryId: null, libraryName: null, folderId: null } });
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'keeps an existing comic request readable and closable, but will not approve it',
|
||||
async () => {
|
||||
const created = await submitOk(requester);
|
||||
await ctx.db.update(schema.bookRequests).set({ mediaKind: 'comic' }).where(eq(schema.bookRequests.id, created.request.id));
|
||||
|
||||
const read = await inject('GET', `/book-requests/${created.request.id}`, requester);
|
||||
expect((read.json() as BookRequestItem).mediaKind).toBe('comic');
|
||||
|
||||
const approve = await inject('POST', `/admin/book-requests/${created.request.id}/approve`, moderator, {});
|
||||
expect(approve.statusCode).toBe(400);
|
||||
expect(await statusOf(created.request.id)).toBe('pending');
|
||||
|
||||
const cancel = await inject('POST', `/book-requests/${created.request.id}/cancel`, requester, {});
|
||||
expect(cancel.statusCode).toBe(200);
|
||||
expect(await statusOf(created.request.id)).toBe('cancelled');
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('held imports', () => {
|
||||
it(
|
||||
'discards a held import, removing the dock entry and failing the request',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 630,
|
||||
"totalRoutes": 631,
|
||||
"byPermission": {
|
||||
"(none)": 278,
|
||||
"Permission.ManageMetadataConfig": 34,
|
||||
@@ -25,7 +25,7 @@
|
||||
"Permission.HardcoverSync": 1,
|
||||
"Permission.ReadwiseSync": 1,
|
||||
"Permission.StorygraphSync": 1,
|
||||
"Permission.BookRequestAccess": 25,
|
||||
"Permission.BookRequestAccess": 26,
|
||||
"Permission.ManageBookRequests": 20,
|
||||
"Permission.BookRequestSelfFulfill": 11,
|
||||
"Permission.PodcastManageFeeds": 16,
|
||||
|
||||
@@ -203,6 +203,18 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request.controller.ts",
|
||||
"className": "BookRequestController",
|
||||
"methodName": "submitPair",
|
||||
"httpMethod": "POST",
|
||||
"path": "/book-requests/pairs",
|
||||
"permissions": ["Permission.BookRequestAccess"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request.controller.ts",
|
||||
"className": "BookRequestController",
|
||||
|
||||
Reference in New Issue
Block a user