From 251dfa0a91808bc772f3d21591a2a27a74c700fa Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Thu, 30 Jan 2025 10:11:10 +0100 Subject: [PATCH] Add comprehensive Home Assistant API integration tests - Created detailed test suite for Home Assistant API interactions - Implemented tests for API connection, state management, service calls, and event handling - Added robust error handling and edge case scenarios for HASS API integration - Mocked fetch and WebSocket to simulate various API response conditions - Covered authentication, state retrieval, service invocation, and event subscription scenarios --- __tests__/hass/api.test.ts | 259 +++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 __tests__/hass/api.test.ts diff --git a/__tests__/hass/api.test.ts b/__tests__/hass/api.test.ts new file mode 100644 index 0000000..7ba6c03 --- /dev/null +++ b/__tests__/hass/api.test.ts @@ -0,0 +1,259 @@ +import { get_hass, HassInstance } from '../../src/hass/index.js'; +import { Response } from 'node-fetch'; + +// Mock node-fetch +jest.mock('node-fetch', () => { + return jest.fn(); +}); + +// Get the mocked fetch function +const mockedFetch = jest.requireMock('node-fetch') as jest.MockedFunction; + +interface MockHassInstance extends HassInstance { + getStates: () => Promise; + getState: (entityId: string) => Promise; + callService: (domain: string, service: string, data: any) => Promise; + subscribeEvents: (callback: (event: any) => void, eventType: string) => Promise; +} + +describe('Home Assistant API Integration', () => { + const MOCK_HASS_HOST = 'http://localhost:8123'; + const MOCK_HASS_TOKEN = 'mock_token_12345'; + + beforeEach(() => { + process.env.HASS_HOST = MOCK_HASS_HOST; + process.env.HASS_TOKEN = MOCK_HASS_TOKEN; + jest.clearAllMocks(); + }); + + describe('API Connection', () => { + it('should initialize connection with valid credentials', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any); + + const hass = await get_hass(); + expect(hass).toBeDefined(); + expect(mockedFetch).toHaveBeenCalledWith( + `${MOCK_HASS_HOST}/api/`, + expect.objectContaining({ + headers: { + Authorization: `Bearer ${MOCK_HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }) + ); + }); + + it('should handle connection errors', async () => { + mockedFetch.mockRejectedValueOnce(new Error('Connection failed')); + await expect(get_hass()).rejects.toThrow('Connection failed'); + }); + + it('should handle invalid credentials', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized' + } as any); + + await expect(get_hass()).rejects.toThrow('Unauthorized'); + }); + + it('should handle missing environment variables', async () => { + delete process.env.HASS_HOST; + delete process.env.HASS_TOKEN; + await expect(get_hass()).rejects.toThrow('Missing required environment variables'); + }); + }); + + describe('State Management', () => { + const mockStates = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { + brightness: 255, + friendly_name: 'Living Room Light' + } + }, + { + entity_id: 'switch.kitchen', + state: 'off', + attributes: { + friendly_name: 'Kitchen Switch' + } + } + ]; + + it('should fetch states successfully', async () => { + mockedFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockStates) + } as any); + + const hass = await get_hass(); + const states = await hass.getStates(); + + expect(states).toEqual(mockStates); + expect(mockedFetch).toHaveBeenCalledWith( + `${MOCK_HASS_HOST}/api/states`, + expect.any(Object) + ); + }); + + it('should get single entity state', async () => { + const mockState = mockStates[0]; + mockedFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockState) + } as any); + + const hass = await get_hass(); + const state = await hass.getState('light.living_room'); + + expect(state).toEqual(mockState); + expect(mockedFetch).toHaveBeenCalledWith( + `${MOCK_HASS_HOST}/api/states/light.living_room`, + expect.any(Object) + ); + }); + }); + + describe('Service Calls', () => { + it('should call services successfully', async () => { + mockedFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]) + } as any); + + const hass = await get_hass(); + await hass.callService('light', 'turn_on', { + entity_id: 'light.living_room', + brightness: 255 + }); + + expect(mockedFetch).toHaveBeenCalledWith( + `${MOCK_HASS_HOST}/api/services/light/turn_on`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + entity_id: 'light.living_room', + brightness: 255 + }) + }) + ); + }); + + it('should handle service call errors', async () => { + mockedFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any) + .mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request' + } as any); + + const hass = await get_hass(); + await expect( + hass.callService('invalid_domain', 'invalid_service', {}) + ).rejects.toThrow('Bad Request'); + }); + }); + + describe('Event Handling', () => { + it('should subscribe to events', async () => { + const mockWS = { + send: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + close: jest.fn() + }; + (global as any).WebSocket = jest.fn(() => mockWS); + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any); + + const hass = await get_hass(); + const callback = jest.fn(); + await hass.subscribeEvents(callback, 'state_changed'); + + expect(mockWS.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"subscribe_events"') + ); + }); + + it('should handle event subscription errors', async () => { + const mockWS = { + send: jest.fn(), + addEventListener: jest.fn((event: string, handler: any) => { + if (event === 'error') { + handler(new Error('WebSocket error')); + } + }), + removeEventListener: jest.fn(), + close: jest.fn() + }; + (global as any).WebSocket = jest.fn(() => mockWS); + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: '2024.1.0' }) + } as any); + + const hass = await get_hass(); + const callback = jest.fn(); + await expect( + hass.subscribeEvents(callback, 'state_changed') + ).rejects.toThrow('WebSocket error'); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors gracefully', async () => { + mockedFetch.mockRejectedValueOnce(new Error('Network error')); + await expect(get_hass()).rejects.toThrow('Network error'); + }); + + it('should handle rate limiting', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests' + } as any); + + await expect(get_hass()).rejects.toThrow('Too Many Requests'); + }); + + it('should handle server errors', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + } as any); + + await expect(get_hass()).rejects.toThrow('Internal Server Error'); + }); + }); +}); \ No newline at end of file