mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user