Files
bookorbit/server/test/annotations-hub.e2e-spec.ts
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

280 lines
11 KiB
TypeScript

import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { createEpubFixture, createPdfFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
createUserAndLogin,
grantLibraryAccess,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
type TestUserSession,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const FIXTURE_CFI = 'epubcfi(/6/2!/4/2,/1:0,/1:7)';
describe('Annotations hub (e2e)', { timeout: 120_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let epub!: LocatedBookFile;
let library!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
let owner!: TestUserSession;
let outsider!: TestUserSession;
const createdIds: number[] = [];
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `annotations-hub-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'hub-book.epub', {
title: `Hub Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
epub = await locateBookByAbsolutePath(ctx, epubPath);
owner = await createUserAndLogin(ctx);
outsider = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, owner.userId, library.libraryId, 'viewer');
for (const note of ['first', 'second', 'third']) {
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(owner.accessToken),
payload: { cfi: FIXTURE_CFI, text: 'fixture', color: '#FACC15', style: 'highlight', note },
});
expect(created.statusCode).toBe(201);
createdIds.push((created.json() as { id: number }).id);
}
}, 120_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('lists annotations across books with book titles and filters', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations?status=active&search=second',
headers: authHeader(owner.accessToken),
});
expect(response.statusCode).toBe(200);
const body = response.json() as { items: { note: string | null; bookTitle: string | null; jumpFileId: number | null }[]; total: number };
expect(body.total).toBe(1);
expect(body.items[0].note).toBe('second');
// The e2e harness mocks metadata extraction, so the title may be null here.
expect(body.items[0]).toHaveProperty('bookTitle');
expect(body.items[0].jumpFileId).toBe(epub.bookFileId);
});
it('surfaces a PDF highlight in the hub with the fields a page deep link needs', async () => {
const marker = `pdfmarker-${randomUUID()}`;
const pdfPath = await createPdfFixture(library.folderPath, `hub-pdf-${randomUUID()}.pdf`, `Hub PDF ${randomUUID()}`);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const pdf = await locateBookByAbsolutePath(ctx, pdfPath);
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${pdf.bookId}/annotations`,
headers: authHeader(owner.accessToken),
payload: {
pdf: { page: 3, rect: { x: 10, y: 20, width: 100, height: 12 }, rects: [{ x: 10, y: 20, width: 100, height: 12 }] },
bookFileId: pdf.bookFileId,
text: marker,
color: '#FACC15',
style: 'highlight',
},
});
expect(created.statusCode).toBe(201);
const createdBody = created.json() as { cfi: string | null; pageno: number | null; pdf: { page: number } | null };
expect(createdBody.cfi).toBeNull();
expect(createdBody.pageno).toBe(4);
expect(createdBody.pdf).toMatchObject({ page: 3 });
const filePage = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${pdf.bookId}/annotations?page=1&pageSize=100&bookFileId=${pdf.bookFileId}&sortBy=position&sortDir=asc`,
headers: authHeader(owner.accessToken),
});
expect(filePage.statusCode).toBe(200);
const filePageBody = filePage.json() as { items: { jumpFileId: number | null; pdf: { page: number } | null }[]; total: number };
expect(filePageBody.total).toBe(1);
expect(filePageBody.items[0]).toMatchObject({ jumpFileId: pdf.bookFileId, pdf: { page: 3 } });
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/annotations?status=active&search=${marker}`,
headers: authHeader(owner.accessToken),
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
items: { cfi: string | null; jumpFileId: number | null; jumpFileFormat: string | null; pageno: number | null }[];
total: number;
};
expect(body.total).toBe(1);
// These are exactly the fields annotationReaderRoute() uses to build ?format=pdf&page=4.
expect(body.items[0]).toMatchObject({ jumpFileId: pdf.bookFileId, jumpFileFormat: 'pdf', cfi: null, pageno: 4 });
const geometry = { page: 0, rect: { x: 1, y: 2, width: 3, height: 4 }, rects: [{ x: 1, y: 2, width: 3, height: 4 }] };
const missingFile = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${pdf.bookId}/annotations`,
headers: authHeader(owner.accessToken),
payload: { pdf: geometry, text: 'missing file id' },
});
expect(missingFile.statusCode).toBe(400);
const wrongBook = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${pdf.bookId}/annotations`,
headers: authHeader(owner.accessToken),
payload: { pdf: geometry, bookFileId: epub.bookFileId, text: 'wrong book' },
});
expect(wrongBook.statusCode).toBe(400);
const wrongFormat = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(owner.accessToken),
payload: { pdf: geometry, bookFileId: epub.bookFileId, text: 'wrong format' },
});
expect(wrongFormat.statusCode).toBe(400);
});
it('is isolated per user', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations',
headers: authHeader(outsider.accessToken),
});
expect(response.statusCode).toBe(200);
expect((response.json() as { total: number }).total).toBe(0);
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/annotations/bulk',
headers: authHeader(outsider.accessToken),
payload: { ids: createdIds, action: 'trash' },
});
expect(bulk.statusCode).toBe(200);
expect((bulk.json() as { affected: number }).affected).toBe(0);
});
it('bulk trashes, lists in trash, restores and purges', async () => {
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/annotations/bulk',
headers: authHeader(owner.accessToken),
payload: { ids: [createdIds[0], createdIds[1]], action: 'trash' },
});
expect(bulk.statusCode).toBe(200);
expect((bulk.json() as { affected: number }).affected).toBe(2);
const trash = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations?status=trashed',
headers: authHeader(owner.accessToken),
});
expect((trash.json() as { total: number }).total).toBe(2);
const restore = await ctx.app.inject({
method: 'POST',
url: `/api/v1/annotations/${createdIds[0]}/restore`,
headers: authHeader(owner.accessToken),
});
expect(restore.statusCode).toBe(200);
expect((restore.json() as { deletedAt: string | null }).deletedAt).toBeNull();
// No device ever synced these, so the purge is not blocked.
const purge = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/annotations/${createdIds[1]}`,
headers: authHeader(owner.accessToken),
});
expect(purge.statusCode).toBe(204);
const trashAfter = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations?status=trashed',
headers: authHeader(owner.accessToken),
});
expect((trashAfter.json() as { total: number }).total).toBe(0);
});
it('bulk recolors active annotations', async () => {
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/annotations/bulk',
headers: authHeader(owner.accessToken),
payload: { ids: createdIds, action: 'restyle', color: '#4ADE80' },
});
expect(bulk.statusCode).toBe(200);
expect((bulk.json() as { affected: number }).affected).toBeGreaterThan(0);
const list = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations?colors=%234ADE80',
headers: authHeader(owner.accessToken),
});
expect((list.json() as { total: number }).total).toBeGreaterThan(0);
});
it('exports markdown with attachment headers', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/annotations/export?format=md',
headers: authHeader(owner.accessToken),
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('markdown');
expect(response.headers['content-disposition']).toContain('attachment');
expect(response.body).toContain('# Annotations');
expect(response.body).toContain('> fixture');
});
it('leaves highlights on a book the user can no longer open out of the hub until access returns', async () => {
const reader = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, reader.userId, library.libraryId, 'viewer');
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(reader.accessToken),
payload: { cfi: FIXTURE_CFI, text: 'fixture', color: '#FACC15', style: 'highlight', note: 'revoked reader' },
});
const id = (created.json() as { id: number }).id;
const hub = (url: string) => ctx.app.inject({ method: 'GET', url, headers: authHeader(reader.accessToken) });
await ctx.db
.delete(schema.userLibraryAccess)
.where(and(eq(schema.userLibraryAccess.userId, reader.userId), eq(schema.userLibraryAccess.libraryId, library.libraryId)));
expect(((await hub('/api/v1/annotations')).json() as { total: number }).total).toBe(0);
expect(((await hub('/api/v1/annotations/overview')).json() as { total: number; shelf: unknown[] }).shelf).toEqual([]);
expect((await hub('/api/v1/annotations/books')).json()).toEqual([]);
expect((await hub('/api/v1/annotations/export?format=md')).body).not.toContain('revoked reader');
expect((await hub(`/api/v1/annotations/${id}/sync-detail`)).statusCode).toBe(404);
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/annotations/bulk',
headers: authHeader(reader.accessToken),
payload: { ids: [id], action: 'trash' },
});
expect((bulk.json() as { affected: number }).affected).toBe(0);
await grantLibraryAccess(ctx, reader.userId, library.libraryId, 'viewer');
expect(((await hub('/api/v1/annotations')).json() as { total: number }).total).toBe(1);
expect((await hub(`/api/v1/annotations/${id}/sync-detail`)).statusCode).toBe(200);
});
it('rejects unauthenticated access', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/annotations' });
expect(response.statusCode).toBe(401);
});
});