Skip to content
Merged
248 changes: 248 additions & 0 deletions src/__tests__/main/cue/cue-cleanup-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* Unit tests for CueCleanupService.
*
* Tests cover:
* - No-op sweep when nothing is stale
* - Eviction of fan-in trackers for removed sessions
* - Eviction of fan-in trackers exceeding 2× timeout
* - Non-eviction of recent fan-in trackers
* - Eviction of stale scheduled dedup keys
* - onTick cadence (sweeps only every CLEANUP_INTERVAL_TICKS ticks)
* - sweep() can be called directly (bypasses tick counter)
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
createCueCleanupService,
CLEANUP_INTERVAL_TICKS,
type CueCleanupServiceDeps,
} from '../../../main/cue/cue-cleanup-service';
import type { CueFanInTracker } from '../../../main/cue/cue-fan-in-tracker';
import type { CueSessionRegistry } from '../../../main/cue/cue-session-registry';

// ─── Helpers ─────────────────────────────────────────────────────────────────

function makeMockTracker(overrides: Partial<CueFanInTracker> = {}): CueFanInTracker {
return {
handleCompletion: vi.fn(),
clearForSession: vi.fn(),
reset: vi.fn(),
getActiveTrackerKeys: vi.fn(() => []),
getTrackerCreatedAt: vi.fn(() => undefined),
expireTracker: vi.fn(),
...overrides,
};
}

function makeMockRegistry(overrides: Partial<CueSessionRegistry> = {}): CueSessionRegistry {
return {
register: vi.fn(),
unregister: vi.fn(),
get: vi.fn(() => undefined),
has: vi.fn(() => false),
snapshot: vi.fn(() => new Map()),
size: vi.fn(() => 0),
markScheduledFired: vi.fn(() => true),
evictStaleScheduledKeys: vi.fn(),
clearScheduledForSession: vi.fn(),
markStartupFired: vi.fn(() => true),
clearStartupForSession: vi.fn(),
clear: vi.fn(),
sweepStaleScheduledKeys: vi.fn(() => 0),
...overrides,
};
}

function makeDeps(overrides: Partial<CueCleanupServiceDeps> = {}): CueCleanupServiceDeps {
return {
fanInTracker: makeMockTracker(),
registry: makeMockRegistry(),
getSessions: vi.fn(() => []),
getSessionTimeoutMs: vi.fn(() => 30 * 60 * 1000),
getCurrentMinute: vi.fn(() => '09:00'),
onLog: vi.fn(),
...overrides,
};
}

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('createCueCleanupService', () => {
beforeEach(() => {
vi.useFakeTimers();
});

describe('sweep — no-op cases', () => {
it('returns zero counts when no trackers or keys exist', () => {
const deps = makeDeps();
const service = createCueCleanupService(deps);
const result = service.sweep();
expect(result).toEqual({ fanInEvicted: 0, scheduledKeysEvicted: 0 });
});

it('does not evict a recent fan-in tracker', () => {
const tracker = makeMockTracker({
getActiveTrackerKeys: vi.fn(() => ['session-1:sub-a']),
getTrackerCreatedAt: vi.fn(() => Date.now() - 1000), // 1 second old
expireTracker: vi.fn(),
});
const deps = makeDeps({
fanInTracker: tracker,
getSessions: () => [{ id: 'session-1' }],
getSessionTimeoutMs: () => 30 * 60 * 1000, // 30 min — 2× = 60 min
});
const service = createCueCleanupService(deps);
const result = service.sweep();

expect(tracker.expireTracker).not.toHaveBeenCalled();
expect(result.fanInEvicted).toBe(0);
});
});

describe('sweep — fan-in eviction', () => {
it('evicts a fan-in tracker whose owner session is no longer active', () => {
const tracker = makeMockTracker({
getActiveTrackerKeys: vi.fn(() => ['removed-session:sub-a']),
getTrackerCreatedAt: vi.fn(() => Date.now()),
expireTracker: vi.fn(),
});
const deps = makeDeps({
fanInTracker: tracker,
getSessions: () => [], // removed-session is gone
});
const service = createCueCleanupService(deps);
const result = service.sweep();

expect(tracker.expireTracker).toHaveBeenCalledWith('removed-session:sub-a');
expect(result.fanInEvicted).toBe(1);
expect(deps.onLog).toHaveBeenCalledWith('warn', expect.stringContaining('removed session'));
});

it('evicts a fan-in tracker whose age exceeds 2× the session timeout', () => {
const timeoutMs = 30 * 60 * 1000; // 30 minutes
const createdAt = Date.now() - 3 * timeoutMs; // 90 minutes ago > 2× timeout
const tracker = makeMockTracker({
getActiveTrackerKeys: vi.fn(() => ['session-1:sub-a']),
getTrackerCreatedAt: vi.fn(() => createdAt),
expireTracker: vi.fn(),
});
const deps = makeDeps({
fanInTracker: tracker,
getSessions: () => [{ id: 'session-1' }],
getSessionTimeoutMs: () => timeoutMs,
});
const service = createCueCleanupService(deps);
const result = service.sweep();

expect(tracker.expireTracker).toHaveBeenCalledWith('session-1:sub-a');
expect(result.fanInEvicted).toBe(1);
expect(deps.onLog).toHaveBeenCalledWith('warn', expect.stringContaining('2× timeout'));
});

it('handles a key without a colon (edge case) by treating the whole key as session ID', () => {
const tracker = makeMockTracker({
getActiveTrackerKeys: vi.fn(() => ['orphaned-key']),
getTrackerCreatedAt: vi.fn(() => undefined),
expireTracker: vi.fn(),
});
const deps = makeDeps({
fanInTracker: tracker,
getSessions: () => [], // no sessions
});
const service = createCueCleanupService(deps);
const result = service.sweep();

// Should be evicted because "orphaned-key" is not in active sessions
expect(tracker.expireTracker).toHaveBeenCalledWith('orphaned-key');
expect(result.fanInEvicted).toBe(1);
});
});

describe('sweep — scheduled key eviction', () => {
it('reports evicted scheduled key count from registry.sweepStaleScheduledKeys', () => {
const registry = makeMockRegistry({
sweepStaleScheduledKeys: vi.fn(() => 3),
});
const deps = makeDeps({ registry });
const service = createCueCleanupService(deps);
const result = service.sweep();

expect(result.scheduledKeysEvicted).toBe(3);
expect(registry.sweepStaleScheduledKeys).toHaveBeenCalledWith('09:00');
expect(deps.onLog).toHaveBeenCalledWith(
'info',
expect.stringContaining('3 stale scheduled key')
);
});

it('passes the current minute from getCurrentMinute to the registry sweep', () => {
const registry = makeMockRegistry({ sweepStaleScheduledKeys: vi.fn(() => 0) });
const deps = makeDeps({
registry,
getCurrentMinute: () => '14:32',
});
const service = createCueCleanupService(deps);
service.sweep();

expect(registry.sweepStaleScheduledKeys).toHaveBeenCalledWith('14:32');
});
});

describe('onTick cadence', () => {
it('does not sweep before CLEANUP_INTERVAL_TICKS ticks', () => {
const registry = makeMockRegistry({ sweepStaleScheduledKeys: vi.fn(() => 0) });
const tracker = makeMockTracker({ getActiveTrackerKeys: vi.fn(() => []) });
const deps = makeDeps({ registry, fanInTracker: tracker });
const service = createCueCleanupService(deps);

for (let i = 0; i < CLEANUP_INTERVAL_TICKS - 1; i++) {
service.onTick();
}

expect(tracker.getActiveTrackerKeys).not.toHaveBeenCalled();
expect(registry.sweepStaleScheduledKeys).not.toHaveBeenCalled();
});

it('triggers a sweep on exactly the Nth tick', () => {
const registry = makeMockRegistry({ sweepStaleScheduledKeys: vi.fn(() => 0) });
const tracker = makeMockTracker({ getActiveTrackerKeys: vi.fn(() => []) });
const deps = makeDeps({ registry, fanInTracker: tracker });
const service = createCueCleanupService(deps);

for (let i = 0; i < CLEANUP_INTERVAL_TICKS; i++) {
service.onTick();
}

expect(tracker.getActiveTrackerKeys).toHaveBeenCalledTimes(1);
expect(registry.sweepStaleScheduledKeys).toHaveBeenCalledTimes(1);
});

it('sweeps again after another full interval', () => {
const registry = makeMockRegistry({ sweepStaleScheduledKeys: vi.fn(() => 0) });
const tracker = makeMockTracker({ getActiveTrackerKeys: vi.fn(() => []) });
const deps = makeDeps({ registry, fanInTracker: tracker });
const service = createCueCleanupService(deps);

for (let i = 0; i < CLEANUP_INTERVAL_TICKS * 2; i++) {
service.onTick();
}

expect(registry.sweepStaleScheduledKeys).toHaveBeenCalledTimes(2);
});
});

describe('sweep — direct invocation', () => {
it('sweep() bypasses the tick counter and runs immediately', () => {
const registry = makeMockRegistry({ sweepStaleScheduledKeys: vi.fn(() => 0) });
const tracker = makeMockTracker({ getActiveTrackerKeys: vi.fn(() => []) });
const deps = makeDeps({ registry, fanInTracker: tracker });
const service = createCueCleanupService(deps);

// No ticks fired — sweep still runs when called directly
service.sweep();

expect(tracker.getActiveTrackerKeys).toHaveBeenCalledTimes(1);
expect(registry.sweepStaleScheduledKeys).toHaveBeenCalledTimes(1);
});
});
});
2 changes: 2 additions & 0 deletions src/__tests__/main/cue/cue-completion-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ vi.mock('../../../main/cue/cue-db', () => ({
pruneCueEvents: vi.fn(),
recordCueEvent: vi.fn(),
updateCueEventStatus: vi.fn(),
safeRecordCueEvent: vi.fn(),
safeUpdateCueEventStatus: vi.fn(),
}));

// Mock reconciler (not exercised in these tests, but avoids heavy imports)
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/main/cue/cue-concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ vi.mock('../../../main/cue/cue-db', () => ({
isCueDbReady: () => true,
recordCueEvent: vi.fn(),
updateCueEventStatus: vi.fn(),
safeRecordCueEvent: vi.fn(),
safeUpdateCueEventStatus: vi.fn(),
}));

// Mock crypto
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/main/cue/cue-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
hasAnyGitHubSeen,
pruneGitHubSeen,
clearGitHubSeenForSubscription,
safeRecordCueEvent,
safeUpdateCueEventStatus,
} from '../../../main/cue/cue-db';

beforeEach(() => {
Expand Down Expand Up @@ -418,3 +420,76 @@ describe('cue-db github seen tracking', () => {
expect(lastRun[0]).toBe('sub-1');
});
});

describe('safeRecordCueEvent', () => {
const dbPath = path.join(os.tmpdir(), 'test-cue-safe.db');

beforeEach(() => {
initCueDb(undefined, dbPath);
vi.clearAllMocks();
runCalls.length = 0;
prepareCalls.length = 0;
});

const testEvent = {
id: 'safe-evt-1',
type: 'time.heartbeat',
triggerName: 'test-trigger',
sessionId: 'session-1',
subscriptionName: 'test-sub',
status: 'running',
} as const;

it('calls through successfully when DB is ready', () => {
safeRecordCueEvent(testEvent);
expect(mockDb.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR REPLACE INTO cue_events')
);
});

it('logs warn and does not throw when underlying function throws', () => {
mockStatement.run.mockImplementationOnce(() => {
throw new Error('DB locked');
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => safeRecordCueEvent(testEvent)).not.toThrow();
consoleSpy.mockRestore();
});

it('does not throw when DB is unavailable (not initialized)', () => {
closeCueDb();
expect(() => safeRecordCueEvent(testEvent)).not.toThrow();
});
});

describe('safeUpdateCueEventStatus', () => {
const dbPath = path.join(os.tmpdir(), 'test-cue-safe-update.db');

beforeEach(() => {
initCueDb(undefined, dbPath);
vi.clearAllMocks();
runCalls.length = 0;
prepareCalls.length = 0;
});

it('calls through successfully when DB is ready', () => {
safeUpdateCueEventStatus('evt-1', 'completed');
expect(mockDb.prepare).toHaveBeenCalledWith(
expect.stringContaining('UPDATE cue_events SET status')
);
});

it('logs warn and does not throw when underlying function throws', () => {
mockStatement.run.mockImplementationOnce(() => {
throw new Error('DB locked');
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => safeUpdateCueEventStatus('evt-1', 'completed')).not.toThrow();
consoleSpy.mockRestore();
});

it('does not throw when DB is unavailable (not initialized)', () => {
closeCueDb();
expect(() => safeUpdateCueEventStatus('evt-1', 'completed')).not.toThrow();
});
});
Loading