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);
});
});
});
Source