From cdbe084c464c917cfdc4dee0800a03c328c9845d Mon Sep 17 00:00:00 2001 From: timon-gaebelein Date: Fri, 5 Dec 2025 16:01:19 +0100 Subject: [PATCH] Add more tests --- src/websocket/websocket-server.test.ts | 169 +++++++++++++++++++++++ src/websocket/websocket.test.ts | 181 +++++++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 src/websocket/websocket-server.test.ts create mode 100644 src/websocket/websocket.test.ts diff --git a/src/websocket/websocket-server.test.ts b/src/websocket/websocket-server.test.ts new file mode 100644 index 0000000..c23cf4e --- /dev/null +++ b/src/websocket/websocket-server.test.ts @@ -0,0 +1,169 @@ +import type { IncomingMessage } from 'node:http'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { type WebSocket, WebSocketServer } from 'ws'; + +import * as websocket from './websocket.js'; +import { startWebSocketServerWithRetry } from './websocket-server.js'; + +interface MockWebSocketServer extends WebSocketServer { + on: ReturnType; + once: ReturnType; + off: ReturnType; +} +interface MockWebSocket extends WebSocket { + send: ReturnType; +} + +vi.mock('./websocket.js'); +vi.mock('ws'); + +const WS_SERVER_PORTS = [3000, 3001, 3002]; +const WS_SERVER_PATH = '/cap-console/logs'; +const WELCOME_MESSAGE = { + path: '/cap-console/logs', + data: { + type: 'welcome', + message: 'Welcome to CAP console plugin', + path: WS_SERVER_PATH, + remoteAddress: '127.0.0.1', + }, +}; + +describe('websocket-server', () => { + let mockWebSocketServer: MockWebSocketServer; + let mockWebSocket: MockWebSocket; + let mockRequest: IncomingMessage; + + const setupSuccessListeningEvent = (mockWebSocketServer: MockWebSocketServer) => { + mockWebSocketServer.once.mockImplementation((event: string, callback: () => void) => { + if (event === 'listening') { + callback(); + } + }); + }; + const setupErrorListeningEvent = (mockWebSocketServer: MockWebSocketServer) => { + mockWebSocketServer.once.mockImplementation((event: string) => { + if (event === 'listening') { + throw new Error('EADDRINUSE'); + } + }); + }; + + beforeEach(() => { + mockWebSocketServer = { + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + } as unknown as MockWebSocketServer; + + mockWebSocket = { + send: vi.fn(), + } as unknown as MockWebSocket; + + mockRequest = { + url: '/cap-console/logs', + socket: { + remoteAddress: '127.0.0.1', + }, + } as unknown as IncomingMessage; + + vi.mocked(WebSocketServer).mockImplementation(() => mockWebSocketServer); + vi.mocked(websocket.registerWebsocketHandlers).mockImplementation(vi.fn()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('startWebSocketServerWithRetry', () => { + test('should start server on first port if available', async () => { + setupSuccessListeningEvent(mockWebSocketServer); + + const wss = await startWebSocketServerWithRetry(WS_SERVER_PORTS); + + expect(wss).toBe(mockWebSocketServer); + expect(WebSocketServer).toHaveBeenCalledWith({ port: WS_SERVER_PORTS[0], path: WS_SERVER_PATH }); + }); + + test('should retry on next port if first port fails', async () => { + mockWebSocketServer.once.mockImplementationOnce((event: string) => { + if (event === 'listening') { + throw new Error('EADDRINUSE'); + } + }); + setupSuccessListeningEvent(mockWebSocketServer); + + const wss = await startWebSocketServerWithRetry(WS_SERVER_PORTS); + + expect(wss).toBeDefined(); + expect(WebSocketServer).toHaveBeenCalledTimes(2); + expect(WebSocketServer).toHaveBeenNthCalledWith(1, { port: WS_SERVER_PORTS[0], path: WS_SERVER_PATH }); + expect(WebSocketServer).toHaveBeenNthCalledWith(2, { port: WS_SERVER_PORTS[1], path: WS_SERVER_PATH }); + }); + + test('should throw error if all ports fail', async () => { + setupErrorListeningEvent(mockWebSocketServer); + + await expect(startWebSocketServerWithRetry(WS_SERVER_PORTS)).rejects.toThrow( + 'Failed to start WebSocket server on ports: 3000,3001' + ); + + expect(WebSocketServer).toHaveBeenCalledTimes(3); + }); + + test('should register server handlers on successful start', async () => { + setupSuccessListeningEvent(mockWebSocketServer); + + await startWebSocketServerWithRetry([3000]); + + expect(mockWebSocketServer.on).toHaveBeenCalledWith('connection', expect.any(Function)); + }); + + test('should handle connection event and sent welcome message', async () => { + let connectionHandler: (ws: WebSocket, req: IncomingMessage) => void; + setupSuccessListeningEvent(mockWebSocketServer); + mockWebSocketServer.on.mockImplementation( + (event: string, callback: (ws: WebSocket, req: IncomingMessage) => void) => { + if (event === 'connection') { + connectionHandler = callback; + } + } + ); + + await startWebSocketServerWithRetry([3000]); + + connectionHandler!(mockWebSocket, mockRequest); + const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0]); + + expect(websocket.registerWebsocketHandlers).toHaveBeenCalledWith(mockWebSocket); + + expect(sentMessage).toMatchObject(WELCOME_MESSAGE); + expect(sentMessage.data.timestamp).toBeDefined(); + }); + + test.each([ + ['URL', { url: undefined, socket: { remoteAddress: '127.0.0.1' } }], + ['remote address', { url: '/cap-console/logs', socket: { remoteAddress: undefined } }], + ])('should not send welcome message if request %s is missing', async (_description, requestData) => { + let connectionHandler: (ws: WebSocket, req: IncomingMessage) => void; + setupSuccessListeningEvent(mockWebSocketServer); + mockWebSocketServer.on.mockImplementation( + (event: string, callback: (ws: WebSocket, req: IncomingMessage) => void) => { + if (event === 'connection') { + connectionHandler = callback; + } + } + ); + + const mockRequestWithMissingData = requestData as unknown as IncomingMessage; + + await startWebSocketServerWithRetry([3000]); + + connectionHandler!(mockWebSocket, mockRequestWithMissingData); + + expect(websocket.registerWebsocketHandlers).toHaveBeenCalledWith(mockWebSocket); + expect(mockWebSocket.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/websocket/websocket.test.ts b/src/websocket/websocket.test.ts new file mode 100644 index 0000000..a45b77e --- /dev/null +++ b/src/websocket/websocket.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { WebSocket } from 'ws'; + +const mockSetRootLogLevel = vi.fn(); +const mockExtendCdsLog = vi.fn(); +const mockRestoreCdsLog = vi.fn(); + +vi.mock('../cds-logger/extended-cds-log-factory.js', () => ({ + default: { + getInstance: vi.fn(() => ({ + setRootLogLevel: mockSetRootLogLevel, + extendCdsLog: mockExtendCdsLog, + restoreCdsLog: mockRestoreCdsLog, + })), + }, +})); + +const { broadcastLogEvent, registerWebsocketHandlers } = await import('./websocket.js'); + +const LOG_UPDATE_MESSAGE = (logger: string) => ({ + command: 'logging/update', + data: { + loggers: [ + { + logger, + level: 'DEBUG', + group: false, + }, + ], + }, +}); +const LOG_MESSAGE = { + path: '/test', + data: { + level: 'INFO', + logger: 'test-logger', + thread: 'main', + type: 'log', + message: ['test message'], + ts: Date.now(), + }, +}; + +describe('websocket', () => { + const openConnections: Array<{ ws: WebSocket; closeHandler: () => void }> = []; + let mockWebSocket: WebSocket; + + const getHandlerFromWebsocket = (ws: WebSocket, event: string) => { + return (ws.on as ReturnType).mock.calls.find((call) => call[0] === event)?.[1]; + }; + + const registerAndTrackConnection = (ws: WebSocket) => { + registerWebsocketHandlers(ws); + + const closeHandler = getHandlerFromWebsocket(ws, 'close'); + if (closeHandler) { + openConnections.push({ ws, closeHandler }); + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockWebSocket = { + send: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; + }); + + afterEach(() => { + openConnections.forEach(({ closeHandler }) => closeHandler()); + openConnections.length = 0; + }); + + describe('registerWebsocketHandlers', () => { + test('should register message and close handlers', () => { + registerAndTrackConnection(mockWebSocket); + + expect(mockWebSocket.on).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWebSocket.on).toHaveBeenCalledWith('close', expect.any(Function)); + expect(mockWebSocket.on).toHaveBeenCalledTimes(2); + }); + + test('should only extend CdsLogFactory on first connection', () => { + const mockWebSocket2 = { + send: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; + registerAndTrackConnection(mockWebSocket2); + registerAndTrackConnection(mockWebSocket); + registerAndTrackConnection(mockWebSocket2); + + expect(mockExtendCdsLog).toHaveBeenCalledTimes(1); + }); + + test('should restore original log factory on last disconnection', async () => { + const mockWebSocket2 = { + send: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; + registerAndTrackConnection(mockWebSocket); + registerAndTrackConnection(mockWebSocket2); + + openConnections[0].closeHandler(); + + expect(mockRestoreCdsLog).not.toHaveBeenCalled(); + + openConnections[1].closeHandler(); + + expect(mockRestoreCdsLog).toHaveBeenCalledTimes(1); + }); + + test('should handle valid logging update message', () => { + registerAndTrackConnection(mockWebSocket); + + const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message'); + const validMessage = Buffer.from(JSON.stringify(LOG_UPDATE_MESSAGE('root'))); + + messageHandler(validMessage); + + expect(mockSetRootLogLevel).toHaveBeenCalledWith('DEBUG'); + }); + + test('should ignore non-root logger updates', () => { + registerAndTrackConnection(mockWebSocket); + + const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message'); + const validMessage = Buffer.from(JSON.stringify(LOG_UPDATE_MESSAGE('some.other.logger'))); + + messageHandler(validMessage); + + expect(mockSetRootLogLevel).not.toHaveBeenCalled(); + }); + + test('should ignore invalid message format', () => { + registerAndTrackConnection(mockWebSocket); + + const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message'); + const invalidMessage = Buffer.from(JSON.stringify({ invalid: 'message' })); + + messageHandler(invalidMessage); + + expect(mockSetRootLogLevel).not.toHaveBeenCalled(); + }); + + test('should handle malformed JSON gracefully', () => { + registerAndTrackConnection(mockWebSocket); + + const messageHandler = getHandlerFromWebsocket(mockWebSocket, 'message'); + const invalidMessage = Buffer.from('not valid json'); + + expect(() => messageHandler(invalidMessage)).toThrow(); + }); + }); + + describe('broadcastLogEvent', () => { + test('should broadcast log event to all open connections', () => { + const mockWebSocket2 = { + send: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; + registerAndTrackConnection(mockWebSocket); + registerAndTrackConnection(mockWebSocket2); + + broadcastLogEvent(LOG_MESSAGE); + + expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(LOG_MESSAGE)); + expect(mockWebSocket2.send).toHaveBeenCalledWith(JSON.stringify(LOG_MESSAGE)); + }); + + test('should handle send errors gracefully', () => { + (mockWebSocket.send as ReturnType).mockImplementation(() => { + throw new Error('Send failed'); + }); + + registerAndTrackConnection(mockWebSocket); + + expect(() => broadcastLogEvent(LOG_MESSAGE)).not.toThrow(); + }); + }); +});