fix(kobo): version cover ids from the cover, not the metadata row

Any write to book_metadata bumped updated_at and minted a fresh
CoverImageId, so a device holding that cover locally fell back to the
placeholder offline. Stamp cover_updated_at at each cover write instead;
migration 0080 backfills it from updated_at so no id churns on upgrade.

Also trims the DECIMAL(10,2) scale Booklore hands back for a series
index, which sorted 2.50 after 2.9. Closes #943.
This commit is contained in:
neon
2026-08-27 17:36:32 +00:00
parent 7ae49c093c
commit b0b21b2b78
16 changed files with 17142 additions and 13 deletions
+19
View File
@@ -707,6 +707,25 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS, ...SHARED_DB_AND_HELPER_PATHS,
], ],
}, },
"kobo-cover-identity": {
id: "kobo-cover-identity",
name: "Kobo Cover Identity",
timeout: 60,
lane: "full",
description: "Kobo CoverImageId stability across metadata edits and cover changes",
vitestTarget: "test/kobo-cover-identity.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/kobo-cover-identity-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/kobo/**",
"server/src/modules/cover/**",
"server/src/modules/metadata/**",
"server/test/kobo-cover-identity.e2e-spec.ts",
"server/test/e2e/reader-state-isolation/**",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"users-admin-lifecycle": { "users-admin-lifecycle": {
id: "users-admin-lifecycle", id: "users-admin-lifecycle",
name: "Users Admin Lifecycle", name: "Users Admin Lifecycle",
@@ -0,0 +1,2 @@
ALTER TABLE "book_metadata" ADD COLUMN "cover_updated_at" timestamp with time zone;--> statement-breakpoint
UPDATE "book_metadata" SET "cover_updated_at" = "updated_at";
File diff suppressed because it is too large Load Diff
@@ -561,6 +561,13 @@
"when": 1787777420432, "when": 1787777420432,
"tag": "0079_add_annotations_user_created_active_idx", "tag": "0079_add_annotations_user_created_active_idx",
"breakpoints": true "breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1787850415027,
"tag": "0080_add_book_metadata_cover_updated_at",
"breakpoints": true
} }
] ]
} }
+3
View File
@@ -79,6 +79,9 @@ export const bookMetadata = pgTable(
.array() .array()
.notNull() .notNull()
.default(sql`'{}'::text[]`), .default(sql`'{}'::text[]`),
// Moves only when the served cover image changes, so a Kobo device is not handed a fresh
// CoverImageId for a picture it already stores locally. See buildVersionedCoverImageId().
coverUpdatedAt: timestamp('cover_updated_at', { withTimezone: true }),
updatedAt: timestamp('updated_at', { withTimezone: true }) updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow() .defaultNow()
.notNull() .notNull()
+56 -1
View File
@@ -132,7 +132,7 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
scoreService as never, scoreService as never,
); );
return { service, mockDb, updateSet, scoreService }; return { service, mockDb, updateSet, scoreService, values, onConflictDoUpdate };
} }
describe('CoverService', () => { describe('CoverService', () => {
@@ -611,4 +611,59 @@ describe('CoverService', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
// A Kobo device keys its locally stored cover by CoverImageId, which is versioned from
// coverUpdatedAt. Leaving the stamp behind here strands the device on a stale picture.
describe('coverUpdatedAt stamping (issue #943)', () => {
it('stamps coverUpdatedAt when a custom cover is uploaded', async () => {
const { service, values, onConflictDoUpdate } = createMutationService();
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
vi.mocked(imageExt).mockReturnValue('jpg');
vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb'));
vi.mocked(readdir).mockResolvedValue([] as never);
vi.mocked(mkdir).mockResolvedValue(undefined as never);
vi.mocked(writeFile).mockResolvedValue(undefined);
vi.mocked(rename).mockResolvedValue(undefined);
await service.uploadCover(12, Buffer.from('image-data'), 'image/jpeg', makeUser());
expect(values).toHaveBeenCalledWith({ bookId: 12, coverSource: 'custom', coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) });
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({ set: { coverSource: 'custom', coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) } }),
);
});
it('stamps coverUpdatedAt when deleting the last cover leaves the book without one', async () => {
const { service, values } = createMutationService();
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg'] as never);
vi.mocked(unlink).mockResolvedValue(undefined);
await service.deleteCover(12, makeUser());
expect(values).toHaveBeenCalledWith({ bookId: 12, coverSource: null, coverUpdatedAt: expect.any(Date), updatedAt: expect.any(Date) });
});
it('stamps coverUpdatedAt when deleting a custom cover falls back to the extracted one', async () => {
const { service, values } = createMutationService();
vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12');
vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg', 'cover_extracted.png'] as never);
vi.mocked(readFile).mockResolvedValue(Buffer.from('extracted-img'));
vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb'));
vi.mocked(unlink).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
await service.deleteCover(12, makeUser());
expect(values).toHaveBeenCalledWith({
bookId: 12,
coverSource: 'extracted',
coverUpdatedAt: expect.any(Date),
updatedAt: expect.any(Date),
});
});
});
}); });
+2 -2
View File
@@ -298,8 +298,8 @@ export class CoverService {
const now = new Date(); const now = new Date();
await this.db await this.db
.insert(bookMetadata) .insert(bookMetadata)
.values({ bookId, coverSource: source, updatedAt: now }) .values({ bookId, coverSource: source, coverUpdatedAt: now, updatedAt: now })
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } }); .onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, coverUpdatedAt: now, updatedAt: now } });
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId)); await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
await this.metadataScoreService.calculateAndSave(bookId); await this.metadataScoreService.calculateAndSave(bookId);
} }
@@ -1103,6 +1103,7 @@ export class KoboSyncService {
seriesName: schema.bookMetadata.seriesName, seriesName: schema.bookMetadata.seriesName,
seriesIndex: schema.bookMetadata.seriesIndex, seriesIndex: schema.bookMetadata.seriesIndex,
metadataUpdatedAt: schema.bookMetadata.updatedAt, metadataUpdatedAt: schema.bookMetadata.updatedAt,
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
fileFormat: schema.bookFiles.format, fileFormat: schema.bookFiles.format,
fileSizeBytes: schema.bookFiles.sizeBytes, fileSizeBytes: schema.bookFiles.sizeBytes,
fileHash: schema.bookFiles.fileHash, fileHash: schema.bookFiles.fileHash,
@@ -1126,6 +1127,7 @@ export class KoboSyncService {
schema.bookMetadata.seriesName, schema.bookMetadata.seriesName,
schema.bookMetadata.seriesIndex, schema.bookMetadata.seriesIndex,
schema.bookMetadata.updatedAt, schema.bookMetadata.updatedAt,
schema.bookMetadata.coverUpdatedAt,
schema.bookFiles.format, schema.bookFiles.format,
schema.bookFiles.sizeBytes, schema.bookFiles.sizeBytes,
schema.bookFiles.fileHash, schema.bookFiles.fileHash,
@@ -1147,7 +1149,7 @@ export class KoboSyncService {
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout); const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout);
const identity = identitiesById.get(row.bookId); const identity = identitiesById.get(row.bookId);
const coverImageId = identity const coverImageId = identity
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt) ? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt)
: String(row.bookId); : String(row.bookId);
return { return {
bookId: row.bookId, bookId: row.bookId,
@@ -1200,6 +1202,7 @@ export class KoboSyncService {
fileHash: schema.bookFiles.fileHash, fileHash: schema.bookFiles.fileHash,
isFixedLayout: schema.bookFiles.isFixedLayout, isFixedLayout: schema.bookFiles.isFixedLayout,
metadataUpdatedAt: schema.bookMetadata.updatedAt, metadataUpdatedAt: schema.bookMetadata.updatedAt,
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
addedAt: schema.books.addedAt, addedAt: schema.books.addedAt,
updatedAt: schema.books.updatedAt, updatedAt: schema.books.updatedAt,
}) })
@@ -1253,7 +1256,7 @@ export class KoboSyncService {
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout); const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout);
const identity = identitiesById.get(row.bookId); const identity = identitiesById.get(row.bookId);
if (!identity) continue; if (!identity) continue;
const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt); const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt);
const language = normalizeKoboLanguage(row.language); const language = normalizeKoboLanguage(row.language);
const isbn = selectKoboIsbn(row.isbn13, row.isbn10); const isbn = selectKoboIsbn(row.isbn13, row.isbn10);
byId.set(row.bookId, { byId.set(row.bookId, {
@@ -132,7 +132,7 @@ export class MissingResourcesRepository {
if (bookIds.length === 0) return 0; if (bookIds.length === 0) return 0;
const result = await this.db const result = await this.db
.update(bookMetadata) .update(bookMetadata)
.set({ coverSource: null, updatedAt: new Date() }) .set({ coverSource: null, coverUpdatedAt: new Date(), updatedAt: new Date() })
.where(and(isNotNull(bookMetadata.coverSource), inArray(bookMetadata.bookId, bookIds))); .where(and(isNotNull(bookMetadata.coverSource), inArray(bookMetadata.bookId, bookIds)));
return result.rowCount ?? 0; return result.rowCount ?? 0;
} }
@@ -272,6 +272,77 @@ describe('MetadataService', () => {
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/11/cover_extracted.png', Buffer.from('image-bytes')); expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/11/cover_extracted.png', Buffer.from('image-bytes'));
}); });
// The Kobo CoverImageId is versioned from coverUpdatedAt, so it must move whenever the served
// image does, and stay put when it does not (issue #943).
describe('coverUpdatedAt stamping', () => {
function stubEpubCoverExtraction(cover: Buffer): void {
mockExtractEpubMetadata.mockResolvedValueOnce({
title: 'Refreshable book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
coverBuffer: null,
});
mockExtractEpubCover.mockResolvedValueOnce(cover);
}
it('stamps coverUpdatedAt when an overwriting extraction replaces the cover', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
mockReaddir.mockResolvedValue(['cover_extracted.png']);
await service.saveExtractedCoverBytes(21, Buffer.from('image-bytes'));
expect(db.update).toHaveBeenCalledWith(bookMetadata);
expect(updateSet).toHaveBeenCalledWith({ coverUpdatedAt: expect.any(Date) });
});
it('stamps coverUpdatedAt when first-writer-wins matches no row but the image still changed', async () => {
const { db, updateSet, selectLimit } = makeDb();
const service = makeService(db);
selectLimit.mockResolvedValue([{ coverSource: 'extracted' }]);
mockReaddir.mockResolvedValue(['cover_extracted.png', 'thumbnail.jpg']);
stubEpubCoverExtraction(Buffer.from('fresher-image-bytes'));
await expect(service.refreshCoverForBook(23, '/book.epub', 'epub')).resolves.toBe(true);
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/23/cover_extracted.png', Buffer.from('fresher-image-bytes'));
expect(updateSet).toHaveBeenCalledWith({ coverUpdatedAt: expect.any(Date) });
});
it('leaves coverUpdatedAt alone when a DB-owned custom cover still wins', async () => {
const { db, updateSet, selectLimit } = makeDb();
const service = makeService(db);
selectLimit.mockResolvedValue([{ coverSource: 'custom' }]);
mockReaddir.mockResolvedValue(['cover_custom.jpg', 'cover_extracted.png', 'thumbnail.jpg']);
stubEpubCoverExtraction(Buffer.from('image-bytes'));
await expect(service.refreshCoverForBook(24, '/book.epub', 'epub')).resolves.toBe(true);
expect(updateSet).not.toHaveBeenCalledWith(expect.objectContaining({ coverUpdatedAt: expect.anything() }));
});
});
it('saveExtractedCoverBytes removes stale custom files unless the DB owns a custom cover', async () => { it('saveExtractedCoverBytes removes stale custom files unless the DB owns a custom cover', async () => {
const { db } = makeDb(); const { db } = makeDb();
const service = makeService(db); const service = makeService(db);
@@ -885,6 +885,9 @@ export class MetadataService {
} }
if (!preserveCustom) { if (!preserveCustom) {
// The extracted cover and its thumbnail were just rewritten, so the served image moved even
// when the first-writer-wins update above matched no row.
await this.db.update(bookMetadata).set({ coverUpdatedAt: now }).where(eq(bookMetadata.bookId, bookId));
await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId)); await this.db.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
} }
} }
@@ -3,7 +3,7 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { BookloreSourceAdapter } from './booklore-source.adapter'; import { BookloreSourceAdapter, parseBookloreSeriesIndex } from './booklore-source.adapter';
import { BOOKLORE_TABLES } from './booklore-tables'; import { BOOKLORE_TABLES } from './booklore-tables';
describe('BookloreSourceAdapter shelf export', () => { describe('BookloreSourceAdapter shelf export', () => {
@@ -1135,6 +1135,28 @@ describe('BookloreSourceAdapter exportData - minimal and edge cases', () => {
}); });
}); });
describe('parseBookloreSeriesIndex', () => {
it.each([
['2.50', '2.5'],
['2.00', '2'],
['0.50', '0.5'],
['2.100', '2.1'],
['1.25', '1.25'],
['2.105', '2.105'],
['3', '3'],
// A trailing zero outside a decimal is a real digit, not scale padding.
['10', '10'],
['100', '100'],
[' 2.50 ', '2.5'],
[2.5, '2.5'],
[null, null],
['', null],
['not-a-number', null],
])('normalizes %o to %o', (input, expected) => {
expect(parseBookloreSeriesIndex(input)).toBe(expected);
});
});
describe('BookloreSourceAdapter private method branches', () => { describe('BookloreSourceAdapter private method branches', () => {
it('fetchUserBookStatuses returns empty array when no progressTable', async () => { it('fetchUserBookStatuses returns empty array when no progressTable', async () => {
const connector = { queryRows: vi.fn(), listColumns: vi.fn() }; const connector = { queryRows: vi.fn(), listColumns: vi.fn() };
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { parseSeriesIndex } from '@bookorbit/types'; import { parseSeriesIndex, type SeriesIndex } from '@bookorbit/types';
import { constants as fsConstants } from 'fs'; import { constants as fsConstants } from 'fs';
import { access, stat } from 'fs/promises'; import { access, stat } from 'fs/promises';
import type mysql from 'mysql2/promise'; import type mysql from 'mysql2/promise';
@@ -527,7 +527,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
language: asString(row.language), language: asString(row.language),
pageCount: asInteger(row.pageCount), pageCount: asInteger(row.pageCount),
seriesName: asString(row.seriesName), seriesName: asString(row.seriesName),
seriesIndex: parseSeriesIndex(row.seriesIndex), seriesIndex: parseBookloreSeriesIndex(row.seriesIndex),
rating: asInteger(row.rating), rating: asInteger(row.rating),
googleBooksId: asString(row.googleBooksId), googleBooksId: asString(row.googleBooksId),
goodreadsId: asString(row.goodreadsId), goodreadsId: asString(row.goodreadsId),
@@ -1214,6 +1214,18 @@ function sqlString(alias: string, column: string | null, outName: string): strin
return `CAST(${alias}.\`${column}\` AS CHAR) AS ${outName}`; return `CAST(${alias}.\`${column}\` AS CHAR) AS ${outName}`;
} }
/**
* Booklore stores the series index in a `DECIMAL(10,2)` column, which the driver hands back at its
* full scale: 2.5 arrives as "2.50". The scale is storage formatting, not a label anyone authored,
* and keeping it sorts the book wrong, because the series sort index reads the fractional part as
* its own number and ranks 50 above 9. Trim it before the value becomes a series index.
*/
export function parseBookloreSeriesIndex(value: unknown): SeriesIndex | null {
const candidate = typeof value === 'string' ? value.trim() : value;
if (typeof candidate !== 'string' || !/^\d+\.\d+$/.test(candidate)) return parseSeriesIndex(candidate);
return parseSeriesIndex(candidate.replace(/0+$/, '').replace(/\.$/, ''));
}
function sqlNumber(alias: string, column: string | null, outName: string): string { function sqlNumber(alias: string, column: string | null, outName: string): string {
if (!column) return `NULL AS ${outName}`; if (!column) return `NULL AS ${outName}`;
return `${alias}.\`${column}\` AS ${outName}`; return `${alias}.\`${column}\` AS ${outName}`;
@@ -191,8 +191,8 @@ export class MigrationImportRepository {
const now = new Date(); const now = new Date();
await this.db await this.db
.insert(schema.bookMetadata) .insert(schema.bookMetadata)
.values({ bookId, coverSource: 'custom', updatedAt: now }) .values({ bookId, coverSource: 'custom', coverUpdatedAt: now, updatedAt: now })
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', updatedAt: now } }); .onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', coverUpdatedAt: now, updatedAt: now } });
} }
// --- User book statuses --- // --- User book statuses ---
+262
View File
@@ -0,0 +1,262 @@
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
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 BookMetadata = { EntitlementId: string; CoverImageId: string; Title: string | null; Publisher: { Name: string } | null };
type SyncEntry = { NewEntitlement?: { BookMetadata: BookMetadata }; ChangedProductMetadata?: { BookMetadata: BookMetadata } };
// A one pixel JPEG, so the cover on disk is real bytes rather than a placeholder string.
const COVER_BYTES = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
);
/**
* A Kobo stores a downloaded book's cover locally under the CoverImageId it was given, so an id
* that churns on unrelated metadata edits strands the device on the placeholder cover whenever it
* is offline. These tests pin the id to the cover itself. See issue #943.
*/
describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let userId!: number;
let bookId!: number;
let device!: KoboDevice;
let entitlementId!: string;
let coverIdHeldByDevice!: string;
async function sync(syncToken?: string): Promise<{ entries: SyncEntry[]; hasMore: boolean; syncToken: string }> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${device.token}/v1/library/sync`,
headers: syncToken ? { 'x-kobo-synctoken': syncToken } : undefined,
});
expect(response.statusCode).toBe(200);
return {
entries: response.json() as SyncEntry[],
hasMore: response.headers['x-kobo-sync'] === 'continue',
syncToken: response.headers['x-kobo-synctoken'] as string,
};
}
async function drainEntries(): Promise<SyncEntry[]> {
const delivered: SyncEntry[] = [];
let syncToken: string | undefined;
for (let page = 0; page < 10; page += 1) {
const response = await sync(syncToken);
delivered.push(...response.entries);
if (!response.hasMore) return delivered;
syncToken = response.syncToken;
}
throw new Error('sync did not finish within ten pages');
}
// Scoped to this book: the shared e2e database can still hold Kobo-eligible books from an
// earlier suite, and this one is not about how many entries a sync carries.
function deliveredMetadata(entries: SyncEntry[]): BookMetadata[] {
return entries.flatMap((entry) => {
const metadata = entry.NewEntitlement?.BookMetadata ?? entry.ChangedProductMetadata?.BookMetadata;
return metadata && metadata.EntitlementId === entitlementId ? [metadata] : [];
});
}
// A drained device is told nothing until something changes, so the id last delivered is the id
// the device is holding. Every test compares against that rather than re-syncing for it.
async function drainDelivered(): Promise<BookMetadata[]> {
const delivered = deliveredMetadata(await drainEntries());
const last = delivered[delivered.length - 1];
if (last) coverIdHeldByDevice = last.CoverImageId;
return delivered;
}
// A book only reports a metadata change once its snapshot row is settled, so quiet this book
// before each test. A sync is never entirely empty: a syncToKobo collection re-sends its
// ChangedTag every time, which is why this waits on the book rather than on the whole payload.
async function settleDevice(): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const delivered = await drainDelivered();
if (delivered.length === 0) return;
}
throw new Error('device never settled: the book kept reporting changes');
}
async function thumbnailStatus(coverImageId: string): Promise<number> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${device.token}/v1/books/${coverImageId}/thumbnail/400/600/false/image.jpg`,
});
return response.statusCode;
}
async function editMetadata(payload: Record<string, unknown>): Promise<void> {
const response = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata`,
headers: authHeader(ctx.adminToken),
payload,
});
expect(response.statusCode).toBe(200);
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `kobo-cover-identity-${randomUUID()}` });
const path = await createEpubFixture(library.folderPath, 'kobo-cover-identity.epub', {
title: 'Kobo Cover Identity',
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
bookId = (await locateBookByAbsolutePath(ctx, path)).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 coverDir = join(process.env.APP_DATA_PATH!, 'covers', String(bookId));
await mkdir(coverDir, { recursive: true });
await writeFile(join(coverDir, 'cover_custom.jpg'), COVER_BYTES);
await ctx.db.update(schema.bookMetadata).set({ coverSource: 'custom', coverUpdatedAt: new Date() }).where(eq(schema.bookMetadata.bookId, bookId));
const collectionResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(ctx.adminToken),
payload: { name: `Kobo Cover Identity ${randomUUID().slice(0, 8)}`, icon: 'book', syncToKobo: true },
});
expect([200, 201]).toContain(collectionResponse.statusCode);
const addBooksResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${(collectionResponse.json() as { id: number }).id}/books`,
headers: authHeader(ctx.adminToken),
payload: { bookIds: [bookId] },
});
expect([200, 201]).toContain(addBooksResponse.statusCode);
const deviceResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/kobo/devices',
headers: authHeader(ctx.adminToken),
payload: { name: 'Kobo Cover Identity' },
});
expect([200, 201]).toContain(deviceResponse.statusCode);
device = deviceResponse.json() as KoboDevice;
// The entitlement row is minted lazily by the first sync, so drain first and resolve after.
const initialEntries = await drainEntries();
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)));
entitlementId = identity!.entitlementId;
const initial = deliveredMetadata(initialEntries);
expect(initial).toHaveLength(1);
coverIdHeldByDevice = initial[0]!.CoverImageId;
await settleDevice();
}, 180_000);
beforeEach(async () => {
await settleDevice();
});
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('keeps the CoverImageId across a metadata edit while still delivering the change', async () => {
const coverIdBefore = coverIdHeldByDevice;
expect(await thumbnailStatus(coverIdBefore)).toBe(200);
await editMetadata({ title: 'Kobo Cover Identity, Revised', publisher: 'Repro Press' });
const delivered = await drainDelivered();
expect(delivered).toHaveLength(1);
// The edit still reaches the device; only the cover identity holds still.
expect(delivered[0]!.Title).toBe('Kobo Cover Identity, Revised');
expect(delivered[0]!.Publisher?.Name).toBe('Repro Press');
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
expect(await thumbnailStatus(coverIdBefore)).toBe(200);
});
it('keeps the CoverImageId across a series edit', async () => {
const coverIdBefore = coverIdHeldByDevice;
await editMetadata({ seriesName: 'Repro Cycle', seriesIndex: '2' });
const delivered = await drainDelivered();
expect(delivered).toHaveLength(1);
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
});
it('keeps the CoverImageId across a derived write no user ever made', async () => {
const coverIdBefore = coverIdHeldByDevice;
const [before] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
// The shape of BookEmbedderRepository.saveEmbedding and updateMetadataScore: one derived
// column, no mention of updatedAt, which $onUpdateFn bumps anyway.
await ctx.db.update(schema.bookMetadata).set({ metadataScore: 42 }).where(eq(schema.bookMetadata.bookId, bookId));
const [after] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt, updatedAt: schema.bookMetadata.updatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
expect(after!.updatedAt.getTime()).toBeGreaterThan(before!.coverUpdatedAt!.getTime());
expect(after!.coverUpdatedAt!.getTime()).toBe(before!.coverUpdatedAt!.getTime());
// And the id the device is actually handed on the next delivery is still the same one.
await editMetadata({ title: 'Kobo Cover Identity, Rescored' });
const delivered = await drainDelivered();
expect(delivered).toHaveLength(1);
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
});
it('still resolves cover ids a device was handed before the id scheme settled', async () => {
const [identity] = await ctx.db
.select({ coverImageId: schema.koboBookEntitlements.coverImageId })
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookId)));
expect(await thumbnailStatus(identity!.coverImageId)).toBe(200);
expect(await thumbnailStatus(`${identity!.coverImageId}_1700000000000`)).toBe(200);
});
it('changes the CoverImageId when the cover itself changes', async () => {
const coverIdBefore = coverIdHeldByDevice;
const deleteResponse = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${bookId}/cover`,
headers: authHeader(ctx.adminToken),
});
expect(deleteResponse.statusCode).toBe(200);
const delivered = await drainDelivered();
expect(delivered).toHaveLength(1);
expect(delivered[0]!.CoverImageId).not.toBe(coverIdBefore);
const [row] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
expect(delivered[0]!.CoverImageId).toBe(`${delivered[0]!.CoverImageId.split('_')[0]}_${row!.coverUpdatedAt!.getTime()}`);
});
});
+2 -2
View File
@@ -527,7 +527,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
language: 'en', language: 'en',
pageCount: 321, pageCount: 321,
seriesName: 'Series Prime', seriesName: 'Series Prime',
seriesIndex: 2.5, seriesIndex: '2.5',
rating: 5, rating: 5,
googleBooksId: 'google-101', googleBooksId: 'google-101',
goodreadsId: 'gr-101', goodreadsId: 'gr-101',
@@ -971,7 +971,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
language: 'en', language: 'en',
pageCount: 222, pageCount: 222,
seriesName: 'Variant Series', seriesName: 'Variant Series',
seriesIndex: 1.25, seriesIndex: '1.25',
rating: 4, rating: 4,
googleBooksId: 'variant-google-id', googleBooksId: 'variant-google-id',
durationSeconds: 987, durationSeconds: 987,