Files
bookorbit/server/test/koreader-readaloud-progress.e2e-spec.ts
T
neon 80244ea4e2 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.
2026-10-20 16:05:27 -06:00

512 lines
25 KiB
TypeScript

import { randomUUID } from 'crypto';
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,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type ReaderStateIsolationE2EContext,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const KOREADER_USERNAME = `readaloud-device-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'ReadaloudProgressPass123';
const DEVICE_ID = 'readaloud-kindle';
const CHAPTER_COUNT = 10;
/** Chapter 7 of the original, where the device reports it has read to. */
const KOREADER_XPOINTER = '/body/DocFragment[7]/body/p[1]/text().0';
const ORIGINAL_CHAPTER_7_CFI = 'epubcfi(/6/14!/4/2/1:0)';
/** Chapter 7 of the read-along copy, inside the sentence span Storyteller wraps the text in. */
const READ_ALONG_CHAPTER_7_CFI = 'epubcfi(/6/14!/4/2/2/1:0)';
const READ_ALONG_CHAPTER_7_SENTENCE = 'OPS/chapter-7.xhtml#c7-s1';
type FileProgress = { percentage: number; cfi: string | null; koreaderProgress: string | null; mediaOverlayFragment: string | null };
/**
* Issue 1508: a Storyteller read-along EPUB beside the original, with no audiobook imported.
* KOReader holds the original, the web reader opens the read-along copy because it becomes the
* primary file, and progress is stored per file, so each side has to reach the other's copy.
*/
describe('KOReader progress with a Storyteller read-along sibling (e2e)', { timeout: 180_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
function epubEntries(title: string, uid: string, readAlong: boolean) {
const chapterIds = Array.from({ length: CHAPTER_COUNT }, (_, index) => index + 1);
const manifest = chapterIds
.map((n) => {
const chapter = ` <item id="chapter-${n}" href="chapter-${n}.xhtml" media-type="application/xhtml+xml"${readAlong ? ` media-overlay="smil-${n}"` : ''} />`;
if (!readAlong) return chapter;
return [
chapter,
` <item id="smil-${n}" href="chapter-${n}.smil" media-type="application/smil+xml" />`,
` <item id="audio-${n}" href="audio/chapter-${n}.mp3" media-type="audio/mpeg" />`,
].join('\n');
})
.join('\n');
const spine = chapterIds.map((n) => ` <itemref idref="chapter-${n}" />`).join('\n');
const entries: Array<{ path: string; content: string | Buffer; store?: boolean }> = [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{
path: 'META-INF/container.xml',
content: `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" /></rootfiles>
</container>`,
},
{
path: 'OPS/content.opf',
content: `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${title}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
${manifest}
</manifest>
<spine>
${spine}
</spine>
</package>`,
},
];
for (const n of chapterIds) {
const sentence = `Chapter ${n} of ${title} has one sentence of prose in it.`;
const body = readAlong ? `<p><span id="c${n}-s1">${sentence}</span></p>` : `<p>${sentence}</p>`;
entries.push({
path: `OPS/chapter-${n}.xhtml`,
content: `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter ${n}</title></head><body>${body}</body></html>`,
});
if (!readAlong) continue;
entries.push({
path: `OPS/chapter-${n}.smil`,
content: `<?xml version="1.0" encoding="UTF-8"?>
<smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">
<body><seq id="seq-${n}" epub:textref="chapter-${n}.xhtml">
<par id="par-${n}"><text src="chapter-${n}.xhtml#c${n}-s1" /><audio src="audio/chapter-${n}.mp3" clipBegin="0s" clipEnd="60s" /></par>
</seq></body>
</smil>`,
});
entries.push({ path: `OPS/audio/chapter-${n}.mp3`, content: Buffer.alloc(2048, n) });
}
return entries;
}
/** Lays out Author/Series/Title/Title.epub, and the Storyteller copy beside it when asked. */
async function writeBook(folder: string, options: { readAlong: boolean }) {
const title = `Read Along ${randomUUID().slice(0, 8)}`;
const uid = `urn:uuid:${randomUUID()}`;
const originalPath = await createZipArchiveFixture(library.folderPath, `${folder}/Title.epub`, epubEntries(title, uid, false));
const readAlongPath = options.readAlong
? await createZipArchiveFixture(library.folderPath, `${folder}/Title (readaloud).epub`, epubEntries(title, uid, true))
: null;
return { title, uid, originalPath, readAlongPath };
}
/** Scans a book whose original and read-along copy share a folder, and checks they became one book. */
async function scanReadAlongBook(folder: string) {
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 { title, original, readAlong, readAlongPath: readAlongPath!, originalHash: await fileHashFor(original.bookFileId) };
}
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!;
}
/** The file the web client opens: `role === 'primary'`, as the library card and detail page pick it. */
async function webReaderFileId(bookId: number): Promise<number> {
const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}`, headers: authHeader(ctx.adminToken) });
expect(response.statusCode).toBe(200);
const detail = response.json() as { files: Array<{ id: number; role: string; format: string | null }> };
const readable = detail.files.filter((file) => file.format === 'epub');
return (readable.find((file) => file.role === 'primary') ?? readable[0]!).id;
}
async function saveFromWebReader(bookFileId: number, percentage: number, cfi: string) {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
payload: { percentage, cfi, source: 'text' },
});
expect([200, 201, 204]).toContain(response.statusCode);
}
/** What the web reader loads when it opens this file. */
async function fileProgress(bookFileId: number): Promise<FileProgress> {
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 FileProgress;
}
/** What the book page's KOReader panel shows. */
async function bookPanel(bookId: number) {
const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/books/${bookId}/progress`, headers: authHeader(ctx.adminToken) });
expect(response.statusCode).toBe(200);
return response.json() as {
bookFileId: number;
canonicalPercentage: number;
devices: Array<{ deviceId: string; percentage: number }>;
heldByReset: Array<{ deviceId: string; percentage: number }>;
};
}
async function resetFileProgress(bookFileId: number) {
const response = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/files/${bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
});
expect([200, 204]).toContain(response.statusCode);
}
async function pushFromKoreader(hash: string, percentage: number) {
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: { document: hash, percentage, progress: KOREADER_XPOINTER, device: 'Kindle', device_id: DEVICE_ID },
});
expect(response.statusCode).toBe(200);
}
async function pullFromKoreader(hash: string): Promise<{ percentage?: number; progress?: string; device?: string }> {
const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${hash}`, headers: deviceHeaders() });
expect(response.statusCode).toBe(200);
return response.json() as { percentage?: number; progress?: string; device?: string };
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `koreader-read-along-${randomUUID()}` });
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('control: without a read-along copy the web reader resumes from the KOReader push', async () => {
const { originalPath } = await writeBook('Author A/Series A/Control', { readAlong: false });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const original = await locateBookByAbsolutePath(ctx, originalPath);
const opened = await webReaderFileId(original.bookId);
expect(opened).toBe(original.bookFileId);
await saveFromWebReader(opened, 67, ORIGINAL_CHAPTER_7_CFI);
await pushFromKoreader(await fileHashFor(original.bookFileId), 0.69);
expect((await fileProgress(opened)).percentage).toBeCloseTo(69, 1);
});
it('KOReader -> web: the web reader resumes from a push on the original once a read-along copy is added', async () => {
const folder = 'Author B/Series B/Added Later';
const { title, uid, originalPath } = await writeBook(folder, { readAlong: false });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const original = await locateBookByAbsolutePath(ctx, originalPath);
const originalHash = await fileHashFor(original.bookFileId);
expect(await webReaderFileId(original.bookId)).toBe(original.bookFileId);
// Storyteller finishes and drops "Title (readaloud).epub" beside the original.
const readAlongPath = await createZipArchiveFixture(library.folderPath, `${folder}/Title (readaloud).epub`, epubEntries(title, uid, true));
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const readAlong = await locateBookByAbsolutePath(ctx, readAlongPath);
expect(readAlong.bookId).toBe(original.bookId);
const opened = await webReaderFileId(original.bookId);
expect(opened).toBe(readAlong.bookFileId);
await saveFromWebReader(opened, 67, READ_ALONG_CHAPTER_7_CFI);
await pushFromKoreader(originalHash, 0.69);
const resumed = await fileProgress(opened);
expect(resumed.percentage).toBeCloseTo(69, 1);
expect(resumed.cfi).toMatch(/^epubcfi\(\/6\/14!/);
// The web reader opens a read-along file at its narration marker when one is stored, so the
// marker has to have moved with the text or the reader would land on a stale sentence.
expect(resumed.mediaOverlayFragment).toBe(READ_ALONG_CHAPTER_7_SENTENCE);
});
it('web -> KOReader: a device holding the original is served the position read on the read-along copy', async () => {
const { original, readAlong, originalHash } = await scanReadAlongBook('Author C/Series C/Reverse');
await saveFromWebReader(readAlong.bookFileId, 67, READ_ALONG_CHAPTER_7_CFI);
const pulled = await pullFromKoreader(originalHash);
expect(pulled.percentage).toBeCloseTo(0.67, 3);
expect(pulled.progress).toMatch(/^\/body\/DocFragment\[7\]\//);
// The original has no narration of its own, so it must not carry the read-along copy's
// sentence id, which the web reader would otherwise try to open it at.
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');
await pushFromKoreader(originalHash, 0.69);
const synced = await fileProgress(readAlong.bookFileId);
expect(synced.percentage).toBeCloseTo(69, 1);
// Opening the book saves the position it opened at.
await saveFromWebReader(readAlong.bookFileId, 69.2, synced.cfi!);
const pulled = await pullFromKoreader(originalHash);
expect(pulled.percentage).toBeGreaterThanOrEqual(0.69);
expect(pulled.progress).toMatch(/^\/body\/DocFragment\[7\]\//);
});
it('the book page reports the device that syncs the original, not the read-along primary', async () => {
const { original, originalHash } = await scanReadAlongBook('Author G/Series G/Panel');
await pushFromKoreader(originalHash, 0.69);
const panel = await bookPanel(original.bookId);
expect(panel.bookFileId).toBe(original.bookFileId);
expect(panel.canonicalPercentage).toBeCloseTo(69, 1);
expect(panel.devices).toEqual([expect.objectContaining({ deviceId: DEVICE_ID, percentage: 69 })]);
});
it('a device on the original held behind a reset is shown and can be released from the book page', async () => {
const { original, originalHash } = await scanReadAlongBook('Author H/Series H/Release');
await pushFromKoreader(originalHash, 0.42);
await resetFileProgress(original.bookFileId);
// The device never saw the reset and replays its old position.
await pushFromKoreader(originalHash, 0.42);
expect((await bookPanel(original.bookId)).heldByReset).toEqual([expect.objectContaining({ deviceId: DEVICE_ID, percentage: 42 })]);
const released = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/books/${original.bookId}/reset-hold/release`,
headers: authHeader(ctx.adminToken),
payload: { deviceId: DEVICE_ID },
});
expect([200, 201]).toContain(released.statusCode);
expect((await bookPanel(original.bookId)).heldByReset).toEqual([]);
expect((await pullFromKoreader(originalHash)).percentage).toBeCloseTo(0.42, 3);
});
it('keeps each copy on its own position when read-aloud sync is disabled', async () => {
const { original, readAlong, originalHash } = await scanReadAlongBook('Author E/Series E/Disabled');
const disabled = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${original.bookId}/read-aloud-sync`,
headers: authHeader(ctx.adminToken),
payload: { mode: 'disabled' },
});
expect(disabled.statusCode).toBe(200);
await saveFromWebReader(readAlong.bookFileId, 67, READ_ALONG_CHAPTER_7_CFI);
await pushFromKoreader(originalHash, 0.69);
expect((await fileProgress(readAlong.bookFileId)).percentage).toBeCloseTo(67, 1);
expect((await fileProgress(original.bookFileId)).percentage).toBeCloseTo(69, 1);
});
it('workaround from the report: deleting the read-along copy resumes from the original', async () => {
const { original, readAlongPath, originalHash } = await scanReadAlongBook('Author F/Series F/Workaround');
await saveFromWebReader(await webReaderFileId(original.bookId), 67, READ_ALONG_CHAPTER_7_CFI);
await pushFromKoreader(originalHash, 0.69);
await rm(readAlongPath);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const opened = await webReaderFileId(original.bookId);
expect(opened).toBe(original.bookFileId);
expect((await fileProgress(opened)).percentage).toBeCloseTo(69, 1);
});
});