Files
bookorbit/server/test/koreader-bookmark-exchange.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

429 lines
16 KiB
TypeScript

import { createHash, randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const KOREADER_USERNAME = `bookmark-device-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'BookmarkDevicePass123';
const DEVICE_ID = 'e2e-bm-device-0001';
const OTHER_DEVICE_ID = 'e2e-bm-device-0002';
// The fixture chapter body is exactly <p>fixture</p>.
const FIXTURE_XPOINTER = '/body/DocFragment[1]/body/p/text().0';
const WEB_POINT_CFI = 'epubcfi(/6/2!/4/2/1:3)';
interface BookmarkBookPayload {
hash: string;
keys: { k: string; dt: string }[];
keysComplete: boolean;
sidecarState?: 'known' | 'new';
changes: Record<string, unknown>[];
}
interface BookmarkExchangeResult {
hash: string;
bookId: number;
accepted: number;
duplicates: number;
rejected: number;
deviceDeleted: number;
toApply: {
add: { serverId: number; pos: string; pageno: number | null; title: string }[];
delete: { serverId: number; key: string; datetime: string | null }[];
};
more: boolean;
skippedConversion: number;
}
interface WebBookmark {
id: number;
cfi: string | null;
title: string;
}
describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let epub!: LocatedBookFile;
let fileHash!: string;
let deviceBookmarkId!: number;
let deviceBookmarkKey!: string;
let webBookmarkId!: number;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function exchange(books: BookmarkBookPayload[], deviceId = DEVICE_ID) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
headers: deviceHeaders(),
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.5.0', books },
});
expect(response.statusCode).toBe(201);
return response.json() as { results: BookmarkExchangeResult[]; unmatched: string[] };
}
async function exchangeAck(books: Record<string, unknown>[], deviceId = DEVICE_ID) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange-ack',
headers: deviceHeaders(),
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.5.0', books },
});
expect(response.statusCode).toBe(201);
return response.json() as { results: { hash: string; acked: number }[]; unmatched: string[] };
}
function pull(deviceId = DEVICE_ID) {
return exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], deviceId);
}
async function listWebBookmarks(): Promise<WebBookmark[]> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
return response.json() as WebBookmark[];
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `koreader-bookmark-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'bookmark-book.epub', {
title: `Bookmark Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
epub = await locateBookByAbsolutePath(ctx, epubPath);
const [fileRow] = await ctx.db
.select({ fileHash: schema.bookFiles.fileHash })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, epub.bookFileId));
expect(fileRow?.fileHash).toBeTruthy();
fileHash = fileRow!.fileHash!;
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
if (credentials.statusCode === 409) {
const updated = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect(updated.statusCode).toBe(200);
} else {
expect([200, 201]).toContain(credentials.statusCode);
}
}, 120_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('advertises the bookmarkSync capability the plugin gates on', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/koreader/plugin/version',
headers: deviceHeaders(),
});
expect(response.statusCode).toBe(200);
expect((response.json() as { capabilities: string[] }).capabilities).toContain('bookmarkSync');
expect((response.json() as { capabilities: string[] }).capabilities).toContain('sidecarState');
});
it('converts a device dogear into a web bookmark and pushes nothing back at that device', async () => {
const first = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One' }],
},
]);
expect(first.results[0]).toMatchObject({ accepted: 1, duplicates: 0, rejected: 0 });
expect(first.results[0].toApply.add).toEqual([]);
expect(first.results[0].toApply.delete).toEqual([]);
const bookmarks = await listWebBookmarks();
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].title).toBe('Chapter One');
expect(bookmarks[0].cfi).toMatch(/^epubcfi\(/);
deviceBookmarkId = bookmarks[0].id;
const [row] = await ctx.db.select().from(schema.bookmarks).where(eq(schema.bookmarks.id, deviceBookmarkId));
expect(row.origin).toBe('koreader');
expect(row.devicePos).toBe(FIXTURE_XPOINTER);
expect(row.pageno).toBe(1);
const retry = await pull();
expect(retry.results[0]).toMatchObject({ accepted: 0, duplicates: 0 });
expect(retry.results[0].toApply.add).toEqual([]);
});
it('rejects a device position that cannot be converted instead of storing a CFI-less row', async () => {
const result = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:05:00', pos: '/body/DocFragment[99]/body/p/text().0' }],
},
]);
expect(result.results[0]).toMatchObject({ accepted: 0, rejected: 1 });
expect(await listWebBookmarks()).toHaveLength(1);
});
it('delivers the device bookmark to a second device at its original position, ack-gated', async () => {
const pulled = await pull(OTHER_DEVICE_ID);
const add = pulled.results[0].toApply.add.find((entry) => entry.serverId === deviceBookmarkId);
expect(add).toBeDefined();
// Device-originated: the stored xpointer is returned verbatim, not round-tripped through the CFI.
expect(add).toMatchObject({ pos: FIXTURE_XPOINTER, pageno: 1, title: 'Chapter One' });
// Idempotent before the ack.
const retry = await pull(OTHER_DEVICE_ID);
expect(retry.results[0].toApply.add.find((entry) => entry.serverId === deviceBookmarkId)).toBeDefined();
const ack = await exchangeAck(
[
{
hash: fileHash,
applied: [{ serverId: deviceBookmarkId, status: 'applied', datetime: '2026-06-02 08:00:00', pos: FIXTURE_XPOINTER }],
deleted: [],
},
],
OTHER_DEVICE_ID,
);
expect(ack.results[0].acked).toBe(1);
const after = await pull(OTHER_DEVICE_ID);
expect(after.results[0].toApply.add).toEqual([]);
});
it('applies a device-side rename without creating a second bookmark', async () => {
const renamed = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One', note: 'Remember this' }],
},
]);
expect(renamed.results[0]).toMatchObject({ accepted: 0, duplicates: 1 });
const bookmarks = await listWebBookmarks();
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].title).toBe('Remember this');
});
it('converts a web bookmark and delivers it to the device, ack-gated', async () => {
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { cfi: WEB_POINT_CFI, bookFileId: epub.bookFileId, title: 'Chapter One' },
});
expect(created.statusCode).toBe(201);
webBookmarkId = (created.json() as { id: number }).id;
const pulled = await pull();
const add = pulled.results[0].toApply.add.find((entry) => entry.serverId === webBookmarkId);
expect(add).toBeDefined();
expect(add!.title).toBe('Chapter One');
expect(add!.pos).toMatch(/^\/body\/DocFragment\[1\]/);
expect(pulled.results[0].skippedConversion).toBe(0);
const ack = await exchangeAck([
{
hash: fileHash,
applied: [{ serverId: webBookmarkId, status: 'applied', datetime: '2026-06-03 09:00:00', pos: add!.pos }],
deleted: [],
},
]);
expect(ack.results[0].acked).toBe(1);
const after = await pull();
expect(after.results[0].toApply.add.find((entry) => entry.serverId === webBookmarkId)).toBeUndefined();
});
it('turns a web delete into a tombstone and propagates it to every linked device', async () => {
const deleted = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/bookmarks/${deviceBookmarkId}`,
headers: authHeader(ctx.adminToken),
});
expect(deleted.statusCode).toBe(204);
// The web no longer lists it, but the row survives so devices can be told.
expect((await listWebBookmarks()).map((bookmark) => bookmark.id)).not.toContain(deviceBookmarkId);
const [row] = await ctx.db.select().from(schema.bookmarks).where(eq(schema.bookmarks.id, deviceBookmarkId));
expect(row.deletedAt).not.toBeNull();
const pulled = await pull();
const entry = pulled.results[0].toApply.delete.find((item) => item.serverId === deviceBookmarkId);
expect(entry).toBeDefined();
// The device is addressed by the identity it acked with, its only local handle.
expect(entry!.key).toMatch(/^[0-9a-f]{32}$/);
expect(entry!.datetime).toBe('2026-06-01 10:00:00');
deviceBookmarkKey = entry!.key;
await exchangeAck([{ hash: fileHash, applied: [], deleted: [{ serverId: deviceBookmarkId, status: 'applied' }] }]);
const afterAck = await pull();
expect(afterAck.results[0].toApply.delete).toEqual([]);
// The second device still holds it and learns about the delete on its own schedule.
const other = await pull(OTHER_DEVICE_ID);
expect(other.results[0].toApply.delete.find((item) => item.serverId === deviceBookmarkId)).toBeDefined();
await exchangeAck([{ hash: fileHash, applied: [], deleted: [{ serverId: deviceBookmarkId, status: 'applied' }] }], OTHER_DEVICE_ID);
});
it('restores the tombstone when a device re-adds a bookmark at the same position', async () => {
const restored = await exchange([
{
hash: fileHash,
keys: [
{ k: createHash('md5').update(`2026-06-04 12:00:00|${FIXTURE_XPOINTER}`).digest('hex'), dt: '2026-06-04 12:00:00' },
{ k: createHash('md5').update('2026-06-03 09:00:00|/body/DocFragment[1]/body/p/text().3').digest('hex'), dt: '2026-06-03 09:00:00' },
],
keysComplete: true,
changes: [{ datetime: '2026-06-04 12:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One' }],
},
]);
expect(restored.results[0]).toMatchObject({ accepted: 1, rejected: 0 });
const bookmarks = await listWebBookmarks();
// The same row came back to life: the unique location index would have refused an insert.
expect(bookmarks.map((bookmark) => bookmark.id)).toContain(deviceBookmarkId);
expect(bookmarks.find((bookmark) => bookmark.id === deviceBookmarkId)!.title).toBe('Chapter One');
expect(deviceBookmarkKey).toMatch(/^[0-9a-f]{32}$/);
// The second device dropped its link when it acked the earlier delete, so the
// restored row reaches it as a fresh add. Re-link it, so the next test can
// prove a deletion on one device still propagates to the other.
const other = await pull(OTHER_DEVICE_ID);
const readds = other.results[0].toApply.add;
expect(readds.map((entry) => entry.serverId)).toEqual(expect.arrayContaining([deviceBookmarkId, webBookmarkId]));
await exchangeAck(
[
{
hash: fileHash,
applied: readds.map((entry, index) => ({
serverId: entry.serverId,
status: 'applied',
datetime: `2026-06-05 09:0${index}:00`,
pos: entry.pos,
})),
deleted: [],
},
],
OTHER_DEVICE_ID,
);
});
it('skips deletion detection when the device could not report a complete key set', async () => {
const result = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
expect(result.results[0].deviceDeleted).toBe(0);
expect(await listWebBookmarks()).toHaveLength(2);
});
it('re-offers every dogear when an older plugin reports none of them, deleting nothing', async () => {
// A re-downloaded book has a new, empty sidecar; older plugins still send its list as complete.
const result = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }]);
expect(result.results[0].deviceDeleted).toBe(0);
expect(await listWebBookmarks()).toHaveLength(2);
const reoffered = result.results[0].toApply.add;
expect(reoffered.map((entry) => entry.serverId).sort((a, b) => a - b)).toEqual([deviceBookmarkId, webBookmarkId].sort((a, b) => a - b));
const ack = await exchangeAck([
{
hash: fileHash,
applied: reoffered.map((entry, index) => ({
serverId: entry.serverId,
status: 'applied',
datetime: `2026-06-04 09:00:0${index}`,
pos: entry.pos,
})),
deleted: [],
},
]);
expect(ack.results[0].acked).toBe(2);
});
it('tombstones bookmarks a known sidecar no longer reports once the key set is complete', async () => {
const result = await exchange([{ hash: fileHash, keys: [], keysComplete: true, sidecarState: 'known', changes: [] }]);
// Both this device's links are gone from its key set, so both bookmarks are deleted.
expect(result.results[0].deviceDeleted).toBe(2);
expect(await listWebBookmarks()).toHaveLength(0);
// A deletion on one device reaches every other device that still holds the row.
const other = await pull(OTHER_DEVICE_ID);
expect(other.results[0].toApply.delete.map((entry) => entry.serverId)).toEqual(expect.arrayContaining([deviceBookmarkId, webBookmarkId]));
expect(other.results[0].toApply.add).toEqual([]);
});
it('reports a hash it cannot resolve as unmatched', async () => {
const unknownHash = 'f'.repeat(32);
const result = await exchange([{ hash: unknownHash, keys: [], keysComplete: true, changes: [] }]);
expect(result.unmatched).toEqual([unknownHash]);
expect(result.results).toEqual([]);
});
it('rejects a malformed payload at the validation boundary', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
headers: deviceHeaders(),
payload: {
deviceId: DEVICE_ID,
deviceModel: 'E2E',
pluginVersion: '0.5.0',
books: [{ hash: fileHash, keys: [], keysComplete: true, changes: [{ datetime: 'not-a-datetime', pos: FIXTURE_XPOINTER }] }],
},
});
expect(response.statusCode).toBe(400);
});
it('refuses an unauthenticated device', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
payload: {
deviceId: DEVICE_ID,
deviceModel: 'E2E',
pluginVersion: '0.5.0',
books: [{ hash: fileHash, keys: [], keysComplete: false, changes: [] }],
},
});
expect(response.statusCode).toBe(401);
});
});