248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
/**
|
||
* 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"}']);
|
||
});
|
||
});
|