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.
This commit is contained in:
neon
2026-10-19 21:04:23 +00:00
parent 7202a59afc
commit 80244ea4e2
349 changed files with 66312 additions and 8386 deletions
+37
View File
@@ -1,4 +1,7 @@
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 {
@@ -235,6 +238,40 @@ describe('Annotations hub (e2e)', { timeout: 120_000 }, () => {
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);
@@ -1,7 +1,7 @@
{
"totalRoutes": 662,
"totalRoutes": 663,
"byPermission": {
"(none)": 286,
"(none)": 287,
"Permission.ManageMetadataConfig": 41,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 35,
@@ -1030,5 +1030,17 @@
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/koreader/koreader-catalog.controller.ts",
"className": "KoreaderCatalogController",
"methodName": "authorPhoto",
"httpMethod": "GET",
"path": "/koreader/plugin/catalog/authors/:authorId/photo",
"permissions": [],
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": ["KoreaderAuthGuard"]
}
]
@@ -242,6 +242,7 @@
"POST /koreader/plugin/annotations",
"POST /koreader/plugin/annotations/exchange",
"POST /koreader/plugin/annotations/exchange-ack",
"POST /koreader/plugin/annotations/pending",
"POST /koreader/plugin/book-states",
"POST /koreader/plugin/bookmarks/exchange",
"POST /koreader/plugin/bookmarks/exchange-ack",
+366 -11
View File
@@ -1,8 +1,9 @@
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { rename, rm } from 'fs/promises';
import { readdir, rename, rm } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { Logger } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
@@ -70,20 +71,31 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
let deviceAnnotationId!: string;
let fileHash!: string;
let adminUserId!: number;
const reportedEtags = new Map<string, string>();
function v3Url(path: string): string {
return `/api/v1/kobo/${deviceToken}/${path}`;
}
async function getAnnotations() {
async function pullAnnotations(recordEtag = true) {
const response = await ctx.app.inject({
method: 'GET',
url: v3Url(`api/v3/content/${contentId}/annotations`),
headers: { 'if-none-match': 'W/"0"' },
});
expect(response.statusCode).toBe(200);
const etag = response.headers.etag;
expect(typeof etag).toBe('string');
if (recordEtag) reportedEtags.set(contentId, String(etag));
await flushTick();
return response.json() as { annotations: Record<string, unknown>[]; nextPageOffsetToken: null };
return {
body: response.json() as { annotations: Record<string, unknown>[]; nextPageOffsetToken: null },
etag: String(etag),
};
}
async function getAnnotations() {
return (await pullAnnotations()).body;
}
async function patchAnnotations(payload: Record<string, unknown>) {
@@ -96,7 +108,11 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
}
async function checkForChanges(): Promise<string[]> {
const response = await ctx.app.inject({ method: 'POST', url: v3Url('api/v3/content/checkforchanges'), payload: {} });
const response = await ctx.app.inject({
method: 'POST',
url: v3Url('api/v3/content/checkforchanges'),
payload: [...reportedEtags].map(([ContentId, etag]) => ({ ContentId, etag })),
});
expect(response.statusCode).toBe(200);
return response.json() as string[];
}
@@ -289,6 +305,25 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
expect(await checkForChanges()).toEqual([]);
});
it('retries a hub edit until the device reports the response etag', async () => {
const [annotation] = await findCanonicalAnnotations();
const edit = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${epub.bookId}/annotations/${annotation.id}`,
headers: authHeader(ctx.adminToken),
payload: { note: 'hub note retry' },
});
expect(edit.statusCode).toBe(200);
expect(await checkForChanges()).toEqual([contentId]);
const abandoned = await pullAnnotations(false);
expect(abandoned.body.annotations[0]).toMatchObject({ noteText: 'hub note retry' });
expect(await checkForChanges()).toEqual([contentId]);
reportedEtags.set(contentId, abandoned.etag);
expect(await checkForChanges()).toEqual([]);
});
it('does not re-ingest the device echo of a served edit', async () => {
const body = await getAnnotations();
const served = body.annotations[0] as { clientLastModifiedUtc: string };
@@ -299,7 +334,7 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
type: 'note',
highlightColor: '#B2E1E8',
highlightedText: 'fixture',
noteText: 'hub note',
noteText: 'hub note retry',
location: KOBO_LOCATION,
clientLastModifiedUtc: served.clientLastModifiedUtc,
},
@@ -307,7 +342,7 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
});
const [annotation] = await findCanonicalAnnotations();
expect(annotation).toMatchObject({ note: 'hub note', color: '#38BDF8' });
expect(annotation).toMatchObject({ note: 'hub note retry', color: '#38BDF8' });
});
it('serves web-origin annotations once push is enabled, with a synthesized location', async () => {
@@ -374,6 +409,8 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
const body = await getAnnotations();
expect(body.annotations).toHaveLength(0);
expect(await checkForChanges()).toEqual([]);
const [state] = await ctx.db
.select()
.from(schema.annotationSyncState)
@@ -385,8 +422,6 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
),
);
expect(state.deleteAckedAt).not.toBeNull();
expect(await checkForChanges()).toEqual([]);
});
it('converts a KoboSpan reading-state bookmark to precise cfi and xpointer progress', async () => {
@@ -552,19 +587,61 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
return { id, type: 'dogear', highlightedText: '', location: DOGEAR_LOCATION, clientLastModifiedUtc: '2026-06-13T08:00:00Z' };
}
it('serves a BookOrbit bookmark as a Kobo dogear and waits for its etag acknowledgment', async () => {
const create = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { bookFileId: epub.bookFileId, cfi: DOGEAR_CFI, title: 'BookOrbit bookmark' },
});
expect(create.statusCode).toBe(201);
expect(await checkForChanges()).toContain(contentId);
const body = await getAnnotations();
const served = body.annotations.find((row) => row.type === 'dogear') as { id: string; location: typeof DOGEAR_LOCATION } | undefined;
expect(served).toBeDefined();
expect(served?.location.span).toMatchObject({
startPath: 'OPS/chapter.xhtml#kobo.1.1',
endPath: 'OPS/chapter.xhtml#kobo.1.1',
startChar: 0,
endChar: 0,
chapterTitle: 'BookOrbit bookmark',
});
const [pending] = await findBookmarkLinks();
expect(pending?.appliedAt).toBeNull();
expect(await checkForChanges()).not.toContain(contentId);
expect((await findBookmarkLinks())[0]?.appliedAt).not.toBeNull();
await patchAnnotations({ deletedAnnotationIds: [served!.id] });
expect((await findBookmarks()).filter((row) => row.deletedAt === null)).toEqual([]);
expect(await findBookmarkLinks()).toEqual([]);
});
it('lands a device dogear as a bookmark and serves it back as a dogear', async () => {
const dogearId = randomUUID();
await patchAnnotations({ updatedAnnotations: [dogear(dogearId)] });
const log = vi.spyOn(Logger.prototype, 'log');
try {
await patchAnnotations({ updatedAnnotations: [dogear(dogearId)] });
const patchEnd = log.mock.calls.map(([line]) => String(line)).find((line) => line.startsWith('[kobo.annotation_patch] [end]'));
expect(patchEnd).toContain('bookmarksCreated=1');
} finally {
log.mockRestore();
}
const live = (await findBookmarks()).filter((row) => row.deletedAt === null);
expect(live).toHaveLength(1);
expect(live[0]).toMatchObject({ origin: 'kobo', title: 'Chapter 1', cfi: DOGEAR_CFI });
expect(live[0]).toMatchObject({ origin: 'kobo', title: 'Chapter 1', cfi: DOGEAR_CFI, bookFileId: epub.bookFileId });
expect((await findCanonicalAnnotations()).filter((row) => row.text === '' && row.deletedAt === null)).toEqual([]);
await ctx.db.update(schema.bookmarks).set({ bookFileId: null }).where(eq(schema.bookmarks.id, live[0]!.id));
const served = (await getAnnotations()).annotations.find((row) => row.id === dogearId);
expect(served).toMatchObject({ type: 'dogear', highlightedText: '' });
expect(served).not.toHaveProperty('highlightColor');
expect(served?.location).toEqual(DOGEAR_LOCATION);
expect((await findBookmarks()).find((row) => row.id === live[0]!.id)?.bookFileId).toBe(epub.bookFileId);
});
it('reports a bookmark deleted in BookOrbit and leaves it out of the next pull', async () => {
@@ -578,8 +655,8 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
expect(await checkForChanges()).toContain(contentId);
expect((await getAnnotations()).annotations.some((row) => row.type === 'dogear')).toBe(false);
expect(await findBookmarkLinks()).toEqual([]);
expect(await checkForChanges()).not.toContain(contentId);
expect(await findBookmarkLinks()).toEqual([]);
});
it('deletes the bookmark when the device deletes its dogear', async () => {
@@ -593,6 +670,45 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
expect(await findBookmarkLinks()).toEqual([]);
});
it('keeps a note added to a dogear as the bookmark note and serves it back as that note', async () => {
const dogearId = randomUUID();
await patchAnnotations({ updatedAnnotations: [dogear(dogearId)] });
const [bookmark] = (await findBookmarks()).filter((row) => row.deletedAt === null);
await patchAnnotations({
updatedAnnotations: [
{ ...dogear(dogearId), type: 'note', highlightColor: '#F6F3B3', noteText: 'Kobo dogear note', clientLastModifiedUtc: '2099-01-01T00:00:00Z' },
],
});
expect((await findCanonicalAnnotations()).filter((row) => row.text === '' && row.deletedAt === null)).toEqual([]);
expect((await findBookmarks()).filter((row) => row.deletedAt === null)).toEqual([
expect.objectContaining({ id: bookmark!.id, title: 'Chapter 1', note: 'Kobo dogear note' }),
]);
const served = (await getAnnotations()).annotations.find((row) => row.id === dogearId);
expect(served).toMatchObject({ type: 'note', noteText: 'Kobo dogear note', highlightedText: '', highlightColor: '#F6F3B3' });
// The device re-sending what it was served, or an older copy, is an echo and leaves the note alone.
await patchAnnotations({ updatedAnnotations: [dogear(dogearId)] });
expect((await findBookmarks()).find((row) => row.id === bookmark!.id)?.note).toBe('Kobo dogear note');
await patchAnnotations({ deletedAnnotationIds: [dogearId] });
});
it('lands a dogear the device first uploads with a note as a bookmark with that note', async () => {
const dogearId = randomUUID();
await patchAnnotations({
updatedAnnotations: [{ ...dogear(dogearId), type: 'note', highlightColor: '#F6F3B3', noteText: 'Noted on the device' }],
});
expect((await findCanonicalAnnotations()).filter((row) => row.text === '' && row.deletedAt === null)).toEqual([]);
expect((await findBookmarks()).filter((row) => row.deletedAt === null)).toEqual([
expect.objectContaining({ origin: 'kobo', title: 'Chapter 1', note: 'Noted on the device' }),
]);
await patchAnnotations({ deletedAnnotationIds: [dogearId] });
});
it('moves a dogear stored as an empty highlight to a bookmark under the same id', async () => {
const legacyId = randomUUID();
await patchAnnotations({
@@ -644,4 +760,243 @@ describe.each(['epub', 'kepub'])('Kobo annotation sync with %s (e2e)', { timeout
placement.mockRestore();
}
});
it('stops rebuilding a book on every check for a new annotation this copy cannot place', async () => {
const create = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color: '#FB923C', style: 'underline' },
});
expect(create.statusCode).toBe(201);
const createdId = (create.json() as { id: number }).id;
const placement = vi
.spyOn(ctx.app.get(KoboSpanConverterService), 'canonicalToKoboSpan')
.mockResolvedValue({ status: 'failed', reason: 'span_not_found', renditionHash: 'this-copy' });
try {
// The check itself rebuilds the book's response, finds nothing new it can serve and records why.
expect(await checkForChanges()).not.toContain(contentId);
expect((await findPositions(createdId)).get('kobo_span')).toMatchObject({ status: 'failed', pos0: null });
// From then on the book is no longer pending, so later checks skip rebuilding it.
const rebuilds = placement.mock.calls.length;
expect(await checkForChanges()).not.toContain(contentId);
expect(placement.mock.calls.length).toBe(rebuilds);
} finally {
placement.mockRestore();
}
});
// D-12: the span a highlight made elsewhere was served is never stored, so a device re-save
// that shortened it used to land as the new text on the old range.
it('moves a highlight made elsewhere to the range the device re-saved it with', async () => {
const before = new Set((await getAnnotations()).annotations.map((row) => String(row.id)));
const create = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color: '#38BDF8', style: 'highlight' },
});
expect(create.statusCode).toBe(201);
const createdId = (create.json() as { id: number }).id;
const served = (await getAnnotations()).annotations.find((row) => !before.has(String(row.id)))!;
expect((served.location as typeof KOBO_LOCATION).span).toMatchObject({ startChar: 0, endChar: 7 });
expect((await findPositions(createdId)).get('kobo_span')).toBeUndefined();
const shortened = { span: { ...KOBO_LOCATION.span, endChar: 3 } };
await patchAnnotations({
updatedAnnotations: [
{
id: served.id,
type: 'highlight',
highlightColor: served.highlightColor,
highlightedText: 'fix',
location: shortened,
clientLastModifiedUtc: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
},
],
});
const [row] = await ctx.db.select().from(schema.annotations).where(eq(schema.annotations.id, createdId));
expect(row).toMatchObject({ text: 'fix', version: 2 });
const positions = await findPositions(createdId);
expect(positions.get('kobo_span')).toMatchObject({ pos0: 'kobo.1.1:0', pos1: 'kobo.1.1:3', status: 'exact' });
expect(positions.get('cfi')).toMatchObject({ pos0: 'epubcfi(/6/2!/4/2,/1:0,/1:3)', status: 'exact' });
expect(positions.get('xpointer')).toMatchObject({ pos0: FIXTURE_XPOINTER_0, pos1: '/body/DocFragment[1]/body/p/text().3', status: 'exact' });
const reserved = (await getAnnotations()).annotations.find((annotation) => annotation.id === served.id)!;
expect((reserved.location as typeof KOBO_LOCATION).span).toMatchObject({ startChar: 0, endChar: 3 });
expect(reserved.highlightedText).toBe('fix');
});
it('revoking a Kobo drops its sync rows, and rows of a Kobo already gone no longer block a purge', async () => {
const second = await ctx.app.inject({
method: 'POST',
url: '/api/v1/kobo/devices',
headers: authHeader(ctx.adminToken),
payload: { name: 'E2E Kobo to revoke' },
});
const secondId = (second.json() as { id: number }).id;
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color: '#FB923C', style: 'highlight' },
});
const annotationId = (created.json() as { id: number }).id;
const goneDeviceId = String(secondId + 100_000);
await ctx.db.insert(schema.annotationSyncState).values([
{ annotationId, userId: adminUserId, source: 'kobo', deviceId: String(secondId), externalKey: randomUUID(), lastAppliedVersion: 1 },
{ annotationId, userId: adminUserId, source: 'kobo', deviceId: goneDeviceId, externalKey: randomUUID(), lastAppliedVersion: 1 },
]);
await ctx.app.inject({ method: 'DELETE', url: `/api/v1/books/${epub.bookId}/annotations/${annotationId}`, headers: authHeader(ctx.adminToken) });
const blocked = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/annotations/${annotationId}`, headers: authHeader(ctx.adminToken) });
expect(blocked.statusCode).toBe(409);
const revoked = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/kobo/devices/${secondId}`, headers: authHeader(ctx.adminToken) });
expect([200, 204]).toContain(revoked.statusCode);
const left = await ctx.db.select().from(schema.annotationSyncState).where(eq(schema.annotationSyncState.annotationId, annotationId));
expect(left.map((row) => row.deviceId)).toEqual([goneDeviceId]);
const purged = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/annotations/${annotationId}`, headers: authHeader(ctx.adminToken) });
expect(purged.statusCode).toBe(204);
});
it('acknowledges a response the device applied even though the book changed before it reported it', async () => {
const createHighlight = async (color: string) => {
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color, style: 'highlight' },
});
expect(created.statusCode).toBe(201);
return (created.json() as { id: number }).id;
};
const koboState = async (annotationId: number) => {
const [state] = await ctx.db
.select()
.from(schema.annotationSyncState)
.where(
and(
eq(schema.annotationSyncState.annotationId, annotationId),
eq(schema.annotationSyncState.source, 'kobo'),
eq(schema.annotationSyncState.deviceId, String(deviceDbId)),
),
);
return state;
};
const first = await createHighlight('#4ADE80');
expect(await checkForChanges()).toEqual([contentId]);
const applied = await pullAnnotations(false);
expect((await koboState(first))?.lastAppliedVersion ?? 0).toBe(0);
const second = await createHighlight('#38BDF8');
reportedEtags.set(contentId, applied.etag);
expect(await checkForChanges()).toEqual([contentId]);
const [row] = await ctx.db.select({ version: schema.annotations.version }).from(schema.annotations).where(eq(schema.annotations.id, first));
expect((await koboState(first)).lastAppliedVersion).toBe(row.version);
expect((await koboState(second))?.lastAppliedVersion ?? 0).toBe(0);
await pullAnnotations();
expect(await checkForChanges()).toEqual([]);
expect((await koboState(second)).lastAppliedVersion).toBeGreaterThan(0);
});
async function annotationIdForDeviceKey(externalKey: string): Promise<number> {
const [state] = await ctx.db
.select({ annotationId: schema.annotationSyncState.annotationId })
.from(schema.annotationSyncState)
.where(and(eq(schema.annotationSyncState.source, 'kobo'), eq(schema.annotationSyncState.externalKey, externalKey)));
return state!.annotationId;
}
it('serves an edit made elsewhere to a device highlight as newer than the device own edit', async () => {
const id = randomUUID();
const deviceEditedAt = new Date(Date.now() - 60_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
await patchAnnotations({
updatedAnnotations: [
{
id,
type: 'highlight',
highlightColor: '#F6F3B3',
highlightedText: 'fixture',
location: KOBO_LOCATION,
clientLastModifiedUtc: deviceEditedAt,
},
],
});
const annotationId = await annotationIdForDeviceKey(id);
const edit = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${epub.bookId}/annotations/${annotationId}`,
headers: authHeader(ctx.adminToken),
payload: { note: 'note added on the web' },
});
expect(edit.statusCode).toBe(200);
const served = (await getAnnotations()).annotations.find((annotation) => annotation.id === id)!;
expect(served).toMatchObject({ type: 'note', noteText: 'note added on the web' });
expect(Date.parse(String(served.clientLastModifiedUtc))).toBeGreaterThan(Date.parse(deviceEditedAt));
});
it('keeps serving the book after the cached conversion the device holds is evicted and rebuilt', async () => {
const cacheDir = join(ctx.fixture.booksPath, '.kepub-cache', String(epub.bookId));
const before = await pullAnnotations(false);
const cached = (await readdir(cacheDir).catch(() => [] as string[])).filter((name) => name.endsWith('.kepub.epub'));
for (const name of cached) await rm(join(cacheDir, name), { force: true });
const after = await pullAnnotations(false);
const served = (pull: typeof before) =>
pull.body.annotations
.map((annotation) => {
const span = (annotation.location as typeof KOBO_LOCATION).span;
return { id: annotation.id, range: [span.chapterFilename, span.startPath, span.startChar, span.endPath, span.endChar] };
})
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
expect(served(after)).toEqual(served(before));
});
it('offers an edited highlight acked under the previous served clock to the device once more', async () => {
const id = randomUUID();
await patchAnnotations({
updatedAnnotations: [
{
id,
type: 'highlight',
highlightColor: '#F6F3B3',
highlightedText: 'fixture',
location: KOBO_LOCATION,
clientLastModifiedUtc: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
},
],
});
const annotationId = await annotationIdForDeviceKey(id);
const edit = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${epub.bookId}/annotations/${annotationId}`,
headers: authHeader(ctx.adminToken),
payload: { color: '#4ADE80' },
});
expect(edit.statusCode).toBe(200);
await getAnnotations();
expect(await checkForChanges()).toEqual([]);
const acked = and(eq(schema.annotationSyncState.annotationId, annotationId), eq(schema.annotationSyncState.source, 'kobo'));
const [state] = await ctx.db.select().from(schema.annotationSyncState).where(acked);
expect(state!.servedClock).toBe(1);
await ctx.db.update(schema.annotationSyncState).set({ servedClock: 0 }).where(acked);
const previousTag = reportedEtags.get(contentId)!;
reportedEtags.set(contentId, previousTag.replace(/-c\d+"$/, '"'));
expect(await checkForChanges()).toEqual([contentId]);
await getAnnotations();
expect(await checkForChanges()).toEqual([]);
const [reacked] = await ctx.db.select().from(schema.annotationSyncState).where(acked);
expect(reacked!.servedClock).toBe(1);
});
});
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { exchangeDeviceId } from '../src/modules/koreader/koreader-exchange-device';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
@@ -29,6 +30,7 @@ interface ExchangeBookPayload {
hash: string;
keys: { k: string; dt: string }[];
keysComplete: boolean;
sidecarState?: 'known' | 'new';
changes: Record<string, unknown>[];
}
@@ -42,12 +44,12 @@ describe('KOReader annotation exchange (e2e)', { timeout: 120_000 }, () => {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function exchange(books: ExchangeBookPayload[], deviceId = DEVICE_ID) {
async function exchange(books: ExchangeBookPayload[], deviceId = DEVICE_ID, extra: Record<string, unknown> = {}) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/annotations/exchange',
headers: deviceHeaders(),
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.4.0', books },
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.4.0', books, ...extra },
});
expect(response.statusCode).toBe(201);
return response.json() as {
@@ -190,12 +192,9 @@ describe('KOReader annotation exchange (e2e)', { timeout: 120_000 }, () => {
expect(created.statusCode).toBe(201);
const webAnnotationId = (created.json() as { id: number }).id;
// First exchange spends the conversion budget and defers the entry.
// A successful conversion is delivered in the same manual exchange.
const first = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
expect(first.results[0].toApply.add).toEqual([]);
const second = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
const add = second.results[0].toApply.add.find((entry) => entry.serverId === webAnnotationId);
const add = first.results[0].toApply.add.find((entry) => entry.serverId === webAnnotationId);
expect(add).toBeDefined();
expect(add).toMatchObject({
drawer: 'underscore',
@@ -286,6 +285,208 @@ describe('KOReader annotation exchange (e2e)', { timeout: 120_000 }, () => {
.from(schema.annotations)
.where(and(eq(schema.annotations.deviceCreatedAt, '2026-06-01 10:00:00'), eq(schema.annotations.bookId, epub.bookId)));
expect(deviceRow.deletedAt).not.toBeNull();
const restored = await ctx.app.inject({
method: 'POST',
url: `/api/v1/annotations/${deviceRow.id}/restore`,
headers: authHeader(ctx.adminToken),
});
expect(restored.statusCode).toBe(200);
expect(restored.json().id).toBe(deviceRow.id);
const restoredPull = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }]);
expect(restoredPull.results[0].applied.deviceDeleted).toBe(0);
const restoredAdd = restoredPull.results[0].toApply.add.find((entry) => entry.serverId === deviceRow.id);
expect(restoredAdd).toBeDefined();
expect(restoredPull.results[0].toApply.edit.some((entry) => entry.serverId === deviceRow.id)).toBe(false);
await exchangeAck([
{
hash: fileHash,
applied: [
{
serverId: restoredAdd!.serverId,
version: restoredAdd!.version,
status: 'applied',
verified: true,
pos0: restoredAdd!.pos0,
pos1: restoredAdd!.pos1,
},
],
deleted: [],
},
]);
const repeated = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
expect(repeated.results[0].toApply.add.some((entry) => entry.serverId === deviceRow.id)).toBe(false);
await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/annotations/${deviceRow.id}`,
headers: authHeader(ctx.adminToken),
});
const unackedDelete = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
expect(unackedDelete.results[0].toApply.delete.some((entry) => entry.serverId === deviceRow.id)).toBe(true);
await ctx.app.inject({ method: 'POST', url: `/api/v1/annotations/${deviceRow.id}/restore`, headers: authHeader(ctx.adminToken) });
const afterLostAck = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }]);
expect(afterLostAck.results[0].applied.deviceDeleted).toBe(0);
expect(afterLostAck.results[0].toApply.add.some((entry) => entry.serverId === deviceRow.id)).toBe(true);
});
it('gives a re-downloaded copy everything back instead of deleting it, and trusts a known sidecar', async () => {
const device = 'e2e-redownload';
for (const color of ['#4ADE80', '#38BDF8']) {
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, text: 'fixture', color, style: 'highlight' },
});
expect(created.statusCode).toBe(201);
}
const ackAll = async (adds: Record<string, unknown>[]) =>
exchangeAck(
[
{
hash: fileHash,
applied: adds.map((entry) => ({ serverId: entry.serverId, version: entry.version, status: 'applied', verified: true })),
deleted: [],
},
],
device,
);
const first = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], device);
const delivered = first.results[0].toApply.add;
expect(delivered.length).toBeGreaterThanOrEqual(2);
await ackAll(delivered);
const deliveredIds = delivered.map((entry) => entry.serverId as number).sort((a, b) => a - b);
// An older plugin sends the fresh sidecar's empty list as complete.
const redownloaded = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }], device);
expect(redownloaded.results[0].applied.deviceDeleted).toBe(0);
expect(redownloaded.results[0].toApply.add.map((entry) => entry.serverId as number).sort((a, b) => a - b)).toEqual(deliveredIds);
const live = await ctx.db.select().from(schema.annotations).where(eq(schema.annotations.bookId, epub.bookId));
for (const id of deliveredIds) expect(live.find((row) => row.id === id)?.deletedAt).toBeNull();
await ackAll(redownloaded.results[0].toApply.add);
// A plugin that says so gets the same, and the device-local clock rides along.
const reported = await exchange([{ hash: fileHash, keys: [], keysComplete: true, sidecarState: 'new', changes: [] }], device);
expect(reported.results[0].applied.deviceDeleted).toBe(0);
expect(reported.results[0].toApply.add).toHaveLength(deliveredIds.length);
await ackAll(reported.results[0].toApply.add);
// A device edit time that only restates the stored content changes nothing visible.
const sample = reported.results[0].toApply.add[0];
const [before] = await ctx.db
.select()
.from(schema.annotations)
.where(eq(schema.annotations.id, sample.serverId as number));
const echo = await exchange(
[
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [
{
datetime: sample.datetime,
datetimeUpdated: '2030-01-01 00:00:00',
drawer: sample.drawer,
color: sample.color,
text: sample.text,
posFormat: 'xpointer',
pos0: sample.pos0,
pos1: sample.pos1,
},
],
},
],
device,
);
expect(echo.results[0].applied.unchanged).toBe(1);
const [after] = await ctx.db
.select()
.from(schema.annotations)
.where(eq(schema.annotations.id, sample.serverId as number));
expect(after.version).toBe(before.version);
expect(after.deviceUpdatedAt).toBe('2030-01-01 00:00:00');
expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime());
// A known sidecar that really lost everything is a real delete.
const deleted = await exchange([{ hash: fileHash, keys: [], keysComplete: true, sidecarState: 'known', changes: [] }], device);
expect(deleted.results[0].applied.deviceDeleted).toBe(deliveredIds.length);
for (const id of deliveredIds) {
await ctx.app.inject({ method: 'POST', url: `/api/v1/annotations/${id}/restore`, headers: authHeader(ctx.adminToken) });
}
});
it('keeps a web recolor beside a device note change, and sends a note edit that lost back to the device', async () => {
// The device clock is reported in UTC here, so device strings and server time line up.
const deviceClock = (offsetMs = 0) => new Date(Date.now() + offsetMs).toISOString().slice(0, 19).replace('T', ' ');
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, text: 'fixture', color: '#FACC15', style: 'highlight', note: 'merge base' },
});
expect(created.statusCode).toBe(201);
const id = (created.json() as { id: number }).id;
const pull = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], DEVICE_ID, { deviceTime: deviceClock() });
const add = pull.results[0].toApply.add.find((entry) => entry.serverId === id)!;
expect(add).toBeDefined();
await exchangeAck([{ hash: fileHash, applied: [{ serverId: id, version: add.version, status: 'applied', verified: true }], deleted: [] }]);
// The device changes the note a minute before the web recolors it.
const deviceEdit = deviceClock(-60_000);
const recolor = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${epub.bookId}/annotations/${id}`,
headers: authHeader(ctx.adminToken),
payload: { color: '#38BDF8' },
});
expect(recolor.statusCode).toBe(200);
const change = (note: string, datetimeUpdated: string) => ({
datetime: add.datetime,
datetimeUpdated,
drawer: 'lighten',
color: 'yellow',
text: 'fixture',
note,
posFormat: 'xpointer',
pos0: FIXTURE_XPOINTER_0,
pos1: FIXTURE_XPOINTER_1,
});
const merged = await exchange(
[{ hash: fileHash, keys: [], keysComplete: false, changes: [change('merge device note', deviceEdit)] }],
DEVICE_ID,
{ deviceTime: deviceClock() },
);
let [row] = await ctx.db.select().from(schema.annotations).where(eq(schema.annotations.id, id));
expect(row.note).toBe('merge device note');
expect(row.color).toBe('#38BDF8');
const recolored = merged.results[0].toApply.edit.find((entry) => entry.serverId === id);
expect(recolored).toMatchObject({ note: 'merge device note', color: 'blue' });
await exchangeAck([{ hash: fileHash, applied: [{ serverId: id, version: recolored!.version, status: 'applied', verified: true }], deleted: [] }]);
// Both change the note while the device is offline; the web's edit is the later one.
const olderDeviceEdit = deviceClock(-60_000);
const webNote = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${epub.bookId}/annotations/${id}`,
headers: authHeader(ctx.adminToken),
payload: { note: 'merge web note' },
});
expect(webNote.statusCode).toBe(200);
const refused = await exchange(
[{ hash: fileHash, keys: [], keysComplete: false, changes: [{ ...change('merge refused', olderDeviceEdit), color: 'blue' }] }],
DEVICE_ID,
{ deviceTime: deviceClock() },
);
[row] = await ctx.db.select().from(schema.annotations).where(eq(schema.annotations.id, id));
expect(row.note).toBe('merge web note');
expect(refused.results[0].toApply.edit.find((entry) => entry.serverId === id)).toMatchObject({ note: 'merge web note' });
});
it('rejects exchange calls without device credentials', async () => {
@@ -296,4 +497,233 @@ describe('KOReader annotation exchange (e2e)', { timeout: 120_000 }, () => {
});
expect(response.statusCode).toBe(401);
});
it('retains and redelivers unverified sidecar annotations missing on reopen, then accepts a verified apply', async () => {
const device = 'e2e-sidecar-reopen';
const first = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], device);
const add = first.results[0].toApply.add[0];
expect(add).toBeDefined();
await exchangeAck(
[
{
hash: fileHash,
applied: [
{
serverId: add.serverId,
version: add.version,
status: 'applied',
verified: false,
pos0: add.pos0,
pos1: add.pos1,
},
],
deleted: [],
},
],
device,
);
const reopened = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }], device);
expect(reopened.results[0].applied.deviceDeleted).toBe(0);
expect(reopened.results[0].toApply.edit).toEqual([]);
expect(reopened.results[0].toApply.add).toContainEqual(expect.objectContaining({ serverId: add.serverId, version: add.version }));
await exchangeAck(
[
{
hash: fileHash,
applied: [
{
serverId: add.serverId,
version: add.version,
status: 'applied',
verified: true,
pos0: add.pos0,
pos1: add.pos1,
},
],
deleted: [],
},
],
device,
);
const applied = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], device);
expect(applied.results[0].toApply.add.some((entry) => entry.serverId === add.serverId)).toBe(false);
});
// D-13: a hub bulk restore kept the device's delete-acked state, so the device's next complete
// key set (which rightly lacks the row) trashed it again everywhere.
it('gives a bulk-restored highlight back to a device that deleted it instead of deleting it again', async () => {
const device = 'e2e-device-0003';
const create = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color: '#F472B6', style: 'highlight', note: 'bulk restore' },
});
expect(create.statusCode).toBe(201);
const id = (create.json() as { id: number }).id;
const delivered = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], device);
const add = delivered.results[0].toApply.add.find((entry) => entry.serverId === id)!;
expect(add).toBeDefined();
await exchangeAck(
[
{
hash: fileHash,
applied: [{ serverId: id, version: add.version, status: 'applied', verified: true, pos0: add.pos0, pos1: add.pos1 }],
deleted: [],
},
],
device,
);
const trash = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/annotations/${id}`,
headers: authHeader(ctx.adminToken),
});
expect(trash.statusCode).toBe(204);
const removal = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], device);
expect(removal.results[0].toApply.delete.some((entry) => entry.serverId === id)).toBe(true);
await exchangeAck([{ hash: fileHash, applied: [], deleted: [{ serverId: id, status: 'applied' }] }], device);
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/annotations/bulk',
headers: authHeader(ctx.adminToken),
payload: { action: 'restore', ids: [id] },
});
expect(bulk.statusCode).toBe(200);
expect(bulk.json()).toEqual({ affected: 1 });
const next = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }], device);
expect(next.results[0].applied.deviceDeleted).toBe(0);
expect(next.results[0].toApply.add.some((entry) => entry.serverId === id)).toBe(true);
const [row] = await ctx.db.select().from(schema.annotations).where(eq(schema.annotations.id, id));
expect(row.deletedAt).toBeNull();
});
it('removing a device drops its per-book sync rows, so its unacknowledged deletes stop blocking a purge', async () => {
const REMOVED_DEVICE_ID = 'e2e-device-0003';
const made = await exchange(
[
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [
{
datetime: '2026-06-03 08:00:00',
drawer: 'lighten',
color: 'green',
text: 'fixture',
posFormat: 'xpointer',
pos0: FIXTURE_XPOINTER_0,
pos1: FIXTURE_XPOINTER_1,
},
],
},
],
REMOVED_DEVICE_ID,
);
expect(made.results[0].applied.created).toBe(1);
const [row] = await ctx.db
.select()
.from(schema.annotations)
.where(and(eq(schema.annotations.deviceCreatedAt, '2026-06-03 08:00:00'), eq(schema.annotations.bookId, epub.bookId)));
const renditionId = exchangeDeviceId(REMOVED_DEVICE_ID, fileHash);
const bookmark = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { cfi: 'epubcfi(/6/2!/4/2/1:3)', title: 'Removed device dogear' },
});
expect(bookmark.statusCode).toBe(201);
await ctx.db
.insert(schema.koreaderBookmarkLinks)
.values({ bookmarkId: (bookmark.json() as { id: number }).id, userId: row.userId, deviceId: renditionId, koreaderKey: 'f'.repeat(32) });
const trashed = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/annotations/${row.id}`,
headers: authHeader(ctx.adminToken),
});
expect(trashed.statusCode).toBe(204);
const blocked = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/annotations/${row.id}`, headers: authHeader(ctx.adminToken) });
expect(blocked.statusCode).toBe(409);
const removed = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/koreader/devices/${REMOVED_DEVICE_ID}`,
headers: authHeader(ctx.adminToken),
});
expect(removed.statusCode).toBe(200);
const leftStates = await ctx.db.select().from(schema.annotationSyncState).where(eq(schema.annotationSyncState.deviceId, renditionId));
const leftLinks = await ctx.db.select().from(schema.koreaderBookmarkLinks).where(eq(schema.koreaderBookmarkLinks.deviceId, renditionId));
expect(leftStates).toEqual([]);
expect(leftLinks).toEqual([]);
const otherDevices = await ctx.db
.select()
.from(schema.annotationSyncState)
.where(eq(schema.annotationSyncState.deviceId, exchangeDeviceId(DEVICE_ID, fileHash)));
expect(otherDevices.length).toBeGreaterThan(0);
const purged = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/annotations/${row.id}`, headers: authHeader(ctx.adminToken) });
expect(purged.statusCode).toBe(204);
});
it('lists a closed book as pending only while something is waiting for the device', async () => {
const SWEEP_DEVICE_ID = 'e2e-device-0004';
const pending = async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/annotations/pending',
headers: deviceHeaders(),
payload: { deviceId: SWEEP_DEVICE_ID, deviceModel: 'E2E', pluginVersion: '0.4.0', hashes: [fileHash.toUpperCase(), 'f'.repeat(32)] },
});
expect(response.statusCode).toBe(201);
return (response.json() as { pending: string[] }).pending;
};
const deliverEverything = async () => {
for (let round = 0; round < 5; round += 1) {
const pull = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], SWEEP_DEVICE_ID);
const toApply = pull.results[0].toApply;
if (toApply.add.length + toApply.edit.length + toApply.delete.length === 0) return;
const applied = [...toApply.add, ...toApply.edit].map((entry) => ({
serverId: entry.serverId,
version: entry.version,
status: 'applied',
verified: true,
pos0: entry.pos0,
pos1: entry.pos1,
}));
const deleted = toApply.delete.map((entry) => ({ serverId: entry.serverId, status: 'applied' }));
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/annotations/exchange-ack',
headers: deviceHeaders(),
payload: { deviceId: SWEEP_DEVICE_ID, deviceModel: 'E2E', pluginVersion: '0.4.0', books: [{ hash: fileHash, applied, deleted }] },
});
expect(response.statusCode).toBe(201);
}
};
expect(await pending()).toEqual([fileHash.toLowerCase()]);
await deliverEverything();
expect(await pending()).toEqual([]);
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { cfi: FIXTURE_CFI, bookFileId: epub.bookFileId, text: 'fixture', color: '#4ADE80', style: 'highlight', note: 'made on the web' },
});
expect(created.statusCode).toBe(201);
expect(await pending()).toEqual([fileHash.toLowerCase()]);
await deliverEverything();
expect(await pending()).toEqual([]);
});
});
@@ -1,4 +1,4 @@
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
@@ -28,6 +28,7 @@ interface BookmarkBookPayload {
hash: string;
keys: { k: string; dt: string }[];
keysComplete: boolean;
sidecarState?: 'known' | 'new';
changes: Record<string, unknown>[];
}
@@ -150,6 +151,7 @@ describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
});
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 () => {
@@ -244,7 +246,7 @@ describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
method: 'POST',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { cfi: WEB_POINT_CFI, title: 'Chapter One' },
payload: { cfi: WEB_POINT_CFI, bookFileId: epub.bookFileId, title: 'Chapter One' },
});
expect(created.statusCode).toBe(201);
webBookmarkId = (created.json() as { id: number }).id;
@@ -304,8 +306,11 @@ describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
const restored = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
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' }],
},
]);
@@ -347,9 +352,33 @@ describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
expect(await listWebBookmarks()).toHaveLength(2);
});
it('tombstones bookmarks the device no longer reports once the key set is complete', async () => {
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);
@@ -151,6 +151,27 @@ describe('KOReader bulk manifest at scale (e2e)', { timeout: 600_000 }, () => {
expect(row?.inaccessible).toBe(0);
});
it('continues a cursor without gaps when the match token changes during enumeration', async () => {
const first = await fetchManifestPage(null);
expect(first.nextCursor).toBeTruthy();
const [file] = await ctx.db
.select({ id: schema.bookFiles.id })
.from(schema.bookFiles)
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(eq(schema.books.libraryId, library.libraryId))
.limit(1);
await ctx.db
.update(schema.bookFiles)
.set({ updatedAt: new Date(Date.now() + 1000) })
.where(eq(schema.bookFiles.id, file!.id));
const second = await fetchManifestPage(first.nextCursor);
expect(second.manifestVersion).not.toBe(first.manifestVersion);
expect(second.restartRequired).toBe(false);
expect(second.items).toHaveLength(PAGE_SIZE);
expect(second.items[0]!.id).toBeGreaterThan(first.items.at(-1)!.id);
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(2 * PAGE_SIZE);
});
it('serves a late keyset page from an index rather than a sequential scan', async () => {
const [midpoint] = await ctx.db
.select({ id: schema.books.id })
+124 -4
View File
@@ -4,13 +4,16 @@ import { join } from 'path';
import { and, asc, eq, gte, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { buildSessionId, buildSessionIdPrefix } from '../src/common/utils/koreader-session-id.utils';
import { Permission } from '@bookorbit/types';
import { buildSessionId, buildSessionIdPrefix, koreaderSessionDeviceKey } from '../src/common/utils/koreader-session-id.utils';
import { KOREADER_SESSION_GAP_SECONDS, deriveKoreaderSessions, type KoreaderPageEvent } from '../src/modules/koreader/koreader-stats.util';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createUserAndLogin,
grantLibraryAccess,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
@@ -43,18 +46,19 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
let epub!: LocatedBookFile;
let epubPath!: string;
let fileHash!: string;
let ownerId!: number;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function uploadPageStats(events: KoreaderPageEvent[], bookFileId?: number) {
async function uploadPageStats(events: KoreaderPageEvent[], bookFileId?: number, deviceId = DEVICE_ID) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/page-stats',
headers: deviceHeaders(),
payload: {
deviceId: DEVICE_ID,
deviceId,
deviceModel: 'E2E',
pluginVersion: '0.4.0',
books: [{ hash: fileHash, bookFileId, events }],
@@ -156,6 +160,11 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect([200, 201]).toContain(credentials.statusCode);
const [credential] = await ctx.db
.select({ userId: schema.koreaderUsers.userId })
.from(schema.koreaderUsers)
.where(eq(schema.koreaderUsers.username, KOREADER_USERNAME));
ownerId = credential!.userId;
}, 180_000);
afterAll(async () => {
@@ -175,6 +184,117 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
expect(comparable(await storedSessions())).toEqual(expectedSessions(history));
});
it('keeps devices with the same eight-character prefix independent across retry and deletion', async () => {
const base = BASE_EPOCH + 1_000_000;
const deviceA = 'shared-prefix-device-A';
const deviceB = 'shared-prefix-device-B';
const eventsA = [{ page: 1, startTime: base, durationSeconds: 40, totalPages: TOTAL_PAGES }];
const eventsB = [{ page: 2, startTime: base, durationSeconds: 70, totalPages: TOTAL_PAGES }];
await uploadPageStats(eventsA, undefined, deviceA);
await uploadPageStats(eventsB, undefined, deviceB);
const rows = await ctx.db
.select()
.from(schema.readingSessions)
.where(and(eq(schema.readingSessions.bookFileId, epub.bookFileId), eq(schema.readingSessions.startedAt, new Date(base * 1000))));
expect(rows.map((row) => row.durationSeconds).sort((a, b) => a - b)).toEqual([40, 70]);
await deleteSessionFromLog(rows.find((row) => row.sessionId === buildSessionId(deviceA, epub.bookFileId, base))!.id);
await uploadPageStats([{ ...eventsB[0]!, page: 3, startTime: base + 80 }], undefined, deviceB);
expect((await uploadPageStats(eventsA, undefined, deviceA)).results[0]!.duplicates).toBe(1);
const surviving = await ctx.db
.select()
.from(schema.readingSessions)
.where(and(eq(schema.readingSessions.bookFileId, epub.bookFileId), eq(schema.readingSessions.startedAt, new Date(base * 1000))));
expect(surviving.map((row) => row.sessionId)).toEqual([buildSessionId(deviceB, epub.bookFileId, base)]);
});
it('upgrades a conflated legacy session from complete raw device clusters without double counting', async () => {
const base = BASE_EPOCH + 2_000_000;
const devices = ['legacy-prefix-A', 'legacy-prefix-B'];
await ctx.db.insert(schema.koreaderPageStats).values(
devices.map((deviceId, index) => ({
userId: ownerId,
bookFileId: epub.bookFileId,
deviceId,
page: 1,
startTime: base,
durationSeconds: 40 + index * 30,
totalPages: TOTAL_PAGES,
})),
);
await ctx.db.insert(schema.readingSessions).values({
userId: ownerId,
bookFileId: epub.bookFileId,
bookId: epub.bookId,
source: 'koreader',
sessionId: `kor:legacy-p:${epub.bookFileId}:${base}`,
startedAt: new Date(base * 1000),
endedAt: new Date((base + 70) * 1000),
durationSeconds: 70,
});
await uploadPageStats([{ page: 2, startTime: base + 80, durationSeconds: 40, totalPages: TOTAL_PAGES }], undefined, devices[0]);
const rows = await ctx.db
.select()
.from(schema.readingSessions)
.where(and(eq(schema.readingSessions.bookFileId, epub.bookFileId), eq(schema.readingSessions.startedAt, new Date(base * 1000))));
expect(rows.map((row) => row.sessionId).sort()).toEqual(devices.map((device) => buildSessionId(device, epub.bookFileId, base)).sort());
expect(rows.map((row) => row.durationSeconds).sort((a, b) => a - b)).toEqual([70, 80]);
});
it('scopes canonical status and page-time uploads to the authenticated reader', async () => {
const reader = await createUserAndLogin(ctx, { permissions: [Permission.KoreaderSync] });
const username = `stats-isolation-${randomUUID().slice(0, 8)}`;
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(reader.accessToken),
payload: { username, password: KOREADER_PASSWORD },
});
expect(credentials.statusCode).toBe(201);
const headers = { 'x-auth-user': username, 'x-auth-key': KOREADER_PASSWORD };
const device = { deviceId: 'isolation-device', deviceModel: 'E2E', pluginVersion: '1.7.0' };
const denied = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/book-states',
headers,
payload: { ...device, books: [{ hash: fileHash, status: 'complete', rating: 1 }] },
});
expect(denied.statusCode).toBe(201);
expect(denied.json()).toEqual({ results: [], unmatched: [fileHash] });
const deniedStats = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/page-stats',
headers,
payload: { ...device, books: [{ hash: fileHash, bookFileId: epub.bookFileId, events: cluster(BASE_EPOCH + 3_000_000, 1, 1) }] },
});
expect(deniedStats.statusCode).toBe(201);
expect(deniedStats.json()).toEqual({ results: [], unmatched: [fileHash] });
await grantLibraryAccess(ctx, reader.userId, library.libraryId);
const empty = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/book-states',
headers,
payload: { ...device, books: [{ hash: fileHash }] },
});
expect(empty.json().results[0]).toMatchObject({ statusSet: false, ratingSet: false, reviewNoteSet: false });
const adminBefore = await ctx.db
.select()
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, ownerId), eq(schema.userBookStatus.bookId, epub.bookId)));
const own = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/book-states',
headers,
payload: { ...device, books: [{ hash: fileHash, status: 'complete', rating: 1 }] },
});
expect(own.json().results[0]).toMatchObject({ status: 'complete', statusSet: true, rating: 1 });
expect(
await ctx.db
.select()
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, ownerId), eq(schema.userBookStatus.bookId, epub.bookId))),
).toEqual(adminBefore);
});
it('merges two stored sessions when a later batch fills the gap between them', async () => {
const early = cluster(BASE_EPOCH + 5_000_000, 10, 0);
const late = cluster(BASE_EPOCH + 5_000_000 + 3 * KOREADER_SESSION_GAP_SECONDS, 10, 40);
@@ -294,7 +414,7 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
await deleteSessionFromLog(derived!.id);
expect(await sessionsBetween(base, base + 86_400)).toEqual([]);
expect(await tombstonesFor(base)).toEqual([
{ deviceKey: DEVICE_ID.slice(0, 8), startTime: base, endTime: sitting.at(-1)!.startTime + sitting.at(-1)!.durationSeconds },
{ deviceKey: koreaderSessionDeviceKey(DEVICE_ID), startTime: base, endTime: sitting.at(-1)!.startTime + sitting.at(-1)!.durationSeconds },
]);
// Ten minutes after the deleted sitting ended: close enough that derivation used to merge the
@@ -1,4 +1,5 @@
import { randomUUID } from 'crypto';
import { rename } from 'fs/promises';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
@@ -275,6 +276,36 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () =>
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 20, cfi: 'epubcfi(/6/2!/4/2/1:1)' }));
});
it('recovers from a future device clock after the clock is corrected', async () => {
const now = Math.floor(Date.now() / 1000);
await syncFromDevice(epubHash, 0.75, '/body/DocFragment[1]/body/p/text().4', now + 86400);
await syncFromDevice(epubHash, 0.2, '/body/DocFragment[1]/body/p/text().1', Math.floor(Date.now() / 1000));
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 20 }));
const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders() });
expect(pull.json()).toEqual(expect.objectContaining({ percentage: 0.2 }));
expect(pull.json().timestamp).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
});
it('preserves the newest pull position after duplicate and delayed bulk updates', async () => {
const now = Math.floor(Date.now() / 1000);
const send = (items: { hash: string; percentage: number; progress: string; timestamp: number }[]) =>
ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/progress',
headers: deviceHeaders(),
payload: { deviceId: DEVICE_ID, deviceModel: 'Regression device', pluginVersion: '1.7.0', items },
});
const newest = { hash: epubHash, percentage: 0.6, progress: '/body/DocFragment[1]/body/p/text().3', timestamp: now };
const delayed = { ...newest, percentage: 0.1, progress: '/body/DocFragment[1]/body/p/text().1', timestamp: now - 60 };
expect((await send([newest, delayed, { ...delayed, timestamp: now - 30 }])).statusCode).toBe(201);
expect((await send([delayed])).statusCode).toBe(201);
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 60 }));
const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders() });
expect(pull.json()).toEqual(expect.objectContaining({ percentage: 0.6, progress: newest.progress }));
});
it('serves percentage fallback for an unresolvable reader position', async () => {
await ctx.db
.update(schema.koreaderDeviceProgress)
@@ -424,4 +455,53 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () =>
await ctx.db.update(schema.bookFiles).set({ fileHash: epubHash }).where(eq(schema.bookFiles.id, epub.bookFileId));
}
});
it('falls back safely for replaced sources and their historical deliveries after rescan', async () => {
const path = await createEpubFixture(library.folderPath, 'replacement/Source.epub', { title: 'Before replacement' });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const file = await locateBookByAbsolutePath(ctx, path);
const oldHash = await fileHashFor(file.bookFileId);
const derivativeHash = 'd'.repeat(32);
const point = '/body/DocFragment[1]/body/p/text().3';
await ctx.db.insert(schema.bookFileHashHistory).values([
{ bookFileId: file.bookFileId, fileHash: oldHash, reason: 'kobo_download' },
{ bookFileId: file.bookFileId, fileHash: derivativeHash, reason: 'kobo_download' },
]);
await syncFromDevice(oldHash, 0.4, point);
expect((await storedProgress(file.bookFileId))?.cfi).toBeTruthy();
const replacement = await createEpubFixture(library.folderPath, 'replacement/Replacement.epub', {
title: 'Replacement with different content identity',
});
await rename(replacement, path);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const newHash = await fileHashFor(file.bookFileId);
expect(newHash).not.toBe(oldHash);
expect(await storedProgress(file.bookFileId)).toEqual(expect.objectContaining({ percentage: 40, cfi: null, koreaderProgress: null }));
for (const hash of [oldHash, derivativeHash]) {
await syncFromDevice(hash, 0.5, point);
expect(await storedProgress(file.bookFileId)).toEqual(expect.objectContaining({ percentage: 50, cfi: null, koreaderProgress: null }));
}
const bulk = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/progress',
headers: deviceHeaders(),
payload: {
deviceId: DEVICE_ID,
deviceModel: 'Regression device',
pluginVersion: '1.7.0',
items: [{ hash: derivativeHash, percentage: 0.6, progress: point }],
},
});
expect(bulk.statusCode).toBe(201);
expect(await storedProgress(file.bookFileId)).toEqual(expect.objectContaining({ percentage: 60, cfi: null, koreaderProgress: null }));
await syncFromDevice(newHash, 0.7, point);
expect((await storedProgress(file.bookFileId))?.cfi).toBeTruthy();
for (const hash of [oldHash, derivativeHash]) {
const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${hash}`, headers: deviceHeaders() });
expect(pull.statusCode).toBe(200);
expect(pull.json()).toEqual(expect.objectContaining({ percentage: 0.7, progress: null }));
}
});
});
@@ -3,6 +3,7 @@ import { rm } from 'fs/promises';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { PositionConverterService } from '../src/modules/position-converter/position-converter.service';
import { createZipArchiveFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
@@ -119,13 +120,13 @@ ${spine}
/** Scans a book whose original and read-along copy share a folder, and checks they became one book. */
async function scanReadAlongBook(folder: string) {
const { originalPath, readAlongPath } = await writeBook(folder, { readAlong: true });
const { title, originalPath, readAlongPath } = await writeBook(folder, { readAlong: true });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const original = await locateBookByAbsolutePath(ctx, originalPath);
const readAlong = await locateBookByAbsolutePath(ctx, readAlongPath!);
expect(readAlong.bookId).toBe(original.bookId);
expect(await webReaderFileId(original.bookId)).toBe(readAlong.bookFileId);
return { original, readAlong, readAlongPath: readAlongPath!, originalHash: await fileHashFor(original.bookFileId) };
return { title, original, readAlong, readAlongPath: readAlongPath!, originalHash: await fileHashFor(original.bookFileId) };
}
async function fileHashFor(bookFileId: number): Promise<string> {
@@ -272,6 +273,163 @@ ${spine}
expect((await fileProgress(original.bookFileId)).mediaOverlayFragment).toBeNull();
});
it('projects a plain-file highlight into its narrated sibling without overwriting its canonical position', async () => {
const { title, original, readAlong } = await scanReadAlongBook('Author K/Series K/Annotation Projection');
const text = `Chapter 7 of ${title} has one sentence of prose in it.`;
const cfi = `epubcfi(/6/14!/4/2,/1:0,/1:${text.length})`;
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${original.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { bookFileId: original.bookFileId, cfi, text },
});
expect(created.statusCode).toBe(201);
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${original.bookId}/annotations?page=1&readerFileId=${readAlong.bookFileId}`,
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
const projected = response.json().items.find((item: { id: number }) => item.id === created.json().id);
expect(projected.jumpFileId).toBe(readAlong.bookFileId);
expect(projected.cfi).not.toBe(cfi);
await expect(
ctx.app.get(PositionConverterService).cfiToXpointer({ bookFileId: readAlong.bookFileId, cfi: projected.cfi, text }),
).resolves.toEqual(expect.objectContaining({ status: 'exact' }));
const legacy = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${original.bookId}/annotations`, headers: authHeader(ctx.adminToken) });
expect(legacy.json().find((item: { id: number }) => item.id === created.json().id)).toEqual(
expect.objectContaining({ cfi, jumpFileId: original.bookFileId }),
);
});
it('keeps annotation and bookmark acknowledgements independent for two renditions on one device', async () => {
const { title, original, readAlong, originalHash } = await scanReadAlongBook('Author L/Series L/Rendition Sidecars');
const narratedHash = await fileHashFor(readAlong.bookFileId);
const text = `Chapter 7 of ${title} has one sentence of prose in it.`;
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${original.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
payload: { bookFileId: original.bookFileId, cfi: `epubcfi(/6/14!/4/2,/1:0,/1:${text.length})`, text },
});
expect(created.statusCode).toBe(201);
const bookmark = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${original.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { bookFileId: original.bookFileId, cfi: ORIGINAL_CHAPTER_7_CFI, title: 'Rendition dogear' },
});
expect(bookmark.statusCode).toBe(201);
const exchange = async (kind: string, hash: string, keysComplete: boolean) => {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/plugin/${kind}/exchange`,
headers: deviceHeaders(),
payload: { deviceId: DEVICE_ID, deviceModel: 'E2E', pluginVersion: '1.7.0', books: [{ hash, keys: [], keysComplete, changes: [] }] },
});
expect(response.statusCode).toBe(201);
return response.json().results[0];
};
const annotationAdd = (await exchange('annotations', originalHash, false)).toApply.add.find(
(entry: { serverId: number }) => entry.serverId === created.json().id,
);
expect(annotationAdd).toBeDefined();
const bookmarkAdd = (await exchange('bookmarks', originalHash, false)).toApply.add.find(
(entry: { serverId: number }) => entry.serverId === bookmark.json().id,
);
expect(bookmarkAdd).toBeDefined();
for (const [kind, applied] of [
['annotations', [{ serverId: annotationAdd.serverId, version: annotationAdd.version, status: 'applied', verified: true }]],
['bookmarks', [{ serverId: bookmarkAdd.serverId, status: 'applied', datetime: '2026-06-02 08:00:00', pos: bookmarkAdd.pos }]],
] as const) {
const ack = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/plugin/${kind}/exchange-ack`,
headers: deviceHeaders(),
payload: { deviceId: DEVICE_ID, deviceModel: 'E2E', pluginVersion: '1.7.0', books: [{ hash: originalHash, applied, deleted: [] }] },
});
expect(ack.statusCode).toBe(201);
expect(ack.json().results[0].acked).toBe(1);
const otherRendition = await exchange(kind, narratedHash, true);
expect(kind === 'annotations' ? otherRendition.applied.deviceDeleted : otherRendition.deviceDeleted).toBe(0);
expect(otherRendition.toApply.add.some((entry: { serverId: number }) => entry.serverId === applied[0].serverId)).toBe(true);
expect((await exchange(kind, originalHash, false)).toApply.add).toEqual([]);
}
const annotations = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${original.bookId}/annotations`,
headers: authHeader(ctx.adminToken),
});
expect(annotations.json().some((entry: { id: number }) => entry.id === created.json().id)).toBe(true);
const bookmarks = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${original.bookId}/bookmarks`, headers: authHeader(ctx.adminToken) });
expect(bookmarks.json().some((entry: { id: number }) => entry.id === bookmark.json().id)).toBe(true);
});
it('projects a source-file bookmark into narrated DOM and preserves the legacy bookmark response', async () => {
const { original, readAlong } = await scanReadAlongBook('Author M/Series M/Bookmark Projection');
const cfi = 'epubcfi(/6/14!/4/2/1:19)';
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${original.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { bookFileId: original.bookFileId, cfi, title: 'Interior prose dogear' },
});
expect(created.statusCode).toBe(201);
const projected = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${original.bookId}/bookmarks?readerFileId=${readAlong.bookFileId}`,
headers: authHeader(ctx.adminToken),
});
expect(projected.statusCode).toBe(200);
const target = projected.json().find((entry: { id: number }) => entry.id === created.json().id);
expect(target.cfi).not.toBe(cfi);
const position = await ctx.app.get(PositionConverterService).cfiPointToXpointer({ bookFileId: readAlong.bookFileId, cfi: target.cfi });
expect(position.pos0).toBe('/body/DocFragment[7]/body/p/span/text().19');
const exchange = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
headers: deviceHeaders(),
payload: {
deviceId: DEVICE_ID,
deviceModel: 'E2E',
pluginVersion: '1.7.0',
books: [{ hash: await fileHashFor(readAlong.bookFileId), keys: [], keysComplete: false, changes: [] }],
},
});
expect(exchange.statusCode).toBe(201);
expect(exchange.json().results[0].toApply.add).toContainEqual(expect.objectContaining({ serverId: created.json().id, pos: position.pos0 }));
const legacy = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${original.bookId}/bookmarks`, headers: authHeader(ctx.adminToken) });
expect(legacy.json().find((entry: { id: number }) => entry.id === created.json().id).cfi).toBe(cfi);
});
// A newer page past the narration takes it along, so listening resumes where the reading went.
it('maps an explicit text jump using its CFI and carries the older narration marker to it', async () => {
const { original, readAlong, originalHash } = await scanReadAlongBook('Author J/Series J/Stale Narration');
await pushFromKoreader(originalHash, 0.69);
expect((await fileProgress(readAlong.bookFileId)).mediaOverlayFragment).toBe(READ_ALONG_CHAPTER_7_SENTENCE);
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${readAlong.bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
payload: {
percentage: 90,
cfi: 'epubcfi(/6/20!/4/2/2/1:0)',
source: 'text',
positionSeconds: 60,
mediaOverlayFragment: READ_ALONG_CHAPTER_7_SENTENCE,
mediaOverlaySectionIndex: 6,
},
});
expect([200, 201, 204]).toContain(response.statusCode);
const pulled = await pullFromKoreader(originalHash);
expect(pulled.progress).toMatch(/^\/body\/DocFragment\[10\]\//);
expect(pulled.percentage).toBeCloseTo(0.9, 3);
expect((await fileProgress(original.bookFileId)).cfi).toMatch(/^epubcfi\(\/6\/20!/);
expect((await fileProgress(readAlong.bookFileId)).mediaOverlayFragment).toBe('OPS/chapter-10.xhtml#c10-s1');
});
it('round trip: the web reader re-saving the synced position does not drag the device backwards', async () => {
const { readAlong, originalHash } = await scanReadAlongBook('Author D/Series D/Round Trip');
@@ -588,20 +588,20 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
);
});
it('returns 403 for reader-state operations against inaccessible libraries', async () => {
it('denies inaccessible reader state and conceals book existence', async () => {
const bookmarkResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${sharedEpub.bookId}/bookmarks`,
headers: authHeader(outsider.accessToken),
});
expect(bookmarkResponse.statusCode).toBe(403);
expect(bookmarkResponse.statusCode).toBe(404);
const annotationResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${sharedEpub.bookId}/annotations`,
headers: authHeader(outsider.accessToken),
});
expect(annotationResponse.statusCode).toBe(403);
expect(annotationResponse.statusCode).toBe(404);
const preferenceResponse = await ctx.app.inject({
method: 'GET',
@@ -649,7 +649,7 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
manifestRevision: '0'.repeat(64),
},
});
expect(audioProgressResponse.statusCode).toBe(403);
expect(audioProgressResponse.statusCode).toBe(404);
});
});
+50 -1
View File
@@ -74,7 +74,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
}
beforeAll(async () => {
ctx = await createE2EContext();
ctx = await createE2EContext({ isolateAppData: true });
const [admin] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'scanner-e2e-admin')).limit(1);
adminUserId = admin.id;
const library = await seedLibrary(ctx.db, { rootPath: E2E_ROOT, mode: 'book_per_file', watch: false });
@@ -223,6 +223,55 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
expect(progress.percentage).toBe(100);
});
// A reread on a book never finished here records the read it implies as an undated completed attempt,
// created after the read in progress. Taken as the latest by id, its missing dates replaced the dates
// of that read once finished, the next time a session landed; a past read added later did the same.
it('4a. keeps the latest read dates over an undated earlier read and a past read added later', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'reading', startedAt: '2026-08-17' });
const reread = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts/start-reread`,
headers: auth(ctx.adminToken),
payload: { resetProgress: false },
});
expect(reread.json()).toMatchObject({ status: 'rereading' });
await patchStatus(book.bookId, { status: 'read', finishedAt: '2026-10-17' });
const bookDates = async () => {
const [row] = await ctx.db
.select()
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, adminUserId), eq(schema.userBookStatus.bookId, book.bookId)));
return { status: row?.status, startedOn: row?.startedAt?.toISOString().slice(0, 10), finishedOn: row?.finishedAt?.toISOString().slice(0, 10) };
};
const finished = { status: 'read', startedOn: '2026-08-17', finishedOn: '2026-10-17' };
expect(await bookDates()).toEqual(finished);
const session = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${book.fileId}/sessions`,
headers: auth(ctx.adminToken),
payload: {
sessionId: `reread-dates-${book.bookId}`,
startedAt: '2026-10-17T18:12:00.000Z',
endedAt: '2026-10-17T18:19:30.000Z',
durationSeconds: 450,
endProgress: 100,
},
});
expect(session.statusCode).toBe(204);
expect(await bookDates()).toEqual(finished);
const past = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2019-03-01', endedOn: '2019-03-20', outcome: 'completed' },
});
expect(past.statusCode).toBe(201);
expect(await bookDates()).toEqual(finished);
});
it('5. adds and edits partial historical attempts without duplicating them', async () => {
const book = await createBook();
const created = await ctx.app.inject({