import { describe, it, expect, beforeEach } from 'vitest'; import { FleetUpdateTrackerService, UPDATE_TIMEOUT_MS, TERMINAL_TTL_MS, type UpdateTracker, } from '../services/FleetUpdateTrackerService'; /** Build a tracker literal with sane defaults so tests set only what matters. */ function mk(over: Partial): UpdateTracker { return { status: 'updating', startedAt: Date.now(), previousVersion: null, previousProcessStart: null, wasOffline: false, operationKind: 'update', ...over, }; } // The service is a process-local singleton. Each test clears any entries left // by a prior test so assertions on size() are deterministic. function clearAll(svc: FleetUpdateTrackerService) { for (const [nodeId] of [...svc.entries()]) svc.delete(nodeId); } describe('FleetUpdateTrackerService', () => { let svc: FleetUpdateTrackerService; beforeEach(() => { svc = FleetUpdateTrackerService.getInstance(); clearAll(svc); }); it('returns the same singleton instance', () => { expect(FleetUpdateTrackerService.getInstance()).toBe(svc); }); it('create() starts an updating tracker with startedAt and no resolvedAt', () => { const before = Date.now(); const tracker = svc.create('updating', '1.0.0', 1234); expect(tracker.status).toBe('updating'); expect(tracker.previousVersion).toBe('1.0.0'); expect(tracker.previousProcessStart).toBe(1234); expect(tracker.wasOffline).toBe(false); expect(tracker.startedAt).toBeGreaterThanOrEqual(before); expect(tracker.resolvedAt).toBeUndefined(); }); it('create() with a terminal status sets resolvedAt immediately', () => { const tracker = svc.create('failed', '1.0.0', null, 'pull failed'); expect(tracker.status).toBe('failed'); expect(tracker.error).toBe('pull failed'); expect(tracker.resolvedAt).toBeTypeOf('number'); }); it('set()/get() round-trips a tracker by node id', () => { const tracker = svc.create('updating', '1.0.0', null); svc.set(7, tracker); expect(svc.get(7)).toEqual(tracker); expect(svc.get(999)).toBeUndefined(); }); it('resolve() returns a terminal copy without mutating the original', () => { const original = svc.create('updating', '1.0.0', null); const resolved = svc.resolve(original, 'completed'); expect(resolved).not.toBe(original); expect(original.status).toBe('updating'); expect(original.resolvedAt).toBeUndefined(); expect(resolved.status).toBe('completed'); expect(resolved.resolvedAt).toBeTypeOf('number'); // Carries forward the immutable fields. expect(resolved.startedAt).toBe(original.startedAt); expect(resolved.previousVersion).toBe(original.previousVersion); }); it('resolve() to timeout/failed carries the error message', () => { const original = svc.create('updating', '1.0.0', null); const timedOut = svc.resolve(original, 'timeout', 'did not come back'); expect(timedOut.status).toBe('timeout'); expect(timedOut.error).toBe('did not come back'); }); it('delete() removes a tracker and reports whether one existed', () => { svc.set(3, svc.create('completed', '1.0.0', null)); expect(svc.size()).toBe(1); expect(svc.delete(3)).toBe(true); expect(svc.delete(3)).toBe(false); expect(svc.get(3)).toBeUndefined(); expect(svc.size()).toBe(0); }); it('entries() iterates every stored tracker keyed by node id', () => { svc.set(1, svc.create('updating', '1.0.0', null)); svc.set(2, svc.create('completed', '1.0.0', null)); const ids = [...svc.entries()].map(([id]) => id).sort(); expect(ids).toEqual([1, 2]); expect(svc.size()).toBe(2); }); it('models the full updating -> completed transition the way the poll loop uses it', () => { // create updating, store, then resolve and store the terminal copy. const updating = svc.create('updating', '1.0.0', 100); svc.set(5, updating); expect(svc.get(5)?.status).toBe('updating'); const completed = svc.resolve(svc.get(5)!, 'completed'); svc.set(5, completed); expect(svc.get(5)?.status).toBe('completed'); expect(svc.get(5)?.resolvedAt).toBeTypeOf('number'); }); describe('sweepStale()', () => { it('times out an updating tracker past the ceiling and leaves a fresh one alone', () => { svc.set(1, mk({ status: 'updating', startedAt: Date.now() - (UPDATE_TIMEOUT_MS + 1_000) })); svc.set(2, mk({ status: 'updating', startedAt: Date.now() })); const result = svc.sweepStale(); expect(result.timedOut).toBe(1); expect(svc.get(1)?.status).toBe('timeout'); expect(svc.get(2)?.status).toBe('updating'); }); it('reaps a completed tracker past its TTL but keeps a recent one', () => { svc.set(1, mk({ status: 'completed', resolvedAt: Date.now() - (TERMINAL_TTL_MS + 1_000) })); svc.set(2, mk({ status: 'completed', resolvedAt: Date.now() })); const result = svc.sweepStale(); expect(result.reaped).toBe(1); expect(svc.get(1)).toBeUndefined(); expect(svc.get(2)?.status).toBe('completed'); }); it('never sweeps failed or timeout trackers (the operator must dismiss them)', () => { const old = Date.now() - (TERMINAL_TTL_MS + 60_000); svc.set(1, mk({ status: 'failed', resolvedAt: old })); svc.set(2, mk({ status: 'timeout', resolvedAt: old })); const result = svc.sweepStale(); expect(result).toEqual({ timedOut: 0, reaped: 0 }); expect(svc.get(1)?.status).toBe('failed'); expect(svc.get(2)?.status).toBe('timeout'); }); it('does not reap a completed tracker that has no resolvedAt', () => { svc.set(1, mk({ status: 'completed', resolvedAt: undefined })); expect(svc.sweepStale().reaped).toBe(0); expect(svc.get(1)?.status).toBe('completed'); }); }); });