import { afterEach, expect, test, vi } from "vitest" import { SocketUnitHeartbeat, resolveSocketUnitHeartbeatOptions, validateSocketUnitHeartbeatOptions, } from "#Source/socket/base.index.ts" afterEach(() => { vi.clearAllTimers() vi.useRealTimers() }) test("SocketUnitHeartbeat validates required handlers and resolves default values", () => { expect(() => validateSocketUnitHeartbeatOptions({ enableActiveHeartbeat: true, }), ).toThrow("Active heartbeat message handler is required") expect(() => validateSocketUnitHeartbeatOptions({ enablePassiveHeartbeat: true, }), ).toThrow("Passive heartbeat message handler is required") expect(() => validateSocketUnitHeartbeatOptions({ heartbeatInterval: 0, }), ).toThrow("Heartbeat interval must be a positive finite number.") expect(() => validateSocketUnitHeartbeatOptions({ maxResponseTime: 0, }), ).toThrow("Max response time must be a positive finite number.") expect(() => validateSocketUnitHeartbeatOptions({ maxHeartbeatLostCount: 0, }), ).toThrow("Max heartbeat lost count must be at least 1.") const resolvedOptions = resolveSocketUnitHeartbeatOptions({}) expect(resolvedOptions).toMatchObject({ enableActiveHeartbeat: false, enablePassiveHeartbeat: false, heartbeatInterval: 30_000, maxResponseTime: 10_000, maxHeartbeatLostCount: 3, }) }) test("SocketUnitHeartbeat unifies active and passive handling with newest-first matching", async () => { vi.useFakeTimers() const sentMessages: Array<{ action: string; messageString: string }> = [] const close = vi.fn(() => { throw new Error("Heartbeat close should not be called in this test.") }) const heartbeat = new SocketUnitHeartbeat<{ kind: string; round?: number }>( { enableActiveHeartbeat: true, enablePassiveHeartbeat: true, heartbeatInterval: 100, maxResponseTime: 500, maxHeartbeatLostCount: 2, activeHeartbeatMessageHandlerGenerator: () => { return { buildPingMessage: () => { return { kind: "ping" } }, verifyPongMessage: (_clientId, message) => { return message.kind === "pong" }, } }, passiveHeartbeatMessageHandlerGenerator: () => { return { verifyPingMessage: (_clientId, message) => { return message.kind === "ping-from-peer" }, buildPongMessage: () => { return { kind: "pong-from-peer" } }, } }, }, { sendMessage: (message) => { sentMessages.push(message) return true }, close, }, ) expect(heartbeat.getSnapshot()).toMatchObject({ enabledActiveHeartbeat: true, enabledPassiveHeartbeat: true, failCount: 0, isRunning: false, recentStates: [], successCount: 0, waitingCount: 0, }) heartbeat.start("client-1") expect(heartbeat.getSnapshot().isRunning).toBe(true) await vi.advanceTimersByTimeAsync(100) await vi.advanceTimersByTimeAsync(100) const waitingSnapshot = heartbeat.getSnapshot() expect(sentMessages).toHaveLength(2) expect(waitingSnapshot.waitingCount).toBe(2) expect(waitingSnapshot.recentStates).toHaveLength(2) expect( waitingSnapshot.recentStates.every((state) => { return state.status === "waiting" && state.hasPendingResponseTimer === true }), ).toBe(true) const activeHandled = heartbeat.handleMessage("client-1", { kind: "pong" }) expect(activeHandled).toBe(true) expect(heartbeat.getSnapshot()).toMatchObject({ failCount: 0, recentStates: [], successCount: 0, waitingCount: 0, }) const passiveHandled = heartbeat.handleMessage("client-1", { kind: "ping-from-peer" }) expect(passiveHandled).toBe(true) expect(sentMessages.at(-1)).toEqual({ action: "Passive heartbeat pong send", messageString: JSON.stringify({ kind: "pong-from-peer" }), }) heartbeat.stop() expect(close).not.toHaveBeenCalled() expect(heartbeat.getSnapshot()).toMatchObject({ isRunning: false, recentStates: [], waitingCount: 0, }) }) test("SocketUnitHeartbeat closes transport after consecutive timeout limit is reached", async () => { vi.useFakeTimers() let closeCount = 0 const heartbeat = new SocketUnitHeartbeat<{ kind: string }>( { enableActiveHeartbeat: true, heartbeatInterval: 100, maxResponseTime: 10, maxHeartbeatLostCount: 2, activeHeartbeatMessageHandlerGenerator: () => { return { buildPingMessage: () => { return { kind: "ping" } }, verifyPongMessage: () => { return false }, } }, }, { sendMessage: () => { return true }, close: () => { closeCount = closeCount + 1 }, }, ) heartbeat.start("client-2") await vi.advanceTimersByTimeAsync(100) await vi.advanceTimersByTimeAsync(10) expect(heartbeat.getSnapshot().failCount).toBe(1) expect(closeCount).toBe(0) await vi.advanceTimersByTimeAsync(100) await vi.advanceTimersByTimeAsync(10) expect(heartbeat.getSnapshot().failCount).toBeGreaterThanOrEqual(1) expect(closeCount).toBe(1) }) test("SocketUnitHeartbeat stays idle when active heartbeat is disabled and ignores unmatched messages", () => { const heartbeat = new SocketUnitHeartbeat<{ kind: string }>( { enablePassiveHeartbeat: true, passiveHeartbeatMessageHandlerGenerator: () => { return { verifyPingMessage: (_clientId, message) => { return message.kind === "ping" }, buildPongMessage: () => { return { kind: "pong" } }, } }, }, { sendMessage: () => { return true }, close: () => { throw new Error("Heartbeat close should not be called in this test.") }, }, ) heartbeat.start("client-3") expect(heartbeat.getSnapshot()).toMatchObject({ isRunning: false, recentStates: [], waitingCount: 0, }) expect(heartbeat.handleMessage("client-3", { kind: "unmatched" })).toBe(false) expect(heartbeat.handleMessage("client-3", { kind: "ping" })).toBe(true) })