Files
bookorbit/server/test/read-aloud-delivery.e2e-spec.ts
T
neon b9e5fe3a29 fix(koreader): keep the configured file name on downloads
Plugins from 1.7.0 request a delivery variant, and every such download had
" - bo-<id>-<variant>" added to its name. Only copies that would share a path are
renamed now: a narrated EPUB's text-only copy and later same-format files. A new
<a|b|c> chained default also stops the default pattern naming authorless
standalone books "Standalone/ - <title>". See docs/READ_ALOUD_DELIVERY.md.

Closes #1893
2026-10-24 21:06:09 +00:00

649 lines
39 KiB
TypeScript

import { createHash, randomUUID } from 'crypto';
import { readFile, rename, stat, writeFile } from 'fs/promises';
import { and, asc, eq } from 'drizzle-orm';
import { Permission } from '@bookorbit/types';
import * as unzipper from 'unzipper';
import { KepubConversionService } from '../src/modules/kobo/services/kepub-conversion.service';
import { SYNC_PAGE_SIZE } from '../src/modules/kobo/services/kobo-sync.service';
import * as schema from '../src/db/schema';
import { waitForCondition } from './e2e/app-harness';
import { createEpubFixture, createZipArchiveFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
createUserAndLogin,
grantLibraryAccess,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type ReaderStateIsolationE2EContext,
type LocatedBookFile,
type TestUserSession,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
describe('Read-aloud delivery contracts (e2e)', { timeout: 180_000 }, () => {
let ctx: ReaderStateIsolationE2EContext;
let book: LocatedBookFile;
let owner: TestUserSession;
let outsider: TestUserSession;
let sourcePath: string;
let sourceBytes: Buffer;
const koHeaders = () => ({ 'x-auth-user': owner.username, 'x-auth-key': owner.password });
const request = (method: 'GET' | 'POST' | 'PUT' | 'PATCH', url: string, payload?: unknown) =>
ctx.app.inject({
method,
url,
headers: authHeader(owner.accessToken),
...(payload !== undefined ? { payload: payload as Record<string, unknown> } : {}),
});
async function detail(variant?: string) {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/plugin/catalog/books/${book.bookId}${variant ? `?variant=${variant}` : ''}`,
headers: koHeaders(),
});
expect(response.statusCode).toBe(200);
return response.json().files.find((file: { id: number }) => file.id === book.bookFileId) as {
id: number;
downloadUrl: string;
selection: string;
sizeBytes: number | null;
fileHash: string | null;
devicePath: string;
};
}
const acquire = (url: string, extraHeaders = {}) => ctx.app.inject({ method: 'GET', url, headers: { ...koHeaders(), ...extraHeaders } });
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
const library = await createLibraryWithFolder(ctx);
sourcePath = await createZipArchiveFixture(library.folderPath, 'narrated.epub', [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{
path: 'META-INF/container.xml',
content:
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
},
{
path: 'OPS/book.opf',
content: `<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Narrated delivery fixture</dc:title><dc:identifier id="uid">urn:uuid:${randomUUID()}</dc:identifier><dc:language>en</dc:language><meta property="media:duration">0:00:10</meta></metadata><manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" media-overlay="overlay"/><item id="overlay" href="overlay.smil" media-type="application/smil+xml"/><item id="audio" href="audio.mp3" media-type="audio/mpeg"/></manifest><spine><itemref idref="chapter"/></spine></package>`,
},
{
path: 'OPS/chapter.xhtml',
content:
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body><p><span id="sentence-1">Narration anchor survives delivery.</span></p></body></html>',
},
{
path: 'OPS/overlay.smil',
content:
'<smil xmlns="http://www.w3.org/ns/SMIL"><body><seq><par><text src="chapter.xhtml#sentence-1"/><audio src="audio.mp3" clipBegin="0s" clipEnd="10s"/></par></seq></body></smil>',
},
{ path: 'OPS/audio.mp3', content: Buffer.alloc(2 * 1024 * 1024, 7), store: true },
]);
sourceBytes = await readFile(sourcePath);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
book = await locateBookByAbsolutePath(ctx, sourcePath);
owner = await createUserAndLogin(ctx, {
permissions: [Permission.KoreaderSync, Permission.KoboSync, Permission.OpdsAccess, Permission.EmailSend],
});
outsider = await createUserAndLogin(ctx, {
permissions: [Permission.KoreaderSync, Permission.KoboSync, Permission.OpdsAccess, Permission.EmailSend],
});
await grantLibraryAccess(ctx, owner.userId, library.libraryId);
expect((await request('POST', '/api/v1/koreader/credentials', { username: owner.username, password: owner.password })).statusCode).toBe(201);
});
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('persists KOReader defaults without changing omitted fields and isolates users', async () => {
expect((await request('PATCH', '/api/v1/koreader/credentials', { readAloudDelivery: 'original' })).statusCode).toBe(200);
expect((await request('PATCH', '/api/v1/koreader/credentials', { syncEnabled: true })).statusCode).toBe(200);
const url = '/api/v1/koreader/plugin/delivery-settings/delivery-device-a';
const settings = await ctx.app.inject({ method: 'GET', url, headers: koHeaders() });
expect(settings.json()).toMatchObject({ readAloudDelivery: 'original', credentialDefault: 'original', deviceConfigured: false });
expect(
(await ctx.app.inject({ method: 'GET', url, headers: { 'x-auth-user': outsider.username, 'x-auth-key': outsider.password } })).statusCode,
).toBe(401);
expect((await request('PATCH', '/api/v1/koreader/credentials', { readAloudDelivery: 'invalid' })).statusCode).toBe(400);
});
it('imports legacy device choices once without overwriting configured defaults', async () => {
expect((await request('PATCH', '/api/v1/koreader/credentials', { readAloudDelivery: 'original' })).statusCode).toBe(200);
const url = '/api/v1/koreader/plugin/delivery-settings/delivery-device-a';
const put = (readAloudDelivery: string | null, migrateOnly = false) =>
ctx.app.inject({ method: 'PUT', url, headers: koHeaders(), payload: { readAloudDelivery, migrateOnly } });
expect((await put('audioless_epub', true)).json()).toMatchObject({ readAloudDelivery: 'original', deviceOverride: null, deviceConfigured: true });
expect((await put('audioless_epub')).json()).toMatchObject({ readAloudDelivery: 'audioless_epub', deviceOverride: 'audioless_epub' });
expect((await put('original', true)).json()).toMatchObject({ readAloudDelivery: 'audioless_epub' });
expect((await put(null)).json()).toMatchObject({ readAloudDelivery: 'original', deviceOverride: null });
expect((await request('PATCH', '/api/v1/koreader/credentials', { readAloudDelivery: null })).statusCode).toBe(200);
const legacy = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/plugin/delivery-settings/delivery-device-b',
headers: koHeaders(),
payload: { readAloudDelivery: 'original', migrateOnly: true },
});
expect(legacy.json()).toMatchObject({ readAloudDelivery: 'original', deviceOverride: 'original' });
expect((await put(null, true)).json()).toMatchObject({ readAloudDelivery: 'audioless_epub' });
});
it('validates device settings and enforces web permissions', async () => {
const url = '/api/v1/koreader/devices/delivery-device-b/delivery-settings';
expect((await request('PATCH', url, { readAloudDelivery: 'audioless_epub' })).statusCode).toBe(200);
expect(
(await ctx.app.inject({ method: 'PATCH', url, headers: authHeader(outsider.accessToken), payload: { readAloudDelivery: 'original' } }))
.statusCode,
).toBe(404);
const restricted = await createUserAndLogin(ctx);
expect(
(await ctx.app.inject({ method: 'PATCH', url, headers: authHeader(restricted.accessToken), payload: { readAloudDelivery: 'original' } }))
.statusCode,
).toBe(403);
expect((await request('PATCH', url, {})).statusCode).toBe(400);
expect((await request('PATCH', url, { readAloudDelivery: 'unknown' })).statusCode).toBe(400);
expect((await request('PATCH', '/api/v1/koreader/devices/bad_device/delivery-settings', { readAloudDelivery: null })).statusCode).toBe(400);
const read = await ctx.app.inject({ method: 'GET', url: '/api/v1/koreader/plugin/delivery-settings/delivery-device-b', headers: koHeaders() });
expect(read.json().readAloudDelivery).toBe('audioless_epub');
});
it('keeps the same device identifier separate between accounts and clears settings with credentials', async () => {
const headers = authHeader(outsider.accessToken);
const credentials = { username: outsider.username, password: outsider.password, readAloudDelivery: 'original' };
expect((await ctx.app.inject({ method: 'POST', url: '/api/v1/koreader/credentials', headers, payload: credentials })).statusCode).toBe(201);
const url = '/api/v1/koreader/plugin/delivery-settings/delivery-device-b';
const pluginHeaders = { 'x-auth-user': outsider.username, 'x-auth-key': outsider.password };
expect((await ctx.app.inject({ method: 'GET', url, headers: pluginHeaders })).json()).toMatchObject({
readAloudDelivery: 'original',
deviceConfigured: false,
});
expect((await ctx.app.inject({ method: 'PUT', url, headers: pluginHeaders, payload: { readAloudDelivery: 'audioless_epub' } })).statusCode).toBe(
200,
);
expect((await ctx.app.inject({ method: 'DELETE', url: '/api/v1/koreader/credentials', headers })).statusCode).toBe(200);
expect((await ctx.app.inject({ method: 'POST', url: '/api/v1/koreader/credentials', headers, payload: credentials })).statusCode).toBe(201);
expect((await ctx.app.inject({ method: 'GET', url, headers: pluginHeaders })).json()).toMatchObject({
readAloudDelivery: 'original',
deviceOverride: null,
deviceConfigured: false,
});
expect((await ctx.app.inject({ method: 'GET', url, headers: koHeaders() })).json()).toMatchObject({
readAloudDelivery: 'audioless_epub',
deviceOverride: 'audioless_epub',
});
});
it('continues past failed delivery pages, retries expired failures, and removes deferred books', async () => {
const library = await createLibraryWithFolder(ctx);
for (let index = 0; index <= SYNC_PAGE_SIZE; index++) {
await createEpubFixture(library.folderPath, `retry-${index}.epub`, { title: `Delivery retry ${index}` });
}
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const files = await ctx.db
.select({ bookId: schema.books.id, path: schema.bookFiles.absolutePath })
.from(schema.books)
.innerJoin(schema.bookFiles, eq(schema.books.primaryFileId, schema.bookFiles.id))
.where(eq(schema.books.libraryId, library.libraryId))
.orderBy(asc(schema.books.id));
expect(files).toHaveLength(SYNC_PAGE_SIZE + 1);
await grantLibraryAccess(ctx, owner.userId, library.libraryId);
const collection = (await request('POST', '/api/v1/collections', { name: 'Delivery retry pagination', icon: 'book', syncToKobo: true })).json();
expect((await request('POST', `/api/v1/collections/${collection.id}/books`, { bookIds: files.map((file) => file.bookId) })).statusCode).toBe(201);
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'original', convertToKepub: false });
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Delivery retry pagination' })).json();
const sync = () => ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/library/sync` });
const deferred = files.slice(0, SYNC_PAGE_SIZE);
for (const file of deferred) await rename(file.path, `${file.path}.unavailable`);
try {
const initial = await sync();
expect(initial.statusCode).toBe(200);
expect(initial.headers['x-kobo-sync']).toBe('continue');
const snapshot = await ctx.db.query.koboLibrarySnapshots.findFirst({ where: eq(schema.koboLibrarySnapshots.deviceId, device.id) });
expect(snapshot).toBeDefined();
const healthy = files[SYNC_PAGE_SIZE]!;
const next = await sync();
expect(next.statusCode).toBe(200);
const identity = await ctx.db.query.koboBookEntitlements.findFirst({
where: and(eq(schema.koboBookEntitlements.userId, owner.userId), eq(schema.koboBookEntitlements.bookId, healthy.bookId)),
});
expect(next.json()).toContainEqual(
expect.objectContaining({
NewEntitlement: expect.objectContaining({ BookEntitlement: expect.objectContaining({ Id: identity!.entitlementId }) }),
}),
);
expect(next.headers['x-kobo-sync']).not.toBe('continue');
const first = deferred[0]!;
await rename(`${first.path}.unavailable`, first.path);
await ctx.db
.update(schema.koboSnapshotBooks)
.set({ deliveryRetryAt: new Date(0) })
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot!.id), eq(schema.koboSnapshotBooks.bookId, first.bookId)));
const recovered = await sync();
expect(recovered.statusCode).toBe(200);
const recoveredRow = await ctx.db.query.koboSnapshotBooks.findFirst({
where: and(eq(schema.koboSnapshotBooks.snapshotId, snapshot!.id), eq(schema.koboSnapshotBooks.bookId, first.bookId)),
});
expect(recoveredRow).toMatchObject({ synced: true, deliveryRetryAt: null, deliveryFailureCode: null });
const removed = deferred[1]!;
const remove = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/collections/${collection.id}/books`,
headers: authHeader(owner.accessToken),
payload: { bookIds: [removed.bookId] },
});
expect(remove.statusCode).toBe(200);
await ctx.db
.update(schema.koboSnapshotBooks)
.set({ pendingDelete: true })
.where(and(eq(schema.koboSnapshotBooks.snapshotId, snapshot!.id), eq(schema.koboSnapshotBooks.bookId, removed.bookId)));
const removal = await sync();
expect(removal.statusCode).toBe(200);
expect(removal.json()).toContainEqual(expect.objectContaining({ ChangedEntitlement: expect.anything() }));
expect(
await ctx.db.query.koboSnapshotBooks.findFirst({
where: and(eq(schema.koboSnapshotBooks.snapshotId, snapshot!.id), eq(schema.koboSnapshotBooks.bookId, removed.bookId)),
}),
).toBeUndefined();
} finally {
for (const file of deferred) await rename(`${file.path}.unavailable`, file.path).catch(() => {});
await ctx.db.delete(schema.collections).where(eq(schema.collections.id, collection.id));
await ctx.db
.delete(schema.userLibraryAccess)
.where(and(eq(schema.userLibraryAccess.userId, owner.userId), eq(schema.userLibraryAccess.libraryId, library.libraryId)));
}
});
it('offers separate pinned variants and preserves every original byte', async () => {
const original = await detail('original');
const text = await detail('audioless_epub');
expect(original.sizeBytes).toBe(sourceBytes.length);
expect(text.sizeBytes).toBeNull();
const configured = (await detail()).devicePath;
expect(configured).toMatch(/\.epub$/);
expect(original.devicePath).toBe(configured);
expect(text.devicePath).toBe(configured.replace(/\.epub$/, ' - Text Only.epub'));
const response = await acquire(original.downloadUrl);
expect(response.statusCode).toBe(200);
expect(response.rawPayload).toEqual(sourceBytes);
expect(response.headers.etag).toBe(`"${createHash('sha256').update(sourceBytes).digest('hex')}"`);
});
it('strips narration, preserves anchors, registers identity, and rebuilds deterministically', async () => {
const file = await detail('audioless_epub');
const first = await acquire(file.downloadUrl);
expect(first.statusCode).toBe(200);
expect(first.headers['x-bookorbit-variant']).toBe('audioless_epub');
const zip = await unzipper.Open.buffer(first.rawPayload);
expect(zip.files.some((entry) => /\.(?:smil|mp3)$/i.test(entry.path))).toBe(false);
expect((await zip.files.find((entry) => entry.path.endsWith('.xhtml'))!.buffer()).toString()).toContain('id="sentence-1"');
const second = await acquire(file.downloadUrl);
expect(second.rawPayload).toEqual(first.rawPayload);
const measured = await detail('audioless_epub');
expect(measured.sizeBytes).toBe(first.rawPayload.length);
expect(measured.fileHash).toBe(first.headers['x-bookorbit-file-hash']);
const fingerprint = first.headers['x-bookorbit-file-hash'];
const match = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/match-check',
headers: koHeaders(),
payload: { hashes: [fingerprint], deviceId: 'delivery-contract', deviceModel: 'Simulator', pluginVersion: '1.5.5' },
});
expect(match.statusCode).toBe(201);
expect(match.json().matches).toContainEqual(expect.objectContaining({ bookFileId: book.bookFileId }));
});
it('serves ranges with artifact validators and rejects invalid offsets', async () => {
const file = await detail('original');
const full = await acquire(file.downloadUrl);
const range = await acquire(file.downloadUrl, { range: 'bytes=20-99', 'if-range': full.headers.etag });
expect(range.statusCode).toBe(206);
expect(range.rawPayload).toEqual(sourceBytes.subarray(20, 100));
expect(range.headers['content-range']).toBe(`bytes 20-99/${sourceBytes.length}`);
expect((await acquire(file.downloadUrl, { range: 'bytes=0-9', 'if-range': '"different"' })).statusCode).toBe(200);
expect((await acquire(file.downloadUrl, { range: `bytes=${sourceBytes.length}-` })).statusCode).toBe(416);
});
it('rejects a variant mismatch and removed book access on a cached artifact', async () => {
const file = await detail('original');
expect((await acquire(file.downloadUrl.replace('variant=original', 'variant=audioless_epub'))).statusCode).toBe(403);
await ctx.db.delete(schema.userLibraryAccess).where(eq(schema.userLibraryAccess.userId, owner.userId));
try {
expect([403, 404]).toContain((await acquire(file.downloadUrl)).statusCode);
} finally {
const [row] = await ctx.db.select({ libraryId: schema.books.libraryId }).from(schema.books).where(eq(schema.books.id, book.bookId));
await grantLibraryAccess(ctx, owner.userId, row!.libraryId);
}
});
it('keeps OPDS profile choices independent and preserves omitted/null update semantics', async () => {
const profiles = [];
for (const variant of ['original', 'audioless_epub']) {
const username = `delivery-${variant}-${randomUUID().slice(0, 8)}`;
const password = 'DeliveryProfile123';
const created = await request('POST', '/api/v1/opds-users', { username, password, sortOrder: 'title_desc', readAloudDelivery: variant });
expect(created.statusCode).toBe(201);
profiles.push({ ...created.json(), username, password });
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/opds/${book.bookId}/download`,
headers: { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` },
});
expect(response.statusCode).toBe(200);
expect(response.headers['x-bookorbit-variant']).toBe(variant);
}
const profile = profiles[0];
const unrelated = await request('PATCH', `/api/v1/opds-users/${profile.id}`, { sortOrder: 'recent' });
expect(unrelated.statusCode).toBe(200);
expect(unrelated.json().readAloudDelivery).toBe('original');
const reset = await request('PATCH', `/api/v1/opds-users/${profile.id}`, { readAloudDelivery: null });
expect(reset.statusCode).toBe(200);
expect(reset.json()).toMatchObject({ sortOrder: 'recent', readAloudDelivery: null });
const crossUser = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/opds-users/${profile.id}`,
headers: authHeader(outsider.accessToken),
payload: { readAloudDelivery: 'original' },
});
expect([403, 404]).toContain(crossUser.statusCode);
});
it('keeps old email payloads valid and normalizes an old-client Kindle edit', async () => {
const created = await request('POST', '/api/v1/email/recipients', { name: 'Reader', email: 'reader@example.com', readAloudDelivery: 'original' });
expect(created.statusCode).toBe(201);
const id = created.json().id;
const changed = await request('PUT', `/api/v1/email/recipients/${id}`, { deviceType: 'kindle' });
expect(changed.statusCode).toBe(200);
expect(changed.json().readAloudDelivery).toBe('audioless_epub');
expect((await request('PUT', `/api/v1/email/recipients/${id}`, { readAloudDelivery: 'original' })).statusCode).toBe(400);
});
it('prepares a small Kobo derivative below the source limit and keeps two devices pinned', async () => {
const first = (await request('POST', '/api/v1/kobo/devices', { name: 'Original device' })).json();
const current = await request('GET', `/api/v1/kobo/devices/${first.id}/books/${book.bookId}/delivery`);
expect(current.statusCode).toBe(200);
expect(current.json()).toBeNull();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'original', convertToKepub: true, kepubConversionLimitMb: 1 });
const original = await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${first.token}/v1/books/${book.bookId}/download` });
expect(original.statusCode).toBe(200);
expect(original.rawPayload).toEqual(sourceBytes);
const conversion = vi.spyOn(ctx.app.get(KepubConversionService), 'acquireKepub');
const second = (await request('POST', '/api/v1/kobo/devices', { name: 'Text device' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'audioless_epub' });
const text = await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${second.token}/v1/books/${book.bookId}/download` });
expect(text.statusCode).toBe(200);
expect(text.rawPayload.length).toBeLessThan(1024 * 1024);
expect(text.headers['x-bookorbit-variant']).toBe('audioless_epub');
const status = (await request('GET', `/api/v1/kobo/devices/${second.id}/books/${book.bookId}/delivery`)).json();
await expect(conversion.mock.results[0]!.value).resolves.toHaveProperty('path');
conversion.mockRestore();
expect(status.metadata.format).toBe('kepub');
const repeated = await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${first.token}/v1/books/${book.bookId}/download` });
expect(repeated.rawPayload).toEqual(sourceBytes);
const base = `/api/v1/kobo/devices/${first.id}`;
const prepared = await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' });
expect(prepared.statusCode).toBe(201);
const replacement = prepared.json();
expect(replacement.state).toBe('prepared');
expect((await request('POST', `${base}/replacements/${replacement.id}/confirm`)).statusCode).toBe(409);
expect((await request('POST', `${base}/replacements/${replacement.id}/cancel`)).statusCode).toBe(201);
expect((await request('GET', `${base}/books/${book.bookId}/delivery`)).json().metadata.actualVariant).toBe('original');
});
it('recovers a guided replacement and requires observed transfer before confirmation', async () => {
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Replacement recovery' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'original' });
const download = () => ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/books/${book.bookId}/download` });
expect((await download()).rawPayload).toEqual(sourceBytes);
const base = `/api/v1/kobo/devices/${device.id}`;
const begin = () => request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' });
const prepared = (await begin()).json();
expect((await begin()).json().id).toBe(prepared.id);
const action = (name: string) => request('POST', `${base}/replacements/${prepared.id}/${name}`);
expect((await action('confirm')).statusCode).toBe(409);
expect((await action('offer')).json().state).toBe('offered');
expect((await action('confirm')).statusCode).toBe(409);
const transferred = await download();
expect(transferred.statusCode).toBe(200);
expect(transferred.headers['x-bookorbit-variant']).toBe('audioless_epub');
await waitForCondition(async () => {
const record = await ctx.db.query.koboDeviceDeliveries.findFirst({ where: eq(schema.koboDeviceDeliveries.id, prepared.id) });
expect(record?.state).toBe('transfer_observed');
});
const recovered = await request('GET', `${base}/books/${book.bookId}/delivery`);
expect(recovered.json()).toMatchObject({ id: prepared.id, state: 'transfer_observed' });
expect((await action('confirm')).json().state).toBe('user_confirmed');
expect((await action('confirm')).json().state).toBe('user_confirmed');
expect((await action('cancel')).json().state).toBe('user_confirmed');
});
it('delivers a book whose replacement was prepared before any copy, and an unfetched cancel hands back the earlier copy', async () => {
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Replacement before delivery' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'original' });
const base = `/api/v1/kobo/devices/${device.id}`;
const download = () => ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/books/${book.bookId}/download` });
const early = (await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
expect(early).toMatchObject({ state: 'prepared', previousId: null });
const first = await download();
expect(first.statusCode).toBe(200);
expect(first.rawPayload).toEqual(sourceBytes);
const delivered = (await request('GET', `${base}/books/${book.bookId}/delivery`)).json();
expect(delivered.id).not.toBe(early.id);
expect(delivered.options.replacement).toBe(false);
await waitForCondition(async () => {
const row = await ctx.db.query.koboDeviceDeliveries.findFirst({ where: eq(schema.koboDeviceDeliveries.id, delivered.id) });
expect(row?.state).toBe('transfer_observed');
});
const pending = (await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
expect((await request('POST', `${base}/replacements/${pending.id}/offer`)).json().state).toBe('offered');
expect((await request('POST', `${base}/replacements/${pending.id}/cancel`)).json()).toMatchObject({ state: 'cancelled', current: false });
expect((await request('GET', `${base}/books/${book.bookId}/delivery`)).json()).toMatchObject({ id: delivered.id, state: 'transfer_observed' });
expect((await download()).rawPayload).toEqual(sourceBytes);
});
it('keeps the current delivery instead of offering a replacement with the bytes the device already has', async () => {
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Identical replacement' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'audioless_epub' });
const base = `/api/v1/kobo/devices/${device.id}`;
expect((await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/books/${book.bookId}/download` })).statusCode).toBe(200);
const delivered = (await request('GET', `${base}/books/${book.bookId}/delivery`)).json();
const prepared = await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' });
expect(prepared.statusCode).toBe(201);
expect(prepared.json()).toMatchObject({ id: delivered.id, options: { replacement: false } });
const rows = await ctx.db.query.koboDeviceDeliveries.findMany({ where: eq(schema.koboDeviceDeliveries.deviceId, device.id) });
expect(rows).toHaveLength(1);
});
describe('Storyteller progress-reset regressions over live HTTP', () => {
let origin: string;
const cfi = 'epubcfi(/6/2!/4/2/2/1:3)';
async function http(method: string, path: string, body?: unknown, authenticated = true) {
const response = await fetch(`${origin}${path}`, {
method,
headers: { ...(authenticated ? authHeader(owner.accessToken) : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
});
expect(response.ok).toBe(true);
return response;
}
async function progress() {
return (await http('GET', `/api/v1/books/files/${book.bookFileId}/progress`)).json();
}
async function save(percentage: number, value: string | null = cfi) {
await http('POST', `/api/v1/books/files/${book.bookFileId}/progress`, { percentage, cfi: value, source: 'text' });
}
const devicePath = (device: { token: string }, suffix: string) => `/api/v1/kobo/${device.token}/v1/${suffix}`;
async function newDevice(name: string) {
return (await http('POST', '/api/v1/kobo/devices', { name })).json() as Promise<{ id: number; token: string }>;
}
async function download(device: { token: string }) {
return http('GET', devicePath(device, `books/${book.bookId}/download`), undefined, false);
}
async function push(device: { token: string }, percentage: number, modified: string) {
const response = await http(
'PUT',
devicePath(device, `library/${book.bookId}/state`),
{
ReadingStates: [
{
CurrentBookmark: { ProgressPercent: percentage, LastModified: modified },
StatusInfo: {
Status: percentage === 0 ? 'ReadyToRead' : 'Reading',
TimesStartedReading: percentage === 0 ? 0 : 1,
LastModified: modified,
},
},
],
},
false,
);
expect(await response.json()).toMatchObject({ RequestResult: 'Success', UpdateResults: [{ CurrentBookmarkResult: { Result: 'Success' } }] });
}
beforeAll(async () => {
await ctx.app.listen(0, '127.0.0.1');
origin = await ctx.app.getUrl();
const collection = await (
await http('POST', '/api/v1/collections', { name: 'Storyteller live progress proof', icon: 'book', syncToKobo: true })
).json();
await http('POST', `/api/v1/collections/${collection.id}/books`, { bookIds: [book.bookId] });
await http('PATCH', '/api/v1/kobo/settings', { twoWayProgressSync: true, readAloudDelivery: 'original' });
});
it('delivers saved progress on the first Kobo sync before any Kobo bookmark exists', async () => {
await save(61);
const device = await newDevice('Initial progress proof');
const response = await http('GET', devicePath(device, 'library/sync'), undefined, false);
const entries = (await response.json()) as Array<{ NewEntitlement?: { ReadingState: { CurrentBookmark: Record<string, unknown> } } }>;
const entitlement = entries.find((entry) => entry.NewEntitlement)?.NewEntitlement;
expect(entitlement?.ReadingState.CurrentBookmark).toMatchObject({ ProgressPercent: 61 });
expect(entitlement?.ReadingState.CurrentBookmark.Location).toBeUndefined();
expect(await progress()).toMatchObject({ percentage: 61, cfi });
});
it('returns canonical percentage when the narrated original has no precise Kobo position conversion', async () => {
await save(61);
const device = await newDevice('Unavailable conversion proof');
expect(Buffer.from(await (await download(device)).arrayBuffer())).toEqual(sourceBytes);
await push(device, 0, '2020-01-01T00:00:00Z');
const states = await (await http('GET', devicePath(device, `library/${book.bookId}/state`), undefined, false)).json();
expect(states[0].CurrentBookmark.ProgressPercent).toBe(61);
expect(states[0].CurrentBookmark.Location).toBeUndefined();
expect(await progress()).toMatchObject({ percentage: 61, cfi });
});
it('preserves progress through replacement, transfer, confirmation and delayed backwards events', async () => {
await save(61);
const device = await newDevice('Replacement progress proof');
await (await download(device)).arrayBuffer();
const base = `/api/v1/kobo/devices/${device.id}`;
const replacement = await (await http('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
await http('POST', `${base}/replacements/${replacement.id}/offer`);
for (const stage of ['offered', 'transfer_observed', 'user_confirmed']) {
if (stage === 'transfer_observed') {
const transfer = await download(device);
expect(transfer.headers.get('x-bookorbit-variant')).toBe('audioless_epub');
expect((await transfer.arrayBuffer()).byteLength).toBeLessThan(1024 * 1024);
}
if (stage === 'user_confirmed') await http('POST', `${base}/replacements/${replacement.id}/confirm`);
const modified = new Date(Date.now() + 60_000).toISOString();
await push(device, 0, modified);
expect(await progress()).toMatchObject({ percentage: 61, cfi });
const raw = await ctx.db.query.koboDeviceReadingStates.findFirst({
where: and(eq(schema.koboDeviceReadingStates.deviceId, device.id), eq(schema.koboDeviceReadingStates.bookId, book.bookId)),
});
expect(raw?.currentBookmark).toMatchObject({ ProgressPercent: 0 });
if (stage === 'user_confirmed') expect(raw?.deliveryId).toBe(replacement.id);
else expect(raw?.deliveryId).not.toBe(replacement.id);
const pulled = await (await http('GET', devicePath(device, `library/${book.bookId}/state`), undefined, false)).json();
expect(pulled[0].CurrentBookmark.ProgressPercent).toBe(61);
expect(pulled[0].StatusInfo.Status).toBe('Reading');
expect(new Date(pulled[0].CurrentBookmark.LastModified).getTime()).toBeGreaterThan(new Date(modified).getTime());
}
await push(device, 20, new Date(Date.now() + 120_000).toISOString());
expect(await progress()).toMatchObject({ percentage: 61, cfi });
const sync = (await (await http('GET', devicePath(device, 'library/sync'), undefined, false)).json()) as Array<{
NewEntitlement?: { ReadingState: { CurrentBookmark: { ProgressPercent: number } } };
ChangedReadingState?: { ReadingState: { CurrentBookmark: { ProgressPercent: number } } };
}>;
expect(sync.some((entry) => (entry.NewEntitlement ?? entry.ChangedReadingState)?.ReadingState.CurrentBookmark.ProgressPercent === 61)).toBe(
true,
);
await save(0, null);
await push(device, 0, new Date(Date.now() + 180_000).toISOString());
expect(await progress()).toMatchObject({ percentage: 0, cfi: null });
});
it('allows deliberate backwards reading on an unchanged known device copy', async () => {
await save(61);
const device = await newDevice('Deliberate reread proof');
await (await download(device)).arrayBuffer();
await waitForCondition(async () => {
const record = await ctx.db.query.koboDeviceDeliveries.findFirst({
where: and(eq(schema.koboDeviceDeliveries.deviceId, device.id), eq(schema.koboDeviceDeliveries.bookId, book.bookId)),
});
expect(record?.state).toBe('transfer_observed');
});
await push(device, 20, new Date(Date.now() + 60_000).toISOString());
expect(await progress()).toMatchObject({ percentage: 20 });
await push(device, 0, new Date(Date.now() + 120_000).toISOString());
expect(await progress()).toMatchObject({ percentage: 0 });
});
it('delivers saved progress before any bookmark exists with two-way sync disabled', async () => {
await http('PATCH', '/api/v1/kobo/settings', { twoWayProgressSync: false });
try {
await save(61);
await ctx.db
.delete(schema.koboReadingStates)
.where(and(eq(schema.koboReadingStates.userId, owner.userId), eq(schema.koboReadingStates.bookId, book.bookId)));
const device = await newDevice('One-way initial progress proof');
const entries = await (await http('GET', devicePath(device, 'library/sync'), undefined, false)).json();
const entitlement = entries.find((entry: { NewEntitlement?: unknown }) => entry.NewEntitlement)?.NewEntitlement;
expect(entitlement?.ReadingState.CurrentBookmark.ProgressPercent).toBe(61);
expect(entitlement?.ReadingState.CurrentBookmark.Location).toBeUndefined();
expect(entitlement?.ReadingState.StatusInfo.Status).toBe('Reading');
expect(await progress()).toMatchObject({ percentage: 61, cfi });
} finally {
await http('PATCH', '/api/v1/kobo/settings', { twoWayProgressSync: true });
}
});
it('returns retained replacement progress even when two-way sync is disabled', async () => {
await http('PATCH', '/api/v1/kobo/settings', { twoWayProgressSync: false });
try {
await save(61);
const device = await newDevice('One-way replacement progress proof');
await (await download(device)).arrayBuffer();
const base = `/api/v1/kobo/devices/${device.id}`;
const replacement = await (await http('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
await http('POST', `${base}/replacements/${replacement.id}/offer`);
await push(device, 0, new Date(Date.now() + 60_000).toISOString());
expect(await progress()).toMatchObject({ percentage: 61, cfi });
const states = await (await http('GET', devicePath(device, `library/${book.bookId}/state`), undefined, false)).json();
expect(states[0].CurrentBookmark.ProgressPercent).toBe(61);
expect(states[0].StatusInfo.Status).toBe('Reading');
const raw = await ctx.db.query.koboDeviceReadingStates.findFirst({
where: and(eq(schema.koboDeviceReadingStates.deviceId, device.id), eq(schema.koboDeviceReadingStates.bookId, book.bookId)),
});
expect(raw?.currentBookmark).toMatchObject({ ProgressPercent: 0 });
} finally {
await http('PATCH', '/api/v1/kobo/settings', { twoWayProgressSync: true });
}
});
});
it('rejects a changed source revision before sending transfer headers', async () => {
const file = await detail('original');
expect((await acquire(file.downloadUrl)).statusCode).toBe(200);
await writeFile(sourcePath, Buffer.concat([sourceBytes, Buffer.from('changed')]));
try {
expect((await acquire(file.downloadUrl)).statusCode).toBe(409);
} finally {
await writeFile(sourcePath, sourceBytes);
const info = await stat(sourcePath);
await ctx.db
.update(schema.bookFiles)
.set({ mtime: info.mtime, sizeBytes: info.size, ino: BigInt(info.ino), updatedAt: new Date() })
.where(eq(schema.bookFiles.id, book.bookFileId));
}
});
});