mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
d8105d4820
Embedding metadata rewrites the book file, so its hash moves even though the book itself is unchanged. Kobo reconcile reads a moved hash as new content and re-delivers the book as a NewEntitlement, which makes the device rebuild its local entry and orphan every annotation attached to the old one. Carry the post-write hash into the device snapshots that recorded the pre-write hash, so reconcile stays on the metadata-only path. The device keeps the bytes it already has and never receives the rewritten file; a content change from any other source still triggers a download. Ref #925
856 lines
39 KiB
TypeScript
856 lines
39 KiB
TypeScript
import { createHash, randomUUID } from 'crypto';
|
|
import { readFile, readdir } from 'fs/promises';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { and, eq, inArray } from 'drizzle-orm';
|
|
import { Client } from 'pg';
|
|
|
|
import * as schema from '../src/db/schema';
|
|
import { waitForCondition } from './e2e/app-harness';
|
|
import { createEpubFixture } 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';
|
|
|
|
type KoboDevice = { id: number; token: string };
|
|
type SyncResponse = { entries: unknown[]; hasMore: boolean; syncToken: string };
|
|
type NewEntitlement = { BookEntitlement: { Id: string }; ReadingState: Record<string, unknown> };
|
|
|
|
const MIGRATIONS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../src/db/migrations');
|
|
|
|
function escapeIdentifier(identifier: string): string {
|
|
return `"${identifier.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
async function applyMigrations(client: Client, filenames: string[]): Promise<void> {
|
|
for (const filename of filenames) {
|
|
await client.query(await readFile(join(MIGRATIONS_DIRECTORY, filename), 'utf8'));
|
|
}
|
|
}
|
|
|
|
function entitlementIds(entries: unknown[]): string[] {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
|
const bookEntitlement = value.NewEntitlement?.BookEntitlement ?? value.ChangedProductMetadata?.BookEntitlement;
|
|
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
|
});
|
|
}
|
|
|
|
function newEntitlementIds(entries: unknown[]): string[] {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
|
const bookEntitlement = value.NewEntitlement?.BookEntitlement;
|
|
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
|
});
|
|
}
|
|
|
|
function newEntitlements(entries: unknown[]): NewEntitlement[] {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as { NewEntitlement?: NewEntitlement };
|
|
return value.NewEntitlement ? [value.NewEntitlement] : [];
|
|
});
|
|
}
|
|
|
|
function changedReadingStates(entries: unknown[]): Record<string, unknown>[] {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as { ChangedReadingState?: { ReadingState: Record<string, unknown> } };
|
|
return value.ChangedReadingState ? [value.ChangedReadingState.ReadingState] : [];
|
|
});
|
|
}
|
|
|
|
function changedProductMetadata(entries: unknown[]): Array<{ BookEntitlement: { Id: string }; BookMetadata: { Title: string | null } }> {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as { ChangedProductMetadata?: { BookEntitlement: { Id: string }; BookMetadata: { Title: string | null } } };
|
|
return value.ChangedProductMetadata ? [value.ChangedProductMetadata] : [];
|
|
});
|
|
}
|
|
|
|
function removedEntitlementIds(entries: unknown[]): string[] {
|
|
return entries.flatMap((entry) => {
|
|
const value = entry as Record<string, Record<string, Record<string, string>>>;
|
|
const bookEntitlement = value.ChangedEntitlement?.BookEntitlement;
|
|
return bookEntitlement?.Id ? [bookEntitlement.Id] : [];
|
|
});
|
|
}
|
|
|
|
describe('Kobo device snapshot migration (e2e)', { timeout: 180_000 }, () => {
|
|
it('retains populated legacy snapshot data for the per-device transition bridge', async () => {
|
|
const baseUrl = new URL(process.env.DATABASE_URL!);
|
|
const databaseName = `kobo_snapshot_${randomUUID().replace(/-/g, '').slice(0, 12)}_e2e`;
|
|
const targetUrl = new URL(baseUrl);
|
|
targetUrl.pathname = `/${databaseName}`;
|
|
const adminUrl = new URL(baseUrl);
|
|
adminUrl.pathname = '/postgres';
|
|
|
|
const admin = new Client({ connectionString: adminUrl.toString() });
|
|
let target: Client | undefined;
|
|
await admin.connect();
|
|
try {
|
|
await admin.query(`CREATE DATABASE ${escapeIdentifier(databaseName)}`);
|
|
target = new Client({ connectionString: targetUrl.toString() });
|
|
await target.connect();
|
|
await target.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
|
await target.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
|
await target.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
|
|
const migrationFiles = (await readdir(MIGRATIONS_DIRECTORY)).filter((filename) => filename.endsWith('.sql')).sort();
|
|
await applyMigrations(
|
|
target,
|
|
migrationFiles.filter((filename) => filename < '0045_kobo-device-owner-key.sql'),
|
|
);
|
|
|
|
const userId = (
|
|
await target.query<{ id: number }>(
|
|
"INSERT INTO users (username, name, password_hash) VALUES ('kobo-migration-legacy', 'Kobo Migration Legacy', 'hash') RETURNING id",
|
|
)
|
|
).rows[0]!.id;
|
|
const libraryId = (await target.query<{ id: number }>("INSERT INTO libraries (name) VALUES ('Kobo Migration Library') RETURNING id")).rows[0]!
|
|
.id;
|
|
const folderId = (
|
|
await target.query<{ id: number }>("INSERT INTO library_folders (library_id, path) VALUES ($1, '/kobo-migration') RETURNING id", [libraryId])
|
|
).rows[0]!.id;
|
|
const bookId = (
|
|
await target.query<{ id: number }>(
|
|
"INSERT INTO books (library_id, library_folder_id, folder_path) VALUES ($1, $2, '/kobo-migration/book') RETURNING id",
|
|
[libraryId, folderId],
|
|
)
|
|
).rows[0]!.id;
|
|
await target.query("INSERT INTO kobo_devices (user_id, name, token) VALUES ($1, 'Legacy Kobo', 'legacy-kobo-migration-token')", [userId]);
|
|
const snapshotId = (await target.query<{ id: number }>('INSERT INTO kobo_library_snapshots (user_id) VALUES ($1) RETURNING id', [userId]))
|
|
.rows[0]!.id;
|
|
await target.query('INSERT INTO kobo_snapshot_books (snapshot_id, book_id, synced) VALUES ($1, $2, true)', [snapshotId, bookId]);
|
|
|
|
await applyMigrations(target, ['0045_kobo-device-owner-key.sql', '0046_kobo-device-snapshots.sql']);
|
|
|
|
const [bridge] = (
|
|
await target.query<{ legacyDeviceCutoffAt: Date; legacyBookCount: string }>(
|
|
`SELECT legacy_device_cutoff_at AS "legacyDeviceCutoffAt", count(sb.book_id) AS "legacyBookCount"
|
|
FROM kobo_library_snapshots AS s
|
|
LEFT JOIN kobo_snapshot_books AS sb ON sb.snapshot_id = s.id
|
|
WHERE s.user_id = $1
|
|
GROUP BY s.legacy_device_cutoff_at`,
|
|
[userId],
|
|
)
|
|
).rows;
|
|
const [deviceSnapshotCount] = (await target.query<{ count: string }>('SELECT count(*) FROM kobo_device_library_snapshots')).rows;
|
|
|
|
expect(bridge?.legacyDeviceCutoffAt).toBeInstanceOf(Date);
|
|
expect(bridge?.legacyBookCount).toBe('1');
|
|
expect(deviceSnapshotCount?.count).toBe('0');
|
|
} finally {
|
|
await target?.end();
|
|
await admin.query(`DROP DATABASE IF EXISTS ${escapeIdentifier(databaseName)} WITH (FORCE)`);
|
|
await admin.end();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Kobo multi-device library sync (e2e)', { timeout: 180_000 }, () => {
|
|
let ctx!: ReaderStateIsolationE2EContext;
|
|
let library!: CreatedLibrary;
|
|
let userId!: number;
|
|
let bookIds!: number[];
|
|
let collectionId!: number;
|
|
let deviceA!: KoboDevice;
|
|
let deviceB!: KoboDevice;
|
|
|
|
function syncUrl(device: KoboDevice): string {
|
|
return `/api/v1/kobo/${device.token}/v1/library/sync`;
|
|
}
|
|
|
|
async function sync(device: KoboDevice, syncToken?: string): Promise<SyncResponse> {
|
|
const response = await ctx.app.inject({
|
|
method: 'GET',
|
|
url: syncUrl(device),
|
|
headers: syncToken ? { 'x-kobo-synctoken': syncToken } : undefined,
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
const responseSyncToken = response.headers['x-kobo-synctoken'];
|
|
expect(typeof responseSyncToken).toBe('string');
|
|
return {
|
|
entries: response.json() as unknown[],
|
|
hasMore: response.headers['x-kobo-sync'] === 'continue',
|
|
syncToken: responseSyncToken as string,
|
|
};
|
|
}
|
|
|
|
async function drainEntries(device: KoboDevice): Promise<unknown[]> {
|
|
const delivered: unknown[] = [];
|
|
let syncToken: string | undefined;
|
|
|
|
for (let page = 0; page < 10; page += 1) {
|
|
const response = await sync(device, syncToken);
|
|
delivered.push(...response.entries);
|
|
if (!response.hasMore) return delivered;
|
|
syncToken = response.syncToken;
|
|
}
|
|
|
|
throw new Error(`Kobo device ${device.id} did not finish syncing within ten pages`);
|
|
}
|
|
|
|
async function drain(device: KoboDevice): Promise<string[]> {
|
|
return entitlementIds(await drainEntries(device));
|
|
}
|
|
|
|
async function createDevice(name: string): Promise<KoboDevice> {
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/kobo/devices',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { name },
|
|
});
|
|
expect([200, 201]).toContain(response.statusCode);
|
|
const body = response.json() as KoboDevice;
|
|
expect(body.id).toBeTypeOf('number');
|
|
expect(body.token).toEqual(expect.any(String));
|
|
return body;
|
|
}
|
|
|
|
async function putReadingState(device: KoboDevice, entitlementId: string, readingState: Record<string, unknown>): Promise<void> {
|
|
const response = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: `/api/v1/kobo/${device.token}/v1/library/${entitlementId}/state`,
|
|
payload: { ReadingStates: [readingState] },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
}
|
|
|
|
async function snapshotState(bookId: number): Promise<Array<{ deviceId: number; synced: boolean; isNew: boolean }>> {
|
|
return ctx.db
|
|
.select({
|
|
deviceId: schema.koboLibrarySnapshots.deviceId,
|
|
synced: schema.koboSnapshotBooks.synced,
|
|
isNew: schema.koboSnapshotBooks.isNew,
|
|
})
|
|
.from(schema.koboSnapshotBooks)
|
|
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
|
.where(and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboSnapshotBooks.bookId, bookId)));
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createReaderStateIsolationE2EContext();
|
|
library = await createLibraryWithFolder(ctx, { name: `kobo-multi-device-${randomUUID()}` });
|
|
|
|
const paths = await Promise.all(
|
|
Array.from({ length: 6 }, (_, index) =>
|
|
createEpubFixture(library.folderPath, `kobo-multi-device-${index + 1}.epub`, {
|
|
title: `Kobo Multi Device ${index + 1}`,
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
}),
|
|
),
|
|
);
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
bookIds = (await Promise.all(paths.map((path) => locateBookByAbsolutePath(ctx, path)))).map((book) => book.bookId);
|
|
|
|
const [user] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'reader-state-e2e-admin'));
|
|
userId = user!.id;
|
|
|
|
const collectionResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/collections',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { name: `Kobo Multi Device ${randomUUID().slice(0, 8)}`, icon: 'book', syncToKobo: true },
|
|
});
|
|
expect([200, 201]).toContain(collectionResponse.statusCode);
|
|
collectionId = (collectionResponse.json() as { id: number }).id;
|
|
|
|
const addBooksResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/collections/${collectionId}/books`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { bookIds },
|
|
});
|
|
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
|
|
|
deviceA = await createDevice('Kobo A');
|
|
deviceB = await createDevice('Kobo B');
|
|
}, 180_000);
|
|
|
|
afterAll(async () => {
|
|
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
|
|
});
|
|
|
|
it('finishes pagination when Kobo echoes first-page reading states before requesting the next page', async () => {
|
|
const settingsResponse = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/kobo/settings',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { twoWayProgressSync: true },
|
|
});
|
|
expect(settingsResponse.statusCode).toBe(200);
|
|
|
|
const echoDevice = await createDevice('Kobo pagination echo');
|
|
const first = await sync(echoDevice);
|
|
const firstEntitlements = newEntitlements(first.entries);
|
|
expect(first.hasMore).toBe(true);
|
|
expect(firstEntitlements).toHaveLength(5);
|
|
|
|
for (const entitlement of firstEntitlements) {
|
|
await putReadingState(echoDevice, entitlement.BookEntitlement.Id, entitlement.ReadingState);
|
|
}
|
|
|
|
const second = await sync(echoDevice, first.syncToken);
|
|
const firstIds = entitlementIds(first.entries);
|
|
const secondIds = entitlementIds(second.entries);
|
|
const expectedIdentityRows = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds)));
|
|
|
|
expect(second.hasMore).toBe(false);
|
|
expect(secondIds).toHaveLength(1);
|
|
expect(secondIds.some((id) => firstIds.includes(id))).toBe(false);
|
|
expect([...firstIds, ...secondIds].sort()).toEqual(expectedIdentityRows.map((row) => row.entitlementId).sort());
|
|
|
|
const revokeResponse = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/kobo/devices/${echoDevice.id}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
expect(revokeResponse.statusCode).toBe(204);
|
|
});
|
|
|
|
it('delivers every initial page independently when two devices interleave', async () => {
|
|
const aFirst = await sync(deviceA);
|
|
const bFirst = await sync(deviceB);
|
|
expect(aFirst.hasMore).toBe(true);
|
|
expect(bFirst.hasMore).toBe(true);
|
|
expect(entitlementIds(aFirst.entries)).toHaveLength(5);
|
|
expect(entitlementIds(bFirst.entries)).toHaveLength(5);
|
|
|
|
const aSecond = await sync(deviceA, aFirst.syncToken);
|
|
expect(aSecond.hasMore).toBe(false);
|
|
const [remainingEntitlementId] = entitlementIds(aSecond.entries);
|
|
const [remainingIdentity] = await ctx.db
|
|
.select({ bookId: schema.koboBookEntitlements.bookId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.entitlementId, remainingEntitlementId!)));
|
|
await putReadingState(deviceA, remainingEntitlementId!, {
|
|
EntitlementId: remainingEntitlementId,
|
|
LastModified: '2026-11-01T00:00:00.000Z',
|
|
CurrentBookmark: { LastModified: '2026-11-01T00:00:00.000Z', ProgressPercent: 15 },
|
|
StatusInfo: { LastModified: '2026-11-01T00:00:00.000Z', Status: 'Reading' },
|
|
});
|
|
expect(await snapshotState(remainingIdentity!.bookId)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ deviceId: deviceA.id, synced: true }),
|
|
expect.objectContaining({ deviceId: deviceB.id, synced: false, isNew: true }),
|
|
]),
|
|
);
|
|
|
|
const bSecond = await sync(deviceB, bFirst.syncToken);
|
|
expect(bSecond.hasMore).toBe(false);
|
|
expect(newEntitlementIds(bSecond.entries)).toContain(remainingEntitlementId);
|
|
|
|
const expectedIds = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds)));
|
|
const expected = expectedIds.map((row) => row.entitlementId).sort();
|
|
expect([...entitlementIds([...aFirst.entries, ...aSecond.entries])].sort()).toEqual(expected);
|
|
expect([...entitlementIds([...bFirst.entries, ...bSecond.entries])].sort()).toEqual(expected);
|
|
|
|
const snapshots = await ctx.db
|
|
.select({ id: schema.koboLibrarySnapshots.id, deviceId: schema.koboLibrarySnapshots.deviceId })
|
|
.from(schema.koboLibrarySnapshots)
|
|
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
|
expect(snapshots.map((snapshot) => snapshot.deviceId).sort()).toEqual([deviceA.id, deviceB.id].sort());
|
|
|
|
const rows = await ctx.db
|
|
.select({ snapshotId: schema.koboSnapshotBooks.snapshotId, synced: schema.koboSnapshotBooks.synced })
|
|
.from(schema.koboSnapshotBooks)
|
|
.where(
|
|
inArray(
|
|
schema.koboSnapshotBooks.snapshotId,
|
|
snapshots.map((snapshot) => snapshot.id),
|
|
),
|
|
);
|
|
expect(rows).toHaveLength(12);
|
|
expect(rows.every((row) => row.synced)).toBe(true);
|
|
});
|
|
|
|
it('fans genuine state changes to other devices without reopening either device on echo', async () => {
|
|
const targetBookId = bookIds[0]!;
|
|
const [identity] = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, targetBookId)));
|
|
const readingState = {
|
|
EntitlementId: identity!.entitlementId,
|
|
LastModified: '2026-12-01T00:00:00.000Z',
|
|
PriorityTimestamp: '2026-12-01T00:00:00.000Z',
|
|
CurrentBookmark: { LastModified: '2026-12-01T00:00:00.000Z', ProgressPercent: 42 },
|
|
Statistics: { LastModified: '2026-12-01T00:00:00.000Z', SpentReadingMinutes: 12 },
|
|
StatusInfo: { LastModified: '2026-12-01T00:00:00.000Z', Status: 'Reading' },
|
|
};
|
|
|
|
await putReadingState(deviceA, identity!.entitlementId, readingState);
|
|
|
|
expect(await snapshotState(targetBookId)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ deviceId: deviceA.id, synced: true }),
|
|
expect.objectContaining({ deviceId: deviceB.id, synced: false, isNew: false }),
|
|
]),
|
|
);
|
|
|
|
const sourceAfterOwnUpdate = await sync(deviceA);
|
|
expect(entitlementIds(sourceAfterOwnUpdate.entries)).toEqual([]);
|
|
|
|
const deliveredToB = await sync(deviceB);
|
|
const deliveredStates = changedReadingStates(deliveredToB.entries);
|
|
expect(entitlementIds(deliveredToB.entries)).toEqual([identity!.entitlementId]);
|
|
expect(deliveredStates).toHaveLength(1);
|
|
expect(deliveredStates[0]).toEqual(expect.objectContaining({ CurrentBookmark: expect.objectContaining({ ProgressPercent: 42 }) }));
|
|
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
|
|
|
await putReadingState(deviceB, identity!.entitlementId, deliveredStates[0]!);
|
|
|
|
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
|
const sourceAfterEcho = await sync(deviceA);
|
|
expect(entitlementIds(sourceAfterEcho.entries)).toEqual([]);
|
|
});
|
|
|
|
it('fans metadata and delivery changes out to every device independently', async () => {
|
|
const changedBookId = bookIds[0]!;
|
|
await ctx.db
|
|
.update(schema.bookMetadata)
|
|
.set({ title: 'Kobo Multi Device Updated', updatedAt: new Date() })
|
|
.where(eq(schema.bookMetadata.bookId, changedBookId));
|
|
|
|
const [changedIdentity] = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, changedBookId)));
|
|
|
|
const aMetadata = await sync(deviceA);
|
|
const bMetadata = await sync(deviceB);
|
|
expect(entitlementIds(aMetadata.entries)).toEqual([changedIdentity!.entitlementId]);
|
|
expect(entitlementIds(bMetadata.entries)).toEqual([changedIdentity!.entitlementId]);
|
|
expect((aMetadata.entries[0] as Record<string, unknown>).ChangedProductMetadata).toBeDefined();
|
|
expect((bMetadata.entries[0] as Record<string, unknown>).ChangedProductMetadata).toBeDefined();
|
|
|
|
const settingsResponse = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/kobo/settings',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { forceEnableHyphenation: true },
|
|
});
|
|
expect(settingsResponse.statusCode).toBe(200);
|
|
|
|
const [aDeliveryEntries, bDeliveryEntries, expectedIdentityRows] = await Promise.all([
|
|
drainEntries(deviceA),
|
|
drainEntries(deviceB),
|
|
ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds))),
|
|
]);
|
|
const expectedIds = expectedIdentityRows.map((row) => row.entitlementId).sort();
|
|
expect(newEntitlementIds(aDeliveryEntries).sort()).toEqual(expectedIds);
|
|
expect(newEntitlementIds(bDeliveryEntries).sort()).toEqual(expectedIds);
|
|
});
|
|
|
|
it('keeps new-device delivery and device-local removal isolated', async () => {
|
|
const deviceC = await createDevice('Kobo C');
|
|
const deviceCDelivered = await drain(deviceC);
|
|
const expectedIdentityRows = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, bookIds)));
|
|
expect(deviceCDelivered.sort()).toEqual(expectedIdentityRows.map((row) => row.entitlementId).sort());
|
|
|
|
const snapshotsBeforeDelete = await ctx.db
|
|
.select({ id: schema.koboLibrarySnapshots.id, deviceId: schema.koboLibrarySnapshots.deviceId })
|
|
.from(schema.koboLibrarySnapshots)
|
|
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
|
const aAndBRows = await ctx.db
|
|
.select({ snapshotId: schema.koboSnapshotBooks.snapshotId, synced: schema.koboSnapshotBooks.synced })
|
|
.from(schema.koboSnapshotBooks)
|
|
.where(
|
|
inArray(
|
|
schema.koboSnapshotBooks.snapshotId,
|
|
snapshotsBeforeDelete
|
|
.filter((snapshot) => snapshot.deviceId === deviceA.id || snapshot.deviceId === deviceB.id)
|
|
.map((snapshot) => snapshot.id),
|
|
),
|
|
);
|
|
expect(aAndBRows).toHaveLength(12);
|
|
expect(aAndBRows.every((row) => row.synced)).toBe(true);
|
|
|
|
const [targetIdentity] = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookIds[0]!)));
|
|
const deleteResponse = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/kobo/${deviceA.token}/v1/library/${targetIdentity!.entitlementId}`,
|
|
});
|
|
expect(deleteResponse.statusCode).toBe(200);
|
|
|
|
const deviceState = await ctx.db
|
|
.select({
|
|
deviceId: schema.koboLibrarySnapshots.deviceId,
|
|
removedByDevice: schema.koboSnapshotBooks.removedByDevice,
|
|
synced: schema.koboSnapshotBooks.synced,
|
|
})
|
|
.from(schema.koboSnapshotBooks)
|
|
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
|
.where(and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboSnapshotBooks.bookId, bookIds[0]!)));
|
|
expect(deviceState.find((row) => row.deviceId === deviceA.id)).toMatchObject({ removedByDevice: true, synced: true });
|
|
expect(deviceState.find((row) => row.deviceId === deviceB.id)).toMatchObject({ removedByDevice: false, synced: true });
|
|
expect(deviceState.find((row) => row.deviceId === deviceC.id)).toMatchObject({ removedByDevice: false, synced: true });
|
|
|
|
const aReadded = await sync(deviceA);
|
|
expect(entitlementIds(aReadded.entries)).toContain(targetIdentity!.entitlementId);
|
|
const bAfterDeviceARemoval = await sync(deviceB);
|
|
expect(entitlementIds(bAfterDeviceARemoval.entries)).not.toContain(targetIdentity!.entitlementId);
|
|
|
|
const revokeResponse = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/kobo/devices/${deviceC.id}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
expect(revokeResponse.statusCode).toBe(204);
|
|
|
|
const revokedSnapshot = await ctx.db.query.koboLibrarySnapshots.findFirst({
|
|
where: eq(schema.koboLibrarySnapshots.deviceId, deviceC.id),
|
|
});
|
|
expect(revokedSnapshot).toBeUndefined();
|
|
|
|
const remainingSnapshots = await ctx.db
|
|
.select({ deviceId: schema.koboLibrarySnapshots.deviceId })
|
|
.from(schema.koboLibrarySnapshots)
|
|
.where(eq(schema.koboLibrarySnapshots.userId, userId));
|
|
expect(remainingSnapshots.map((snapshot) => snapshot.deviceId).sort()).toEqual([deviceA.id, deviceB.id].sort());
|
|
});
|
|
|
|
it('bridges every pre-upgrade device even after the global numeric-removal marker was cleared', async () => {
|
|
const [legacyEligiblePath, legacyRemovedPath] = await Promise.all([
|
|
createEpubFixture(library.folderPath, `kobo-legacy-bridge-${randomUUID()}.epub`, {
|
|
title: 'Kobo Legacy Bridge',
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
}),
|
|
createEpubFixture(library.folderPath, `kobo-legacy-removed-${randomUUID()}.epub`, {
|
|
title: 'Kobo Legacy Removed',
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
}),
|
|
]);
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
const [legacyEligibleBookId, legacyRemovedBookId] = await Promise.all([
|
|
locateBookByAbsolutePath(ctx, legacyEligiblePath).then((book) => book.bookId),
|
|
locateBookByAbsolutePath(ctx, legacyRemovedPath).then((book) => book.bookId),
|
|
]);
|
|
const addBookResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/collections/${collectionId}/books`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { bookIds: [legacyEligibleBookId] },
|
|
});
|
|
expect([200, 201]).toContain(addBookResponse.statusCode);
|
|
|
|
const legacyDeviceA = await createDevice('Kobo Legacy Bridge A');
|
|
const legacyDeviceB = await createDevice('Kobo Legacy Bridge B');
|
|
await ctx.db.insert(schema.koboBookEntitlements).values([
|
|
{ userId, bookId: legacyEligibleBookId, needsLegacyNumericRemoval: false },
|
|
{ userId, bookId: legacyRemovedBookId, needsLegacyNumericRemoval: false },
|
|
]);
|
|
const [legacySnapshot] = await ctx.db
|
|
.insert(schema.koboLegacyLibrarySnapshots)
|
|
.values({ userId, legacyDeviceCutoffAt: new Date(Date.now() + 60_000) })
|
|
.returning({ id: schema.koboLegacyLibrarySnapshots.id });
|
|
await ctx.db.insert(schema.koboLegacySnapshotBooks).values([
|
|
{ snapshotId: legacySnapshot!.id, bookId: legacyEligibleBookId, synced: true },
|
|
{ snapshotId: legacySnapshot!.id, bookId: legacyRemovedBookId, synced: true },
|
|
]);
|
|
|
|
const firstDeviceEntries = await drainEntries(legacyDeviceA);
|
|
const markerStateAfterFirstDevice = await ctx.db
|
|
.select({ bookId: schema.koboBookEntitlements.bookId, needsLegacyNumericRemoval: schema.koboBookEntitlements.needsLegacyNumericRemoval })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(
|
|
and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, [legacyEligibleBookId, legacyRemovedBookId])),
|
|
);
|
|
expect(markerStateAfterFirstDevice.every((row) => !row.needsLegacyNumericRemoval)).toBe(true);
|
|
|
|
const secondDeviceEntries = await drainEntries(legacyDeviceB);
|
|
const identityRows = await ctx.db
|
|
.select({ bookId: schema.koboBookEntitlements.bookId, entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(
|
|
and(eq(schema.koboBookEntitlements.userId, userId), inArray(schema.koboBookEntitlements.bookId, [legacyEligibleBookId, legacyRemovedBookId])),
|
|
);
|
|
const identitiesByBookId = new Map(identityRows.map((row) => [row.bookId, row.entitlementId]));
|
|
const legacyEligibleEntitlementId = identitiesByBookId.get(legacyEligibleBookId);
|
|
const legacyRemovedEntitlementId = identitiesByBookId.get(legacyRemovedBookId);
|
|
if (!legacyEligibleEntitlementId || !legacyRemovedEntitlementId) {
|
|
throw new Error('Legacy bridge did not create entitlement identities');
|
|
}
|
|
|
|
for (const deliveredEntries of [firstDeviceEntries, secondDeviceEntries]) {
|
|
expect(removedEntitlementIds(deliveredEntries)).toEqual(
|
|
expect.arrayContaining([String(legacyEligibleBookId), String(legacyRemovedBookId), legacyRemovedEntitlementId]),
|
|
);
|
|
expect(entitlementIds(deliveredEntries)).toContain(legacyEligibleEntitlementId);
|
|
expect(entitlementIds(deliveredEntries)).not.toContain(legacyRemovedEntitlementId);
|
|
}
|
|
|
|
const retiredLegacySnapshot = await ctx.db.query.koboLegacyLibrarySnapshots.findFirst({
|
|
where: eq(schema.koboLegacyLibrarySnapshots.userId, userId),
|
|
});
|
|
expect(retiredLegacySnapshot).toBeUndefined();
|
|
});
|
|
|
|
describe('manual read status delivery', () => {
|
|
let statusBookIds!: number[];
|
|
|
|
async function setReadStatus(bookId: number, status: string): Promise<Record<string, unknown>> {
|
|
const response = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${bookId}/status`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { status },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
return response.json() as Record<string, unknown>;
|
|
}
|
|
|
|
async function entitlementIdFor(bookId: number): Promise<string> {
|
|
const [identity] = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookId)));
|
|
if (!identity) throw new Error(`Book ${bookId} has no Kobo entitlement identity`);
|
|
return identity.entitlementId;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const paths = await Promise.all(
|
|
Array.from({ length: 4 }, () =>
|
|
createEpubFixture(library.folderPath, `kobo-manual-status-${randomUUID()}.epub`, {
|
|
title: `Kobo Manual Status ${randomUUID().slice(0, 8)}`,
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
}),
|
|
),
|
|
);
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
statusBookIds = (await Promise.all(paths.map((path) => locateBookByAbsolutePath(ctx, path)))).map((book) => book.bookId);
|
|
|
|
const addBooksResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/collections/${collectionId}/books`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { bookIds: statusBookIds },
|
|
});
|
|
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
|
}, 180_000);
|
|
|
|
beforeEach(async () => {
|
|
await drainEntries(deviceA);
|
|
await drainEntries(deviceB);
|
|
});
|
|
|
|
it('delivers Finished for a book marked read that was never opened anywhere', async () => {
|
|
const targetBookId = statusBookIds[0]!;
|
|
const existingState = await ctx.db.query.koboReadingStates.findFirst({
|
|
where: and(eq(schema.koboReadingStates.userId, userId), eq(schema.koboReadingStates.bookId, targetBookId)),
|
|
});
|
|
expect(existingState).toBeUndefined();
|
|
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
|
|
|
await setReadStatus(targetBookId, 'read');
|
|
|
|
expect(await snapshotState(targetBookId)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ deviceId: deviceA.id, synced: false, isNew: false }),
|
|
expect.objectContaining({ deviceId: deviceB.id, synced: false, isNew: false }),
|
|
]),
|
|
);
|
|
|
|
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
|
const delivered = changedReadingStates(await drainEntries(deviceA));
|
|
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
|
expect(state).toEqual(expect.objectContaining({ StatusInfo: expect.objectContaining({ Status: 'Finished' }) }));
|
|
});
|
|
|
|
it('keeps the device bookmark when a partly read book is corrected to read', async () => {
|
|
const targetBookId = statusBookIds[1]!;
|
|
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
|
const deviceModified = new Date(Date.now() - 60_000).toISOString();
|
|
await putReadingState(deviceA, targetEntitlementId, {
|
|
EntitlementId: targetEntitlementId,
|
|
LastModified: deviceModified,
|
|
PriorityTimestamp: deviceModified,
|
|
CurrentBookmark: { LastModified: deviceModified, ProgressPercent: 42 },
|
|
Statistics: { LastModified: deviceModified, SpentReadingMinutes: 30 },
|
|
StatusInfo: { LastModified: deviceModified, Status: 'Reading', TimesStartedReading: 2 },
|
|
});
|
|
await drainEntries(deviceB);
|
|
|
|
await setReadStatus(targetBookId, 'read');
|
|
|
|
const delivered = changedReadingStates(await drainEntries(deviceB));
|
|
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
|
expect(state).toEqual(
|
|
expect.objectContaining({
|
|
StatusInfo: expect.objectContaining({ Status: 'Finished', TimesStartedReading: 2 }),
|
|
CurrentBookmark: expect.objectContaining({ ProgressPercent: 42 }),
|
|
Statistics: expect.objectContaining({ SpentReadingMinutes: 30 }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not resync when the manual status leaves the Kobo status unchanged', async () => {
|
|
const targetBookId = statusBookIds[2]!;
|
|
await setReadStatus(targetBookId, 'reading');
|
|
await drainEntries(deviceA);
|
|
await drainEntries(deviceB);
|
|
|
|
await setReadStatus(targetBookId, 'on_hold');
|
|
|
|
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
|
});
|
|
|
|
it('marks a book read after a skewed device clock started its attempt in the future', async () => {
|
|
const targetBookId = statusBookIds[3]!;
|
|
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
|
const futureModified = new Date(Date.now() + 120 * 24 * 60 * 60 * 1000).toISOString();
|
|
const futureDay = futureModified.slice(0, 10);
|
|
await putReadingState(deviceA, targetEntitlementId, {
|
|
EntitlementId: targetEntitlementId,
|
|
LastModified: futureModified,
|
|
PriorityTimestamp: futureModified,
|
|
CurrentBookmark: { LastModified: futureModified, ProgressPercent: 35 },
|
|
Statistics: { LastModified: futureModified },
|
|
StatusInfo: { LastModified: futureModified, Status: 'Reading', TimesStartedReading: 1 },
|
|
});
|
|
const [attemptBefore] = await ctx.db
|
|
.select({ startedOn: schema.readingAttempts.startedOn })
|
|
.from(schema.readingAttempts)
|
|
.where(and(eq(schema.readingAttempts.userId, userId), eq(schema.readingAttempts.bookId, targetBookId)));
|
|
expect(attemptBefore?.startedOn).toBe(futureDay);
|
|
|
|
const status = await setReadStatus(targetBookId, 'read');
|
|
|
|
expect(status).toMatchObject({ status: 'read', finishedAt: futureDay });
|
|
const delivered = changedReadingStates(await drainEntries(deviceB));
|
|
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
|
expect(state).toEqual(expect.objectContaining({ StatusInfo: expect.objectContaining({ Status: 'Finished' }) }));
|
|
});
|
|
});
|
|
|
|
describe('metadata edits that rewrite the book file', () => {
|
|
let writeBookId!: number;
|
|
let writePrimaryFileId!: number;
|
|
let writeEntitlementId!: string;
|
|
let writeDevice!: KoboDevice;
|
|
|
|
async function primaryFileHash(): Promise<string | null> {
|
|
const [row] = await ctx.db
|
|
.select({ fileHash: schema.bookFiles.fileHash })
|
|
.from(schema.books)
|
|
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
|
|
.where(eq(schema.books.id, writeBookId));
|
|
if (!row) throw new Error(`Book ${writeBookId} has no primary file`);
|
|
return row.fileHash;
|
|
}
|
|
|
|
async function snapshotFileHash(deviceId: number): Promise<string | null> {
|
|
const [row] = await ctx.db
|
|
.select({ fileHash: schema.koboSnapshotBooks.fileHash })
|
|
.from(schema.koboSnapshotBooks)
|
|
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
|
.where(and(eq(schema.koboLibrarySnapshots.deviceId, deviceId), eq(schema.koboSnapshotBooks.bookId, writeBookId)));
|
|
if (!row) throw new Error(`Device ${deviceId} has no snapshot row for book ${writeBookId}`);
|
|
return row.fileHash;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const writeLibrary = await createLibraryWithFolder(ctx, { name: `kobo-file-write-${randomUUID()}` });
|
|
await ctx.db.update(schema.libraries).set({ fileWriteEnabled: true }).where(eq(schema.libraries.id, writeLibrary.libraryId));
|
|
|
|
const path = await createEpubFixture(writeLibrary.folderPath, `kobo-file-write-${randomUUID()}.epub`, {
|
|
title: 'Kobo File Write Seed',
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
});
|
|
await triggerAndWaitForLibraryScan(ctx, writeLibrary.libraryId);
|
|
writeBookId = (await locateBookByAbsolutePath(ctx, path)).bookId;
|
|
|
|
const [book] = await ctx.db.select({ primaryFileId: schema.books.primaryFileId }).from(schema.books).where(eq(schema.books.id, writeBookId));
|
|
if (!book?.primaryFileId) throw new Error(`Book ${writeBookId} has no primary file`);
|
|
writePrimaryFileId = book.primaryFileId;
|
|
|
|
const addBooksResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/collections/${collectionId}/books`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { bookIds: [writeBookId] },
|
|
});
|
|
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
|
|
|
writeDevice = await createDevice('Kobo File Write');
|
|
await drainEntries(writeDevice);
|
|
|
|
const [identity] = await ctx.db
|
|
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
|
.from(schema.koboBookEntitlements)
|
|
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, writeBookId)));
|
|
if (!identity) throw new Error(`Book ${writeBookId} has no Kobo entitlement identity`);
|
|
writeEntitlementId = identity.entitlementId;
|
|
}, 180_000);
|
|
|
|
it('delivers changed metadata without re-issuing the entitlement after the file is rewritten', async () => {
|
|
const hashBeforeEdit = await primaryFileHash();
|
|
expect(hashBeforeEdit).toEqual(expect.any(String));
|
|
expect(await snapshotFileHash(writeDevice.id)).toBe(hashBeforeEdit);
|
|
|
|
const patchResponse = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${writeBookId}/metadata`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { title: 'Kobo File Write Updated', description: 'Rewritten by the metadata write' },
|
|
});
|
|
expect(patchResponse.statusCode).toBe(200);
|
|
|
|
await waitForCondition(async () => {
|
|
if ((await primaryFileHash()) === hashBeforeEdit) throw new Error('the metadata write has not rewritten the EPUB yet');
|
|
}, 60_000);
|
|
|
|
const hashAfterEdit = await primaryFileHash();
|
|
expect(hashAfterEdit).not.toBe(hashBeforeEdit);
|
|
// Read before syncing: reconcile writes the current hash onto the row either way, so after a
|
|
// sync this no longer distinguishes an acknowledged rewrite from a re-delivered one.
|
|
const acknowledgedHash = await snapshotFileHash(writeDevice.id);
|
|
|
|
const delivered = await drainEntries(writeDevice);
|
|
const changed = changedProductMetadata(delivered).filter((entry) => entry.BookEntitlement.Id === writeEntitlementId);
|
|
|
|
expect(newEntitlementIds(delivered)).not.toContain(writeEntitlementId);
|
|
expect(changed).toHaveLength(1);
|
|
expect(changed[0]!.BookMetadata.Title).toBe('Kobo File Write Updated');
|
|
expect(acknowledgedHash).toBe(hashAfterEdit);
|
|
});
|
|
|
|
it('re-issues the entitlement when the file changes outside a metadata write', async () => {
|
|
await drainEntries(writeDevice);
|
|
|
|
// A scanner-detected content change moves the hash without the file-write acknowledgement,
|
|
// so the device genuinely holds stale bytes and has to download the book again.
|
|
await ctx.db
|
|
.update(schema.bookFiles)
|
|
.set({ fileHash: createHash('sha256').update(randomUUID()).digest('hex') })
|
|
.where(eq(schema.bookFiles.id, writePrimaryFileId));
|
|
|
|
const delivered = await drainEntries(writeDevice);
|
|
expect(newEntitlementIds(delivered)).toContain(writeEntitlementId);
|
|
});
|
|
});
|
|
});
|