diff --git a/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts index 2e9eb8f8..fe3032b0 100644 --- a/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts +++ b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts @@ -13,7 +13,7 @@ type MockFetchResponse = { type MockFetch = ReturnType Promise>>; 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(); + }); }); diff --git a/apps/readest-app/src/services/hardcover/HardcoverClient.ts b/apps/readest-app/src/services/hardcover/HardcoverClient.ts index e0c63699..3f0a5285 100644 --- a/apps/readest-app/src/services/hardcover/HardcoverClient.ts +++ b/apps/readest-app/src/services/hardcover/HardcoverClient.ts @@ -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, }, }); diff --git a/apps/readest-app/src/services/hardcover/hardcover-graphql.ts b/apps/readest-app/src/services/hardcover/hardcover-graphql.ts index a8d72f6d..2178d6ca 100644 --- a/apps/readest-app/src/services/hardcover/hardcover-graphql.ts +++ b/apps/readest-app/src/services/hardcover/hardcover-graphql.ts @@ -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(