fix(hardcover): never send a book id as edition_id (#4792) (#4794)

When a book was matched via Hardcover title search with no featured
edition and the user had not selected a specific edition, the sync
client fell back to using the Hardcover book id as the edition_id.
Hardcover's Action rejects that with a parse-failed error
("ActionWebhookErrorResponse ... key 'message' not found"), so progress
and note sync failed for those books.

Leave editionId null when no real edition is known, make the read and
journal mutations accept a nullable edition_id, and omit edition_id when
adding a book so Hardcover uses the book's default edition.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-26 00:41:00 +08:00
committed by GitHub
parent 58f84d18c1
commit 4c39d769e6
3 changed files with 102 additions and 10 deletions
@@ -13,7 +13,7 @@ type MockFetchResponse = {
type MockFetch = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<MockFetchResponse>>>;
type TestBookContext = {
editionId: number;
editionId: number | null;
pages: number | null;
bookId: number;
bookPages: number | null;
@@ -712,4 +712,90 @@ describe('HardcoverClient', () => {
userBook: { id: 303 },
});
});
test('leaves the edition id unresolved when title search lacks a featured edition and the user has none selected', async () => {
const book = { title: 'Crime and Punishment', author: 'Fyodor Dostoevsky' } as Book;
// authenticate
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ data: { me: { id: 1 } } }),
});
// QUERY_SEARCH_BOOK — real Typesense hit shape, no featured_edition_id present
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
data: { search: { results: { hits: [{ document: { id: 713309, pages: 311 } }] } } },
}),
});
// QUERY_GET_BOOK_USER_DATA — currently-reading book, but no specific edition selected
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
data: {
editions: [
{
book: {
id: 713309,
pages: 311,
user_books: [
{
id: 303,
status_id: 2,
edition: null,
user_book_reads: [{ id: 505, started_at: '2026-06-25', edition: null }],
},
],
},
},
],
},
}),
});
const context = await clientApi.fetchBookContext(book);
expect(context?.bookId).toBe(713309);
// Regression #4792: the edition id must NOT fall back to the book id. Sending
// a book id as edition_id makes Hardcover's Action reject the read mutation
// with a parse-failed error. Leave it null when no real edition is known.
expect(context?.editionId).toBeNull();
});
test('forwards a null edition id to the read mutation when no edition is resolved (#4792)', async () => {
const book = {
createdAt: 1711737600000,
title: 'Crime and Punishment',
author: 'Test',
} as Book;
const config = { progress: [3, 311] } as BookConfig;
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
editionId: null,
pages: 311,
bookId: 713309,
bookPages: 311,
userBook: {
id: 303,
status_id: 2,
user_book_reads: [{ id: 505, started_at: '2026-06-25' }],
},
});
const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({});
await client.pushProgress(book, config);
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
const updateReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation UpdateRead'),
);
expect(updateReadCall).toBeDefined();
const variables = updateReadCall?.[1] as { edition_id?: unknown };
expect(variables.edition_id).toBeNull();
});
});
@@ -23,7 +23,10 @@ type HardcoverSettingsLike = {
};
type BookContext = {
editionId: number;
// Null when no real Hardcover edition is known (e.g. a title-search match with
// no featured edition and no user-selected edition). Never fall back to the
// book id here — Hardcover rejects a book id used as an edition_id (#4792).
editionId: number | null;
pages: number | null;
bookId: number;
bookPages: number | null;
@@ -284,9 +287,10 @@ export class HardcoverClient {
if (!rawBookId) return null;
const bookId = Number(rawBookId);
const editionId = Number(
hit.featured_edition_id ?? hit.document?.featured_edition_id ?? bookId,
);
const rawEditionId = hit.featured_edition_id ?? hit.document?.featured_edition_id;
// Only the featured edition is a real edition id; never substitute the book
// id (Hardcover rejects a book id passed as edition_id, #4792).
const editionId = rawEditionId != null ? Number(rawEditionId) : null;
const pages =
hit.pages != null
? Number(hit.pages)
@@ -461,7 +465,7 @@ export class HardcoverClient {
if (context.userBook) return context;
const data = await this.request<
{ object: { book_id: number; edition_id: number; status_id: number } },
{ object: { book_id: number; edition_id?: number; status_id: number } },
{
insert_user_book: {
error?: string | null;
@@ -474,7 +478,9 @@ export class HardcoverClient {
>(MUTATION_INSERT_USER_BOOK, {
object: {
book_id: context.bookId,
edition_id: context.editionId,
// Omit edition_id entirely when unknown so Hardcover falls back to the
// book's default edition instead of rejecting an invalid one (#4792).
...(context.editionId != null ? { edition_id: context.editionId } : {}),
status_id: isReading ? 2 : 1,
},
});
@@ -129,7 +129,7 @@ mutation UpdateUserBook($user_book_id: Int!, $object: UserBookUpdateInput!) {
`;
export const MUTATION_INSERT_READ = `
mutation InsertRead($user_book_id: Int!, $edition_id: Int!, $progress_pages: Int!, $started_at: date!) {
mutation InsertRead($user_book_id: Int!, $edition_id: Int, $progress_pages: Int!, $started_at: date!) {
insert_user_book_read(
user_book_id: $user_book_id
user_book_read: {
@@ -142,7 +142,7 @@ mutation InsertRead($user_book_id: Int!, $edition_id: Int!, $progress_pages: Int
`;
export const MUTATION_UPDATE_READ = `
mutation UpdateRead($id: Int!, $progress_pages: Int!, $edition_id: Int!, $started_at: date!) {
mutation UpdateRead($id: Int!, $progress_pages: Int!, $edition_id: Int, $started_at: date!) {
update_user_book_read(
id: $id
object: {
@@ -156,7 +156,7 @@ mutation UpdateRead($id: Int!, $progress_pages: Int!, $edition_id: Int!, $starte
export const MUTATION_INSERT_JOURNAL = `
mutation InsertReadingJournal(
$book_id: Int!, $edition_id: Int!, $event: String!, $entry: String!,
$book_id: Int!, $edition_id: Int, $event: String!, $entry: String!,
$action_at: date, $page: Int!, $possible: Int!, $percent: Float!, $privacy_setting_id: Int!
) {
insert_reading_journal(