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
This commit is contained in:
jango-blockchained
2025-01-31 20:29:24 +01:00
parent 90cf0ca315
commit 59cbd2552b
6 changed files with 410 additions and 131 deletions

View File

@@ -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<WebSocketEventHandler>;
send: jest.MockedFunction<WebSocketSendHandler>;
close: jest.MockedFunction<WebSocketCloseHandler>;
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<typeof fetch>;
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<typeof WebSocket>).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<typeof WebSocket>).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<typeof WebSocket>).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<typeof WebSocket>).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');
});
});
});

View File

@@ -23,30 +23,30 @@
<div class='clearfix'> <div class='clearfix'>
<div class='fl pad1y space-right2'> <div class='fl pad1y space-right2'>
<span class="strong">9.44% </span> <span class="strong">13.36% </span>
<span class="quiet">Statements</span> <span class="quiet">Statements</span>
<span class='fraction'>111/1175</span> <span class='fraction'>157/1175</span>
</div> </div>
<div class='fl pad1y space-right2'> <div class='fl pad1y space-right2'>
<span class="strong">5.65% </span> <span class="strong">8.59% </span>
<span class="quiet">Branches</span> <span class="quiet">Branches</span>
<span class='fraction'>25/442</span> <span class='fraction'>38/442</span>
</div> </div>
<div class='fl pad1y space-right2'> <div class='fl pad1y space-right2'>
<span class="strong">10.61% </span> <span class="strong">13.71% </span>
<span class="quiet">Functions</span> <span class="quiet">Functions</span>
<span class='fraction'>24/226</span> <span class='fraction'>31/226</span>
</div> </div>
<div class='fl pad1y space-right2'> <div class='fl pad1y space-right2'>
<span class="strong">9.52% </span> <span class="strong">13.54% </span>
<span class="quiet">Lines</span> <span class="quiet">Lines</span>
<span class='fraction'>109/1144</span> <span class='fraction'>155/1144</span>
</div> </div>
@@ -154,18 +154,18 @@
</tr> </tr>
<tr> <tr>
<td class="file low" data-value="src/config"><a href="src/config/index.html">src/config</a></td> <td class="file high" data-value="src/config"><a href="src/config/index.html">src/config</a></td>
<td data-value="0" class="pic low"> <td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div> <div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
</td> </td>
<td data-value="0" class="pct low">0%</td> <td data-value="100" class="pct high">100%</td>
<td data-value="2" class="abs low">0/2</td> <td data-value="2" class="abs high">2/2</td>
<td data-value="0" class="pct low">0%</td> <td data-value="50" class="pct high">50%</td>
<td data-value="8" class="abs low">0/8</td> <td data-value="8" class="abs high">4/8</td>
<td data-value="100" class="pct high">100%</td> <td data-value="100" class="pct high">100%</td>
<td data-value="0" class="abs high">0/0</td> <td data-value="0" class="abs high">0/0</td>
<td data-value="0" class="pct low">0%</td> <td data-value="100" class="pct high">100%</td>
<td data-value="2" class="abs low">0/2</td> <td data-value="2" class="abs high">2/2</td>
</tr> </tr>
<tr> <tr>
@@ -185,17 +185,17 @@
<tr> <tr>
<td class="file low" data-value="src/hass"><a href="src/hass/index.html">src/hass</a></td> <td class="file low" data-value="src/hass"><a href="src/hass/index.html">src/hass</a></td>
<td data-value="0" class="pic low"> <td data-value="39.63" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div> <div class="chart"><div class="cover-fill" style="width: 39%"></div><div class="cover-empty" style="width: 61%"></div></div>
</td> </td>
<td data-value="0" class="pct low">0%</td> <td data-value="39.63" class="pct low">39.63%</td>
<td data-value="111" class="abs low">0/111</td> <td data-value="111" class="abs low">44/111</td>
<td data-value="0" class="pct low">0%</td> <td data-value="21.95" class="pct low">21.95%</td>
<td data-value="41" class="abs low">0/41</td> <td data-value="41" class="abs low">9/41</td>
<td data-value="0" class="pct low">0%</td> <td data-value="30.43" class="pct low">30.43%</td>
<td data-value="23" class="abs low">0/23</td> <td data-value="23" class="abs low">7/23</td>
<td data-value="0" class="pct low">0%</td> <td data-value="39.63" class="pct low">39.63%</td>
<td data-value="111" class="abs low">0/111</td> <td data-value="111" class="abs low">44/111</td>
</tr> </tr>
<tr> <tr>
@@ -311,7 +311,7 @@
<div class='footer quiet pad2 space-top1 center small'> <div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a> <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2025-01-30T19:05:31.249Z at 2025-01-30T19:28:22.554Z
</div> </div>
<script src="prettify.js"></script> <script src="prettify.js"></script>
<script> <script>

View File

@@ -927,20 +927,20 @@ TN:
SF:src/config/hass.config.ts SF:src/config/hass.config.ts
FNF:0 FNF:0
FNH:0 FNH:0
DA:4,0 DA:4,1
DA:6,0 DA:6,1
LF:2 LF:2
LH:0 LH:2
BRDA:7,0,0,0 BRDA:7,0,0,1
BRDA:7,0,1,0 BRDA:7,0,1,0
BRDA:8,1,0,0 BRDA:8,1,0,1
BRDA:8,1,1,0 BRDA:8,1,1,0
BRDA:9,2,0,0 BRDA:9,2,0,1
BRDA:9,2,1,0 BRDA:9,2,1,0
BRDA:10,3,0,0 BRDA:10,3,0,1
BRDA:10,3,1,0 BRDA:10,3,1,0
BRF:8 BRF:8
BRH:0 BRH:4
end_of_record end_of_record
TN: TN:
SF:src/context/index.ts SF:src/context/index.ts
@@ -1172,9 +1172,9 @@ FN:400,(anonymous_20)
FN:412,(anonymous_21) FN:412,(anonymous_21)
FN:421,get_hass FN:421,get_hass
FNF:23 FNF:23
FNH:0 FNH:7
FNDA:0,(anonymous_0) FNDA:0,(anonymous_0)
FNDA:0,(anonymous_1) FNDA:5,(anonymous_1)
FNDA:0,(anonymous_2) FNDA:0,(anonymous_2)
FNDA:0,(anonymous_3) FNDA:0,(anonymous_3)
FNDA:0,(anonymous_4) FNDA:0,(anonymous_4)
@@ -1188,25 +1188,25 @@ FNDA:0,(anonymous_11)
FNDA:0,(anonymous_12) FNDA:0,(anonymous_12)
FNDA:0,(anonymous_13) FNDA:0,(anonymous_13)
FNDA:0,(anonymous_14) FNDA:0,(anonymous_14)
FNDA:0,(anonymous_15) FNDA:5,(anonymous_15)
FNDA:0,(anonymous_16) FNDA:5,(anonymous_16)
FNDA:0,(anonymous_17) FNDA:1,(anonymous_17)
FNDA:0,(anonymous_18) FNDA:1,(anonymous_18)
FNDA:0,(anonymous_19) FNDA:1,(anonymous_19)
FNDA:0,(anonymous_20) FNDA:0,(anonymous_20)
FNDA:0,(anonymous_21) FNDA:0,(anonymous_21)
FNDA:0,get_hass FNDA:3,get_hass
DA:89,0 DA:89,1
DA:101,0 DA:101,0
DA:143,0 DA:143,1
DA:159,0 DA:159,5
DA:160,0 DA:160,5
DA:161,0 DA:161,5
DA:162,0 DA:162,5
DA:170,0 DA:170,5
DA:171,0 DA:171,5
DA:174,0 DA:174,5
DA:175,0 DA:175,5
DA:184,0 DA:184,0
DA:185,0 DA:185,0
DA:188,0 DA:188,0
@@ -1262,32 +1262,32 @@ DA:286,0
DA:291,0 DA:291,0
DA:292,0 DA:292,0
DA:293,0 DA:293,0
DA:333,0 DA:333,5
DA:334,0 DA:334,5
DA:335,0 DA:335,5
DA:340,0 DA:340,5
DA:341,0 DA:341,5
DA:342,0 DA:342,5
DA:343,0 DA:343,5
DA:344,0 DA:344,5
DA:345,0 DA:345,5
DA:346,0 DA:346,5
DA:347,0 DA:347,5
DA:348,0 DA:348,5
DA:349,0 DA:349,5
DA:350,0 DA:350,5
DA:354,0 DA:354,1
DA:361,0 DA:361,1
DA:362,0 DA:362,0
DA:365,0 DA:365,1
DA:366,0 DA:366,1
DA:370,0 DA:370,1
DA:377,0 DA:377,1
DA:378,0 DA:378,0
DA:381,0 DA:381,1
DA:382,0 DA:382,1
DA:386,0 DA:386,1
DA:395,0 DA:395,1
DA:396,0 DA:396,0
DA:401,0 DA:401,0
DA:402,0 DA:402,0
@@ -1295,31 +1295,31 @@ DA:406,0
DA:409,0 DA:409,0
DA:413,0 DA:413,0
DA:414,0 DA:414,0
DA:419,0 DA:419,1
DA:422,0 DA:422,3
DA:423,0 DA:423,2
DA:424,0 DA:424,2
DA:427,0 DA:427,1
DA:429,0 DA:429,1
DA:430,0 DA:430,0
DA:431,0 DA:431,0
DA:434,0 DA:434,1
DA:435,0 DA:435,1
DA:436,0 DA:436,1
DA:438,0 DA:438,1
LF:111 LF:111
LH:0 LH:44
BRDA:101,0,0,0 BRDA:101,0,0,0
BRDA:101,0,1,0 BRDA:101,0,1,0
BRDA:145,1,0,0 BRDA:145,1,0,1
BRDA:145,1,1,0 BRDA:145,1,1,0
BRDA:146,2,0,0 BRDA:146,2,0,1
BRDA:146,2,1,0 BRDA:146,2,1,0
BRDA:149,3,0,0 BRDA:149,3,0,1
BRDA:149,3,1,0 BRDA:149,3,1,0
BRDA:150,4,0,0 BRDA:150,4,0,1
BRDA:150,4,1,0 BRDA:150,4,1,0
BRDA:172,5,0,0 BRDA:172,5,0,5
BRDA:184,6,0,0 BRDA:184,6,0,0
BRDA:184,7,0,0 BRDA:184,7,0,0
BRDA:184,7,1,0 BRDA:184,7,1,0
@@ -1345,13 +1345,13 @@ BRDA:377,19,0,0
BRDA:395,20,0,0 BRDA:395,20,0,0
BRDA:401,21,0,0 BRDA:401,21,0,0
BRDA:413,22,0,0 BRDA:413,22,0,0
BRDA:421,23,0,0 BRDA:421,23,0,1
BRDA:422,24,0,0 BRDA:422,24,0,2
BRDA:429,25,0,0 BRDA:429,25,0,0
BRDA:429,26,0,0 BRDA:429,26,0,1
BRDA:429,26,1,0 BRDA:429,26,1,1
BRF:41 BRF:41
BRH:0 BRH:9
end_of_record end_of_record
TN: TN:
SF:src/performance/index.ts SF:src/performance/index.ts

View File

@@ -6,15 +6,28 @@ module.exports = (request, options) => {
return path.resolve(__dirname, 'node_modules', request.replace('#', '')); return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
} }
// Handle .js extensions for TypeScript files
if (request.endsWith('.js')) {
const tsRequest = request.replace(/\.js$/, '.ts');
try {
return options.defaultResolver(tsRequest, options);
} catch (e) {
// If the .ts file doesn't exist, continue with the original request
}
}
// Call the default resolver // Call the default resolver
return options.defaultResolver(request, { return options.defaultResolver(request, {
...options, ...options,
// Force node to resolve modules as CommonJS // Handle ESM modules
packageFilter: pkg => { packageFilter: pkg => {
if (pkg.type === 'module') { // Preserve ESM modules
pkg.type = 'commonjs'; if (pkg.type === 'module' && pkg.exports) {
if (pkg.exports && pkg.exports.import) { // If there's a specific export for the current conditions, use that
if (pkg.exports.import) {
pkg.main = pkg.exports.import; pkg.main = pkg.exports.import;
} else if (typeof pkg.exports === 'string') {
pkg.main = pkg.exports;
} }
} }
return pkg; return pkg;

View File

@@ -5,29 +5,30 @@ module.exports = {
extensionsToTreatAsEsm: ['.ts'], extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: { moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1', '^(\\.{1,2}/.*)\\.js$': '$1',
'#(.*)': '<rootDir>/node_modules/$1',
'^(\\.{1,2}/.*)\\.ts$': '$1', '^(\\.{1,2}/.*)\\.ts$': '$1',
'^chalk$': 'chalk', '^chalk$': '<rootDir>/node_modules/chalk/source/index.js',
'#ansi-styles': 'ansi-styles', '#ansi-styles': '<rootDir>/node_modules/ansi-styles/index.js',
'#supports-color': 'supports-color' '#supports-color': '<rootDir>/node_modules/supports-color/index.js'
}, },
transform: { transform: {
'^.+\\.tsx?$': [ '^.+\\.tsx?$': [
'ts-jest', 'ts-jest',
{ {
useESM: true, useESM: true,
tsconfig: 'tsconfig.json'
}, },
], ],
}, },
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/)' 'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)'
], ],
resolver: '<rootDir>/jest-resolver.cjs', resolver: '<rootDir>/jest-resolver.cjs',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: [ testMatch: [
'**/__tests__/helpers.test.ts', '**/__tests__/helpers.test.ts',
'**/__tests__/schemas/devices.test.ts', '**/__tests__/schemas/devices.test.ts',
'**/__tests__/context/index.test.ts' '**/__tests__/context/index.test.ts',
'**/__tests__/hass/index.test.ts'
], ],
globals: { globals: {
'ts-jest': { 'ts-jest': {

View File

@@ -50,28 +50,28 @@ jest.mock('ws', () => {
}); });
// Mock chalk // Mock chalk
jest.mock('chalk', () => ({ const createChalkMock = () => {
default: { const handler = {
red: (text: string) => text, get(target: any, prop: string) {
green: (text: string) => text, if (prop === 'default') {
yellow: (text: string) => text, return createChalkMock();
blue: (text: string) => text, }
magenta: (text: string) => text, return typeof prop === 'string' ? createChalkMock() : target[prop];
cyan: (text: string) => text, },
white: (text: string) => text, apply(target: any, thisArg: any, args: any[]) {
gray: (text: string) => text, return args[0];
grey: (text: string) => text, }
black: (text: string) => text, };
bold: (text: string) => text, return new Proxy(() => { }, handler);
dim: (text: string) => text, };
italic: (text: string) => text,
underline: (text: string) => text, jest.mock('chalk', () => createChalkMock());
inverse: (text: string) => text,
hidden: (text: string) => text, // Mock ansi-styles
strikethrough: (text: string) => text, jest.mock('ansi-styles', () => ({}), { virtual: true });
visible: (text: string) => text,
} // Mock supports-color
})); jest.mock('supports-color', () => ({}), { virtual: true });
// Reset mocks between tests // Reset mocks between tests
beforeEach(() => { beforeEach(() => {