import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Instance, InstancePage } from '@multi-simadmin/contracts'; import type { InstanceService } from '../instances/instance-service.js'; import type { ConnectionProbe } from './connection-probe.js'; import type { ConnectionSettingsService } from './connection-settings-service.js'; import { FleetHeartbeatCoordinator } from './fleet-heartbeat.js'; function instance(id: string): Instance { return { id, name: id, origin: `http://10.0.0.1:3000`, authMode: 'none', configRevision: 1, createdAt: '2026-09-05T00:00:00.000Z', updatedAt: '2026-09-05T00:00:00.000Z', } as unknown as Instance; } function page(items: readonly Instance[], pageSize: number): InstancePage { return { items: [...items], page: { page: 1, pageSize, total: items.length } }; } function coordinator(options: { readonly pages: ReadonlyMap; readonly pageSize: number; readonly probe?: (id: string) => Promise; readonly heartbeatMs?: number; readonly concurrency?: number; }) { const listed: string[] = []; const instances = { async list(query: { page?: number; pageSize?: number }) { const pageSize = options.pageSize; const current = query.page ?? 1; listed.push(String(current)); return page(options.pages.get(current) ?? [], pageSize); }, } as unknown as InstanceService; const probed: string[] = []; const probe = { async test(id: string) { probed.push(id); await options.probe?.(id); return { instanceId: id, authenticated: true, checkedAt: '2026-09-05T00:00:00.000Z' }; }, } as unknown as ConnectionProbe; const settings = { heartbeatMs: options.heartbeatMs ?? 30_000, } as unknown as ConnectionSettingsService; const beat = new FleetHeartbeatCoordinator({ instances, probe, settings, pageSize: options.pageSize, now: () => new Date('2026-09-05T00:00:00.000Z'), ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), }); return { beat, listed, probed }; } afterEach(() => { vi.useRealTimers(); }); describe('FleetHeartbeatCoordinator', () => { it('probes every instance once across pages', async () => { const { beat, listed, probed } = coordinator({ pageSize: 2, pages: new Map([ [1, [instance('a'), instance('b')]], [2, [instance('c')]], ]), }); await expect(beat.runOnce()).resolves.toEqual({ probed: 3, failed: 0, startedAt: '2026-09-05T00:00:00.000Z', finishedAt: '2026-09-05T00:00:00.000Z', }); expect(listed).toEqual(['1', '2']); expect([...probed].sort()).toEqual(['a', 'b', 'c']); }); it('stops paging on a short page and probes a duplicated id once', async () => { const { beat, listed, probed } = coordinator({ pageSize: 2, pages: new Map([[1, [instance('a'), instance('a')]]]), }); await beat.runOnce(); expect(listed).toEqual(['1', '2']); expect(probed).toEqual(['a']); }); it('counts a failing device without abandoning the rest of the fleet', async () => { const { beat } = coordinator({ pageSize: 10, concurrency: 1, pages: new Map([[1, [instance('a'), instance('b'), instance('c')]]]), probe: async (id) => { if (id === 'b') throw new Error('ECONNREFUSED'); }, }); await expect(beat.runOnce()).resolves.toMatchObject({ probed: 2, failed: 1 }); }); it('shares one pass between concurrent refresh requests', async () => { let release: (() => void) | undefined; const gate = new Promise((resolve) => { release = resolve; }); const { beat, probed } = coordinator({ pageSize: 10, concurrency: 1, pages: new Map([[1, [instance('a')]]]), probe: () => gate, }); const first = beat.runOnce(); const second = beat.runOnce(); release?.(); await Promise.all([first, second]); expect(probed).toEqual(['a']); }); it('runs a beat on the configured cadence and stops cleanly', async () => { vi.useFakeTimers(); const { beat, probed } = coordinator({ pageSize: 10, pages: new Map([[1, [instance('a')]]]), heartbeatMs: 5_000, }); beat.start(); beat.start(); await vi.advanceTimersByTimeAsync(5_000); expect(probed).toEqual(['a']); await vi.advanceTimersByTimeAsync(5_000); expect(probed).toEqual(['a', 'a']); beat.stop(); await vi.advanceTimersByTimeAsync(60_000); expect(probed).toHaveLength(2); }); it('refuses an unusable worker or page size', () => { const base = { instances: {} as unknown as InstanceService, probe: {} as unknown as ConnectionProbe, settings: {} as unknown as ConnectionSettingsService, }; expect(() => new FleetHeartbeatCoordinator({ ...base, concurrency: 0 })).toThrow(RangeError); expect(() => new FleetHeartbeatCoordinator({ ...base, concurrency: 17 })).toThrow(RangeError); expect(() => new FleetHeartbeatCoordinator({ ...base, pageSize: 0 })).toThrow(RangeError); expect(() => new FleetHeartbeatCoordinator({ ...base, pageSize: 101 })).toThrow(RangeError); }); });