mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
cc50b55eeb
Add original and text-only EPUB delivery choices across Kobo, KOReader, OPDS, web downloads and email while keeping source files unchanged. Persist server defaults, device overrides and captured artifact identities so retries and guided replacement retain the selected rendition. Preserve saved progress across first opens, replacement resets and stale updates, and project annotations against the copy delivered to each device. Handle XML resource references, large inodes, retry backoff and background worker state correctly. Consolidate the schema changes into one migration and retain released-client request and response compatibility. Verify with the full server and client checks, delivery and sync HTTP suites, independent code reviews, and live KOReader download/settings/reader proof. Fixes #1496 Closes #1603 Closes #1714 Fixes #1723 Ref #1633 Ref #1508 Ref #1621 Ref #1136
428 lines
18 KiB
TypeScript
428 lines
18 KiB
TypeScript
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<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, timestamp?: 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 }),
|
|
...(timestamp === undefined ? {} : { timestamp }),
|
|
},
|
|
});
|
|
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,
|
|
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('does not let a delayed single update overwrite newer web progress or the next device pull', async () => {
|
|
const cfi = 'epubcfi(/6/2!/4/2/1:3)';
|
|
await saveFromWebReader(epub.bookFileId, { percentage: 62, cfi, source: 'text' });
|
|
|
|
await syncFromDevice(epubHash, 0.05, '/body/DocFragment[1]/body', Math.floor(Date.now() / 1000) - 3600);
|
|
|
|
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 62, 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()).toEqual(expect.objectContaining({ percentage: 0.62, progress: '/body/DocFragment[1]/body/p/text().3', device: 'web' }));
|
|
});
|
|
|
|
it('retains the newest device position when an older single update arrives afterwards', async () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const recent = '/body/DocFragment[1]/body/p/text().4';
|
|
await syncFromDevice(epubHash, 0.75, recent, now);
|
|
await syncFromDevice(epubHash, 0.2, '/body/DocFragment[1]/body/p/text().1', now - 60);
|
|
|
|
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 75, cfi: 'epubcfi(/6/2!/4/2/1:4)' }));
|
|
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({ percentage: 0.75, progress: recent }));
|
|
});
|
|
|
|
it('accepts deliberate backward updates with the same second timestamp', async () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await syncFromDevice(epubHash, 0.75, '/body/DocFragment[1]/body/p/text().4', now);
|
|
await syncFromDevice(epubHash, 0.2, '/body/DocFragment[1]/body/p/text().1', now);
|
|
|
|
await expect(readerProgress(epub.bookFileId)).resolves.toEqual(expect.objectContaining({ percentage: 20, cfi: 'epubcfi(/6/2!/4/2/1:1)' }));
|
|
});
|
|
|
|
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));
|
|
}
|
|
});
|
|
});
|