Source

tests/unit/socket-physics-handler.test.js

import { createMockPhysicsResponse, createMockStateBroadcast, } from "@tests/unit/mocks/physics-gateway.mocks";
import { createMockWebSocketClient } from "@tests/unit/mocks/websocket.mocks";
import { PhysicsCommandType } from "@typez/physics";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
 * Mock the physics gateway service
 */
const mockPhysicsGatewayService = {
    sendCommand: vi.fn(),
    subscribe: vi.fn(),
    unsubscribe: vi.fn(),
    connect: vi.fn(),
    close: vi.fn(),
};
vi.mock("@config/env", () => ({
    env: { PHYSICS_GATEWAY_CLEANUP_DELAY_MS: 0 },
}));
vi.mock("@services/physics-gateway.service", () => ({
    PhysicsGatewayService: vi.fn().mockImplementation(() => mockPhysicsGatewayService),
}));
describe("Physics WebSocket Handler", () => {
    let setupPhysicsHandlers;
    let mockClient;
    beforeEach(async () => {
        vi.clearAllMocks();
        mockClient = createMockWebSocketClient();
        // Reset modules and import fresh
        vi.resetModules();
        const module = await import("../../socket/handlers/physics");
        setupPhysicsHandlers = module.setupPhysicsHandlers;
    });
    describe("setupPhysicsHandlers", () => {
        it("should register physics:command event listener", () => {
            setupPhysicsHandlers(mockClient);
            expect(mockClient.on).toHaveBeenCalledWith("physics:command", expect.any(Function));
        });
        it("should register close event listener on WebSocket", () => {
            setupPhysicsHandlers(mockClient);
            expect(mockClient.ws.on).toHaveBeenCalledWith("close", expect.any(Function));
        });
    });
    describe("physics:command handling", () => {
        it("should process valid START command and subscribe to physics state", async () => {
            const mockResponse = createMockPhysicsResponse({
                type: PhysicsCommandType.START,
                success: true,
                investigation_id: "test-investigation-123",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(mockResponse);
            setupPhysicsHandlers(mockClient);
            // Get the registered handler
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            expect(handler).toBeDefined();
            // Trigger the handler with valid command
            const validCommand = {
                type: PhysicsCommandType.START,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(validCommand);
            // Wait for async operations
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.sendCommand).toHaveBeenCalledWith(validCommand);
                expect(mockClient.send).toHaveBeenCalledWith("physics:response", mockResponse);
                expect(mockPhysicsGatewayService.subscribe).toHaveBeenCalledWith("test-investigation-123", expect.any(Function));
            });
        });
        it("should not subscribe if START command fails", async () => {
            const mockResponse = createMockPhysicsResponse({
                type: PhysicsCommandType.START,
                success: false,
                error: "Failed to start",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(mockResponse);
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const validCommand = {
                type: PhysicsCommandType.START,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(validCommand);
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.subscribe).not.toHaveBeenCalled();
            });
        });
        it("should not subscribe twice to the same investigation", async () => {
            const mockResponse = createMockPhysicsResponse({
                type: PhysicsCommandType.START,
                success: true,
                investigation_id: "test-investigation-123",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValue(mockResponse);
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const validCommand = {
                type: PhysicsCommandType.START,
                investigationId: "test-investigation-123",
                payload: {},
            };
            // Call twice
            await handler(validCommand);
            await handler(validCommand);
            await vi.waitFor(() => {
                // Should only subscribe once
                expect(mockPhysicsGatewayService.subscribe).toHaveBeenCalledTimes(1);
            });
        });
        it.each([
            { type: PhysicsCommandType.STOP, name: "STOP" },
            { type: PhysicsCommandType.DESTROY, name: "DESTROY" },
        ])("should unsubscribe on successful $name command", async ({ type }) => {
            // First START
            const startResponse = createMockPhysicsResponse({
                type: PhysicsCommandType.START,
                success: true,
                investigation_id: "test-investigation-123",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(startResponse);
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const startCommand = {
                type: PhysicsCommandType.START,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(startCommand);
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.subscribe).toHaveBeenCalled();
            });
            // Then STOP or DESTROY
            const response = createMockPhysicsResponse({
                type,
                success: true,
                investigation_id: "test-investigation-123",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(response);
            const command = {
                type,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(command);
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.unsubscribe).toHaveBeenCalledWith("test-investigation-123", expect.any(Function));
            });
        });
        it("should handle invalid command data and send error response", async () => {
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            // Invalid command (missing required fields)
            const invalidCommand = {
                type: "INVALID_TYPE",
                // missing investigationId
            };
            await handler(invalidCommand);
            await vi.waitFor(() => {
                expect(mockClient.send).toHaveBeenCalledWith("physics:response", expect.objectContaining({
                    success: false,
                    error_code: "INVALID_COMMAND",
                }));
                expect(mockPhysicsGatewayService.sendCommand).not.toHaveBeenCalled();
            });
        });
        it("should handle service errors and send error response", async () => {
            mockPhysicsGatewayService.sendCommand.mockRejectedValueOnce(new Error("Service unavailable"));
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const validCommand = {
                type: PhysicsCommandType.GENERIC,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(validCommand);
            await vi.waitFor(() => {
                expect(mockClient.send).toHaveBeenCalledWith("physics:response", expect.objectContaining({
                    success: false,
                    error: "Service unavailable",
                    error_code: "INTERNAL_ERROR",
                }));
            });
        });
        it.each([
            { type: PhysicsCommandType.PAUSE, name: "PAUSE" },
            { type: PhysicsCommandType.RESUME, name: "RESUME" },
        ])("should process $name command without subscription changes", async ({ type }) => {
            const mockResponse = createMockPhysicsResponse({
                type,
                success: true,
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(mockResponse);
            setupPhysicsHandlers(mockClient);
            const handler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const command = {
                type,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await handler(command);
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.sendCommand).toHaveBeenCalledWith(command);
                expect(mockClient.send).toHaveBeenCalledWith("physics:response", mockResponse);
                expect(mockPhysicsGatewayService.subscribe).not.toHaveBeenCalled();
                expect(mockPhysicsGatewayService.unsubscribe).not.toHaveBeenCalled();
            });
        });
    });
    describe("WebSocket close handling", () => {
        it("should close physics gateway on close", async () => {
            // Subscribe to multiple investigations
            const investigations = ["inv-1", "inv-2", "inv-3"];
            for (const invId of investigations) {
                const mockResponse = createMockPhysicsResponse({
                    type: PhysicsCommandType.START,
                    success: true,
                    investigation_id: invId,
                });
                mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(mockResponse);
            }
            setupPhysicsHandlers(mockClient);
            const commandHandler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            // Start all investigations
            for (const invId of investigations) {
                await commandHandler({
                    type: PhysicsCommandType.START,
                    investigationId: invId,
                    payload: {},
                });
            }
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.subscribe).toHaveBeenCalledTimes(3);
            });
            // Trigger close event
            const closeHandler = mockClient.ws.on.mock.calls.find((call) => call[0] === "close")?.[1];
            expect(closeHandler).toBeDefined();
            closeHandler();
            // Should close the physics gateway service
            expect(mockPhysicsGatewayService.close).toHaveBeenCalled();
        });
        it("should handle close with no active subscriptions", () => {
            setupPhysicsHandlers(mockClient);
            const closeHandler = mockClient.ws.on.mock.calls.find((call) => call[0] === "close")?.[1];
            expect(closeHandler).toBeDefined();
            // Should not throw error
            expect(() => closeHandler()).not.toThrow();
            expect(mockPhysicsGatewayService.close).toHaveBeenCalled();
        });
    });
    describe("State broadcast forwarding", () => {
        it("should forward physics:state broadcasts to client", async () => {
            const mockResponse = createMockPhysicsResponse({
                type: PhysicsCommandType.START,
                success: true,
                investigation_id: "test-investigation-123",
            });
            mockPhysicsGatewayService.sendCommand.mockResolvedValueOnce(mockResponse);
            setupPhysicsHandlers(mockClient);
            const commandHandler = mockClient.on.mock.calls.find((call) => call[0] === "physics:command")?.[1];
            const startCommand = {
                type: PhysicsCommandType.START,
                investigationId: "test-investigation-123",
                payload: {},
            };
            await commandHandler(startCommand);
            await vi.waitFor(() => {
                expect(mockPhysicsGatewayService.subscribe).toHaveBeenCalled();
            });
            // Get the subscription callback
            const subscribeCall = mockPhysicsGatewayService.subscribe.mock.calls[0];
            if (!subscribeCall) {
                throw new Error("Subscribe should have been called");
            }
            const stateCallback = subscribeCall[1];
            // Simulate state broadcast
            const mockState = createMockStateBroadcast({
                investigationId: "test-investigation-123",
            });
            stateCallback(mockState);
            // Should forward to client only state.data (same as ZeroMQ format for Unity)
            expect(mockClient.send).toHaveBeenCalledWith("physics:state", mockState.data);
        });
    });
});