fix(storygraph): preserve reading attempt start date (#687)

* fix(storygraph): preserve reading attempt start date

Skip redundant active-reading status updates before syncing progress so StoryGraph keeps the existing reading attempt and start date.

Fixes #667

* test(storygraph): cover active status equivalence

Exercise exact and cross-mapped active reading statuses so patch coverage verifies every start-date preservation path.

* test(storygraph): complete active status matrix

Cover the exact rereading status pair and retain literal tuple types for every active-reading equivalence case.

---------

Co-authored-by: chooh2 <chooh2@users.noreply.github.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
chooh2
2026-07-22 05:48:44 +02:00
committed by GitHub
parent 8db7cb796f
commit d051039a2d
2 changed files with 52 additions and 1 deletions
@@ -229,6 +229,41 @@ describe('StorygraphSyncService', () => {
);
});
it.each([
['rereading', 'reading'],
['currently reading', 'reading'],
['currently reading', 'rereading'],
['rereading', 'rereading'],
] as const)('updates progress without resetting a %s StoryGraph attempt for local status %s', async (currentStatus, localStatus) => {
mockSettingsService.getCookiesForUser.mockResolvedValue(cookies);
mockRepo.findSyncableBook.mockResolvedValue({ ...readingBook, status: localStatus });
mockRepo.findBookState.mockResolvedValue({
storygraphBookId: 'abc-123',
matchMethod: 'isbn',
lastSyncedAt: new Date('2026-07-01T00:00:00Z'),
lastSyncedStatus: localStatus,
lastSyncedProgress: 10,
});
mockMatchService.matchBook.mockResolvedValue({ storygraphBookId: 'abc-123', matchMethod: 'isbn' });
mockClient.get.mockResolvedValue({
status: 200,
html: `<button class="read-status-label">${currentStatus}</button><input name="read_status[book_num_of_pages]" value="288">`,
redirectedToSignIn: false,
});
await expect(makeService().syncBook(1, 1)).resolves.toBe('synced');
expect(mockClient.post).toHaveBeenCalledTimes(1);
expect(mockClient.post).toHaveBeenCalledWith(
1,
cookies,
'/update-progress',
expect.objectContaining({ 'read_status[progress_number]': '42', book_id: 'abc-123' }),
'csrf-token',
);
expect(mockClient.post).not.toHaveBeenCalledWith(1, cookies, expect.stringContaining('/update-status.js'), expect.anything(), 'csrf-token');
});
it('records a failure when StoryGraph rejects the progress update', async () => {
mockSettingsService.getCookiesForUser.mockResolvedValue(cookies);
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
@@ -522,7 +522,9 @@ export class StorygraphSyncService {
}
private async updateStatus(userId: number, cookies: StorygraphCookies, storygraphBookId: string, status: string): Promise<void> {
const { csrf } = await this.fetchBookPage(userId, cookies, storygraphBookId);
const { html, csrf } = await this.fetchBookPage(userId, cookies, storygraphBookId);
const currentStatus = this.extractCurrentStatus(html);
if (this.statusesMatch(currentStatus, status)) return;
const response = await this.client.post(userId, cookies, `/update-status.js?book_id=${storygraphBookId}&status=${status}`, {}, csrf);
@@ -543,6 +545,20 @@ export class StorygraphSyncService {
throw new Error(`status_update_failed:${response.status}`);
}
private extractCurrentStatus(html: string): string | null {
const $ = cheerio.load(html);
const label = $('.read-status-label').first().text().trim();
return label ? label.toLowerCase().replace(/\s+/g, '-') : null;
}
private statusesMatch(currentStatus: string | null, requestedStatus: string): boolean {
if (currentStatus === requestedStatus) return true;
return (
(currentStatus === STORYGRAPH_STATUS.CURRENTLY_READING || currentStatus === STORYGRAPH_STATUS.REREADING) &&
(requestedStatus === STORYGRAPH_STATUS.CURRENTLY_READING || requestedStatus === STORYGRAPH_STATUS.REREADING)
);
}
private async updateProgress(userId: number, cookies: StorygraphCookies, storygraphBookId: string, progress: number): Promise<void> {
const { html, csrf } = await this.fetchBookPage(userId, cookies, storygraphBookId);
const bookNumOfPages = this.extractBookNumOfPages(html);