From 0b7a8eaf25362ebbe7c2f51a3a9c0ea815805a95 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 09:29:37 +0100 Subject: [PATCH] test(core): assert PeerLifecycle's dispatch and self-connect guards directly PeerLifecycle was previously exercised only indirectly through end-to-end mesh integration tests, leaving several individual branches, self-connect guards, and handleDataMessage's method-dispatch branches unobserved. A couple of the survivors Stryker reported against handleDataMessage were timeouts rather than survivors under the whole-suite command runner: emptying the method body doesn't fail fast, it hangs some unrelated integration test waiting on state that never arrives. A direct unit test that asserts on the same mutation fails immediately instead, converting a slow timeout into a fast, precise kill. PeerLifecycleDeps is a narrow, injectable surface built exactly for direct testing: a fake deps object with vi.fn() collaborators asserts on handlePeerList/handlePeerJoined's self-connect guards, handlePeerConnected's state_sync gating on non-empty agent state, handleDataMessage's method-dispatch branches, handleBecomeCoordinator's full peer-table replacement and stale-agent-checker start, and handlePeerDisconnected's peerInfo cleanup. --- src/test/peer-lifecycle.test.ts | 215 ++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/test/peer-lifecycle.test.ts diff --git a/src/test/peer-lifecycle.test.ts b/src/test/peer-lifecycle.test.ts new file mode 100644 index 0000000..0db94fa --- /dev/null +++ b/src/test/peer-lifecycle.test.ts @@ -0,0 +1,215 @@ +/** + * Direct, DI-based unit tests for PeerLifecycle -- it was previously exercised only indirectly through end-to-end mesh integration tests, leaving several individual branches, self-connect guards, and handleDataMessage's method-dispatch branches unobserved. A few of the survivors Stryker reports against handleDataMessage's whole-suite timeout classification: emptying its body doesn't fail fast, it hangs some unrelated integration test waiting on state that never arrives, so a direct unit test that fails immediately on the same mutation converts a slow timeout into a fast, precise kill. PeerLifecycleDeps is a narrow, injectable surface built exactly for this direct testing. + */ +import { describe, expect, it, vi } from "vitest"; +import { + PeerLifecycle, + type PeerLifecycleDeps, +} from "../core/peer-lifecycle.js"; +import type { AgentIdentity, DeliveryEvent } from "../core/types.js"; +import type { PeerInfo, SerialisedState } from "../core/wire-protocol.js"; + +const OWNER_ID = "owner-device"; +const COORDINATOR_PORT = 19876; + +function peerInfo(id: string, port = 1): PeerInfo { + return { id, port, startedAt: "2026-01-01T00:00:00.000Z" }; +} + +function emptyState(): SerialisedState { + return { agents: {}, rooms: {}, messages: {}, dms: {}, deliveryQueues: {} }; +} + +interface Harness { + deps: PeerLifecycleDeps; + lifecycle: PeerLifecycle; + transport: { + connectToPeer: ReturnType; + send: ReturnType; + broadcast: ReturnType; + becomeCoordinator: ReturnType; + }; + flushPendingRoomRequests: ReturnType; + applyStateSync: ReturnType; + applyPatch: ReturnType; + staleAgentCheckerStart: ReturnType; +} + +function makeHarness(): Harness { + const transport = { + connectToPeer: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined), + broadcast: vi.fn().mockResolvedValue(undefined), + becomeCoordinator: vi.fn().mockResolvedValue(undefined), + }; + const flushPendingRoomRequests = vi.fn().mockResolvedValue(undefined); + const applyStateSync = + vi.fn(); + const applyPatch = vi.fn().mockResolvedValue(undefined); + const staleAgentCheckerStart = + vi.fn(); + const deps: PeerLifecycleDeps = { + peerInfo: new Map(), + agents: new Map(), + coordinatorPort: COORDINATOR_PORT, + getPeerId: () => OWNER_ID, + requireTransport: () => transport as never, + serialise: () => emptyState(), + roomProtocol: { flushPendingRoomRequests }, + deliveryEngine: { applyStateSync, applyPatch }, + staleAgentChecker: { start: staleAgentCheckerStart }, + }; + return { + deps, + lifecycle: new PeerLifecycle(deps), + transport, + flushPendingRoomRequests, + applyStateSync, + applyPatch, + staleAgentCheckerStart, + }; +} + +describe("PeerLifecycle — handlePeerList", () => { + it("records every peer and dials each one except itself", () => { + const h = makeHarness(); + const self = peerInfo(OWNER_ID); + const other = peerInfo("other-peer"); + h.lifecycle.handlePeerList([self, other]); + + expect(h.deps.peerInfo.get(OWNER_ID)).toEqual(self); + expect(h.deps.peerInfo.get("other-peer")).toEqual(other); + expect(h.transport.connectToPeer).toHaveBeenCalledTimes(1); + expect(h.transport.connectToPeer).toHaveBeenCalledWith(other, OWNER_ID); + }); +}); + +describe("PeerLifecycle — handlePeerJoined", () => { + it("records the joining peer and dials it when it isn't this store's own id", () => { + const h = makeHarness(); + const other = peerInfo("other-peer"); + h.lifecycle.handlePeerJoined(other); + + expect(h.deps.peerInfo.get("other-peer")).toEqual(other); + expect(h.transport.connectToPeer).toHaveBeenCalledWith(other, OWNER_ID); + }); + + it("records but never dials itself when the joined peer is this store's own id", () => { + const h = makeHarness(); + const self = peerInfo(OWNER_ID); + h.lifecycle.handlePeerJoined(self); + + expect(h.deps.peerInfo.get(OWNER_ID)).toEqual(self); + expect(h.transport.connectToPeer).not.toHaveBeenCalled(); + }); +}); + +describe("PeerLifecycle — handlePeerConnected", () => { + it("sends a state_sync when this store already has agent state", async () => { + const h = makeHarness(); + h.deps.agents.set(OWNER_ID, { id: OWNER_ID } as AgentIdentity); + + await h.lifecycle.handlePeerConnected({ id: "conn-1" }, peerInfo("x")); + + expect(h.transport.send).toHaveBeenCalledWith( + { id: "conn-1" }, + { method: "state_sync", state: emptyState() }, + ); + }); + + it("sends no state_sync when this store has no agent state at all", async () => { + const h = makeHarness(); + + await h.lifecycle.handlePeerConnected({ id: "conn-1" }, peerInfo("x")); + + expect(h.transport.send).not.toHaveBeenCalled(); + }); + + it("always flushes pending room requests for the connection, regardless of state", async () => { + const h = makeHarness(); + await h.lifecycle.handlePeerConnected({ id: "conn-1" }, peerInfo("x")); + expect(h.flushPendingRoomRequests).toHaveBeenCalledWith("conn-1"); + }); +}); + +describe("PeerLifecycle — handleDataMessage", () => { + it("applies a normalised state_sync to the delivery engine", async () => { + const h = makeHarness(); + await h.lifecycle.handleDataMessage( + { id: "conn-1" }, + { method: "state_sync", state: emptyState() }, + ); + expect(h.applyStateSync).toHaveBeenCalledWith(emptyState()); + }); + + it("applies a state_update's patch to the delivery engine", async () => { + const h = makeHarness(); + const event: DeliveryEvent = { + type: "connection_request", + connectionId: "c", + peerId: "p", + dataPort: 1, + name: "n", + fingerprint: "fp", + }; + await h.lifecycle.handleDataMessage( + { id: "conn-1" }, + { + method: "state_update", + patch: { type: "delivery", agentId: "a", event }, + }, + ); + expect(h.applyPatch).toHaveBeenCalledWith({ + type: "delivery", + agentId: "a", + event, + }); + }); + + it("applies neither for a message method it doesn't dispatch on", async () => { + const h = makeHarness(); + await h.lifecycle.handleDataMessage( + { id: "conn-1" }, + { method: "introduce", peerId: "p", dataPort: 1 }, + ); + expect(h.applyStateSync).not.toHaveBeenCalled(); + expect(h.applyPatch).not.toHaveBeenCalled(); + }); +}); + +describe("PeerLifecycle — handleBecomeCoordinator", () => { + it("becomes coordinator, replaces the peer table with exactly the handoff list, dials every peer, and starts stale-agent probing", async () => { + const h = makeHarness(); + h.deps.peerInfo.set("stale-peer", peerInfo("stale-peer")); + const incoming = [peerInfo("a"), peerInfo("b")]; + + await h.lifecycle.handleBecomeCoordinator(incoming); + + expect(h.transport.becomeCoordinator).toHaveBeenCalledWith( + "127.0.0.1", + COORDINATOR_PORT, + ); + expect(h.deps.peerInfo.has("stale-peer")).toBe(false); + expect(h.deps.peerInfo.get("a")).toEqual(incoming[0]); + expect(h.deps.peerInfo.get("b")).toEqual(incoming[1]); + expect(h.transport.connectToPeer).toHaveBeenCalledTimes(2); + expect(h.transport.connectToPeer).toHaveBeenCalledWith( + incoming[0], + OWNER_ID, + ); + expect(h.transport.connectToPeer).toHaveBeenCalledWith( + incoming[1], + OWNER_ID, + ); + expect(h.staleAgentCheckerStart).toHaveBeenCalledTimes(1); + }); +}); + +describe("PeerLifecycle — handlePeerDisconnected", () => { + it("removes the disconnected peer's entry from peerInfo", () => { + const h = makeHarness(); + h.deps.peerInfo.set("gone-peer", peerInfo("gone-peer")); + h.lifecycle.handlePeerDisconnected({ id: "gone-peer" }); + expect(h.deps.peerInfo.has("gone-peer")).toBe(false); + }); +});