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 f9449b74..38f60ee7 100644 --- a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts +++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts @@ -42,10 +42,19 @@ interface TrackerInternals { writeLock: { locked: boolean }; } +function replaceFlushNow(tracker: TrackerInternals, replacement: () => void): () => void { + const original = tracker.flushNow; + tracker.flushNow = replacement; + return () => { + tracker.flushNow = original; + }; +} + test('delete maintenance fails closed when queued writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; let deleteRunnerCalls = 0; + let restoreFlushNow = (): void => {}; try { const Ctor = await loadTrackerCtor(); @@ -61,10 +70,10 @@ test('delete maintenance fails closed when queued writes cannot drain', async () seedTwoEntries(internals.db); queueSubtitleLines(internals, 1); let flushCalls = 0; - internals.flushNow = () => { + restoreFlushNow = replaceFlushNow(internals, () => { flushCalls += 1; if (flushCalls > 1) throw new Error('bounded no-progress sentinel'); - }; + }); await assert.rejects(internals.deleteSession(1), /queue did not drain/i); @@ -72,6 +81,7 @@ test('delete maintenance fails closed when queued writes cannot drain', async () assert.equal(deleteRunnerCalls, 0); assert.equal(internals.writeLock.locked, false); } finally { + restoreFlushNow(); tracker?.destroy(); cleanupDbPath(dbPath); } @@ -80,6 +90,7 @@ test('delete maintenance fails closed when queued writes cannot drain', async () test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; + let restoreFlushNow = (): void => {}; try { const Ctor = await loadTrackerCtor(); @@ -88,7 +99,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes seedTwoEntries(internals.db); internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run(); queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; + restoreFlushNow = replaceFlushNow(internals, () => {}); await assert.rejects( internals.reassignAnimeAnilist(1, { anilistId: 123 }), @@ -107,6 +118,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes ], ); } finally { + restoreFlushNow(); tracker?.destroy(); cleanupDbPath(dbPath); } @@ -115,6 +127,7 @@ test('reassignAnimeAnilist fails closed before resolving a conflict when writes test('mergeAnime fails closed when queued writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; + let restoreFlushNow = (): void => {}; try { const Ctor = await loadTrackerCtor(); @@ -122,7 +135,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => { const internals = tracker as unknown as TrackerInternals; seedTwoEntries(internals.db); queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; + restoreFlushNow = replaceFlushNow(internals, () => {}); await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i); @@ -134,6 +147,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => { [1, 2], ); } finally { + restoreFlushNow(); tracker?.destroy(); cleanupDbPath(dbPath); } @@ -142,6 +156,7 @@ test('mergeAnime fails closed when queued writes cannot drain', async () => { test('moveVideoToAnime fails closed when queued writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; + let restoreFlushNow = (): void => {}; try { const Ctor = await loadTrackerCtor(); @@ -149,7 +164,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () = const internals = tracker as unknown as TrackerInternals; seedTwoEntries(internals.db); queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; + restoreFlushNow = replaceFlushNow(internals, () => {}); await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i); assert.equal( @@ -163,6 +178,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () = 2, ); } finally { + restoreFlushNow(); tracker?.destroy(); cleanupDbPath(dbPath); } @@ -171,6 +187,7 @@ test('moveVideoToAnime fails closed when queued writes cannot drain', async () = test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; + let restoreFlushNow = (): void => {}; try { const Ctor = await loadTrackerCtor(); @@ -178,10 +195,11 @@ test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', as const internals = tracker as unknown as TrackerInternals; seedTwoEntries(internals.db); queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; + restoreFlushNow = replaceFlushNow(internals, () => {}); await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i); } finally { + restoreFlushNow(); tracker?.destroy(); cleanupDbPath(dbPath); } diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 6410648d..8693b657 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -617,18 +617,13 @@ test('tracker starts the injected lexical rollup backfill when it is pending', a } }); -test('destroy waits for lexical backfill shutdown before finalizing and draining writes', async () => { +test('destroy drains queued telemetry and events after lexical backfill stops', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; let releaseBackfill = (): void => {}; - let releaseTermination = (): void => {}; const heldBackfill = new Promise((resolve) => { releaseBackfill = resolve; }); - const heldTermination = new Promise((resolve) => { - releaseTermination = resolve; - }); - const calls: string[] = []; try { const setupDb = new Database(dbPath); @@ -643,41 +638,37 @@ test('destroy waits for lexical backfill shutdown before finalizing and draining const Ctor = await loadTrackerCtor(); tracker = new Ctor( - { dbPath }, + { dbPath, policy: { batchSize: 1, flushIntervalMs: 60_000 } }, { runLexicalRollupBackfillTask: async () => heldBackfill, - destroyLexicalRollupBackfillRunner: async () => { - calls.push('stop'); + destroyLexicalRollupBackfillRunner: () => { releaseBackfill(); - await heldTermination; - calls.push('stopped'); }, }, ); tracker.handleMediaChange('/tmp/destroy-backfill.mkv', 'Destroy Backfill'); tracker.recordCardsMined(1); - - const privateApi = tracker as unknown as { - queue: unknown[]; - finalizeActiveSession: () => void; - }; - const finalizeActiveSession = privateApi.finalizeActiveSession.bind(tracker); - privateApi.finalizeActiveSession = () => { - finalizeActiveSession(); - calls.push(`finalized:${privateApi.queue.length}`); - }; + tracker.recordLookup(true); const destroyTask = tracker.destroy(); assert.ok(destroyTask instanceof Promise); - assert.deepEqual(calls, ['stop']); - assert.ok(privateApi.queue.length > 0); - - releaseTermination(); await destroyTask; - assert.deepEqual(calls, ['stop', 'stopped', 'finalized:0']); + + const db = new Database(dbPath); + try { + const eventCount = db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as { + total: number; + }; + const telemetryCount = db + .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry') + .get() as { total: number }; + assert.equal(eventCount.total, 2); + assert.equal(telemetryCount.total, 2); + } finally { + db.close(); + } } finally { releaseBackfill(); - releaseTermination(); await tracker?.destroy(); cleanupDbPath(dbPath); } diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 617aaace..fb0d8383 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -654,6 +654,7 @@ export class ImmersionTrackerService { const pendingLexicalBackfill = this.lexicalRollupBackfillTask; const finish = (): void => { this.finalizeActiveSession(); + this.requireWriteQueueDrained('destroying immersion tracker'); this.isDestroyed = true; this.deleteMaintenanceScheduler.destroy(); this.destroyDeleteMaintenanceRunner();