/** * WS reconnect state machine + frame contract tests. * * The ramp under test is the one the service team must expect from the client: * 1s → 2s → 4s → 8s → 16s → 30s (cap), jittered ±25%, reset on a successful * open. */ import { AlarmSocket, DEFAULT_RECONNECT_POLICY, buildAck, buildPing, parseFrame, ReconnectMachine, type AlarmFrame, } from '../src/services/alarmSocket'; // A deterministic "random" that always returns the midpoint, so jitter is 1.0× // and the ramp is exactly the nominal one. const midpoint = () => 0.5; describe('ReconnectMachine ramp', () => { it('follows 1s→2s→4s→8s→16s→30s and saturates at the cap', () => { const m = new ReconnectMachine({}, midpoint); expect(m.nominalDelayFor(1)).toBe(1000); expect(m.nominalDelayFor(2)).toBe(2000); expect(m.nominalDelayFor(3)).toBe(4000); expect(m.nominalDelayFor(4)).toBe(8000); expect(m.nominalDelayFor(5)).toBe(16000); expect(m.nominalDelayFor(6)).toBe(30000); expect(m.nominalDelayFor(7)).toBe(30000); expect(m.nominalDelayFor(50)).toBe(30000); }); it('applies the configured jitter band', () => { const low = new ReconnectMachine({}, () => 0).nominalDelayFor(3); const high = new ReconnectMachine({}, () => 1).nominalDelayFor(3); const lo = new ReconnectMachine({}, () => 0).delayFor(3); const hi = new ReconnectMachine({}, () => 1).delayFor(3); expect(lo).toBe(Math.round(low * (1 - DEFAULT_RECONNECT_POLICY.jitter))); expect(hi).toBe(Math.round(high * (1 + DEFAULT_RECONNECT_POLICY.jitter))); }); it('reports the same delay through onClosed as delayFor(attempt)', () => { const m = new ReconnectMachine({}, midpoint); expect(m.onClosed()).toBe(1000); expect(m.onClosed()).toBe(2000); expect(m.onClosed()).toBe(4000); }); it('resets the ramp after a successful open', () => { const m = new ReconnectMachine({}, midpoint); m.onClosed(); m.onClosed(); m.onClosed(); expect(m.getAttempts()).toBe(3); m.onOpened(); expect(m.getAttempts()).toBe(0); expect(m.getState()).toBe('open'); expect(m.onClosed()).toBe(1000); }); it('walks the state machine idle → connecting → open → reconnecting', () => { const m = new ReconnectMachine({}, midpoint); expect(m.getState()).toBe('idle'); m.onConnecting(); expect(m.getState()).toBe('connecting'); m.onOpened(); expect(m.getState()).toBe('open'); m.onClosed(); expect(m.getState()).toBe('reconnecting'); m.onClosedPermanently(); expect(m.getState()).toBe('closed'); }); it('never returns a negative delay', () => { const m = new ReconnectMachine({ baseMs: 0 }, midpoint); expect(m.delayFor(1)).toBeGreaterThanOrEqual(0); }); }); describe('frame contract', () => { it('parses a trigger frame', () => { const raw = JSON.stringify({ type: 'alarm', alertId: 'alr_1', eventId: 'evt_9', action: 'trigger', message: 'Fire drill', serverTime: '2026-10-18T17:00:00Z', }); expect(parseFrame(raw)).toEqual({ type: 'alarm', alertId: 'alr_1', eventId: 'evt_9', action: 'trigger', message: 'Fire drill', serverTime: '2026-10-18T17:00:00Z', }); }); it('ignores a legacy autoStopSeconds field instead of carrying it forward', () => { // The service no longer sends it; a stale sender must not resurrect the // "alarm expires on a timer" behaviour in any consumer. const frame = parseFrame( JSON.stringify({ type: 'alarm', alertId: 'a', eventId: 'e', action: 'trigger', autoStopSeconds: 60, }), ); expect(frame.type).toBe('alarm'); expect(frame).not.toHaveProperty('autoStopSeconds'); }); it('parses a disarm frame', () => { const frame = parseFrame( JSON.stringify({ type: 'alarm', alertId: 'a', eventId: 'e', action: 'disarm' }), ); expect(frame.type).toBe('alarm'); expect(frame.type === 'alarm' && frame.action).toBe('disarm'); }); it('parses ack and ping frames', () => { expect(parseFrame('{"type":"ack","eventId":"e1"}')).toEqual({ type: 'ack', eventId: 'e1' }); expect(parseFrame('{"type":"ping"}')).toEqual({ type: 'ping' }); }); it('degrades malformed / unknown frames to unknown instead of throwing', () => { for (const junk of [ '', 'not json', '[]', 'null', '{"type":"alarm"}', '{"type":"alarm","alertId":"a","eventId":"e","action":"explode"}', '{"type":"ack"}', '{"type":"something-else"}', ]) { expect(parseFrame(junk)).toEqual({ type: 'unknown' }); } }); it('builds the client→server frames exactly', () => { expect(buildAck('evt_9')).toBe('{"type":"ack","eventId":"evt_9"}'); expect(buildPing()).toBe('{"type":"ping"}'); }); }); // ── A minimal fake WebSocket so the wrapper is exercised without a network ─── class FakeSocket { static last: FakeSocket | null = null; onopen: (() => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; onerror: (() => void) | null = null; onclose: (() => void) | null = null; sent: string[] = []; closed = false; constructor(public url: string) { FakeSocket.last = this; } send(data: string) { this.sent.push(data); } close() { this.closed = true; this.onclose?.(); } // test helpers open() { this.onopen?.(); } message(data: unknown) { this.onmessage?.({ data }); } drop() { this.onclose?.(); } } describe('AlarmSocket wrapper', () => { const url = 'ws://127.0.0.1:8088/ws/alarm?alertId=alr_1&token=sub_x'; it('acks a received alarm frame automatically', () => { const alarms: AlarmFrame[] = []; const socket = new AlarmSocket({ url, onAlarm: f => alarms.push(f), socketFactory: u => new FakeSocket(u) as unknown as WebSocket, random: midpoint, pingIntervalMs: 0, }); socket.connect(); const ws = FakeSocket.last!; ws.open(); ws.message(JSON.stringify({ type: 'alarm', alertId: 'alr_1', eventId: 'evt_1', action: 'trigger' })); expect(alarms).toHaveLength(1); expect(ws.sent).toEqual(['{"type":"ack","eventId":"evt_1"}']); expect(socket.getState()).toBe('open'); }); it('schedules a reconnect with the ramp delay after a drop', () => { const delays: number[] = []; const socket = new AlarmSocket({ url, onAlarm: () => {}, socketFactory: u => new FakeSocket(u) as unknown as WebSocket, random: midpoint, pingIntervalMs: 0, setTimer: (fn, ms) => { delays.push(ms); return 0 as unknown as ReturnType; }, clearTimer: () => {}, }); socket.connect(); FakeSocket.last!.open(); FakeSocket.last!.drop(); FakeSocket.last!.drop(); expect(delays).toEqual([1000, 2000]); expect(socket.getState()).toBe('reconnecting'); }); it('stops reconnecting once closed by the user', () => { let timers = 0; const socket = new AlarmSocket({ url, onAlarm: () => {}, socketFactory: u => new FakeSocket(u) as unknown as WebSocket, random: midpoint, pingIntervalMs: 0, setTimer: () => { timers += 1; return 0 as unknown as ReturnType; }, clearTimer: () => {}, }); socket.connect(); FakeSocket.last!.open(); socket.close(); expect(socket.getState()).toBe('closed'); expect(timers).toBe(0); }); it('does not send when the socket is not open', () => { const socket = new AlarmSocket({ url, onAlarm: () => {}, socketFactory: u => new FakeSocket(u) as unknown as WebSocket, random: midpoint, pingIntervalMs: 0, }); socket.connect(); expect(socket.send('{"type":"ping"}')).toBe(false); FakeSocket.last!.open(); expect(socket.send('{"type":"ping"}')).toBe(true); expect(FakeSocket.last!.sent).toEqual(['{"type":"ping"}']); }); });