mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
1c27e5dd05
Report a linked copy whose bytes differ from the library file as positionIncompatible instead of unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress. Treat a position-less place within a page as the same place and jump to the nearest page. Suggest library books with the same title and authors for books to review, with bulk linking. See docs/KOREADER_HASH_MATCHING.md. Closes #1872 Closes #1919
570 lines
30 KiB
TypeScript
570 lines
30 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { readFile, readdir } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { and, eq, sql } from 'drizzle-orm';
|
|
import { Permission } from '@bookorbit/types';
|
|
|
|
import * as schema from '../src/db/schema';
|
|
import { deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../src/db/book-file-hash-history';
|
|
import { KoboDeliveryService } from '../src/modules/kobo/services/kobo-delivery.service';
|
|
import { KoboSettingsService } from '../src/modules/kobo/services/kobo-settings.service';
|
|
import { KoboDownloadHashRegistrationService } from '../src/modules/kobo/services/kobo-download-hash-registration.service';
|
|
import { computeFileHash } from '../src/common/utils/file-hash.utils';
|
|
import { KoreaderRepository } from '../src/modules/koreader/koreader.repository';
|
|
import { AppSettingsService } from '../src/modules/app-settings/app-settings.service';
|
|
import { KoboDownloadRepository } from '../src/modules/kobo/kobo-download.repository';
|
|
import { KoboDownloadHashBackfillService } from '../src/modules/kobo/services/kobo-download-hash-backfill.service';
|
|
import { KepubConversionService } from '../src/modules/kobo/services/kepub-conversion.service';
|
|
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-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('Kobo download identity in KOReader (e2e)', { timeout: 180_000 }, () => {
|
|
let ctx: ReaderStateIsolationE2EContext;
|
|
let source: LocatedBookFile;
|
|
let other: LocatedBookFile;
|
|
let user: TestUserSession;
|
|
let outsider: TestUserSession;
|
|
let deviceToken: string;
|
|
let downloadPath: string;
|
|
let deliveredHash: string;
|
|
let originalHash: string;
|
|
|
|
function koHeaders(session = user) {
|
|
return { 'x-auth-user': session.username, 'x-auth-key': session.password };
|
|
}
|
|
|
|
async function match(hash: string, session = user, cachedFileId?: number) {
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/koreader/plugin/match-check',
|
|
headers: koHeaders(session),
|
|
payload: {
|
|
deviceId: 'kobo-download-e2e',
|
|
deviceModel: 'Kobo',
|
|
pluginVersion: '1.5.5',
|
|
hashes: [hash],
|
|
...(cachedFileId ? { books: [{ hash, source: 'current_file', bookFileId: cachedFileId, title: 'Cached KOReader title' }] } : {}),
|
|
},
|
|
});
|
|
expect(response.statusCode).toBe(201);
|
|
return response.json() as { matches: { hash: string; bookId: number; bookFileId: number }[]; libraryVersion: string };
|
|
}
|
|
|
|
async function download(token = deviceToken) {
|
|
return ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${token}/v1/books/${source.bookId}/download` });
|
|
}
|
|
|
|
async function withCurrentHashCollision(run: () => Promise<void>) {
|
|
const [stored] = await ctx.db.select({ hash: schema.bookFiles.fileHash }).from(schema.bookFiles).where(eq(schema.bookFiles.id, other.bookFileId));
|
|
await ctx.db.update(schema.bookFiles).set({ fileHash: deliveredHash }).where(eq(schema.bookFiles.id, other.bookFileId));
|
|
try {
|
|
await run();
|
|
} finally {
|
|
await ctx.db.update(schema.bookFiles).set({ fileHash: stored!.hash }).where(eq(schema.bookFiles.id, other.bookFileId));
|
|
await ctx.db
|
|
.delete(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createReaderStateIsolationE2EContext();
|
|
const library = await createLibraryWithFolder(ctx);
|
|
const sourcePath = await createEpubFixture(library.folderPath, 'source.epub', {
|
|
title: 'Original download source',
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
});
|
|
const otherPath = await createEpubFixture(library.folderPath, 'other.epub', { title: 'Independent book', uid: `urn:uuid:${randomUUID()}` });
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
source = await locateBookByAbsolutePath(ctx, sourcePath);
|
|
other = await locateBookByAbsolutePath(ctx, otherPath);
|
|
originalHash = await computeFileHash(sourcePath);
|
|
user = await createUserAndLogin(ctx, { permissions: [Permission.KoboSync, Permission.KoreaderSync] });
|
|
outsider = await createUserAndLogin(ctx, { permissions: [Permission.KoreaderSync] });
|
|
await grantLibraryAccess(ctx, user.userId, library.libraryId);
|
|
for (const session of [user, outsider]) {
|
|
const credentials = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/koreader/credentials',
|
|
headers: authHeader(session.accessToken),
|
|
payload: { username: session.username, password: session.password },
|
|
});
|
|
expect(credentials.statusCode).toBe(201);
|
|
}
|
|
const device = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/kobo/devices',
|
|
headers: authHeader(user.accessToken),
|
|
payload: { name: 'Kobo download regression' },
|
|
});
|
|
expect(device.statusCode).toBe(201);
|
|
deviceToken = (device.json() as { token: string }).token;
|
|
const collection = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/collections',
|
|
headers: authHeader(user.accessToken),
|
|
payload: { name: 'Kobo download regression', icon: 'book', syncToKobo: true },
|
|
});
|
|
expect(collection.statusCode).toBe(201);
|
|
const add = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/collections/${(collection.json() as { id: number }).id}/books`,
|
|
headers: authHeader(user.accessToken),
|
|
payload: { bookIds: [source.bookId] },
|
|
});
|
|
expect(add.statusCode).toBe(201);
|
|
const settings = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/kobo/settings',
|
|
headers: authHeader(user.accessToken),
|
|
payload: { convertToKepub: true, storeSync: false },
|
|
});
|
|
expect(settings.statusCode).toBe(200);
|
|
// The external converter is substituted with a different valid EPUB. The download, hashing,
|
|
// history persistence, matching, access checks and progress endpoints are the real app.
|
|
downloadPath = await createEpubFixture(join(ctx.fixture.booksPath, '.kepub-cache', String(source.bookId)), `${originalHash}.kepub.epub`, {
|
|
title: 'Delivered Kobo artifact',
|
|
uid: `urn:uuid:${randomUUID()}`,
|
|
});
|
|
deliveredHash = await computeFileHash(downloadPath);
|
|
expect(deliveredHash).not.toBe(originalHash);
|
|
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(downloadPath);
|
|
const sync = await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${deviceToken}/v1/library/sync` });
|
|
expect(sync.statusCode).toBe(200);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
vi.restoreAllMocks();
|
|
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
|
|
});
|
|
|
|
it('matches a converted download with the unchanged KOReader request and leaves the source hash intact', async () => {
|
|
const before = await match(deliveredHash);
|
|
const outsiderBefore = await match(deliveredHash, outsider);
|
|
expect(before.matches).toEqual([]);
|
|
const response = await download();
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.rawPayload).toEqual(await readFile(downloadPath));
|
|
const after = await match(deliveredHash);
|
|
expect(after.matches).toEqual([{ hash: deliveredHash, bookId: source.bookId, bookFileId: source.bookFileId }]);
|
|
expect(after.libraryVersion).not.toBe(before.libraryVersion);
|
|
expect((await match(deliveredHash, outsider)).libraryVersion).toBe(outsiderBefore.libraryVersion);
|
|
const [stored] = await ctx.db
|
|
.select({ hash: schema.bookFiles.fileHash })
|
|
.from(schema.bookFiles)
|
|
.where(eq(schema.bookFiles.id, source.bookFileId));
|
|
expect(stored!.hash).toBe(originalHash);
|
|
expect((await match(originalHash)).matches[0]!.bookFileId).toBe(source.bookFileId);
|
|
});
|
|
|
|
it('registers cached/repeated downloads idempotently', async () => {
|
|
const version = (await match(deliveredHash)).libraryVersion;
|
|
expect((await download()).statusCode).toBe(200);
|
|
expect((await download()).statusCode).toBe(200);
|
|
const history = await ctx.db
|
|
.select()
|
|
.from(schema.bookFileHashHistory)
|
|
.where(and(eq(schema.bookFileHashHistory.bookFileId, source.bookFileId), eq(schema.bookFileHashHistory.fileHash, deliveredHash)));
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]!.reason).toBe('kobo_download');
|
|
expect((await match(deliveredHash)).libraryVersion).toBe(version);
|
|
});
|
|
|
|
it('ships a download while its library is locked, rolls back registration, then replays its queued identity', async () => {
|
|
const path = await createEpubFixture(ctx.fixture.rootPath, 'lock-wait.epub', { title: 'Lock wait regression', uid: `urn:uuid:${randomUUID()}` });
|
|
const hash = await computeFileHash(path);
|
|
const device = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/kobo/devices',
|
|
headers: authHeader(user.accessToken),
|
|
payload: { name: 'Pinned retry device' },
|
|
});
|
|
expect(device.statusCode).toBe(201);
|
|
const freshDevice = device.json() as { id: number; token: string };
|
|
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(path);
|
|
await ctx.app.get(KoboDeliveryService).offer(user.userId, freshDevice.id, source.bookId);
|
|
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(downloadPath);
|
|
const [book] = await ctx.db.select({ libraryId: schema.books.libraryId }).from(schema.books).where(eq(schema.books.id, source.bookId));
|
|
const [before] = await ctx.db
|
|
.select({ revision: schema.libraries.koreaderHashRevision })
|
|
.from(schema.libraries)
|
|
.where(eq(schema.libraries.id, book!.libraryId));
|
|
let release!: () => void;
|
|
let ready!: () => void;
|
|
const locked = new Promise<void>((resolve) => {
|
|
ready = resolve;
|
|
});
|
|
const hold = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const blocker = ctx.db.transaction(async (tx) => {
|
|
await tx.execute(sql`select id from libraries where id = ${book!.libraryId} for update`);
|
|
ready();
|
|
await hold;
|
|
});
|
|
// Surface a setup failure without waiting indefinitely for the ready signal.
|
|
await Promise.race([locked, blocker]);
|
|
try {
|
|
const duplicateStart = Date.now();
|
|
expect((await download()).statusCode).toBe(200);
|
|
expect(Date.now() - duplicateStart).toBeLessThan(1000);
|
|
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(path);
|
|
const startedAt = Date.now();
|
|
const response = await download(freshDevice.token);
|
|
const durationMs = Date.now() - startedAt;
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.rawPayload).toEqual(await readFile(path));
|
|
expect(durationMs).toBeGreaterThanOrEqual(850);
|
|
expect(durationMs).toBeLessThan(5000);
|
|
expect((await match(hash)).matches).toEqual([]);
|
|
expect(await readdir(join(ctx.fixture.booksPath, '.kobo-download-hashes'))).toContain(`${source.bookFileId}-${hash}`);
|
|
const history = await ctx.db.select().from(schema.bookFileHashHistory).where(eq(schema.bookFileHashHistory.fileHash, hash));
|
|
expect(history).toEqual([]);
|
|
const [during] = await ctx.db
|
|
.select({ revision: schema.libraries.koreaderHashRevision })
|
|
.from(schema.libraries)
|
|
.where(eq(schema.libraries.id, book!.libraryId));
|
|
expect(during!.revision).toBe(before!.revision);
|
|
} finally {
|
|
release();
|
|
await blocker;
|
|
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(downloadPath);
|
|
}
|
|
await ctx.app.get(KoboDownloadHashRegistrationService).replay();
|
|
expect((await match(hash)).matches).toEqual([{ hash, bookId: source.bookId, bookFileId: source.bookFileId }]);
|
|
const [after] = await ctx.db
|
|
.select({ revision: schema.libraries.koreaderHashRevision })
|
|
.from(schema.libraries)
|
|
.where(eq(schema.libraries.id, book!.libraryId));
|
|
expect(after!.revision).toBe(before!.revision + 1);
|
|
});
|
|
|
|
it('does not expose the generated identity to a user without access to the source library', async () => {
|
|
expect((await match(deliveredHash, outsider)).matches).toEqual([]);
|
|
});
|
|
|
|
it('records KOReader progress on the source book file after a converted download', async () => {
|
|
const response = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/koreader/syncs/progress',
|
|
headers: koHeaders(),
|
|
payload: { document: deliveredHash, percentage: 0.25, device: 'Kobo regression', device_id: 'kobo-download-e2e' },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
const progress = await ctx.db
|
|
.select()
|
|
.from(schema.koreaderDeviceProgress)
|
|
.where(and(eq(schema.koreaderDeviceProgress.userId, user.userId), eq(schema.koreaderDeviceProgress.bookFileId, source.bookFileId)));
|
|
expect(progress).toHaveLength(1);
|
|
expect(progress[0]!.percentage).toBeCloseTo(0.25);
|
|
expect(progress[0]!.orphaned).toBe(false);
|
|
});
|
|
|
|
it('allows relink, unlink, automatic sync, list, and link again after intrinsic registration', async () => {
|
|
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: source.bookFileId });
|
|
try {
|
|
const relinked = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/koreader/hash-links/${deliveredHash}`,
|
|
headers: authHeader(user.accessToken),
|
|
payload: { bookId: other.bookId },
|
|
});
|
|
expect(relinked.statusCode).toBe(200);
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(other.bookFileId);
|
|
expect((await match(deliveredHash, user, source.bookFileId)).matches[0]!.bookFileId).toBe(other.bookFileId);
|
|
const [preserved] = await ctx.db
|
|
.select()
|
|
.from(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
expect(preserved!.bookFileId).toBe(other.bookFileId);
|
|
const unlinked = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/koreader/hash-links/${deliveredHash}`,
|
|
headers: authHeader(user.accessToken),
|
|
});
|
|
expect(unlinked.statusCode).toBe(200);
|
|
// Background matching resumes intrinsic resolution without removing the user's correction queue.
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(source.bookFileId);
|
|
await ctx.app
|
|
.get(KoreaderRepository)
|
|
.upsertUnmatchedBooks(user.userId, [
|
|
{ hash: deliveredHash, source: 'current_file', metadataAmbiguous: true, title: 'Ambiguous metadata from background sync' },
|
|
]);
|
|
const pending = await ctx.app.inject({ method: 'GET', url: '/api/v1/koreader/unmatched-books', headers: authHeader(user.accessToken) });
|
|
expect(pending.statusCode).toBe(200);
|
|
expect(pending.json()).toEqual(expect.arrayContaining([expect.objectContaining({ hash: deliveredHash })]));
|
|
const linked = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/koreader/unmatched-books/${deliveredHash}/link`,
|
|
headers: authHeader(user.accessToken),
|
|
payload: { bookId: other.bookId },
|
|
});
|
|
expect(linked.statusCode).toBe(201);
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(other.bookFileId);
|
|
const after = await ctx.app.inject({ method: 'GET', url: '/api/v1/koreader/unmatched-books', headers: authHeader(user.accessToken) });
|
|
expect(after.json()).not.toEqual(expect.arrayContaining([expect.objectContaining({ hash: deliveredHash })]));
|
|
} finally {
|
|
await ctx.db
|
|
.delete(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
await ctx.db
|
|
.delete(schema.koreaderUnmatchedBooks)
|
|
.where(and(eq(schema.koreaderUnmatchedBooks.userId, user.userId), eq(schema.koreaderUnmatchedBooks.hash, deliveredHash)));
|
|
}
|
|
});
|
|
|
|
it('does not allow another user to relink or unlink a manual override', async () => {
|
|
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: source.bookFileId });
|
|
try {
|
|
for (const method of ['PATCH', 'DELETE'] as const) {
|
|
const response = await ctx.app.inject({
|
|
method,
|
|
url: `/api/v1/koreader/hash-links/${deliveredHash}`,
|
|
headers: authHeader(outsider.accessToken),
|
|
...(method === 'PATCH' ? { payload: { bookId: other.bookId } } : {}),
|
|
});
|
|
expect(response.statusCode).toBe(404);
|
|
}
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(source.bookFileId);
|
|
} finally {
|
|
await ctx.db
|
|
.delete(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
}
|
|
});
|
|
|
|
it('keeps an issued offer pinned across settings edits and queues registration during a database outage', async () => {
|
|
const repository = ctx.app.get(KoboDownloadRepository);
|
|
const settings = ctx.app.get(KoboSettingsService);
|
|
const storedSettings = await settings.getSettings(user.userId);
|
|
const settingsSpy = vi
|
|
.spyOn(settings, 'getSettings')
|
|
.mockResolvedValue({ ...storedSettings, convertToKepub: false, readAloudDelivery: 'original' });
|
|
const registrationSpy = vi.spyOn(repository, 'registerDeliveredHash').mockRejectedValue(new Error('Transient database outage'));
|
|
try {
|
|
const response = await download();
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.rawPayload).toEqual(await readFile(downloadPath));
|
|
expect(registrationSpy).toHaveBeenCalledWith(source.bookFileId, deliveredHash);
|
|
expect(await readdir(join(ctx.fixture.booksPath, '.kobo-download-hashes'))).toContain(`${source.bookFileId}-${deliveredHash}`);
|
|
} finally {
|
|
registrationSpy.mockRestore();
|
|
settingsSpy.mockRestore();
|
|
}
|
|
await ctx.app.get(KoboDownloadHashRegistrationService).replay();
|
|
expect((await match(deliveredHash)).matches).toEqual([{ hash: deliveredHash, bookId: source.bookId, bookFileId: source.bookFileId }]);
|
|
});
|
|
|
|
it('increments the scoped revision once for concurrent duplicate registrations and supports rollback', async () => {
|
|
const repository = ctx.app.get(KoboDownloadRepository);
|
|
const before = (await match(deliveredHash)).libraryVersion;
|
|
const outsiderBefore = (await match(deliveredHash, outsider)).libraryVersion;
|
|
const [sourceBook] = await ctx.db.select({ libraryId: schema.books.libraryId }).from(schema.books).where(eq(schema.books.id, source.bookId));
|
|
const [beforeRevision] = await ctx.db
|
|
.select({ revision: schema.libraries.koreaderHashRevision })
|
|
.from(schema.libraries)
|
|
.where(eq(schema.libraries.id, sourceBook!.libraryId));
|
|
const hash = 'd'.repeat(32);
|
|
await Promise.all(Array.from({ length: 10 }, () => repository.registerDeliveredHash(source.bookFileId, hash)));
|
|
const rows = await ctx.db
|
|
.select()
|
|
.from(schema.bookFileHashHistory)
|
|
.where(and(eq(schema.bookFileHashHistory.bookFileId, source.bookFileId), eq(schema.bookFileHashHistory.fileHash, hash)));
|
|
expect(rows).toHaveLength(1);
|
|
const [afterRevision] = await ctx.db
|
|
.select({ revision: schema.libraries.koreaderHashRevision })
|
|
.from(schema.libraries)
|
|
.where(eq(schema.libraries.id, sourceBook!.libraryId));
|
|
expect(afterRevision!.revision).toBe(beforeRevision!.revision + 1);
|
|
const after = (await match(deliveredHash)).libraryVersion;
|
|
expect(after).not.toBe(before);
|
|
expect((await match(deliveredHash, outsider)).libraryVersion).toBe(outsiderBefore);
|
|
await repository.registerDeliveredHash(source.bookFileId, hash);
|
|
expect((await match(deliveredHash)).libraryVersion).toBe(after);
|
|
await expect(
|
|
ctx.db.transaction(async (tx) => {
|
|
await deleteBookFilesWithHashInvalidation(tx, eq(schema.bookFiles.id, source.bookFileId));
|
|
throw new Error('Roll back deletion');
|
|
}),
|
|
).rejects.toThrow('Roll back deletion');
|
|
expect((await match(deliveredHash)).libraryVersion).toBe(after);
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(source.bookFileId);
|
|
});
|
|
|
|
it('uses the timestamp expression index for the bounded version lookup', async () => {
|
|
await ctx.db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local enable_seqscan = off`);
|
|
const plan = await tx.execute(
|
|
sql`explain (format json) select greatest(created_at, updated_at) from book_files order by greatest(created_at, updated_at) desc limit 1`,
|
|
);
|
|
const serialized = JSON.stringify(plan.rows);
|
|
expect(serialized).toContain('book_files_change_timestamp_idx');
|
|
expect(serialized).not.toContain('Seq Scan');
|
|
});
|
|
});
|
|
|
|
it('preserves an existing explicit link even when a subsequent download registers a different intrinsic identity', async () => {
|
|
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: other.bookFileId });
|
|
try {
|
|
expect((await download()).statusCode).toBe(200);
|
|
// The delivered bytes belong to the source book, so the linked file cannot place their positions.
|
|
expect((await match(deliveredHash)).matches).toEqual([
|
|
{ hash: deliveredHash, bookId: other.bookId, bookFileId: other.bookFileId, positionCompatible: false },
|
|
]);
|
|
const response = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/koreader/syncs/progress',
|
|
headers: koHeaders(),
|
|
payload: { document: deliveredHash, percentage: 0.6, device: 'Kobo regression', device_id: 'explicit-prior-link' },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
const progress = await ctx.db
|
|
.select()
|
|
.from(schema.koreaderDeviceProgress)
|
|
.where(and(eq(schema.koreaderDeviceProgress.userId, user.userId), eq(schema.koreaderDeviceProgress.deviceId, 'explicit-prior-link')));
|
|
expect(progress).toHaveLength(1);
|
|
expect(progress[0]!.bookFileId).toBe(other.bookFileId);
|
|
} finally {
|
|
await ctx.db
|
|
.delete(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
}
|
|
});
|
|
|
|
it('rejects a delivered/current hash collision instead of assigning progress to the other book', async () => {
|
|
await withCurrentHashCollision(async () => {
|
|
expect((await match(deliveredHash)).matches).toEqual([]);
|
|
const before = await ctx.db.select().from(schema.koreaderDeviceProgress).where(eq(schema.koreaderDeviceProgress.userId, user.userId));
|
|
const response = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/koreader/syncs/progress',
|
|
headers: koHeaders(),
|
|
payload: { document: deliveredHash, percentage: 0.99, device: 'Kobo regression', device_id: 'cross-source-collision' },
|
|
});
|
|
expect(response.statusCode).toBe(404);
|
|
const after = await ctx.db.select().from(schema.koreaderDeviceProgress).where(eq(schema.koreaderDeviceProgress.userId, user.userId));
|
|
expect(after).toEqual(before);
|
|
});
|
|
});
|
|
|
|
it('honors an explicit source-file link for a delivered/current collision on match and progress', async () => {
|
|
await withCurrentHashCollision(async () => {
|
|
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: source.bookFileId });
|
|
expect((await download()).statusCode).toBe(200);
|
|
expect((await match(deliveredHash)).matches).toEqual([{ hash: deliveredHash, bookId: source.bookId, bookFileId: source.bookFileId }]);
|
|
const response = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/koreader/syncs/progress',
|
|
headers: koHeaders(),
|
|
payload: { document: deliveredHash, percentage: 0.5, device: 'Kobo regression', device_id: 'cross-source-linked' },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
const progress = await ctx.db
|
|
.select()
|
|
.from(schema.koreaderDeviceProgress)
|
|
.where(and(eq(schema.koreaderDeviceProgress.userId, user.userId), eq(schema.koreaderDeviceProgress.deviceId, 'cross-source-linked')));
|
|
expect(progress).toHaveLength(1);
|
|
expect(progress[0]!.bookFileId).toBe(source.bookFileId);
|
|
expect(progress[0]!.percentage).toBeCloseTo(0.5);
|
|
});
|
|
});
|
|
|
|
it('preserves ambiguity when two source books have the same delivered hash', async () => {
|
|
const repository = ctx.app.get(KoboDownloadRepository);
|
|
await repository.registerDeliveredHash(other.bookFileId, deliveredHash);
|
|
expect((await match(deliveredHash)).matches).toEqual([]);
|
|
});
|
|
|
|
it('leaves explicit user disambiguation intact across subsequent downloads', async () => {
|
|
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: source.bookFileId });
|
|
expect((await download()).statusCode).toBe(200);
|
|
expect((await match(deliveredHash)).matches[0]!.bookFileId).toBe(source.bookFileId);
|
|
const [link] = await ctx.db
|
|
.select()
|
|
.from(schema.koreaderBookHashLinks)
|
|
.where(and(eq(schema.koreaderBookHashLinks.userId, user.userId), eq(schema.koreaderBookHashLinks.hash, deliveredHash)));
|
|
expect(link!.bookFileId).toBe(source.bookFileId);
|
|
});
|
|
|
|
it('recovers an already downloaded artifact from the retained cache without another download', async () => {
|
|
await ctx.db.delete(schema.bookFileHashHistory).where(eq(schema.bookFileHashHistory.fileHash, deliveredHash));
|
|
await ctx.db.delete(schema.koreaderBookHashLinks).where(eq(schema.koreaderBookHashLinks.hash, deliveredHash));
|
|
expect((await match(deliveredHash)).matches).toEqual([]);
|
|
await ctx.app.get(AppSettingsService).setValue('kobo_download_hash_backfill', '0');
|
|
const recovered = await ctx.app.get(KoboDownloadHashBackfillService).run();
|
|
expect(recovered.registered).toBeGreaterThanOrEqual(1);
|
|
expect(recovered.failed).toBe(0);
|
|
expect((await match(deliveredHash)).matches).toEqual([{ hash: deliveredHash, bookId: source.bookId, bookFileId: source.bookFileId }]);
|
|
});
|
|
|
|
it('does not retarget a retained conversion after its original source identity is lost', async () => {
|
|
const repository = ctx.app.get(KoboDownloadRepository);
|
|
await expect(repository.findCachedSourceFileId(source.bookId, originalHash)).resolves.toBe(source.bookFileId);
|
|
await ctx.db
|
|
.update(schema.bookFiles)
|
|
.set({ fileHash: 'c'.repeat(32) })
|
|
.where(eq(schema.bookFiles.id, source.bookFileId));
|
|
await expect(repository.findCachedSourceFileId(source.bookId, originalHash)).resolves.toBeNull();
|
|
await ctx.db.insert(schema.bookFileHashHistory).values({ bookFileId: source.bookFileId, fileHash: originalHash, reason: 'external_change' });
|
|
await expect(repository.findCachedSourceFileId(source.bookId, originalHash)).resolves.toBe(source.bookFileId);
|
|
});
|
|
it('invalidates a cached token when a non-newest book and its historical identities are deleted', async () => {
|
|
const library = await createLibraryWithFolder(ctx);
|
|
const path = await createEpubFixture(library.folderPath, 'deleted.epub', { title: 'Deletion regression', uid: `urn:uuid:${randomUUID()}` });
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
const target = await locateBookByAbsolutePath(ctx, path);
|
|
await grantLibraryAccess(ctx, user.userId, library.libraryId);
|
|
const repository = ctx.app.get(KoboDownloadRepository);
|
|
const hash = 'e'.repeat(32);
|
|
await repository.registerDeliveredHash(target.bookFileId, hash);
|
|
await ctx.db
|
|
.update(schema.bookFiles)
|
|
.set({ createdAt: new Date('2000-01-01'), updatedAt: new Date('2000-01-01') })
|
|
.where(eq(schema.bookFiles.id, target.bookFileId));
|
|
const before = (await match(hash)).libraryVersion;
|
|
await deleteBooksWithHashInvalidation(ctx.db, eq(schema.books.id, target.bookId));
|
|
const after = await match(hash);
|
|
expect(after.libraryVersion).not.toBe(before);
|
|
expect(after.matches).toEqual([]);
|
|
});
|
|
|
|
it('replaces an offer its source can no longer rebuild instead of failing every download of the book', async () => {
|
|
const created = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/kobo/devices',
|
|
headers: authHeader(user.accessToken),
|
|
payload: { name: 'Stale offer device' },
|
|
});
|
|
const device = created.json() as { id: number; token: string };
|
|
const deliveries = ctx.app.get(KoboDeliveryService);
|
|
const conversion = vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(downloadPath);
|
|
const stale = await deliveries.offer(user.userId, device.id, source.bookId);
|
|
const rebuilt = await createEpubFixture(ctx.fixture.rootPath, 'rebuilt.epub', { title: 'Rebuilt conversion', uid: `urn:uuid:${randomUUID()}` });
|
|
conversion.mockResolvedValue(rebuilt);
|
|
try {
|
|
const response = await download(device.token);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.rawPayload).toEqual(await readFile(rebuilt));
|
|
const fresh = await deliveries.current(user.userId, device.id, source.bookId);
|
|
expect(fresh!.id).not.toBe(stale.id);
|
|
expect(fresh!.previousId).toBeNull();
|
|
expect((await deliveries.byId(user.userId, device.id, stale.id))!.current).toBe(false);
|
|
expect((await download(device.token)).rawPayload).toEqual(await readFile(rebuilt));
|
|
} finally {
|
|
conversion.mockResolvedValue(downloadPath);
|
|
}
|
|
});
|
|
});
|