import { randomUUID } from 'crypto'; import { and, eq } from 'drizzle-orm'; import * as schema from '../src/db/schema'; import { ReadingSessionService } from '../src/modules/reading-session/reading-session.service'; 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'; const DEVICE_ID = 'progress-regression-device'; /** * 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 { return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD }; } async function fileHashFor(bookFileId: number): Promise { 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, device: 'Regression device', device_id: DEVICE_ID, ...(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) { 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, device: 'Regression device', device_id: DEVICE_ID, 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.each(['web', 'narration'])('restores the complete %s reader position and retains its text offset on device upload', async (source) => { await ctx.db .update(schema.koreaderDeviceProgress) .set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') }) .where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId)); const cfi = 'epubcfi(/6/2!/4/2/1:3)'; // Narration only advances a position; begin behind the position being restored. await saveFromWebReader(epub.bookFileId, { percentage: 10, cfi: 'epubcfi(/6/2!/4/2/1:0)' }); await saveFromWebReader(epub.bookFileId, { percentage: 55, cfi, ...(source === 'narration' ? { source } : {}) }); const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders(), }); expect(pull.statusCode).toBe(200); const remote = pull.json() as { percentage: number; progress: string; device: string }; expect(remote).toEqual(expect.objectContaining({ percentage: 0.55, progress: '/body/DocFragment[1]/body/p/text().3', device: 'web' })); // Readers paginate differently, but the text point must survive the lifecycle upload. await syncFromDevice(epubHash, 0.52, remote.progress); await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi, percentage: 52 })); expect((await storedProgress(epub.bookFileId))?.koreaderProgress).toBe(remote.progress); }); it('preserves positions through repeated web and native-engine handoffs', async () => { for (let offset = 1; offset <= 4; offset += 1) { const cfi = `epubcfi(/6/2!/4/2/1:${offset})`; await saveFromWebReader(epub.bookFileId, { percentage: 20 + offset, cfi }); const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders() }); expect(pull.statusCode).toBe(200); expect(pull.json().progress).toBe(`/body/DocFragment[1]/body/p/text().${offset}`); const nextOffset = offset + 1; await syncFromDevice(epubHash, (25 + offset) / 100, `/body[1]/DocFragment[1]/body[1]/p[1]/text()[1].${nextOffset}`); await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ cfi: `epubcfi(/6/2!/4/2/1:${nextOffset})` })); } }); it('serves percentage fallback for an unresolvable reader position', async () => { await ctx.db .update(schema.koreaderDeviceProgress) .set({ updatedAt: new Date('2020-01-01T00:00:00.000Z') }) .where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId)); await saveFromWebReader(epub.bookFileId, { percentage: 30, cfi: 'epubcfi(/6/2!/4/999/1:3)' }); const pull = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${epubHash}`, headers: deviceHeaders(), }); expect(pull.statusCode).toBe(200); expect(pull.json()).toEqual(expect.objectContaining({ progress: null, percentage: 0.3, device: 'web' })); }); it('never turns KOSync progress pushes into reading sessions', async () => { await syncFromDevice(epubHash, 0.4, XPOINTER); await ctx.db .update(schema.koreaderDeviceProgress) .set({ updatedAt: new Date(Date.now() - 12 * 60 * 1000) }) .where(and(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId), eq(schema.koreaderDeviceProgress.deviceId, DEVICE_ID))); await syncFromDevice(epubHash, 0.5, XPOINTER); const sessions = await ctx.db .select({ id: schema.readingSessions.id }) .from(schema.readingSessions) .where(and(eq(schema.readingSessions.bookFileId, epub.bookFileId), eq(schema.readingSessions.source, 'koreader'))); expect(sessions).toEqual([]); }); it('removes only historical KOSync estimates during repair', async () => { const [koreaderUser] = await ctx.db .select({ userId: schema.koreaderUsers.userId }) .from(schema.koreaderUsers) .where(eq(schema.koreaderUsers.username, KOREADER_USERNAME)); expect(koreaderUser).toBeDefined(); const legacySessionId = `ks-${'a'.repeat(12)}-${'b'.repeat(32)}`; const measuredSessionId = 'kor:cleanup-supported'; const webSessionId = 'web-cleanup-supported'; const startedAt = new Date('2026-08-31T10:00:00.000Z'); const endedAt = new Date('2026-08-31T10:10:00.000Z'); await ctx.db.insert(schema.readingSessions).values( [ { sessionId: legacySessionId, source: 'koreader' as const }, { sessionId: measuredSessionId, source: 'koreader' as const }, { sessionId: webSessionId, source: 'web' as const }, ].map((session) => ({ ...session, userId: koreaderUser!.userId, bookId: epub.bookId, bookFileId: epub.bookFileId, startedAt, endedAt, durationSeconds: 600, progressDelta: 2, endProgress: 50, })), ); await expect(ctx.app.get(ReadingSessionService).deleteLegacyKoreaderSyncEstimatesBatch(500)).resolves.toEqual({ deleted: 1 }); const remaining = await ctx.db .select({ sessionId: schema.readingSessions.sessionId }) .from(schema.readingSessions) .where(and(eq(schema.readingSessions.userId, koreaderUser!.userId), eq(schema.readingSessions.bookFileId, epub.bookFileId))); expect(remaining.map((row) => row.sessionId).sort()).toEqual([measuredSessionId, webSessionId].sort()); }); it('uses a user-scoped link when the same book has two files with the same document hash', async () => { const [sourceFile] = await ctx.db .select({ bookId: schema.bookFiles.bookId, libraryFolderId: schema.bookFiles.libraryFolderId, absolutePath: schema.bookFiles.absolutePath, relPath: schema.bookFiles.relPath, ino: schema.bookFiles.ino, sizeBytes: schema.bookFiles.sizeBytes, mtime: schema.bookFiles.mtime, format: schema.bookFiles.format, }) .from(schema.bookFiles) .where(eq(schema.bookFiles.id, epub.bookFileId)); const [koreaderUser] = await ctx.db .select({ userId: schema.koreaderUsers.userId }) .from(schema.koreaderUsers) .where(eq(schema.koreaderUsers.username, KOREADER_USERNAME)); expect(sourceFile).toBeDefined(); expect(koreaderUser).toBeDefined(); const suffix = randomUUID(); const [linkedFile] = await ctx.db .insert(schema.bookFiles) .values({ ...sourceFile!, absolutePath: `${sourceFile!.absolutePath}.same-hash-${suffix}`, relPath: sourceFile!.relPath ? `${sourceFile!.relPath}.same-hash-${suffix}` : null, fileHash: epubHash, role: 'content', }) .returning({ id: schema.bookFiles.id }); try { await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: koreaderUser!.userId, hash: epubHash, bookFileId: linkedFile!.id, }); await syncFromDevice(epubHash, 0.61, XPOINTER); const [deviceProgress] = await ctx.db .select({ bookFileId: schema.koreaderDeviceProgress.bookFileId, percentage: schema.koreaderDeviceProgress.percentage }) .from(schema.koreaderDeviceProgress) .where( and( eq(schema.koreaderDeviceProgress.bookFileId, linkedFile!.id), eq(schema.koreaderDeviceProgress.userId, koreaderUser!.userId), eq(schema.koreaderDeviceProgress.deviceId, DEVICE_ID), ), ); expect(deviceProgress).toEqual({ bookFileId: linkedFile!.id, percentage: 0.61 }); } finally { await ctx.db.delete(schema.bookFiles).where(eq(schema.bookFiles.id, linkedFile!.id)); } }); 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)); } }); });