Files
alarm-client/__tests__/alarmSocket.test.ts
T
2026-10-18 22:42:00 +01:00

248 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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',
autoStopSeconds: 60,
});
expect(parseFrame(raw)).toEqual({
type: 'alarm',
alertId: 'alr_1',
eventId: 'evt_9',
action: 'trigger',
message: 'Fire drill',
serverTime: '2026-10-18T17:00:00Z',
autoStopSeconds: 60,
});
});
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<typeof setTimeout>;
},
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<typeof setTimeout>;
},
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"}']);
});
});