This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
/**
* 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"}']);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Error plumbing: the service answers `{"error":{"code":…,"message":…}}`, and
* the join path has to tell the user something true without naming a mechanism
* it cannot observe. These tests pin both halves.
*/
import { ApiError, configureApi, post } from '../src/services/apiClient';
import { joinErrorMessage } from '../src/services/alarmApi';
const g = globalThis as unknown as { fetch: typeof fetch };
const realFetch = g.fetch;
function stubFetch(body: string, status = 400): void {
g.fetch = (async () => ({
ok: false,
status,
text: async () => body,
})) as unknown as typeof fetch;
}
beforeEach(() => {
configureApi({
baseUrl: 'http://alarm.test',
mode: 'direct',
serviceRef: null,
token: null,
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
});
});
afterEach(() => {
g.fetch = realFetch;
});
describe('apiClient error unwrapping', () => {
it('extracts the service error code and the clean message', async () => {
stubFetch(
JSON.stringify({
error: { code: 'ERR_ALERT_TOKEN_INVALID', message: 'invalid subscribe token' },
}),
);
await expect(post('joinAlert', {})).rejects.toMatchObject({
name: 'ApiError',
status: 400,
code: 'ERR_ALERT_TOKEN_INVALID',
message: 'invalid subscribe token',
});
});
it('keeps a non-JSON body as the message and reports no code', async () => {
stubFetch('Bad Gateway', 502);
await expect(post('joinAlert', {})).rejects.toMatchObject({
status: 502,
code: null,
message: 'Bad Gateway',
});
});
});
describe('joinErrorMessage', () => {
it('renders a token problem as a remedy, never as "expired"', () => {
// JoinAlert returns this one code for a rotated token AND for a malformed
// one, so the client cannot say which happened.
const err = new ApiError(400, 'invalid subscribe token', 'ERR_ALERT_TOKEN_INVALID');
const copy = joinErrorMessage(err);
expect(copy).toBe('This code is no longer valid — ask the owner for a new one.');
expect(copy).not.toMatch(/expired/i);
});
it('passes through any other server message', () => {
expect(joinErrorMessage(new ApiError(404, 'alert not found', 'ERR_NOT_FOUND'))).toBe(
'alert not found',
);
});
it('falls back for errors that are not ApiError instances', () => {
expect(joinErrorMessage(new Error('boom'))).toBe('boom');
expect(joinErrorMessage(undefined)).toBe('Could not subscribe this device.');
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* QR payload parser tests — the frozen payload contract.
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
*/
import {
buildQrPayload,
buildQrSchemePayload,
parseManualToken,
parseQrPayload,
qrErrorMessage,
truncateToken,
QR_HOST,
} from '../src/lib/qrPayload';
const ID = 'alr_7f3c9a1b2d';
const TOKEN = 'sub_9Qx2mZp4Lk7VeR1sTn0';
describe('buildQrPayload', () => {
it('renders the canonical contract payload', () => {
expect(buildQrPayload(ID, TOKEN)).toBe(`https://${QR_HOST}/a/${ID}#${TOKEN}`);
});
it('renders the custom-scheme variant', () => {
expect(buildQrSchemePayload(ID, TOKEN)).toBe(`dcntrlized-alarm://a/${ID}#${TOKEN}`);
});
});
describe('parseQrPayload', () => {
it('parses the canonical payload', () => {
const res = parseQrPayload(buildQrPayload(ID, TOKEN));
expect(res).toEqual({
ok: true,
payload: { alertId: ID, subscribeToken: TOKEN, source: 'url' },
});
});
it('parses the custom scheme', () => {
const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN));
expect(res.ok && res.payload.alertId).toBe(ID);
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
expect(res.ok && res.payload.source).toBe('scheme');
});
it('accepts a relative a/{id}#{token} payload', () => {
const res = parseQrPayload(`a/${ID}#${TOKEN}`);
expect(res.ok && res.payload.source).toBe('relative');
expect(res.ok && res.payload.alertId).toBe(ID);
});
it('accepts a raw {id}#{token} payload (what the owner reads off the panel)', () => {
const res = parseQrPayload(`${ID}#${TOKEN}`);
expect(res.ok && res.payload.source).toBe('raw');
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
});
it('tolerates surrounding whitespace and a trailing slash', () => {
const res = parseQrPayload(` https://${QR_HOST}/a/${ID}/#${TOKEN} `);
expect(res.ok && res.payload.alertId).toBe(ID);
});
it('tolerates query noise before the fragment', () => {
const res = parseQrPayload(`https://${QR_HOST}/a/${ID}?utm=x#${TOKEN}`);
expect(res.ok && res.payload.alertId).toBe(ID);
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
});
it('accepts a subdomain of the alarm host (staging)', () => {
const res = parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`);
expect(res.ok).toBe(true);
});
it('accepts localhost / LAN dev payloads', () => {
expect(parseQrPayload(`http://127.0.0.1:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
expect(parseQrPayload(`http://192.168.1.232:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
});
it('rejects a foreign host', () => {
expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`)).toEqual({
ok: false,
error: 'not-an-alarm-code',
});
});
it('rejects a foreign scheme', () => {
expect(parseQrPayload(`ftp://${QR_HOST}/a/${ID}#${TOKEN}`)).toEqual({
ok: false,
error: 'not-an-alarm-code',
});
});
it('reports an empty payload', () => {
expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' });
});
it('reports a missing token', () => {
expect(parseQrPayload(`https://${QR_HOST}/a/${ID}`)).toEqual({
ok: false,
error: 'missing-token',
});
});
it('reports a missing alert id', () => {
expect(parseQrPayload(`https://${QR_HOST}/a/#${TOKEN}`)).toEqual({
ok: false,
error: 'missing-alert-id',
});
});
it('never throws on garbage', () => {
for (const junk of ['###', 'https://', '://a/b#c', '\u0000', '{"json":true}']) {
expect(() => parseQrPayload(junk)).not.toThrow();
}
});
it('round-trips ids that need encoding', () => {
const weird = 'alr/with space';
const built = buildQrPayload(weird, TOKEN);
const res = parseQrPayload(built);
expect(res.ok && res.payload.alertId).toBe(weird);
});
});
describe('parseManualToken', () => {
it('accepts a bare token when the alert id is known', () => {
const res = parseManualToken(TOKEN, ID);
expect(res).toEqual({
ok: true,
payload: { alertId: ID, subscribeToken: TOKEN, source: 'raw' },
});
});
it('accepts a full payload even when an id is also supplied', () => {
const res = parseManualToken(buildQrPayload(ID, TOKEN), 'other');
expect(res.ok && res.payload.alertId).toBe(ID);
});
it('fails a bare token with no known alert id', () => {
expect(parseManualToken(TOKEN, '')).toEqual({ ok: false, error: 'missing-alert-id' });
});
it('fails empty input', () => {
expect(parseManualToken('', ID)).toEqual({ ok: false, error: 'empty' });
});
});
describe('copy helpers', () => {
it('truncates tokens to 12 chars + ellipsis', () => {
expect(truncateToken('0123456789abcdef')).toBe('0123456789ab…');
expect(truncateToken('short')).toBe('short');
});
it('has a message for every error', () => {
for (const err of ['empty', 'not-an-alarm-code', 'missing-alert-id', 'missing-token'] as const) {
expect(qrErrorMessage(err).length).toBeGreaterThan(0);
}
});
});
+62
View File
@@ -0,0 +1,62 @@
import { parseServerTime, relativeTime, relativeSince, absoluteLocal } from '../src/lib/time';
const NOW = Date.parse('2026-10-18T20:00:00Z');
const at = (msAgo: number) => new Date(NOW - msAgo);
describe('parseServerTime', () => {
it('parses RFC3339', () => {
expect(parseServerTime('2026-10-18T19:59:00Z')?.getTime()).toBe(
Date.parse('2026-10-18T19:59:00Z'),
);
});
it('returns null for absent or garbage values', () => {
expect(parseServerTime(undefined)).toBeNull();
expect(parseServerTime(null)).toBeNull();
expect(parseServerTime('')).toBeNull();
expect(parseServerTime('not-a-date')).toBeNull();
});
});
describe('relativeTime', () => {
it('says "just now" under a second, including for clock skew', () => {
expect(relativeTime(at(0), NOW)).toBe('just now');
expect(relativeTime(at(500), NOW)).toBe('just now');
// A phone clock ahead of the server must never render "in 3 seconds".
expect(relativeTime(at(-5000), NOW)).toBe('just now');
});
it('counts seconds under a minute', () => {
expect(relativeTime(at(1000), NOW)).toBe('1s ago');
expect(relativeTime(at(2000), NOW)).toBe('2s ago');
expect(relativeTime(at(59000), NOW)).toBe('59s ago');
});
it('counts minutes, hours and days above that', () => {
expect(relativeTime(at(60000), NOW)).toBe('1m ago');
expect(relativeTime(at(13 * 60000 + 42000), NOW)).toBe('13m ago');
expect(relativeTime(at(3600000), NOW)).toBe('1h ago');
expect(relativeTime(at(47 * 3600000), NOW)).toBe('1d ago');
expect(relativeTime(at(3 * 86400000), NOW)).toBe('3d ago');
});
});
describe('relativeSince', () => {
it('formats a server string', () => {
const iso = new Date(NOW - 2000).toISOString();
expect(relativeSince(iso, NOW)).toBe('2s ago');
});
it('returns null when there is nothing to format', () => {
expect(relativeSince(undefined, NOW)).toBeNull();
expect(relativeSince('nope', NOW)).toBeNull();
});
});
describe('absoluteLocal', () => {
it('renders zero-padded local hh:mm:ss', () => {
const d = new Date(2026, 9, 18, 8, 5, 3);
expect(absoluteLocal(d)).toBe('08:05:03');
});
});