import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { DatabaseSync as NodeDatabaseSync } from "node:sqlite"; type ImmersionTrackerService = import("./immersion-tracker-service").ImmersionTrackerService; type ImmersionTrackerServiceCtor = typeof import("./immersion-tracker-service").ImmersionTrackerService; type DatabaseSyncCtor = typeof NodeDatabaseSync; const DatabaseSync: DatabaseSyncCtor | null = (() => { try { return ( require("node:sqlite") as { DatabaseSync?: DatabaseSyncCtor } ).DatabaseSync ?? null; } catch { return null; } })(); const testIfSqlite = DatabaseSync ? test : test.skip; let trackerCtor: ImmersionTrackerServiceCtor | null = null; async function loadTrackerCtor(): Promise { if (trackerCtor) return trackerCtor; const mod = await import("./immersion-tracker-service"); trackerCtor = mod.ImmersionTrackerService; return trackerCtor; } function makeDbPath(): string { const dir = fs.mkdtempSync( path.join(os.tmpdir(), "subminer-immersion-test-"), ); return path.join(dir, "immersion.sqlite"); } function cleanupDbPath(dbPath: string): void { const dir = path.dirname(dbPath); if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } testIfSqlite("startSession generates UUID-like session identifiers", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath }); tracker.handleMediaChange("/tmp/episode.mkv", "Episode"); const privateApi = tracker as unknown as { flushTelemetry: (force?: boolean) => void; flushNow: () => void; }; privateApi.flushTelemetry(true); privateApi.flushNow(); const db = new DatabaseSync!(dbPath); const row = db .prepare("SELECT session_uuid FROM imm_sessions LIMIT 1") .get() as { session_uuid: string } | null; db.close(); assert.equal(typeof row?.session_uuid, "string"); assert.equal(row?.session_uuid?.startsWith("session-"), false); assert.ok(/^[0-9a-fA-F-]{36}$/.test(row?.session_uuid || "")); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); testIfSqlite("destroy finalizes active session and persists final telemetry", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath }); tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2"); tracker.recordSubtitleLine("Hello immersion", 0, 1); tracker.destroy(); const db = new DatabaseSync!(dbPath); const sessionRow = db .prepare("SELECT ended_at_ms FROM imm_sessions LIMIT 1") .get() as { ended_at_ms: number | null } | null; const telemetryCountRow = db .prepare("SELECT COUNT(*) AS total FROM imm_session_telemetry") .get() as { total: number }; db.close(); assert.ok(sessionRow); assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0); assert.ok(Number(telemetryCountRow.total) >= 2); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); testIfSqlite("persists and retrieves minimum immersion tracking fields", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath }); tracker.handleMediaChange("/tmp/episode-3.mkv", "Episode 3"); tracker.recordSubtitleLine("alpha beta", 0, 1.2); tracker.recordCardsMined(2); tracker.recordLookup(true); tracker.recordPlaybackPosition(12.5); const privateApi = tracker as unknown as { flushTelemetry: (force?: boolean) => void; flushNow: () => void; }; privateApi.flushTelemetry(true); privateApi.flushNow(); const summaries = await tracker.getSessionSummaries(10); assert.ok(summaries.length >= 1); assert.ok(summaries[0].linesSeen >= 1); assert.ok(summaries[0].cardsMined >= 2); tracker.destroy(); const db = new DatabaseSync!(dbPath); const videoRow = db .prepare( "SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1", ) .get() as { canonical_title: string; source_path: string | null; duration_ms: number; } | null; const telemetryRow = db .prepare( `SELECT lines_seen, words_seen, tokens_seen, cards_mined FROM imm_session_telemetry ORDER BY sample_ms DESC LIMIT 1`, ) .get() as { lines_seen: number; words_seen: number; tokens_seen: number; cards_mined: number; } | null; db.close(); assert.ok(videoRow); assert.equal(videoRow?.canonical_title, "Episode 3"); assert.equal(videoRow?.source_path, "/tmp/episode-3.mkv"); assert.ok(Number(videoRow?.duration_ms ?? -1) >= 0); assert.ok(telemetryRow); assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1); assert.ok(Number(telemetryRow?.words_seen ?? 0) >= 2); assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2); assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); testIfSqlite("applies configurable queue, flush, and retention policy", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath, policy: { batchSize: 10, flushIntervalMs: 250, queueCap: 1500, payloadCapBytes: 512, maintenanceIntervalMs: 2 * 60 * 60 * 1000, retention: { eventsDays: 14, telemetryDays: 45, dailyRollupsDays: 730, monthlyRollupsDays: 3650, vacuumIntervalDays: 14, }, }, }); const privateApi = tracker as unknown as { batchSize: number; flushIntervalMs: number; queueCap: number; maxPayloadBytes: number; maintenanceIntervalMs: number; eventsRetentionMs: number; telemetryRetentionMs: number; dailyRollupRetentionMs: number; monthlyRollupRetentionMs: number; vacuumIntervalMs: number; }; assert.equal(privateApi.batchSize, 10); assert.equal(privateApi.flushIntervalMs, 250); assert.equal(privateApi.queueCap, 1500); assert.equal(privateApi.maxPayloadBytes, 512); assert.equal(privateApi.maintenanceIntervalMs, 7_200_000); assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000); assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000); assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000); assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000); assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); testIfSqlite("monthly rollups are grouped by calendar month", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath }); const privateApi = tracker as unknown as { db: NodeDatabaseSync; runRollupMaintenance: () => void; }; const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0); const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0); privateApi.db.exec(` INSERT INTO imm_videos ( video_id, video_key, canonical_title, source_type, duration_ms, created_at_ms, updated_at_ms ) VALUES ( 1, 'local:/tmp/video.mkv', 'Episode', 1, 0, ${januaryStartedAtMs}, ${januaryStartedAtMs} ) `); privateApi.db.exec(` INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms, ended_at_ms ) VALUES ( 1, '11111111-1111-1111-1111-111111111111', 1, ${januaryStartedAtMs}, 2, ${januaryStartedAtMs}, ${januaryStartedAtMs}, ${januaryStartedAtMs + 5000} ) `); privateApi.db.exec(` INSERT INTO imm_session_telemetry ( session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen, words_seen, tokens_seen, cards_mined, lookup_count, lookup_hits, pause_count, pause_ms, seek_forward_count, seek_backward_count, media_buffer_events ) VALUES ( 1, ${januaryStartedAtMs + 1000}, 5000, 5000, 1, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0 ) `); privateApi.db.exec(` INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms, ended_at_ms ) VALUES ( 2, '22222222-2222-2222-2222-222222222222', 1, ${februaryStartedAtMs}, 2, ${februaryStartedAtMs}, ${februaryStartedAtMs}, ${februaryStartedAtMs + 5000} ) `); privateApi.db.exec(` INSERT INTO imm_session_telemetry ( session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen, words_seen, tokens_seen, cards_mined, lookup_count, lookup_hits, pause_count, pause_ms, seek_forward_count, seek_backward_count, media_buffer_events ) VALUES ( 2, ${februaryStartedAtMs + 1000}, 4000, 4000, 2, 3, 3, 1, 1, 1, 0, 0, 0, 0, 0 ) `); privateApi.runRollupMaintenance(); const rows = await tracker.getMonthlyRollups(10); const videoRows = rows.filter((row) => row.videoId === 1); assert.equal(videoRows.length, 2); assert.equal(videoRows[0].rollupDayOrMonth, 202602); assert.equal(videoRows[1].rollupDayOrMonth, 202601); } finally { tracker?.destroy(); cleanupDbPath(dbPath); } }); testIfSqlite("flushSingle reuses cached prepared statements", async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; let originalPrepare: NodeDatabaseSync["prepare"] | null = null; try { const Ctor = await loadTrackerCtor(); tracker = new Ctor({ dbPath }); const privateApi = tracker as unknown as { db: NodeDatabaseSync; flushSingle: (write: { kind: "telemetry" | "event"; sessionId: number; sampleMs: number; eventType?: number; lineIndex?: number | null; segmentStartMs?: number | null; segmentEndMs?: number | null; wordsDelta?: number; cardsDelta?: number; payloadJson?: string | null; totalWatchedMs?: number; activeWatchedMs?: number; linesSeen?: number; wordsSeen?: number; tokensSeen?: number; cardsMined?: number; lookupCount?: number; lookupHits?: number; pauseCount?: number; pauseMs?: number; seekForwardCount?: number; seekBackwardCount?: number; mediaBufferEvents?: number; }) => void; }; originalPrepare = privateApi.db.prepare; let prepareCalls = 0; privateApi.db.prepare = (...args: Parameters) => { prepareCalls += 1; return originalPrepare!.apply(privateApi.db, args); }; const preparedRestore = originalPrepare; privateApi.db.exec(` INSERT INTO imm_videos ( video_id, video_key, canonical_title, source_type, duration_ms, created_at_ms, updated_at_ms ) VALUES ( 1, 'local:/tmp/prepared.mkv', 'Prepared', 1, 0, 1000, 1000 ) `); privateApi.db.exec(` INSERT INTO imm_sessions ( session_id, session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms, ended_at_ms ) VALUES ( 1, '33333333-3333-3333-3333-333333333333', 1, 1000, 2, 1000, 1000, 2000 ) `); privateApi.flushSingle({ kind: "telemetry", sessionId: 1, sampleMs: 1500, totalWatchedMs: 1000, activeWatchedMs: 1000, linesSeen: 1, wordsSeen: 2, tokensSeen: 2, cardsMined: 0, lookupCount: 0, lookupHits: 0, pauseCount: 0, pauseMs: 0, seekForwardCount: 0, seekBackwardCount: 0, mediaBufferEvents: 0, }); privateApi.flushSingle({ kind: "event", sessionId: 1, sampleMs: 1600, eventType: 1, lineIndex: 1, segmentStartMs: 0, segmentEndMs: 1000, wordsDelta: 2, cardsDelta: 0, payloadJson: '{"event":"subtitle-line"}', }); privateApi.db.prepare = preparedRestore; assert.equal(prepareCalls, 0); } finally { if (tracker && originalPrepare) { const privateApi = tracker as unknown as { db: NodeDatabaseSync }; privateApi.db.prepare = originalPrepare; } tracker?.destroy(); cleanupDbPath(dbPath); } });