mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -707,6 +707,25 @@ export const E2E_SUITES = Object.freeze({
|
||||
...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": {
|
||||
id: "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,
|
||||
"tag": "0079_add_annotations_user_created_active_idx",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 80,
|
||||
"version": "7",
|
||||
"when": 1787850415027,
|
||||
"tag": "0080_add_book_metadata_cover_updated_at",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ export const bookMetadata = pgTable(
|
||||
.array()
|
||||
.notNull()
|
||||
.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 })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
|
||||
@@ -132,7 +132,7 @@ function createMutationService(options?: { assertFieldsUnlocked?: ReturnType<typ
|
||||
scoreService as never,
|
||||
);
|
||||
|
||||
return { service, mockDb, updateSet, scoreService };
|
||||
return { service, mockDb, updateSet, scoreService, values, onConflictDoUpdate };
|
||||
}
|
||||
|
||||
describe('CoverService', () => {
|
||||
@@ -611,4 +611,59 @@ describe('CoverService', () => {
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,8 +298,8 @@ export class CoverService {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.insert(bookMetadata)
|
||||
.values({ bookId, coverSource: source, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } });
|
||||
.values({ bookId, coverSource: source, coverUpdatedAt: now, 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.metadataScoreService.calculateAndSave(bookId);
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,7 @@ export class KoboSyncService {
|
||||
seriesName: schema.bookMetadata.seriesName,
|
||||
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
|
||||
fileFormat: schema.bookFiles.format,
|
||||
fileSizeBytes: schema.bookFiles.sizeBytes,
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
@@ -1126,6 +1127,7 @@ export class KoboSyncService {
|
||||
schema.bookMetadata.seriesName,
|
||||
schema.bookMetadata.seriesIndex,
|
||||
schema.bookMetadata.updatedAt,
|
||||
schema.bookMetadata.coverUpdatedAt,
|
||||
schema.bookFiles.format,
|
||||
schema.bookFiles.sizeBytes,
|
||||
schema.bookFiles.fileHash,
|
||||
@@ -1147,7 +1149,7 @@ export class KoboSyncService {
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout);
|
||||
const identity = identitiesById.get(row.bookId);
|
||||
const coverImageId = identity
|
||||
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.metadataUpdatedAt)
|
||||
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt)
|
||||
: String(row.bookId);
|
||||
return {
|
||||
bookId: row.bookId,
|
||||
@@ -1200,6 +1202,7 @@ export class KoboSyncService {
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
isFixedLayout: schema.bookFiles.isFixedLayout,
|
||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
|
||||
addedAt: schema.books.addedAt,
|
||||
updatedAt: schema.books.updatedAt,
|
||||
})
|
||||
@@ -1253,7 +1256,7 @@ export class KoboSyncService {
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout);
|
||||
const identity = identitiesById.get(row.bookId);
|
||||
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 isbn = selectKoboIsbn(row.isbn13, row.isbn10);
|
||||
byId.set(row.bookId, {
|
||||
|
||||
@@ -132,7 +132,7 @@ export class MissingResourcesRepository {
|
||||
if (bookIds.length === 0) return 0;
|
||||
const result = await this.db
|
||||
.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)));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
@@ -272,6 +272,77 @@ describe('MetadataService', () => {
|
||||
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 () => {
|
||||
const { db } = makeDb();
|
||||
const service = makeService(db);
|
||||
|
||||
@@ -885,6 +885,9 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
it('fetchUserBookStatuses returns empty array when no progressTable', async () => {
|
||||
const connector = { queryRows: vi.fn(), listColumns: vi.fn() };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { parseSeriesIndex } from '@bookorbit/types';
|
||||
import { parseSeriesIndex, type SeriesIndex } from '@bookorbit/types';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { access, stat } from 'fs/promises';
|
||||
import type mysql from 'mysql2/promise';
|
||||
@@ -527,7 +527,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
|
||||
language: asString(row.language),
|
||||
pageCount: asInteger(row.pageCount),
|
||||
seriesName: asString(row.seriesName),
|
||||
seriesIndex: parseSeriesIndex(row.seriesIndex),
|
||||
seriesIndex: parseBookloreSeriesIndex(row.seriesIndex),
|
||||
rating: asInteger(row.rating),
|
||||
googleBooksId: asString(row.googleBooksId),
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!column) return `NULL AS ${outName}`;
|
||||
return `${alias}.\`${column}\` AS ${outName}`;
|
||||
|
||||
@@ -191,8 +191,8 @@ export class MigrationImportRepository {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.insert(schema.bookMetadata)
|
||||
.values({ bookId, coverSource: 'custom', updatedAt: now })
|
||||
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', updatedAt: now } });
|
||||
.values({ bookId, coverSource: 'custom', coverUpdatedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom', coverUpdatedAt: now, updatedAt: now } });
|
||||
}
|
||||
|
||||
// --- User book statuses ---
|
||||
|
||||
@@ -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()}`);
|
||||
});
|
||||
});
|
||||
@@ -527,7 +527,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
|
||||
language: 'en',
|
||||
pageCount: 321,
|
||||
seriesName: 'Series Prime',
|
||||
seriesIndex: 2.5,
|
||||
seriesIndex: '2.5',
|
||||
rating: 5,
|
||||
googleBooksId: 'google-101',
|
||||
goodreadsId: 'gr-101',
|
||||
@@ -971,7 +971,7 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
|
||||
language: 'en',
|
||||
pageCount: 222,
|
||||
seriesName: 'Variant Series',
|
||||
seriesIndex: 1.25,
|
||||
seriesIndex: '1.25',
|
||||
rating: 4,
|
||||
googleBooksId: 'variant-google-id',
|
||||
durationSeconds: 987,
|
||||
|
||||
Reference in New Issue
Block a user