diff --git a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts index 413aeceb..948e18ec 100644 --- a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts +++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts @@ -33,12 +33,85 @@ interface TrackerInternals { db: DatabaseSync; queue: unknown[]; recordWrite: (write: Record) => void; + deleteSession: (sessionId: number) => Promise; mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise; moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise; rebuildLifetimeSummaries: () => Promise; + reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise; flushNow: () => void; + writeLock: { locked: boolean }; } +test('delete maintenance fails closed when queued writes cannot drain', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let deleteRunnerCalls = 0; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath, policy: { batchSize: 2 } }, + { + runDeleteMaintenanceTask: async () => { + deleteRunnerCalls += 1; + }, + }, + ); + const internals = tracker as unknown as TrackerInternals; + seedTwoEntries(internals.db); + queueSubtitleLines(internals, 1); + let flushCalls = 0; + internals.flushNow = () => { + flushCalls += 1; + if (flushCalls > 1) throw new Error('bounded no-progress sentinel'); + }; + + await assert.rejects(internals.deleteSession(1), /queue did not drain/i); + + assert.equal(flushCalls, 1); + assert.equal(deleteRunnerCalls, 0); + assert.equal(internals.writeLock.locked, false); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); + const internals = tracker as unknown as TrackerInternals; + seedTwoEntries(internals.db); + internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run(); + queueSubtitleLines(internals, 1); + internals.flushNow = () => {}; + + await assert.rejects( + internals.reassignAnimeAnilist(1, { anilistId: 123 }), + /queue did not drain/i, + ); + + assert.deepEqual( + internals.db + .prepare( + 'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id', + ) + .all(), + [ + { animeId: 1, anilistId: null }, + { animeId: 2, anilistId: 123 }, + ], + ); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('mergeAnime fails closed when queued writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index b1b929d6..dfa0ce1d 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -442,8 +442,7 @@ export class ImmersionTrackerService { batchWindowMs: DELETE_MAINTENANCE_BATCH_WINDOW_MS, runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task), onBusy: () => { - this.flushTelemetry(true); - while (this.queue.length > 0) this.flushNow(); + this.requireWriteQueueDrained('delete maintenance'); this.writeLock.locked = true; }, onIdle: () => { @@ -891,6 +890,7 @@ export class ImmersionTrackerService { coverUrl?: string | null; }, ): Promise { + this.requireWriteQueueDrained('reassigning an AniList entry'); // The user is acting on this entry, so it is the one that survives when // another row already claims the same AniList id. const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {