Files
bookorbit/server/test/book-notebook.e2e-spec.ts
T
neon ff880a470c feat(annotations): add book journal, starred highlights and colour names
Add per-book journal entries keyed by a client id, a star toggle that does not bump the annotation
version pushed to devices, a per-user colour legend, and an edit route so bookmark title and note
changes reach the server. All of it is additive and advertised through app-info features, so iOS
sends new fields only to servers that support them. Also sort annotations by numeric CFI steps
instead of comparing CFI strings.
2026-10-06 20:54:22 +00:00

338 lines
18 KiB
TypeScript

import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { SERVER_FEATURES, type AnnotationItem, type BookJournalEntry } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { createEpubFixture } 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';
type InjectMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
describe('Book notebook (e2e)', { timeout: 120_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let bookA!: LocatedBookFile;
let bookB!: LocatedBookFile;
let owner!: TestUserSession;
let peer!: TestUserSession;
let outsider!: TestUserSession;
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
const library = await createLibraryWithFolder(ctx, { name: `notebook-${randomUUID()}` });
const pathA = await createEpubFixture(library.folderPath, 'notebook-a.epub', {
title: `Notebook A ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
const pathB = await createEpubFixture(library.folderPath, 'notebook-b.epub', {
title: `Notebook B ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
bookA = await locateBookByAbsolutePath(ctx, pathA);
bookB = await locateBookByAbsolutePath(ctx, pathB);
owner = await createUserAndLogin(ctx);
peer = await createUserAndLogin(ctx);
outsider = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, owner.userId, library.libraryId, 'viewer');
await grantLibraryAccess(ctx, peer.userId, library.libraryId, 'viewer');
}, 120_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
function call(user: TestUserSession, method: InjectMethod, url: string, payload?: Record<string, unknown>) {
return ctx.app.inject({ method, url: `/api/v1${url}`, headers: authHeader(user.accessToken), ...(payload !== undefined && { payload }) });
}
it('advertises the notebook capabilities on app-info', async () => {
const response = await call(owner, 'GET', '/app-info');
expect(response.statusCode).toBe(200);
expect((response.json() as { features: string[] }).features).toEqual([...SERVER_FEATURES]);
});
describe('journal', () => {
it('creates idempotently on the client id and lists oldest first', async () => {
const clientId = randomUUID();
const created = await call(owner, 'POST', `/books/${bookA.bookId}/journal`, {
clientId,
body: ' First thought ',
quote: 'A quoted line',
chapterTitle: 'Chapter 1',
positionPercent: 12.5,
cfi: 'epubcfi(/6/8!/4/2/1:0)',
createdAt: '2026-01-02T03:04:05.000Z',
});
expect(created.statusCode).toBe(201);
const entry = created.json() as BookJournalEntry;
expect(entry).toMatchObject({
clientId,
bookId: bookA.bookId,
body: 'First thought',
quote: 'A quoted line',
chapterTitle: 'Chapter 1',
positionPercent: 12.5,
cfi: 'epubcfi(/6/8!/4/2/1:0)',
positionSeconds: null,
createdAt: '2026-01-02T03:04:05.000Z',
deletedAt: null,
});
const replay = await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: clientId.toUpperCase(), body: 'Different body' });
expect(replay.statusCode).toBe(201);
expect(replay.json()).toEqual(entry);
const later = await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: randomUUID(), body: 'Second thought' });
expect(later.statusCode).toBe(201);
const list = await call(owner, 'GET', `/books/${bookA.bookId}/journal`);
expect(list.statusCode).toBe(200);
const bodies = (list.json() as BookJournalEntry[]).map((item) => item.body);
expect(bodies.indexOf('First thought')).toBeLessThan(bodies.indexOf('Second thought'));
expect(bodies.filter((body) => body === 'First thought')).toHaveLength(1);
});
it('clamps a createdAt far in the future to the server clock', async () => {
const before = Date.now();
const response = await call(owner, 'POST', `/books/${bookA.bookId}/journal`, {
clientId: randomUUID(),
body: 'From a fast clock',
createdAt: new Date(before + 24 * 60 * 60 * 1000).toISOString(),
});
expect(response.statusCode).toBe(201);
const createdAt = Date.parse((response.json() as BookJournalEntry).createdAt);
expect(createdAt).toBeGreaterThanOrEqual(before - 1000);
expect(createdAt).toBeLessThanOrEqual(Date.now() + 1000);
});
it('answers 409 when a client id is reused on another book', async () => {
const clientId = randomUUID();
expect((await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId, body: 'On A' })).statusCode).toBe(201);
const conflict = await call(owner, 'POST', `/books/${bookB.bookId}/journal`, { clientId, body: 'On B' });
expect(conflict.statusCode).toBe(409);
});
it('rejects blank bodies, malformed ids and unknown fields with 400', async () => {
expect((await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: randomUUID(), body: ' ' })).statusCode).toBe(400);
expect((await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: 'nope', body: 'x' })).statusCode).toBe(400);
expect((await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: randomUUID(), body: 'x', mood: 'calm' })).statusCode).toBe(400);
expect(
(await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId: randomUUID(), body: 'x', positionPercent: 101 })).statusCode,
).toBe(400);
expect((await call(owner, 'PATCH', `/books/${bookA.bookId}/journal/not-a-uuid`, { body: 'x' })).statusCode).toBe(400);
expect((await call(owner, 'GET', `/books/${bookA.bookId}/journal?status=everything`)).statusCode).toBe(400);
});
it('edits, trashes, restores and permanently deletes through the trash only', async () => {
const clientId = randomUUID();
const base = `/books/${bookA.bookId}/journal/${clientId}`;
await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId, body: 'Lifecycle', quote: 'Quote to clear' });
const edited = await call(owner, 'PATCH', base, { body: 'Lifecycle edited', quote: null, positionSeconds: 30 });
expect(edited.statusCode).toBe(200);
expect(edited.json()).toMatchObject({ body: 'Lifecycle edited', quote: null, positionSeconds: 30 });
expect((await call(owner, 'PATCH', base, { body: null })).statusCode).toBe(400);
expect((await call(owner, 'DELETE', `${base}/permanent`)).statusCode).toBe(404);
expect((await call(owner, 'DELETE', base)).statusCode).toBe(204);
expect((await call(owner, 'DELETE', base)).statusCode).toBe(204);
expect((await call(owner, 'PATCH', base, { body: 'Edit in trash' })).statusCode).toBe(404);
const active = (await call(owner, 'GET', `/books/${bookA.bookId}/journal`)).json() as BookJournalEntry[];
expect(active.some((item) => item.clientId === clientId)).toBe(false);
const trashed = (await call(owner, 'GET', `/books/${bookA.bookId}/journal?status=trashed`)).json() as BookJournalEntry[];
const inTrash = trashed.find((item) => item.clientId === clientId);
expect(inTrash?.deletedAt).toEqual(expect.any(String));
const restored = await call(owner, 'POST', `${base}/restore`);
expect(restored.statusCode).toBe(200);
expect(restored.json()).toMatchObject({ clientId, deletedAt: null, body: 'Lifecycle edited' });
expect((await call(owner, 'POST', `${base}/restore`)).statusCode).toBe(404);
expect((await call(owner, 'DELETE', base)).statusCode).toBe(204);
expect((await call(owner, 'DELETE', `${base}/permanent`)).statusCode).toBe(204);
expect((await call(owner, 'DELETE', `${base}/permanent`)).statusCode).toBe(404);
expect((await call(owner, 'DELETE', base)).statusCode).toBe(404);
const [row] = await ctx.db.select().from(schema.bookJournalEntries).where(eq(schema.bookJournalEntries.clientId, clientId));
expect(row).toBeUndefined();
});
it('keeps every entry private to its author and the book behind library access', async () => {
const clientId = randomUUID();
await call(owner, 'POST', `/books/${bookA.bookId}/journal`, { clientId, body: 'Private' });
const peerList = (await call(peer, 'GET', `/books/${bookA.bookId}/journal`)).json() as BookJournalEntry[];
expect(peerList.some((item) => item.clientId === clientId)).toBe(false);
expect((await call(peer, 'PATCH', `/books/${bookA.bookId}/journal/${clientId}`, { body: 'Hijack' })).statusCode).toBe(404);
expect((await call(peer, 'DELETE', `/books/${bookA.bookId}/journal/${clientId}`)).statusCode).toBe(404);
const peerOwn = await call(peer, 'POST', `/books/${bookA.bookId}/journal`, { clientId, body: 'Same id, different reader' });
expect(peerOwn.statusCode).toBe(201);
expect((peerOwn.json() as BookJournalEntry).body).toBe('Same id, different reader');
expect((await call(outsider, 'GET', `/books/${bookA.bookId}/journal`)).statusCode).toBe(404);
expect((await call(outsider, 'POST', `/books/${bookA.bookId}/journal`, { clientId: randomUUID(), body: 'x' })).statusCode).toBe(404);
});
});
describe('annotations', () => {
async function createAnnotation(cfi: string, note: string | null = null): Promise<number> {
const response = await call(owner, 'POST', `/books/${bookB.bookId}/annotations`, { cfi, text: `text at ${cfi}`, color: '#FACC15', note });
expect(response.statusCode).toBe(201);
return (response.json() as { id: number }).id;
}
async function storedVersion(id: number) {
const [row] = await ctx.db
.select({ version: schema.annotations.version, starredAt: schema.annotations.starredAt })
.from(schema.annotations)
.where(eq(schema.annotations.id, id));
return row;
}
let tenId: number;
let eightId: number;
let eightLaterId: number;
beforeAll(async () => {
tenId = await createAnnotation('epubcfi(/6/10!/4/2/1:0)', 'remove me');
eightLaterId = await createAnnotation('epubcfi(/6/8!/4/2/1:12)');
eightId = await createAnnotation('epubcfi(/6/8!/4/2/1:9)');
});
it('sorts by reading position numerically, so /6/8 comes before /6/10', async () => {
const response = await call(owner, 'GET', `/books/${bookB.bookId}/annotations?page=1&pageSize=25&sortBy=position&sortDir=asc`);
expect(response.statusCode).toBe(200);
const ids = (response.json() as { items: AnnotationItem[] }).items.map((item) => item.id);
expect(ids).toEqual([eightId, eightLaterId, tenId]);
});
it('exports a book in reading order', async () => {
const response = await call(owner, 'GET', `/annotations/export?format=json&bookId=${bookB.bookId}`);
expect(response.statusCode).toBe(200);
const ids = (JSON.parse(response.body) as { id: number }[]).map((row) => row.id);
expect(ids).toEqual([eightId, eightLaterId, tenId]);
});
it('stars and unstars without bumping the version devices sync from', async () => {
const before = await storedVersion(eightId);
const starred = await call(owner, 'PATCH', `/books/${bookB.bookId}/annotations/${eightId}`, { starred: true });
expect(starred.statusCode).toBe(200);
const starredAt = (starred.json() as AnnotationItem).starredAt;
expect(starredAt).toEqual(expect.any(String));
expect((await storedVersion(eightId)).version).toBe(before.version);
const again = await call(owner, 'PATCH', `/books/${bookB.bookId}/annotations/${eightId}`, { starred: true });
expect((again.json() as AnnotationItem).starredAt).toBe(starredAt);
const list = (await call(owner, 'GET', `/books/${bookB.bookId}/annotations`)).json() as AnnotationItem[];
expect(list.find((item) => item.id === eightId)?.starredAt).toBe(starredAt);
expect(list.find((item) => item.id === tenId)?.starredAt).toBeNull();
const unstarred = await call(owner, 'PATCH', `/books/${bookB.bookId}/annotations/${eightId}`, { starred: false });
expect((unstarred.json() as AnnotationItem).starredAt).toBeNull();
expect((await storedVersion(eightId)).version).toBe(before.version);
expect((await call(owner, 'PATCH', `/books/${bookB.bookId}/annotations/${eightId}`, { starred: null })).statusCode).toBe(400);
});
it('clears a note with null and bumps the version for that content change', async () => {
const before = await storedVersion(tenId);
const response = await call(owner, 'PATCH', `/books/${bookB.bookId}/annotations/${tenId}`, { note: null });
expect(response.statusCode).toBe(200);
expect((response.json() as AnnotationItem).note).toBeNull();
expect((await storedVersion(tenId)).version).toBe(before.version + 1);
});
it('bulk stars active rows only and reports what changed', async () => {
const trashedId = await createAnnotation('epubcfi(/6/12!/4/2/1:0)');
expect((await call(owner, 'DELETE', `/books/${bookB.bookId}/annotations/${trashedId}`)).statusCode).toBe(204);
const versionBefore = (await storedVersion(eightLaterId)).version;
const star = await call(owner, 'POST', '/annotations/bulk', { ids: [eightLaterId, tenId, trashedId], action: 'star' });
expect(star.statusCode).toBe(200);
expect(star.json()).toEqual({ affected: 2 });
expect((await storedVersion(trashedId)).starredAt).toBeNull();
expect((await storedVersion(eightLaterId)).version).toBe(versionBefore);
const hub = (await call(owner, 'GET', `/annotations?bookId=${bookB.bookId}`)).json() as { items: AnnotationItem[] };
expect(hub.items.find((item) => item.id === tenId)?.starredAt).toEqual(expect.any(String));
const peerStar = await call(peer, 'POST', '/annotations/bulk', { ids: [eightId], action: 'star' });
expect(peerStar.json()).toEqual({ affected: 0 });
const unstar = await call(owner, 'POST', '/annotations/bulk', { ids: [eightLaterId, tenId, eightId], action: 'unstar' });
expect(unstar.json()).toEqual({ affected: 2 });
});
});
describe('bookmarks', () => {
it('renames and annotates a bookmark, and reports the new fields', async () => {
const created = await call(owner, 'POST', `/books/${bookA.bookId}/bookmarks`, { cfi: 'epubcfi(/6/14!/4/2/1:0)', title: 'Chapter 7' });
expect(created.statusCode).toBe(201);
const bookmark = created.json() as {
id: number;
note: string | null;
updatedAt: string;
clientId: string;
origin: string;
chapterId: string | null;
};
expect(bookmark).toMatchObject({ note: null, origin: 'web', chapterId: null, clientId: expect.any(String), updatedAt: expect.any(String) });
const edited = await call(owner, 'PATCH', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`, { title: ' The duel ', note: 'Reread this' });
expect(edited.statusCode).toBe(200);
const after = edited.json() as typeof bookmark & { title: string };
expect(after).toMatchObject({ id: bookmark.id, title: 'The duel', note: 'Reread this', clientId: bookmark.clientId });
expect(Date.parse(after.updatedAt)).toBeGreaterThanOrEqual(Date.parse(bookmark.updatedAt));
const cleared = await call(owner, 'PATCH', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`, { note: ' ' });
expect(cleared.json()).toMatchObject({ title: 'The duel', note: null });
const list = (await call(owner, 'GET', `/books/${bookA.bookId}/bookmarks`)).json() as { id: number; title: string; note: string | null }[];
expect(list.find((item) => item.id === bookmark.id)).toMatchObject({ title: 'The duel', note: null });
expect((await call(owner, 'PATCH', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`, { title: '' })).statusCode).toBe(400);
expect((await call(peer, 'PATCH', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`, { title: 'Hijack' })).statusCode).toBe(404);
expect((await call(owner, 'DELETE', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`)).statusCode).toBe(204);
expect((await call(owner, 'PATCH', `/books/${bookA.bookId}/bookmarks/${bookmark.id}`, { title: 'Gone' })).statusCode).toBe(404);
});
});
describe('annotation colour names', () => {
it('defaults to none, stores normalised names and rejects invalid ones', async () => {
const initial = await call(owner, 'GET', '/user-preferences/annotation-colors');
expect(initial.statusCode).toBe(200);
expect(initial.json()).toEqual({ settings: { names: {} } });
const saved = await call(owner, 'PUT', '/user-preferences/annotation-colors', { settings: { names: { '#facc15': ' Ideas ', '#4ADE80': '' } } });
expect(saved.statusCode).toBe(204);
expect((await call(owner, 'GET', '/user-preferences/annotation-colors')).json()).toEqual({ settings: { names: { '#FACC15': 'Ideas' } } });
expect((await call(peer, 'GET', '/user-preferences/annotation-colors')).json()).toEqual({ settings: { names: {} } });
expect((await call(owner, 'PUT', '/user-preferences/annotation-colors', { settings: { names: { yellow: 'Ideas' } } })).statusCode).toBe(400);
const tooMany = Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`#${i.toString(16).padStart(6, '0')}`, `Name ${i}`]));
expect((await call(owner, 'PUT', '/user-preferences/annotation-colors', { settings: { names: tooMany } })).statusCode).toBe(400);
expect((await call(owner, 'GET', '/user-preferences/annotation-colors')).json()).toEqual({ settings: { names: { '#FACC15': 'Ideas' } } });
});
});
});