Add central notification channels, rules, queue and delivery logs, fleet organization groups and tags, device discovery, the device action catalog, instance module reads, the log centre, connection settings and system maintenance as native /api/v1 routes backed by the existing secret store, audit trail and pinned upstream transport.
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
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<number, readonly Instance[]>;
|
|
readonly pageSize: number;
|
|
readonly probe?: (id: string) => Promise<void>;
|
|
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<void>((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);
|
|
});
|
|
});
|