fix(kobo): deliver a changed cover to a Kobo that already has the book

A Kobo downloads a cover only for a new entitlement, so a changed cover
sent as changed metadata left the book with a text-only cover until the
home screen fetched a small tile. Record the cover id on each device
snapshot row and re-announce the book when it moves; rows from before
the change learn it silently. Serve tile-size requests at a full-screen
size so the sleep screen never stretches a tile.
This commit is contained in:
neon
2026-10-17 04:03:34 +00:00
parent 2dca4bc85c
commit edfa20e19f
9 changed files with 24159 additions and 24 deletions
@@ -0,0 +1 @@
ALTER TABLE "kobo_device_snapshot_books" ADD COLUMN "cover_image_id" varchar(128);
File diff suppressed because it is too large Load Diff
@@ -841,6 +841,13 @@
"when": 1792206724086, "when": 1792206724086,
"tag": "0119_add_kobo_full_size_covers", "tag": "0119_add_kobo_full_size_covers",
"breakpoints": true "breakpoints": true
},
{
"idx": 120,
"version": "7",
"when": 1792209194389,
"tag": "0120_record_kobo_snapshot_cover_ids",
"breakpoints": true
} }
] ]
} }
+3
View File
@@ -157,6 +157,9 @@ export const koboSnapshotBooks = pgTable(
fileHash: varchar('file_hash', { length: 64 }), fileHash: varchar('file_hash', { length: 64 }),
deliveryHash: varchar('delivery_hash', { length: 64 }), deliveryHash: varchar('delivery_hash', { length: 64 }),
metadataHash: varchar('metadata_hash', { length: 64 }), metadataHash: varchar('metadata_hash', { length: 64 }),
// The versioned cover id the device was last told about. A Kobo downloads a cover only for a
// NewEntitlement, so a moved id re-announces the book as one instead of changed metadata.
coverImageId: varchar('cover_image_id', { length: 128 }),
}, },
(t) => [ (t) => [
primaryKey({ columns: [t.snapshotId, t.bookId] }), primaryKey({ columns: [t.snapshotId, t.bookId] }),
@@ -831,15 +831,15 @@ describe('KoboSyncService', () => {
return []; return [];
} }
it('issues only CREATE TEMP and 8 maintenance queries when eligibleBooks is empty', async () => { it('issues only CREATE TEMP and 9 maintenance queries when eligibleBooks is empty', async () => {
const { fn: txExecute } = makeTxExecute(); const { fn: txExecute } = makeTxExecute();
const db = makeReconcileDb(txExecute); const db = makeReconcileDb(txExecute);
const service = makeService(db); const service = makeService(db);
await (service as any).reconcileSnapshot(42, []); await (service as any).reconcileSnapshot(42, []);
// CREATE TEMP + 8 maintenance queries (no batch insert when list is empty) // CREATE TEMP + 9 maintenance queries (no batch insert when list is empty)
expect(txExecute).toHaveBeenCalledTimes(9); expect(txExecute).toHaveBeenCalledTimes(10);
}); });
it('issues one batch INSERT when eligibleBooks fits in a single batch', async () => { it('issues one batch INSERT when eligibleBooks fits in a single batch', async () => {
@@ -852,8 +852,8 @@ describe('KoboSyncService', () => {
{ bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2' }, { bookId: 2, fileHash: null, deliveryHash: 'd2', metadataHash: 'm2' },
]); ]);
// CREATE TEMP + 1 batch INSERT + 8 maintenance queries // CREATE TEMP + 1 batch INSERT + 9 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(10); expect(txExecute).toHaveBeenCalledTimes(11);
const batchInsert = captured[1]; const batchInsert = captured[1];
const sqlStrings = extractSqlStrings(batchInsert); const sqlStrings = extractSqlStrings(batchInsert);
@@ -861,12 +861,14 @@ describe('KoboSyncService', () => {
expect(sqlStrings.some((s) => s.includes('unnest'))).toBe(false); expect(sqlStrings.some((s) => s.includes('unnest'))).toBe(false);
}); });
it('passes correct bookId, fileHash, deliveryHash, and metadataHash as individual params in VALUES rows', async () => { it('passes correct bookId, fileHash, deliveryHash, metadataHash, and coverImageId as individual params in VALUES rows', async () => {
const { fn: txExecute, captured } = makeTxExecute(); const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute); const db = makeReconcileDb(txExecute);
const service = makeService(db); const service = makeService(db);
await (service as any).reconcileSnapshot(5, [{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz' }]); await (service as any).reconcileSnapshot(5, [
{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz', coverImageId: 'cover-7_1700000000000' },
]);
const batchInsert = captured[1]; const batchInsert = captured[1];
const params = extractSqlParams(batchInsert); const params = extractSqlParams(batchInsert);
@@ -874,6 +876,7 @@ describe('KoboSyncService', () => {
expect(params).toContain('abc'); expect(params).toContain('abc');
expect(params).toContain('delivery'); expect(params).toContain('delivery');
expect(params).toContain('xyz'); expect(params).toContain('xyz');
expect(params).toContain('cover-7_1700000000000');
}); });
it('passes null fileHash correctly in VALUES rows', async () => { it('passes null fileHash correctly in VALUES rows', async () => {
@@ -903,8 +906,8 @@ describe('KoboSyncService', () => {
})); }));
await (service as any).reconcileSnapshot(99, eligible); await (service as any).reconcileSnapshot(99, eligible);
// CREATE TEMP + 2 batch INSERTs + 8 maintenance queries // CREATE TEMP + 2 batch INSERTs + 9 maintenance queries
expect(txExecute).toHaveBeenCalledTimes(11); expect(txExecute).toHaveBeenCalledTimes(12);
}); });
it('includes snapshotId as a param in all maintenance queries', async () => { it('includes snapshotId as a param in all maintenance queries', async () => {
@@ -915,8 +918,8 @@ describe('KoboSyncService', () => {
const snapshotId = 77; const snapshotId = 77;
await (service as any).reconcileSnapshot(snapshotId, [{ bookId: 1, fileHash: 'f', deliveryHash: 'd', metadataHash: 'm' }]); await (service as any).reconcileSnapshot(snapshotId, [{ bookId: 1, fileHash: 'f', deliveryHash: 'd', metadataHash: 'm' }]);
// Statements at index 2-9 are the 8 maintenance queries // Statements at index 2-10 are the 9 maintenance queries
const maintenanceStmts = captured.slice(2, 10); const maintenanceStmts = captured.slice(2, 11);
for (const stmt of maintenanceStmts) { for (const stmt of maintenanceStmts) {
const params = extractSqlParams(stmt); const params = extractSqlParams(stmt);
expect(params).toContain(snapshotId); expect(params).toContain(snapshotId);
@@ -987,7 +990,7 @@ describe('KoboSyncService', () => {
expect(deleteStmt).toBeDefined(); expect(deleteStmt).toBeDefined();
}); });
it('marks delivery changes as new entitlements and metadata-only changes as metadata updates', async () => { it('marks delivery and cover changes as new entitlements and other metadata changes as metadata updates', async () => {
const { fn: txExecute, captured } = makeTxExecute(); const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute); const db = makeReconcileDb(txExecute);
const service = makeService(db); const service = makeService(db);
@@ -1007,9 +1010,29 @@ describe('KoboSyncService', () => {
expect(extractSqlStrings(deliveryStmt).join(' ')).toContain('is_new = true'); expect(extractSqlStrings(deliveryStmt).join(' ')).toContain('is_new = true');
expect(metadataStmt).toBeDefined(); expect(metadataStmt).toBeDefined();
const metadataSql = extractSqlStrings(metadataStmt).join(' '); const metadataSql = extractSqlStrings(metadataStmt).join(' ');
expect(metadataSql).toContain('is_new = false'); // A Kobo downloads a cover only for a new entitlement, so a moved cover id must re-announce the book.
expect(metadataSql).toContain('is_new = sb.cover_image_id IS NOT NULL AND sb.cover_image_id IS DISTINCT FROM e.cover_image_id');
expect(metadataSql).toContain('cover_image_id = e.cover_image_id');
expect(metadataSql).toContain('sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash'); expect(metadataSql).toContain('sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash');
}); });
it('records cover ids on rows reconciled before they existed without re-announcing the book', async () => {
const { fn: txExecute, captured } = makeTxExecute();
const db = makeReconcileDb(txExecute);
const service = makeService(db);
await (service as any).reconcileSnapshot(5, [
{ bookId: 7, fileHash: 'abc', deliveryHash: 'delivery', metadataHash: 'xyz', coverImageId: 'cover-7' },
]);
const backfill = captured.find((stmt) => extractSqlStrings(stmt).join(' ').includes('sb.cover_image_id IS NULL'));
expect(backfill).toBeDefined();
const backfillSql = extractSqlStrings(backfill).join(' ');
expect(backfillSql).toContain('SET cover_image_id = e.cover_image_id');
expect(backfillSql).not.toContain('is_new');
expect(backfillSql).not.toContain('synced');
expect(captured.indexOf(backfill)).toBe(captured.length - 1);
});
}); });
it('buildMetadataHash covers normalized language, ISBN, identity, cover, and serializer changes', () => { it('buildMetadataHash covers normalized language, ISBN, identity, cover, and serializer changes', () => {
@@ -1361,6 +1384,7 @@ describe('KoboSyncService', () => {
bookId: 5, bookId: 5,
fileHash: 'file-hash', fileHash: 'file-hash',
deliveryHash: expect.any(String), deliveryHash: expect.any(String),
coverImageId: 'cover-5',
metadataHash: expect.any(String), metadataHash: expect.any(String),
needsLegacyNumericRemoval: true, needsLegacyNumericRemoval: true,
}, },
@@ -42,6 +42,7 @@ type EligibleSnapshotRow = {
fileHash: string | null; fileHash: string | null;
deliveryHash: string; deliveryHash: string;
metadataHash: string; metadataHash: string;
coverImageId: string;
needsLegacyNumericRemoval?: boolean; needsLegacyNumericRemoval?: boolean;
}; };
@@ -55,6 +56,7 @@ type SnapshotSeedRow = {
fileHash: string | null; fileHash: string | null;
deliveryHash: string | null; deliveryHash: string | null;
metadataHash: string | null; metadataHash: string | null;
coverImageId: string | null;
}; };
type Snapshot = typeof schema.koboLibrarySnapshots.$inferSelect; type Snapshot = typeof schema.koboLibrarySnapshots.$inferSelect;
@@ -376,6 +378,7 @@ export class KoboSyncService {
fileHash: book.fileHash, fileHash: book.fileHash,
deliveryHash: book.deliveryHash, deliveryHash: book.deliveryHash,
metadataHash: book.metadataHash, metadataHash: book.metadataHash,
coverImageId: book.coverImageId,
})), })),
]; ];
@@ -412,6 +415,7 @@ export class KoboSyncService {
fileHash: null, fileHash: null,
deliveryHash: null, deliveryHash: null,
metadataHash: null, metadataHash: null,
coverImageId: null,
})), })),
) )
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -451,6 +455,7 @@ export class KoboSyncService {
file_hash varchar(64), file_hash varchar(64),
delivery_hash varchar(64) NOT NULL, delivery_hash varchar(64) NOT NULL,
metadata_hash varchar(64) NOT NULL, metadata_hash varchar(64) NOT NULL,
cover_image_id varchar(128) NOT NULL,
needs_legacy_numeric_removal boolean NOT NULL needs_legacy_numeric_removal boolean NOT NULL
) ON COMMIT DROP ) ON COMMIT DROP
`); `);
@@ -458,18 +463,22 @@ export class KoboSyncService {
for (let index = 0; index < eligibleBooks.length; index += SNAPSHOT_RECONCILE_BATCH_SIZE) { for (let index = 0; index < eligibleBooks.length; index += SNAPSHOT_RECONCILE_BATCH_SIZE) {
const chunk = eligibleBooks.slice(index, index + SNAPSHOT_RECONCILE_BATCH_SIZE); const chunk = eligibleBooks.slice(index, index + SNAPSHOT_RECONCILE_BATCH_SIZE);
const valueRows = sql.join( const valueRows = sql.join(
chunk.map((b) => sql`(${b.bookId}, ${b.fileHash}, ${b.deliveryHash}, ${b.metadataHash}, ${b.needsLegacyNumericRemoval ?? false})`), chunk.map(
(b) =>
sql`(${b.bookId}, ${b.fileHash}, ${b.deliveryHash}, ${b.metadataHash}, ${b.coverImageId}, ${b.needsLegacyNumericRemoval ?? false})`,
),
sql`, `, sql`, `,
); );
await tx.execute(sql` await tx.execute(sql`
INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, delivery_hash, metadata_hash, needs_legacy_numeric_removal) INSERT INTO kobo_eligible_books_tmp (book_id, file_hash, delivery_hash, metadata_hash, cover_image_id, needs_legacy_numeric_removal)
VALUES ${valueRows} VALUES ${valueRows}
ON CONFLICT (book_id) ON CONFLICT (book_id)
DO UPDATE DO UPDATE
SET file_hash = EXCLUDED.file_hash, SET file_hash = EXCLUDED.file_hash,
delivery_hash = EXCLUDED.delivery_hash, delivery_hash = EXCLUDED.delivery_hash,
metadata_hash = EXCLUDED.metadata_hash, metadata_hash = EXCLUDED.metadata_hash,
cover_image_id = EXCLUDED.cover_image_id,
needs_legacy_numeric_removal = EXCLUDED.needs_legacy_numeric_removal needs_legacy_numeric_removal = EXCLUDED.needs_legacy_numeric_removal
`); `);
} }
@@ -495,8 +504,8 @@ export class KoboSyncService {
await tx.execute(sql` await tx.execute(sql`
INSERT INTO ${schema.koboSnapshotBooks} INSERT INTO ${schema.koboSnapshotBooks}
(snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, needs_legacy_numeric_removal, file_hash, delivery_hash, metadata_hash) (snapshot_id, book_id, synced, pending_delete, is_new, removed_by_device, needs_legacy_numeric_removal, file_hash, delivery_hash, metadata_hash, cover_image_id)
SELECT ${snapshotId}, e.book_id, false, false, true, false, e.needs_legacy_numeric_removal, e.file_hash, e.delivery_hash, e.metadata_hash SELECT ${snapshotId}, e.book_id, false, false, true, false, e.needs_legacy_numeric_removal, e.file_hash, e.delivery_hash, e.metadata_hash, e.cover_image_id
FROM kobo_eligible_books_tmp e FROM kobo_eligible_books_tmp e
LEFT JOIN ${schema.koboSnapshotBooks} sb LEFT JOIN ${schema.koboSnapshotBooks} sb
ON sb.snapshot_id = ${snapshotId} ON sb.snapshot_id = ${snapshotId}
@@ -511,7 +520,8 @@ export class KoboSyncService {
is_new = true, is_new = true,
file_hash = e.file_hash, file_hash = e.file_hash,
delivery_hash = e.delivery_hash, delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash metadata_hash = e.metadata_hash,
cover_image_id = e.cover_image_id
FROM kobo_eligible_books_tmp e FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId} WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id AND sb.book_id = e.book_id
@@ -526,7 +536,8 @@ export class KoboSyncService {
is_new = true, is_new = true,
file_hash = e.file_hash, file_hash = e.file_hash,
delivery_hash = e.delivery_hash, delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash metadata_hash = e.metadata_hash,
cover_image_id = e.cover_image_id
FROM kobo_eligible_books_tmp e FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId} WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id AND sb.book_id = e.book_id
@@ -553,7 +564,8 @@ export class KoboSyncService {
is_new = true, is_new = true,
file_hash = e.file_hash, file_hash = e.file_hash,
delivery_hash = e.delivery_hash, delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash metadata_hash = e.metadata_hash,
cover_image_id = e.cover_image_id
FROM kobo_eligible_books_tmp e FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId} WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id AND sb.book_id = e.book_id
@@ -566,10 +578,11 @@ export class KoboSyncService {
await tx.execute(sql` await tx.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb UPDATE ${schema.koboSnapshotBooks} AS sb
SET synced = false, SET synced = false,
is_new = false, is_new = sb.cover_image_id IS NOT NULL AND sb.cover_image_id IS DISTINCT FROM e.cover_image_id,
file_hash = e.file_hash, file_hash = e.file_hash,
delivery_hash = e.delivery_hash, delivery_hash = e.delivery_hash,
metadata_hash = e.metadata_hash metadata_hash = e.metadata_hash,
cover_image_id = e.cover_image_id
FROM kobo_eligible_books_tmp e FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId} WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id AND sb.book_id = e.book_id
@@ -580,6 +593,17 @@ export class KoboSyncService {
AND sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash AND sb.delivery_hash IS NOT DISTINCT FROM e.delivery_hash
AND sb.metadata_hash IS DISTINCT FROM e.metadata_hash AND sb.metadata_hash IS DISTINCT FROM e.metadata_hash
`); `);
// Rows reconciled before cover ids were recorded learn the current one silently, so an
// upgrade does not re-announce every book and send each device after every cover again.
await tx.execute(sql`
UPDATE ${schema.koboSnapshotBooks} AS sb
SET cover_image_id = e.cover_image_id
FROM kobo_eligible_books_tmp e
WHERE sb.snapshot_id = ${snapshotId}
AND sb.book_id = e.book_id
AND sb.cover_image_id IS NULL
`);
}); });
} }
@@ -1257,6 +1281,7 @@ export class KoboSyncService {
bookId: row.bookId, bookId: row.bookId,
fileHash: row.fileHash, fileHash: row.fileHash,
deliveryHash: delivery.hash, deliveryHash: delivery.hash,
coverImageId,
metadataHash: this.buildMetadataHash({ metadataHash: this.buildMetadataHash({
title: row.title, title: row.title,
authors: row.authorNamesCsv ? row.authorNamesCsv.split(',').filter((name) => name.length > 0) : [], authors: row.authorNamesCsv ? row.authorNamesCsv.split(',').filter((name) => name.length > 0) : [],
@@ -172,6 +172,17 @@ describe('KoboThumbnailService', () => {
expect(createReadStreamMock).not.toHaveBeenCalled(); expect(createReadStreamMock).not.toHaveBeenCalled();
}); });
it('serves a tile-size request at a full-screen size, so the sleep screen never stretches a tile', async () => {
resolveCoverAt(await writeCover(1600, 2400));
statMock.mockResolvedValueOnce({ mtimeMs: 4321 } as never);
const reply = makeReply();
await service.serveThumbnail(7, 12, { width: 355, height: 530, quality: 80, greyscale: false }, undefined, reply as never);
await expect(sharp(reply.send.mock.calls[0][0] as Buffer).metadata()).resolves.toMatchObject({ width: 1280, height: 1920 });
expect(reply.header).toHaveBeenCalledWith('ETag', '"4321-1440x1920-q80"');
});
it('never enlarges a cover smaller than the box', async () => { it('never enlarges a cover smaller than the box', async () => {
resolveCoverAt(await writeCover(300, 450)); resolveCoverAt(await writeCover(300, 450));
statMock.mockResolvedValueOnce({ mtimeMs: 1 } as never); statMock.mockResolvedValueOnce({ mtimeMs: 1 } as never);
@@ -18,6 +18,8 @@ export interface KoboCoverRequest {
} }
const MAX_COVER_EDGE = 2400; const MAX_COVER_EDGE = 2400;
const SMALLEST_KOBO_SCREEN = { width: 600, height: 800 };
const FULL_SCREEN_BOX = { width: 1440, height: 1920 };
const DEFAULT_COVER_QUALITY = 85; const DEFAULT_COVER_QUALITY = 85;
const MAX_INPUT_PIXELS = 100_000_000; const MAX_INPUT_PIXELS = 100_000_000;
const RENDER_CONCURRENCY = 2; const RENDER_CONCURRENCY = 2;
@@ -35,6 +37,15 @@ export function parseKoboCoverRequest(width: string, height: string, quality?: s
}; };
} }
/**
* A Kobo fetching a changed cover asks only for its home-screen tile, and its sleep screen then
* stretches that tile. A box smaller than any Kobo screen is served at a size that fills every one.
*/
function coverBox(request: KoboCoverRequest): KoboCoverRequest {
if (request.width >= SMALLEST_KOBO_SCREEN.width || request.height >= SMALLEST_KOBO_SCREEN.height) return request;
return { ...request, ...FULL_SCREEN_BOX };
}
function parseEdge(value: string): number | null { function parseEdge(value: string): number | null {
if (!/^\d{1,5}$/.test(value)) return null; if (!/^\d{1,5}$/.test(value)) return null;
const edge = Number(value); const edge = Number(value);
@@ -102,7 +113,8 @@ export class KoboThumbnailService {
* screen or full-screen cover is not the grid thumbnail stretched. Resolves false when there is * screen or full-screen cover is not the grid thumbnail stretched. Resolves false when there is
* no cover to scale, leaving the thumbnail to answer. * no cover to scale, leaving the thumbnail to answer.
*/ */
private async serveSizedCover(bookId: number, request: KoboCoverRequest, ifNoneMatch: string | undefined, reply: FastifyReply): Promise<boolean> { private async serveSizedCover(bookId: number, requested: KoboCoverRequest, ifNoneMatch: string | undefined, reply: FastifyReply): Promise<boolean> {
const request = coverBox(requested);
const coverPath = await this.coverStore.resolve(bookId, { medium: 'ebook', variant: 'cover' }); const coverPath = await this.coverStore.resolve(bookId, { medium: 'ebook', variant: 'cover' });
if (!coverPath) return false; if (!coverPath) return false;
let mtimeMs: number; let mtimeMs: number;
+9 -1
View File
@@ -40,6 +40,7 @@ describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
let device!: KoboDevice; let device!: KoboDevice;
let entitlementId!: string; let entitlementId!: string;
let coverIdHeldByDevice!: string; let coverIdHeldByDevice!: string;
let lastDeliveredAs: Array<keyof SyncEntry> = [];
async function sync(syncToken?: string): Promise<{ entries: SyncEntry[]; hasMore: boolean; syncToken: string }> { async function sync(syncToken?: string): Promise<{ entries: SyncEntry[]; hasMore: boolean; syncToken: string }> {
const response = await ctx.app.inject({ const response = await ctx.app.inject({
@@ -79,7 +80,11 @@ describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
// A drained device is told nothing until something changes, so the id last delivered is the id // 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. // the device is holding. Every test compares against that rather than re-syncing for it.
async function drainDelivered(): Promise<BookMetadata[]> { async function drainDelivered(): Promise<BookMetadata[]> {
const delivered = deliveredMetadata(await drainEntries()); const entries = await drainEntries();
const delivered = deliveredMetadata(entries);
lastDeliveredAs = entries.flatMap((entry) =>
(['NewEntitlement', 'ChangedProductMetadata'] as const).filter((kind) => entry[kind]?.BookMetadata.EntitlementId === entitlementId),
);
const last = delivered[delivered.length - 1]; const last = delivered[delivered.length - 1];
if (last) coverIdHeldByDevice = last.CoverImageId; if (last) coverIdHeldByDevice = last.CoverImageId;
return delivered; return delivered;
@@ -216,6 +221,7 @@ describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
expect(delivered[0]!.Title).toBe('Kobo Cover Identity, Revised'); expect(delivered[0]!.Title).toBe('Kobo Cover Identity, Revised');
expect(delivered[0]!.Publisher?.Name).toBe('Repro Press'); expect(delivered[0]!.Publisher?.Name).toBe('Repro Press');
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore); expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
expect(lastDeliveredAs).toEqual(['ChangedProductMetadata']);
expect(await thumbnailStatus(coverIdBefore)).toBe(200); expect(await thumbnailStatus(coverIdBefore)).toBe(200);
}); });
@@ -277,6 +283,8 @@ describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
const delivered = await drainDelivered(); const delivered = await drainDelivered();
expect(delivered).toHaveLength(1); expect(delivered).toHaveLength(1);
expect(delivered[0]!.CoverImageId).not.toBe(coverIdBefore); expect(delivered[0]!.CoverImageId).not.toBe(coverIdBefore);
// A Kobo downloads a cover only for a new entitlement; as changed metadata it would show text.
expect(lastDeliveredAs).toEqual(['NewEntitlement']);
const [row] = await ctx.db const [row] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt }) .select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })