|
1 | 1 | import { describe, expect, it, vi } from "vitest"; |
2 | 2 | import type { GatewayServerLiveState } from "./server-live-state.js"; |
3 | | -import { createGatewayRequestContext } from "./server-request-context.js"; |
| 3 | +import { |
| 4 | + createGatewayRequestContext, |
| 5 | + type GatewayRequestContextParams, |
| 6 | +} from "./server-request-context.js"; |
| 7 | + |
| 8 | +function makeContextParams( |
| 9 | + overrides: Partial<GatewayRequestContextParams> = {}, |
| 10 | +): GatewayRequestContextParams { |
| 11 | + const runtimeState: Pick<GatewayServerLiveState, "cronState"> = { |
| 12 | + cronState: { |
| 13 | + cron: { start: vi.fn(), stop: vi.fn() } as never, |
| 14 | + storePath: "/tmp/cron", |
| 15 | + cronEnabled: true, |
| 16 | + }, |
| 17 | + }; |
| 18 | + return { |
| 19 | + deps: {} as never, |
| 20 | + runtimeState, |
| 21 | + execApprovalManager: undefined, |
| 22 | + pluginApprovalManager: undefined, |
| 23 | + loadGatewayModelCatalog: vi.fn(async () => []), |
| 24 | + getHealthCache: vi.fn(() => null), |
| 25 | + refreshHealthSnapshot: vi.fn(async () => ({}) as never), |
| 26 | + logHealth: { error: vi.fn() }, |
| 27 | + logGateway: { warn: vi.fn(), info: vi.fn(), error: vi.fn() } as never, |
| 28 | + incrementPresenceVersion: vi.fn(() => 1), |
| 29 | + getHealthVersion: vi.fn(() => 1), |
| 30 | + broadcast: vi.fn(), |
| 31 | + broadcastToConnIds: vi.fn(), |
| 32 | + nodeSendToSession: vi.fn(), |
| 33 | + nodeSendToAllSubscribed: vi.fn(), |
| 34 | + nodeSubscribe: vi.fn(), |
| 35 | + nodeUnsubscribe: vi.fn(), |
| 36 | + nodeUnsubscribeAll: vi.fn(), |
| 37 | + hasConnectedMobileNode: vi.fn(() => false), |
| 38 | + clients: new Set(), |
| 39 | + enforceSharedGatewayAuthGenerationForConfigWrite: vi.fn(), |
| 40 | + nodeRegistry: {} as never, |
| 41 | + agentRunSeq: new Map(), |
| 42 | + chatAbortControllers: new Map(), |
| 43 | + chatAbortedRuns: new Map(), |
| 44 | + chatRunBuffers: new Map(), |
| 45 | + chatDeltaSentAt: new Map(), |
| 46 | + chatDeltaLastBroadcastLen: new Map(), |
| 47 | + addChatRun: vi.fn(), |
| 48 | + removeChatRun: vi.fn(), |
| 49 | + subscribeSessionEvents: vi.fn(), |
| 50 | + unsubscribeSessionEvents: vi.fn(), |
| 51 | + subscribeSessionMessageEvents: vi.fn(), |
| 52 | + unsubscribeSessionMessageEvents: vi.fn(), |
| 53 | + unsubscribeAllSessionEvents: vi.fn(), |
| 54 | + getSessionEventSubscriberConnIds: vi.fn(() => new Set<string>()), |
| 55 | + registerToolEventRecipient: vi.fn(), |
| 56 | + dedupe: new Map(), |
| 57 | + wizardSessions: new Map(), |
| 58 | + findRunningWizard: vi.fn(() => null), |
| 59 | + purgeWizardSession: vi.fn(), |
| 60 | + getRuntimeSnapshot: vi.fn(() => ({}) as never), |
| 61 | + startChannel: vi.fn(async () => undefined), |
| 62 | + stopChannel: vi.fn(async () => undefined), |
| 63 | + markChannelLoggedOut: vi.fn(), |
| 64 | + wizardRunner: vi.fn(async () => undefined), |
| 65 | + broadcastVoiceWakeChanged: vi.fn(), |
| 66 | + unavailableGatewayMethods: new Set(), |
| 67 | + ...overrides, |
| 68 | + }; |
| 69 | +} |
4 | 70 |
|
5 | 71 | describe("createGatewayRequestContext", () => { |
6 | 72 | it("reads cron state live from runtime state", () => { |
@@ -77,4 +143,66 @@ describe("createGatewayRequestContext", () => { |
77 | 143 | expect(context.cron).toBe(cronB); |
78 | 144 | expect(context.cronStorePath).toBe("/tmp/cron-b"); |
79 | 145 | }); |
| 146 | + |
| 147 | + it("invalidateClientsForDevice sets the flag on matching clients without closing the socket", () => { |
| 148 | + const target = { |
| 149 | + connId: "conn-target", |
| 150 | + connect: { device: { id: "device-1" }, role: "primary" }, |
| 151 | + socket: { close: vi.fn() }, |
| 152 | + }; |
| 153 | + const unrelated = { |
| 154 | + connId: "conn-unrelated", |
| 155 | + connect: { device: { id: "device-2" }, role: "primary" }, |
| 156 | + socket: { close: vi.fn() }, |
| 157 | + }; |
| 158 | + const clients = new Set([target, unrelated] as never); |
| 159 | + |
| 160 | + const context = createGatewayRequestContext(makeContextParams({ clients })); |
| 161 | + context.invalidateClientsForDevice?.("device-1", { reason: "device-token-rotated" }); |
| 162 | + |
| 163 | + expect((target as { invalidated?: boolean }).invalidated).toBe(true); |
| 164 | + expect((target as { invalidatedReason?: string }).invalidatedReason).toBe( |
| 165 | + "device-token-rotated", |
| 166 | + ); |
| 167 | + expect(target.socket.close).not.toHaveBeenCalled(); |
| 168 | + |
| 169 | + expect((unrelated as { invalidated?: boolean }).invalidated).toBeUndefined(); |
| 170 | + expect(unrelated.socket.close).not.toHaveBeenCalled(); |
| 171 | + }); |
| 172 | + |
| 173 | + it("disconnectClientsForDevice also marks the invalidated flag before closing", () => { |
| 174 | + const target = { |
| 175 | + connId: "conn-target", |
| 176 | + connect: { device: { id: "device-1" }, role: "primary" }, |
| 177 | + socket: { close: vi.fn() }, |
| 178 | + }; |
| 179 | + const clients = new Set([target] as never); |
| 180 | + |
| 181 | + const context = createGatewayRequestContext(makeContextParams({ clients })); |
| 182 | + context.disconnectClientsForDevice?.("device-1"); |
| 183 | + |
| 184 | + expect((target as { invalidated?: boolean }).invalidated).toBe(true); |
| 185 | + expect((target as { invalidatedReason?: string }).invalidatedReason).toBe("device-removed"); |
| 186 | + expect(target.socket.close).toHaveBeenCalledWith(4001, "device removed"); |
| 187 | + }); |
| 188 | + |
| 189 | + it("invalidateClientsForDevice filters by role when provided", () => { |
| 190 | + const primary = { |
| 191 | + connId: "conn-primary", |
| 192 | + connect: { device: { id: "device-1" }, role: "primary" }, |
| 193 | + socket: { close: vi.fn() }, |
| 194 | + }; |
| 195 | + const secondary = { |
| 196 | + connId: "conn-secondary", |
| 197 | + connect: { device: { id: "device-1" }, role: "secondary" }, |
| 198 | + socket: { close: vi.fn() }, |
| 199 | + }; |
| 200 | + const clients = new Set([primary, secondary] as never); |
| 201 | + |
| 202 | + const context = createGatewayRequestContext(makeContextParams({ clients })); |
| 203 | + context.invalidateClientsForDevice?.("device-1", { role: "primary" }); |
| 204 | + |
| 205 | + expect((primary as { invalidated?: boolean }).invalidated).toBe(true); |
| 206 | + expect((secondary as { invalidated?: boolean }).invalidated).toBeUndefined(); |
| 207 | + }); |
80 | 208 | }); |
0 commit comments