Files
buzz/desktop/src/shared/api/relayReconnectController.test.mjs
T

517 lines
16 KiB
JavaScript

/**
* Unit tests for RelayReconnectController.
*
* Tests the production controller class with injected dependencies — no
* React, no Tauri, no DOM required. Covers the three-phase strategy,
* cancellation token correctness, single-flight guard, and state transitions.
*/
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { RelayReconnectController } from "./relayReconnectController.ts";
// ── Dep builder helpers ───────────────────────────────────────────────────────
/** Build a minimal deps object with controllable preconnect behaviour. */
function makeDeps({
preconnectResult = async () => {},
hookConfiguredResult = async () => false,
runHookResult = async () => {},
connectionStateListener = null,
} = {}) {
const timers = [];
const intervals = [];
let timerSeq = 0;
let intervalSeq = 0;
const deps = {
preconnect: mock.fn(preconnectResult),
hookConfigured: mock.fn(hookConfiguredResult),
runHook: mock.fn(runHookResult),
subscribeToConnectionState: mock.fn((listener) => {
if (connectionStateListener) connectionStateListener.set(listener);
return () => {};
}),
onSuccess: mock.fn(),
onBackstop: mock.fn(),
setTimeout: mock.fn((fn, ms) => {
const id = ++timerSeq;
timers.push({ id, fn, ms });
return id;
}),
clearTimeout: mock.fn((id) => {
const idx = timers.findIndex((t) => t.id === id);
if (idx !== -1) timers.splice(idx, 1);
}),
setInterval: mock.fn((fn, ms) => {
const id = ++intervalSeq;
intervals.push({ id, fn, ms });
return id;
}),
clearInterval: mock.fn((id) => {
const idx = intervals.findIndex((t) => t.id === id);
if (idx !== -1) intervals.splice(idx, 1);
}),
// Test helpers to fire timers/intervals.
_fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
_fireInterval: (id) => intervals.find((t) => t.id === id)?.fn(),
_timers: timers,
_intervals: intervals,
};
return deps;
}
// ── Phase 1: fast path ────────────────────────────────────────────────────────
test("fast-path success — hook never invoked, onSuccess fires, state resets", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
const result = await ctrl.start(deps);
assert.equal(result, true, "start() returns true on fast-path success");
assert.equal(deps.hookConfigured.mock.calls.length, 0, "hook not queried");
assert.equal(deps.runHook.mock.calls.length, 0, "hook not run");
assert.equal(deps.onSuccess.mock.calls.length, 1, "onSuccess fired");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
test("fast-path success — no poll interval or backstop scheduled", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
assert.equal(deps.setInterval.mock.calls.length, 0, "no poll interval");
// The fast-path withDeadline schedules one timeout for the deadline, but
// that is cleared by finally(). After success no backstop should remain.
assert.equal(
deps._timers.length,
0,
"no outstanding timers after fast-path win",
);
});
// ── Phase 2: escalation ───────────────────────────────────────────────────────
test("escalation fires only when fast path fails and hook is configured", async () => {
const ctrl = new RelayReconnectController();
let callIdx = 0;
const deps = makeDeps({
preconnectResult: async () => {
// First call (fast path) fails; subsequent calls (poll) succeed.
if (++callIdx === 1) throw new Error("relay unreachable");
},
hookConfiguredResult: async () => true,
});
// Don't await — it enters phase 3 and returns false.
const promise = ctrl.start(deps);
// Let the async phases run.
await promise;
assert.equal(
deps.hookConfigured.mock.calls.length,
1,
"hook-configured checked once",
);
assert.equal(deps.runHook.mock.calls.length, 1, "hook run once");
});
test("escalation skipped when hook not configured", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"hook not run when not configured",
);
});
test("hook failure is non-fatal — polling still starts", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => true,
runHookResult: async () => {
throw new Error("hook blew up");
},
});
// Should not throw.
let threw = false;
try {
await ctrl.start(deps);
} catch {
threw = true;
}
assert.equal(threw, false, "hook failure does not propagate");
assert.ok(
deps._intervals.length > 0 || deps._timers.length > 0,
"phase 3 timers scheduled",
);
});
// ── Phase 3: poll-until-connected ─────────────────────────────────────────────
test("poll preempts backstop — onSuccess fires when poll wins", async () => {
const ctrl = new RelayReconnectController();
let callIdx = 0;
const deps = makeDeps({
preconnectResult: async () => {
if (++callIdx <= 2) throw new Error("not yet");
// Third call succeeds — simulates poll winning.
},
hookConfiguredResult: async () => false,
});
// Phase 1 fails, enters phase 3. start() returns false.
const promise = ctrl.start(deps);
await promise;
// Fire the poll interval twice to reach the succeeding call.
const intervalId = deps._intervals[0]?.id;
assert.ok(intervalId !== undefined, "poll interval is active");
await deps._fireInterval(intervalId); // call 2 — fails
await deps._fireInterval(intervalId); // call 3 — succeeds
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fired after poll win",
);
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
test("connection-state emitter fires onSuccess and cancels poll/backstop", async () => {
let capturedListener = null;
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
connectionStateListener: {
set: (l) => {
capturedListener = l;
},
},
});
await ctrl.start(deps);
// Emitter fires "connected" — should resolve without waiting for poll.
assert.ok(capturedListener !== null, "connection-state listener registered");
capturedListener("connected");
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fired via emitter",
);
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
assert.equal(deps._intervals.length, 0, "poll interval cancelled");
assert.equal(deps._timers.length, 0, "backstop cancelled");
});
test("backstop fires onBackstop, not onSuccess, and resets state", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("always fails");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
// Locate and fire the backstop timer (largest ms).
const backstopTimer = deps._timers.reduce(
(max, t) => (t.ms >= max.ms ? t : max),
{ ms: 0, id: -1, fn: null },
);
assert.ok(backstopTimer.id !== -1, "backstop timer exists");
backstopTimer.fn();
assert.equal(deps.onBackstop.mock.calls.length, 1, "onBackstop called");
assert.equal(deps.onSuccess.mock.calls.length, 0, "onSuccess NOT called");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
// ── Cancellation token ────────────────────────────────────────────────────────
test("cancellation token — superseded poll callback does not call onSuccess", async () => {
const ctrl = new RelayReconnectController();
let preconnectCallCount = 0;
let resolveFirstPreconnect;
const deps = makeDeps({
preconnectResult: async () => {
preconnectCallCount++;
if (preconnectCallCount === 1) {
// First fast-path call: stall so we can cancel mid-flight.
await new Promise((resolve) => {
resolveFirstPreconnect = resolve;
});
throw new Error("cancelled");
}
},
hookConfiguredResult: async () => false,
});
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Cancel the attempt before the stalled preconnect resolves.
ctrl.cancel();
// Now resolve the stalled promise — it should be ignored.
resolveFirstPreconnect?.();
await attempt1;
// Start attempt #2 — clean state.
const deps2 = makeDeps({ preconnectResult: async () => {} });
const result2 = await ctrl.start(deps2);
assert.equal(result2, true, "second attempt succeeds on fast path");
assert.equal(
deps2.onSuccess.mock.calls.length,
1,
"only second attempt's onSuccess fires",
);
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"first attempt's onSuccess never fires",
);
});
test("cancel mid fast-path — preconnect resolves SUCCESSFULLY after cancel but onSuccess never fires", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
// preconnect stalls then SUCCEEDS — worst case: cancel races a fast-path win.
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
const attempt = ctrl.start(deps);
// Cancel while fast-path is in flight.
ctrl.cancel();
// Let preconnect resolve successfully after cancel.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after cancel",
);
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle",
);
assert.equal(deps._timers.length, 0, "no timers outstanding after cancel");
assert.equal(
deps._intervals.length,
0,
"no intervals outstanding after cancel",
);
});
test("last subscriber unsubscribe cancels in-flight attempt", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
// Two subscribers.
const unsub1 = ctrl.subscribe(() => {});
const unsub2 = ctrl.subscribe(() => {});
const attempt = ctrl.start(deps);
// First unsub — still one subscriber, should NOT cancel.
unsub1();
assert.equal(
ctrl.getState().isPending,
true,
"still pending after first unsub",
);
// Last unsub — should cancel.
unsub2();
assert.equal(ctrl.getState().isPending, false, "cancelled after last unsub");
// Let preconnect resolve successfully — should be ignored.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after last-subscriber cancel",
);
});
// ── Single-flight guard ───────────────────────────────────────────────────────
test("second start() while first is pending returns false immediately", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
});
const deps2 = makeDeps({ preconnectResult: async () => {} });
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Second start should be rejected immediately.
const result2 = await ctrl.start(deps2);
assert.equal(
result2,
false,
"second start returns false while first pending",
);
assert.equal(
deps2.preconnect.mock.calls.length,
0,
"second attempt never calls preconnect",
);
// Finish the first attempt.
resolvePreconnect?.();
await attempt1;
});
// ── State listener ────────────────────────────────────────────────────────────
test("subscribers receive state transitions and can unsubscribe", async () => {
const ctrl = new RelayReconnectController();
const states = [];
const unsub = ctrl.subscribe((s) => states.push({ ...s }));
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
unsub();
// Should have seen: initial idle, pending=true, pending=false (success).
assert.ok(states.length >= 2, "at least 2 state transitions observed");
assert.equal(
states[states.length - 1].isPending,
false,
"last state is not pending",
);
// After unsubscribe, no further updates.
const countBefore = states.length;
const deps2 = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps2);
assert.equal(states.length, countBefore, "no updates after unsubscribe");
});
// ── OSS build boundary ────────────────────────────────────────────────────────
test("OSS build (hookConfigured returns false) — runHook never called", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"runHook not called in OSS build",
);
});
// ── Synchronous connected emission ───────────────────────────────────────────
test("sync connected emission — onSuccess fires once, no interval/backstop installed, subscription cleaned up", async () => {
const ctrl = new RelayReconnectController();
// subscribeToConnectionState fake that invokes the listener synchronously
// with "connected" BEFORE returning the cleanup handle. This models the
// production subscribeToConnectionState documented behaviour: it fires the
// listener with the current state before returning.
let cleanupCalled = false;
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
// Override subscribeToConnectionState with the synchronous-emission fake.
deps.subscribeToConnectionState = mock.fn((listener) => {
// Invoke immediately — simulates "already connected" at subscribe time.
listener("connected");
// Return cleanup handle (production unsubscribe fn).
const cleanup = () => {
cleanupCalled = true;
};
return cleanup;
});
await ctrl.start(deps);
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fires exactly once",
);
assert.equal(deps._intervals.length, 0, "no poll interval installed");
assert.equal(deps._timers.length, 0, "no backstop timer installed");
assert.equal(cleanupCalled, true, "subscription cleanup handle was called");
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle after sync success",
);
});