Files
bookorbit/server/test/koreader-progress-position.e2e-spec.ts
T
neon 67da9a2491 fix(server): reject ambiguous KOReader file hashes
Treat partial-hash matches across distinct books as unresolved so progress cannot be written to an unrelated file. Preserve same-book duplicates and user-scoped manual links as valid disambiguation paths.

Fixes #1135
2026-08-30 05:08:58 +00:00

219 lines
8.4 KiB
TypeScript

import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { createCbzFixture, createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const KOREADER_USERNAME = `progress-position-device-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'ProgressPositionPass123';
const XPOINTER = '/body/DocFragment[8]/body/p[12]/text().0';
/**
* KOReader reports a paged document's position as a page number and a reflowable one's as an
* xpointer. Both arrive on the same endpoint, so these cases assert the position lands in the
* column the web reader actually resumes from for that format, against a real database.
*/
describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let comic!: LocatedBookFile;
let epub!: LocatedBookFile;
let comicHash!: string;
let epubHash!: string;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function fileHashFor(bookFileId: number): Promise<string> {
const [row] = await ctx.db.select({ fileHash: schema.bookFiles.fileHash }).from(schema.bookFiles).where(eq(schema.bookFiles.id, bookFileId));
expect(row?.fileHash).toBeTruthy();
return row!.fileHash!;
}
async function syncFromDevice(hash: string, percentage: number, progress?: string | number) {
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: { document: hash, percentage, ...(progress === undefined ? {} : { progress }) },
});
expect(response.statusCode).toBe(200);
}
async function storedProgress(bookFileId: number) {
const [row] = await ctx.db
.select({
percentage: schema.readingProgress.percentage,
cfi: schema.readingProgress.cfi,
pageNumber: schema.readingProgress.pageNumber,
koreaderProgress: schema.readingProgress.koreaderProgress,
})
.from(schema.readingProgress)
.where(eq(schema.readingProgress.bookFileId, bookFileId));
return row;
}
/** What the web reader loads when it opens the book. */
async function readerProgress(bookFileId: number) {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
return response.json() as { percentage: number; cfi: string | null; pageNumber: number | null };
}
async function saveFromWebReader(bookFileId: number, payload: Record<string, unknown>) {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
payload,
});
expect([200, 201, 204]).toContain(response.statusCode);
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `koreader-progress-position-${randomUUID()}` });
const comicPath = await createCbzFixture(library.folderPath, 'progress-position-comic.cbz', {
title: `Progress Position Comic ${randomUUID()}`,
});
const epubPath = await createEpubFixture(library.folderPath, 'progress-position-book.epub', {
title: `Progress Position Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
comic = await locateBookByAbsolutePath(ctx, comicPath);
epub = await locateBookByAbsolutePath(ctx, epubPath);
comicHash = await fileHashFor(comic.bookFileId);
epubHash = await fileHashFor(epub.bookFileId);
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect([200, 201]).toContain(credentials.statusCode);
}, 180_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('stores a paged position as a page number the web reader can resume from', async () => {
await syncFromDevice(comicHash, 0.4, '117');
const stored = await storedProgress(comic.bookFileId);
expect(stored?.pageNumber).toBe(117);
expect(stored?.cfi).toBeNull();
expect(stored?.koreaderProgress).toBe('117');
expect(stored?.percentage).toBeCloseTo(40, 5);
await expect(readerProgress(comic.bookFileId)).resolves.toEqual(expect.objectContaining({ pageNumber: 117, cfi: null }));
});
it('accepts the numeric page KOReader serializes for a paged document', async () => {
await syncFromDevice(comicHash, 0.5, 208);
expect((await storedProgress(comic.bookFileId))?.pageNumber).toBe(208);
});
it('advances the stored page on the next sync of the same book', async () => {
await syncFromDevice(comicHash, 0.6, '242');
expect((await storedProgress(comic.bookFileId))?.pageNumber).toBe(242);
});
it('leaves the page column empty for a reflowable document', async () => {
await syncFromDevice(epubHash, 0.3, XPOINTER);
const stored = await storedProgress(epub.bookFileId);
expect(stored?.pageNumber).toBeNull();
expect(stored?.koreaderProgress).toBe(XPOINTER);
expect((await readerProgress(epub.bookFileId)).pageNumber).toBeNull();
});
it('accepts document metadata on progress sync requests', async () => {
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: {
document: epubHash,
percentage: 0.35,
progress: XPOINTER,
metadata: { filename: 'progress-position-book.epub', title: 'Progress Position Book', authors: 'Test Author' },
},
});
expect(response.statusCode).toBe(200);
});
it('replaces a page saved by the web reader instead of discarding it', async () => {
await saveFromWebReader(comic.bookFileId, { percentage: 12, pageNumber: 34 });
expect((await storedProgress(comic.bookFileId))?.pageNumber).toBe(34);
await syncFromDevice(comicHash, 0.55, '221');
expect((await storedProgress(comic.bookFileId))?.pageNumber).toBe(221);
});
it('clears a stale page when the device sends a position it cannot supply', async () => {
await syncFromDevice(comicHash, 0.7, '300');
expect((await storedProgress(comic.bookFileId))?.pageNumber).toBe(300);
await syncFromDevice(comicHash, 0.75);
const stored = await storedProgress(comic.bookFileId);
expect(stored?.pageNumber).toBeNull();
expect(stored?.percentage).toBeCloseTo(75, 5);
});
it('rejects a page that would overflow the column rather than failing the sync', async () => {
await syncFromDevice(comicHash, 0.8, '99999999999999');
const stored = await storedProgress(comic.bookFileId);
expect(stored?.pageNumber).toBeNull();
expect(stored?.percentage).toBeCloseTo(80, 5);
});
it('does not route progress when the document hash matches different books', async () => {
const comicProgressBefore = await storedProgress(comic.bookFileId);
const epubProgressBefore = await storedProgress(epub.bookFileId);
await ctx.db.update(schema.bookFiles).set({ fileHash: comicHash }).where(eq(schema.bookFiles.id, epub.bookFileId));
try {
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: { document: comicHash, percentage: 0.99, progress: '999' },
});
expect(response.statusCode).toBe(404);
await expect(storedProgress(comic.bookFileId)).resolves.toEqual(comicProgressBefore);
await expect(storedProgress(epub.bookFileId)).resolves.toEqual(epubProgressBefore);
} finally {
await ctx.db.update(schema.bookFiles).set({ fileHash: epubHash }).where(eq(schema.bookFiles.id, epub.bookFileId));
}
});
});