mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): stop hardcover sync colliding on reading attempt read ids
Read selection was positional: prefer any open remote read, else a cached book-level id, else the first read returned, while the primary attempt was re-derived each sync from book dates. The two could disagree, assigning one Hardcover read to two attempts and violating reading_attempts_external_uidx, which aborted the sync and persisted the failure. Treat reading_attempts.external_id as the mapping of record: an attempt keeps the read it owns, an unlinked attempt adopts only an unclaimed read and claims it locally before the remote write, and a fresh read is minted when nothing is adoptable. Never merge or delete attempts to resolve a conflict, and keep soft-deleted ids reserved so importExternalRead tombstones still hold. Also skip the link write when an attempt already owns its read, and map the aggregated attempt timestamp to a Date so change detection stops comparing a driver string against lastSyncedAt. Closes #907
This commit is contained in:
@@ -494,4 +494,137 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it('19. reports an ownership conflict instead of throwing when another attempt holds the read', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const owner = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2022-01-01',
|
||||
endedOn: '2022-01-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
const rival = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2022-03-01',
|
||||
endedOn: '2022-03-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, owner.id, 9101)).resolves.toBe('linked');
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, rival.id, 9101)).resolves.toBe('conflict');
|
||||
|
||||
const [rivalRow] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, rival.id));
|
||||
expect(rivalRow?.externalId).toBeNull();
|
||||
const [ownerRow] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, owner.id));
|
||||
expect(ownerRow).toMatchObject({ externalId: '9101', deletedAt: null });
|
||||
});
|
||||
|
||||
it('20. keeps a soft-deleted attempt tombstone reserved against reuse', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const removed = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2022-06-01',
|
||||
endedOn: '2022-06-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
await hardcover.linkReadingAttempt(adminUserId, removed.id, 9202);
|
||||
await attempts.delete(adminUserId, book.bookId, removed.id);
|
||||
const survivor = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2022-09-01',
|
||||
endedOn: '2022-09-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, survivor.id, 9202)).resolves.toBe('conflict');
|
||||
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toContain(9202);
|
||||
});
|
||||
|
||||
it('21. lists claimed read ids across live and soft-deleted attempts', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const live = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2023-01-01',
|
||||
endedOn: '2023-01-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
const deleted = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2023-04-01',
|
||||
endedOn: '2023-04-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
await hardcover.linkReadingAttempt(adminUserId, live.id, 9301);
|
||||
await hardcover.linkReadingAttempt(adminUserId, deleted.id, 9302);
|
||||
await attempts.delete(adminUserId, book.bookId, deleted.id);
|
||||
|
||||
const claimed = await hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId);
|
||||
|
||||
expect(new Set(claimed)).toEqual(new Set([9301, 9302]));
|
||||
});
|
||||
|
||||
it('22. lets an attempt restamp the same read but rejects reassignment', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const attempt = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2023-07-01',
|
||||
endedOn: '2023-07-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9401)).resolves.toBe('linked');
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9401)).resolves.toBe('linked');
|
||||
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9402)).resolves.toBe('conflict');
|
||||
|
||||
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toEqual([9401]);
|
||||
});
|
||||
|
||||
it('23. lets exactly one concurrent claim establish attempt ownership', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const attempt = await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2023-08-01',
|
||||
endedOn: '2023-08-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
|
||||
const outcomes = await Promise.all([
|
||||
hardcover.linkReadingAttempt(adminUserId, attempt.id, 9501),
|
||||
hardcover.linkReadingAttempt(adminUserId, attempt.id, 9502),
|
||||
]);
|
||||
|
||||
expect(outcomes.sort()).toEqual(['conflict', 'linked']);
|
||||
const claimed = await hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId);
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect([9501, 9502]).toContain(claimed[0]);
|
||||
});
|
||||
it('24. surfaces attempt timestamps as Dates so sync change detection can compare them', async () => {
|
||||
const book = await createBook();
|
||||
const attempts = ctx.app.get(ReadingAttemptService);
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
const before = new Date(Date.now() - 60_000);
|
||||
await attempts.createHistorical(adminUserId, book.bookId, {
|
||||
startedOn: '2024-02-01',
|
||||
endedOn: '2024-02-05',
|
||||
outcome: 'completed',
|
||||
});
|
||||
|
||||
const sync = await hardcover.findBookSyncData(adminUserId, book.bookId);
|
||||
const updatedAt = sync?.attemptsUpdatedAt ?? null;
|
||||
|
||||
// A raw driver timestamp string compares false against a Date, which silently disables the
|
||||
// attempt-history branch of hasChanges and stops attempt edits from ever re-triggering a sync.
|
||||
expect(updatedAt).toBeInstanceOf(Date);
|
||||
expect(updatedAt instanceof Date && updatedAt > before).toBe(true);
|
||||
});
|
||||
|
||||
it('25. leaves the attempt timestamp null for a book with no attempts', async () => {
|
||||
const book = await createBook();
|
||||
const hardcover = ctx.app.get(HardcoverRepository);
|
||||
|
||||
const sync = await hardcover.findBookSyncData(adminUserId, book.bookId);
|
||||
|
||||
expect(sync?.attemptsUpdatedAt ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user