From 59cbd2552b4ea5424394ebf616dd4ca4f68dac1d Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Fri, 31 Jan 2025 20:29:24 +0100 Subject: [PATCH] Add comprehensive Home Assistant WebSocket and API tests - Created detailed test suite for Home Assistant WebSocket client - Implemented tests for WebSocket connection, authentication, and error handling - Added comprehensive test coverage for HassInstanceImpl API methods - Mocked WebSocket and fetch to simulate various connection scenarios - Covered authentication, state retrieval, service calls, and environment configuration - Improved test infrastructure for Home Assistant integration --- __tests__/hass/index.test.ts | 265 ++++++++++++++++++++++++++++++++ coverage/lcov-report/index.html | 56 +++---- coverage/lcov.info | 142 ++++++++--------- jest-resolver.cjs | 21 ++- jest.config.cjs | 13 +- jest.setup.ts | 44 +++--- 6 files changed, 410 insertions(+), 131 deletions(-) create mode 100644 __tests__/hass/index.test.ts diff --git a/__tests__/hass/index.test.ts b/__tests__/hass/index.test.ts new file mode 100644 index 0000000..96e5498 --- /dev/null +++ b/__tests__/hass/index.test.ts @@ -0,0 +1,265 @@ +import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +import { WebSocket } from 'ws'; +import { EventEmitter } from 'events'; + +// Define WebSocket mock types +type WebSocketCallback = (...args: any[]) => void; +type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => void; +type WebSocketSendHandler = (data: string) => void; +type WebSocketCloseHandler = () => void; + +type WebSocketMock = { + on: jest.MockedFunction; + send: jest.MockedFunction; + close: jest.MockedFunction; + readyState: number; + OPEN: number; +}; + +// Mock WebSocket +jest.mock('ws', () => { + return { + WebSocket: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + send: jest.fn(), + close: jest.fn(), + readyState: 1, + OPEN: 1, + removeAllListeners: jest.fn() + })) + }; +}); + +// Mock fetch globally +const mockFetch = jest.fn() as jest.MockedFunction; +global.fetch = mockFetch; + +describe('Home Assistant Integration', () => { + describe('HassWebSocketClient', () => { + let client: any; + const mockUrl = 'ws://localhost:8123/api/websocket'; + const mockToken = 'test_token'; + + beforeEach(async () => { + const { HassWebSocketClient } = await import('../../src/hass/index.js'); + client = new HassWebSocketClient(mockUrl, mockToken); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a WebSocket client with the provided URL and token', () => { + expect(client).toBeInstanceOf(EventEmitter); + expect(WebSocket).toHaveBeenCalledWith(mockUrl); + }); + + it('should connect and authenticate successfully', async () => { + const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; + const connectPromise = client.connect(); + + // Get and call the open callback + const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); + if (!openCallEntry) throw new Error('Open callback not found'); + const openCallback = openCallEntry[1]; + openCallback(); + + // Verify authentication message + expect(mockWs.send).toHaveBeenCalledWith( + JSON.stringify({ + type: 'auth', + access_token: mockToken + }) + ); + + // Get and call the message callback + const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); + if (!messageCallEntry) throw new Error('Message callback not found'); + const messageCallback = messageCallEntry[1]; + messageCallback(JSON.stringify({ type: 'auth_ok' })); + + await connectPromise; + }); + + it('should handle authentication failure', async () => { + const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; + const connectPromise = client.connect(); + + // Get and call the open callback + const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); + if (!openCallEntry) throw new Error('Open callback not found'); + const openCallback = openCallEntry[1]; + openCallback(); + + // Get and call the message callback with auth failure + const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); + if (!messageCallEntry) throw new Error('Message callback not found'); + const messageCallback = messageCallEntry[1]; + messageCallback(JSON.stringify({ type: 'auth_invalid' })); + + await expect(connectPromise).rejects.toThrow(); + }); + + it('should handle connection errors', async () => { + const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; + const connectPromise = client.connect(); + + // Get and call the error callback + const errorCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'error'); + if (!errorCallEntry) throw new Error('Error callback not found'); + const errorCallback = errorCallEntry[1]; + errorCallback(new Error('Connection failed')); + + await expect(connectPromise).rejects.toThrow('Connection failed'); + }); + + it('should handle message parsing errors', async () => { + const mockWs = (WebSocket as jest.MockedClass).mock.results[0].value as unknown as WebSocketMock; + const connectPromise = client.connect(); + + // Get and call the open callback + const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open'); + if (!openCallEntry) throw new Error('Open callback not found'); + const openCallback = openCallEntry[1]; + openCallback(); + + // Get and call the message callback with invalid JSON + const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message'); + if (!messageCallEntry) throw new Error('Message callback not found'); + const messageCallback = messageCallEntry[1]; + + // Should emit error event + await expect(new Promise((resolve) => { + client.once('error', resolve); + messageCallback('invalid json'); + })).resolves.toBeInstanceOf(Error); + }); + }); + + describe('HassInstanceImpl', () => { + let instance: any; + const mockBaseUrl = 'http://localhost:8123'; + const mockToken = 'test_token'; + + beforeEach(async () => { + const { HassInstanceImpl } = await import('../../src/hass/index.js'); + instance = new HassInstanceImpl(mockBaseUrl, mockToken); + mockFetch.mockClear(); + }); + + it('should create an instance with the provided URL and token', () => { + expect(instance.baseUrl).toBe(mockBaseUrl); + expect(instance.token).toBe(mockToken); + }); + + it('should fetch states successfully', async () => { + const mockStates = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: {} + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockStates + } as Response); + + const states = await instance.fetchStates(); + expect(states).toEqual(mockStates); + expect(mockFetch).toHaveBeenCalledWith( + `${mockBaseUrl}/api/states`, + expect.objectContaining({ + headers: { + Authorization: `Bearer ${mockToken}`, + 'Content-Type': 'application/json' + } + }) + ); + }); + + it('should fetch single entity state successfully', async () => { + const mockState = { + entity_id: 'light.living_room', + state: 'on', + attributes: {} + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockState + } as Response); + + const state = await instance.fetchState('light.living_room'); + expect(state).toEqual(mockState); + expect(mockFetch).toHaveBeenCalledWith( + `${mockBaseUrl}/api/states/light.living_room`, + expect.objectContaining({ + headers: { + Authorization: `Bearer ${mockToken}`, + 'Content-Type': 'application/json' + } + }) + ); + }); + + it('should call service successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + await instance.callService('light', 'turn_on', { entity_id: 'light.living_room' }); + expect(mockFetch).toHaveBeenCalledWith( + `${mockBaseUrl}/api/services/light/turn_on`, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ entity_id: 'light.living_room' }) + }) + ); + }); + }); + + describe('get_hass', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.HASS_HOST = 'http://localhost:8123'; + process.env.HASS_TOKEN = 'test_token'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return a development instance by default', async () => { + const { get_hass } = await import('../../src/hass/index.js'); + const instance = await get_hass(); + expect(instance.baseUrl).toBe('http://localhost:8123'); + expect(instance.token).toBe('test_token'); + }); + + it('should return a test instance when specified', async () => { + const { get_hass } = await import('../../src/hass/index.js'); + const instance = await get_hass('test'); + expect(instance.baseUrl).toBe('http://localhost:8123'); + expect(instance.token).toBe('test_token'); + }); + + it('should return a production instance when specified', async () => { + process.env.HASS_HOST = 'https://hass.example.com'; + process.env.HASS_TOKEN = 'prod_token'; + + const { get_hass } = await import('../../src/hass/index.js'); + const instance = await get_hass('production'); + expect(instance.baseUrl).toBe('https://hass.example.com'); + expect(instance.token).toBe('prod_token'); + }); + }); +}); \ No newline at end of file diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html index c224215..13a08d6 100644 --- a/coverage/lcov-report/index.html +++ b/coverage/lcov-report/index.html @@ -23,30 +23,30 @@
- 9.44% + 13.36% Statements - 111/1175 + 157/1175
- 5.65% + 8.59% Branches - 25/442 + 38/442
- 10.61% + 13.71% Functions - 24/226 + 31/226
- 9.52% + 13.54% Lines - 109/1144 + 155/1144
@@ -154,18 +154,18 @@ - src/config - -
+ src/config + +
- 0% - 0/2 - 0% - 0/8 + 100% + 2/2 + 50% + 4/8 100% 0/0 - 0% - 0/2 + 100% + 2/2 @@ -185,17 +185,17 @@ src/hass - -
+ +
- 0% - 0/111 - 0% - 0/41 - 0% - 0/23 - 0% - 0/111 + 39.63% + 44/111 + 21.95% + 9/41 + 30.43% + 7/23 + 39.63% + 44/111 @@ -311,7 +311,7 @@