fix(server): resolve numeric-prefixed Hardcover slugs

Require the complete metadata value to be a valid GraphQL integer before using an ID lookup. Keep slug matching and sync change detection consistent through one parser.

Fixes #1243
This commit is contained in:
neon
2026-09-02 00:48:55 +00:00
parent 19afacb0f9
commit 43a7c671d9
7 changed files with 113 additions and 16 deletions
@@ -144,6 +144,7 @@ describe('HardcoverBookMatchService', () => {
const result = await makeService().matchBook(1, 'tok', book);
expect(result?.matchMethod).toBe('metadata_id');
expect(result?.hardcoverBookId).toBe(77);
expect(mockClient.query).toHaveBeenCalledWith(1, 'tok', expect.stringContaining('query FindBookById'), { id: 77 });
});
it('matches by metadata slug when hardcoverMetadataId is not numeric', async () => {
@@ -168,6 +169,42 @@ describe('HardcoverBookMatchService', () => {
);
});
it('treats a digit-prefixed Hardcover slug as a slug instead of a partial numeric ID', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockImplementation((_userId, _token, query: string) => {
if (query.includes('query FindBookById')) {
return { books: [{ id: 84, editions: [{ id: 24634818, pages: 284 }] }] };
}
if (query.includes('query FindBookBySlug')) {
return { books: [{ id: 277100, editions: [{ id: 15528636, pages: 114, isbn_13: '9780140143508' }] }] };
}
throw new Error('Unexpected query');
});
const book = {
...baseBook,
title: '84, Charing Cross Road',
hardcoverMetadataId: '84-charing-cross-road',
isbn13: '9780140143508',
};
const result = await makeService().matchBook(1, 'tok', book);
expect(result).toEqual({
hardcoverBookId: 277100,
hardcoverEditionId: 15528636,
editionPages: 114,
editionAudioSeconds: null,
matchMethod: 'metadata_id',
});
expect(mockClient.query).toHaveBeenCalledTimes(1);
expect(mockClient.query).toHaveBeenCalledWith(1, 'tok', expect.stringContaining('query FindBookBySlug'), {
slug: '84-charing-cross-road',
});
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({ hardcoverBookId: 277100, hardcoverEditionId: 15528636, matchMethod: 'metadata_id' }),
);
});
it('falls back to isbn13 when metadata_id fails', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockRejectedValueOnce(new Error('not found')).mockResolvedValueOnce({ books: [{ id: 88, editions: [{ id: 11 }] }] });
@@ -3,6 +3,7 @@ import type { HardcoverEdition as HardcoverEditionSummary, HardcoverEditionsResu
import { parsePublishedDateKey } from '../../common/utils/published-date.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { parseHardcoverBookId } from './hardcover-id.utils';
import type { BookSyncData } from './hardcover.repository';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverRepository } from './hardcover.repository';
@@ -241,8 +242,8 @@ export class HardcoverBookMatchService {
let match: HardcoverBookMatch | null = null;
if (book.hardcoverMetadataId) {
const id = parseInt(book.hardcoverMetadataId, 10);
if (!isNaN(id)) {
const id = parseHardcoverBookId(book.hardcoverMetadataId);
if (id !== null) {
match = await this.matchByHardcoverId(userId, token, id, book);
} else {
match = await this.matchByHardcoverSlug(userId, token, book.hardcoverMetadataId, book);
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { parseHardcoverBookId } from './hardcover-id.utils';
describe('parseHardcoverBookId', () => {
it.each([
['1', 1],
['84', 84],
['00084', 84],
['2147483647', 2_147_483_647],
])('parses the complete positive GraphQL integer %s', (value, expected) => {
expect(parseHardcoverBookId(value)).toBe(expected);
});
it.each([null, undefined, '', '0', '-84', '+84', '84.0', '84-charing-cross-road', '84abc', ' 84', '84 ', '2147483648', '9007199254740992'])(
'rejects a value that is not a complete positive GraphQL integer: %s',
(value) => {
expect(parseHardcoverBookId(value)).toBeNull();
},
);
});
@@ -0,0 +1,7 @@
const GRAPHQL_INT_MAX = 2_147_483_647;
export function parseHardcoverBookId(value: string | null | undefined): number | null {
if (!value || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 && id <= GRAPHQL_INT_MAX ? id : null;
}
@@ -18,6 +18,7 @@ import { UserBookStatusService } from '../user-book-status/user-book-status.serv
import { ReadingAttemptService } from '../user-book-status/reading-attempt.service';
import { HARDCOVER_STATUS } from './hardcover.constants';
import { HardcoverClientService } from './hardcover-client.service';
import { parseHardcoverBookId } from './hardcover-id.utils';
import { HardcoverImportFuzzyIndex } from './hardcover-import-fuzzy-index';
import { type HardcoverImportLocalBook, HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
@@ -317,7 +318,7 @@ export class HardcoverImportService {
for (const book of books) {
const metadataId = book.hardcoverMetadataId?.trim();
if (metadataId) {
const numericId = parseNumericId(metadataId);
const numericId = parseHardcoverBookId(metadataId);
if (numericId != null) pushUnique(indexes.byHardcoverId, numericId, book);
else pushUnique(indexes.byHardcoverSlug, normalizeSlug(metadataId), book);
}
@@ -617,12 +618,6 @@ function pushUnique<K>(map: Map<K, HardcoverImportLocalBook[]>, key: K, book: Ha
if (!current.some((candidate) => candidate.bookId === book.bookId)) current.push(book);
}
function parseNumericId(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) ? id : null;
}
function normalizeIsbn(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.replace(/[^0-9X]/gi, '').toUpperCase();
@@ -304,6 +304,47 @@ describe('HardcoverSyncService', () => {
expect(mockClient.query).not.toHaveBeenCalled();
});
it('does not treat a digit-prefixed Hardcover slug as a changed numeric book id', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findBookSyncData.mockResolvedValue({ ...readingBook, hardcoverMetadataId: '84-charing-cross-road' });
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 277100,
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
});
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
expect(mockClient.query).not.toHaveBeenCalled();
});
it('re-syncs when a complete numeric Hardcover metadata id changes', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
const changedBook = { ...readingBook, hardcoverMetadataId: '84' };
mockRepo.findBookSyncData.mockResolvedValue(changedBook);
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 277100,
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
});
mockMatchService.matchBook.mockResolvedValue(null);
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockMatchService.matchBook).toHaveBeenCalledWith(1, 'tok', changedBook);
warnSpy.mockRestore();
});
it('stores no_match error when match fails', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
@@ -19,6 +19,7 @@ import { BookService } from '../book/book.service';
import { HARDCOVER_STATUS } from './hardcover.constants';
import { HardcoverBookMatchService } from './hardcover-book-match.service';
import { HardcoverClientService } from './hardcover-client.service';
import { parseHardcoverBookId } from './hardcover-id.utils';
import {
attemptOwnedReadId,
type HardcoverReadCandidate,
@@ -840,7 +841,7 @@ export class HardcoverSyncService {
if (!state?.lastSyncedAt) return true;
if (book.attemptsUpdatedAt && book.attemptsUpdatedAt > state.lastSyncedAt) return true;
if (book.hardcoverMetadataId && state.syncError === 'no_match') return true;
const metadataHardcoverId = this.parseNumericHardcoverMetadataId(book.hardcoverMetadataId);
const metadataHardcoverId = parseHardcoverBookId(book.hardcoverMetadataId);
if (metadataHardcoverId !== null && metadataHardcoverId !== state.hardcoverBookId) return true;
if (book.status !== state.lastSyncedStatus) return true;
if (book.progress !== state.lastSyncedProgress) return true;
@@ -852,12 +853,6 @@ export class HardcoverSyncService {
return false;
}
private parseNumericHardcoverMetadataId(value: string | null | undefined): number | null {
if (!value) return null;
const id = parseInt(value, 10);
return isNaN(id) ? null : id;
}
private buildAttemptSnapshot(book: BookSyncData) {
return {
lastSyncedAt: new Date(),