Enhance Jest Configuration and Resolver for ESM and TypeScript Support
- Updated Jest configuration to handle .mts and .mjs file extensions - Improved Jest resolver to better support ESM modules and @digital-alchemy packages - Added comprehensive test coverage for AI router, Home Assistant integration, and WebSocket client - Expanded test scenarios for error handling, event subscriptions, and service interactions
This commit is contained in:
223
__tests__/ai/endpoints/ai-router.test.ts
Normal file
223
__tests__/ai/endpoints/ai-router.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import router from '../../../src/ai/endpoints/ai-router.js';
|
||||||
|
import type { AIResponse, AIError } from '../../../src/ai/types/index.js';
|
||||||
|
|
||||||
|
// Mock NLPProcessor
|
||||||
|
jest.mock('../../../src/ai/nlp/processor.js', () => {
|
||||||
|
return {
|
||||||
|
NLPProcessor: jest.fn().mockImplementation(() => ({
|
||||||
|
processCommand: jest.fn().mockImplementation(async () => ({
|
||||||
|
intent: {
|
||||||
|
action: 'turn_on',
|
||||||
|
target: 'light.living_room',
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
confidence: {
|
||||||
|
overall: 0.9,
|
||||||
|
intent: 0.95,
|
||||||
|
entities: 0.85,
|
||||||
|
context: 0.9
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
validateIntent: jest.fn().mockImplementation(async () => true),
|
||||||
|
suggestCorrections: jest.fn().mockImplementation(async () => [
|
||||||
|
'Try using simpler commands',
|
||||||
|
'Specify the device name clearly'
|
||||||
|
])
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AI Router', () => {
|
||||||
|
let app: express.Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/ai', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /ai/interpret', () => {
|
||||||
|
const validRequest = {
|
||||||
|
input: 'turn on the living room lights',
|
||||||
|
context: {
|
||||||
|
user_id: 'test_user',
|
||||||
|
session_id: 'test_session',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
location: 'home',
|
||||||
|
previous_actions: [],
|
||||||
|
environment_state: {}
|
||||||
|
},
|
||||||
|
model: 'claude' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should successfully interpret a valid command', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/ai/interpret')
|
||||||
|
.send(validRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = response.body as AIResponse;
|
||||||
|
expect(typeof body.natural_language).toBe('string');
|
||||||
|
expect(body.structured_data).toEqual(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
action_taken: 'turn_on',
|
||||||
|
entities_affected: ['light.living_room'],
|
||||||
|
state_changes: expect.any(Object)
|
||||||
|
}));
|
||||||
|
expect(Array.isArray(body.next_suggestions)).toBe(true);
|
||||||
|
expect(body.confidence).toEqual(expect.objectContaining({
|
||||||
|
overall: expect.any(Number),
|
||||||
|
intent: expect.any(Number),
|
||||||
|
entities: expect.any(Number),
|
||||||
|
context: expect.any(Number)
|
||||||
|
}));
|
||||||
|
expect(body.context).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid input format', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/ai/interpret')
|
||||||
|
.send({
|
||||||
|
input: 123, // Invalid input type
|
||||||
|
context: validRequest.context
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const error = response.body.error as AIError;
|
||||||
|
expect(error.code).toBe('PROCESSING_ERROR');
|
||||||
|
expect(typeof error.message).toBe('string');
|
||||||
|
expect(typeof error.suggestion).toBe('string');
|
||||||
|
expect(Array.isArray(error.recovery_options)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing required fields', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/ai/interpret')
|
||||||
|
.send({
|
||||||
|
input: 'turn on the lights'
|
||||||
|
// Missing context
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const error = response.body.error as AIError;
|
||||||
|
expect(error.code).toBe('PROCESSING_ERROR');
|
||||||
|
expect(typeof error.message).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rate limiting', async () => {
|
||||||
|
// Make multiple requests to trigger rate limiting
|
||||||
|
const requests = Array(101).fill(validRequest);
|
||||||
|
const responses = await Promise.all(
|
||||||
|
requests.map(() =>
|
||||||
|
request(app)
|
||||||
|
.post('/ai/interpret')
|
||||||
|
.send(validRequest)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateLimitedResponses = responses.filter(r => r.status === 429);
|
||||||
|
expect(rateLimitedResponses.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /ai/execute', () => {
|
||||||
|
const validRequest = {
|
||||||
|
intent: {
|
||||||
|
action: 'turn_on',
|
||||||
|
target: 'light.living_room',
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
user_id: 'test_user',
|
||||||
|
session_id: 'test_session',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
location: 'home',
|
||||||
|
previous_actions: [],
|
||||||
|
environment_state: {}
|
||||||
|
},
|
||||||
|
model: 'claude' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should successfully execute a valid intent', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/ai/execute')
|
||||||
|
.send(validRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = response.body as AIResponse;
|
||||||
|
expect(typeof body.natural_language).toBe('string');
|
||||||
|
expect(body.structured_data).toEqual(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
action_taken: 'turn_on',
|
||||||
|
entities_affected: ['light.living_room'],
|
||||||
|
state_changes: expect.any(Object)
|
||||||
|
}));
|
||||||
|
expect(Array.isArray(body.next_suggestions)).toBe(true);
|
||||||
|
expect(body.confidence).toEqual(expect.objectContaining({
|
||||||
|
overall: expect.any(Number),
|
||||||
|
intent: expect.any(Number),
|
||||||
|
entities: expect.any(Number),
|
||||||
|
context: expect.any(Number)
|
||||||
|
}));
|
||||||
|
expect(body.context).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid intent format', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/ai/execute')
|
||||||
|
.send({
|
||||||
|
intent: {
|
||||||
|
action: 123 // Invalid action type
|
||||||
|
},
|
||||||
|
context: validRequest.context
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const error = response.body.error as AIError;
|
||||||
|
expect(error.code).toBe('PROCESSING_ERROR');
|
||||||
|
expect(typeof error.message).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /ai/suggestions', () => {
|
||||||
|
const validRequest = {
|
||||||
|
context: {
|
||||||
|
user_id: 'test_user',
|
||||||
|
session_id: 'test_session',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
location: 'home',
|
||||||
|
previous_actions: [],
|
||||||
|
environment_state: {}
|
||||||
|
},
|
||||||
|
model: 'claude' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return a list of suggestions', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/ai/suggestions')
|
||||||
|
.send(validRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray(response.body.suggestions)).toBe(true);
|
||||||
|
expect(response.body.suggestions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing context', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/ai/suggestions')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const error = response.body.error as AIError;
|
||||||
|
expect(error.code).toBe('PROCESSING_ERROR');
|
||||||
|
expect(typeof error.message).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import type { HassInstanceImpl } from '../../src/hass/index.js';
|
||||||
|
import type { Entity, Event } from '../../src/types/hass.js';
|
||||||
|
|
||||||
// Define WebSocket mock types
|
// Define WebSocket mock types
|
||||||
type WebSocketCallback = (...args: any[]) => void;
|
type WebSocketCallback = (...args: any[]) => void;
|
||||||
@@ -14,21 +16,22 @@ type WebSocketMock = {
|
|||||||
close: jest.MockedFunction<WebSocketCloseHandler>;
|
close: jest.MockedFunction<WebSocketCloseHandler>;
|
||||||
readyState: number;
|
readyState: number;
|
||||||
OPEN: number;
|
OPEN: number;
|
||||||
|
removeAllListeners: jest.MockedFunction<() => void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock WebSocket
|
// Mock WebSocket
|
||||||
jest.mock('ws', () => {
|
const mockWebSocket: WebSocketMock = {
|
||||||
return {
|
on: jest.fn<WebSocketEventHandler>(),
|
||||||
WebSocket: jest.fn().mockImplementation(() => ({
|
send: jest.fn<WebSocketSendHandler>(),
|
||||||
on: jest.fn(),
|
close: jest.fn<WebSocketCloseHandler>(),
|
||||||
send: jest.fn(),
|
readyState: 1,
|
||||||
close: jest.fn(),
|
OPEN: 1,
|
||||||
readyState: 1,
|
removeAllListeners: jest.fn()
|
||||||
OPEN: 1,
|
};
|
||||||
removeAllListeners: jest.fn()
|
|
||||||
}))
|
jest.mock('ws', () => ({
|
||||||
};
|
WebSocket: jest.fn().mockImplementation(() => mockWebSocket)
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
|
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
|
||||||
@@ -43,29 +46,24 @@ describe('Home Assistant Integration', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { HassWebSocketClient } = await import('../../src/hass/index.js');
|
const { HassWebSocketClient } = await import('../../src/hass/index.js');
|
||||||
client = new HassWebSocketClient(mockUrl, mockToken);
|
client = new HassWebSocketClient(mockUrl, mockToken);
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a WebSocket client with the provided URL and token', () => {
|
it('should create a WebSocket client with the provided URL and token', () => {
|
||||||
expect(client).toBeInstanceOf(EventEmitter);
|
expect(client).toBeInstanceOf(EventEmitter);
|
||||||
expect(WebSocket).toHaveBeenCalledWith(mockUrl);
|
expect(jest.mocked(WebSocket)).toHaveBeenCalledWith(mockUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should connect and authenticate successfully', async () => {
|
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();
|
const connectPromise = client.connect();
|
||||||
|
|
||||||
// Get and call the open callback
|
// Get and call the open callback
|
||||||
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
|
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
||||||
if (!openCallEntry) throw new Error('Open callback not found');
|
if (!openCallback) throw new Error('Open callback not found');
|
||||||
const openCallback = openCallEntry[1];
|
|
||||||
openCallback();
|
openCallback();
|
||||||
|
|
||||||
// Verify authentication message
|
// Verify authentication message
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
access_token: mockToken
|
access_token: mockToken
|
||||||
@@ -73,60 +71,51 @@ describe('Home Assistant Integration', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get and call the message callback
|
// Get and call the message callback
|
||||||
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
|
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
||||||
if (!messageCallEntry) throw new Error('Message callback not found');
|
if (!messageCallback) throw new Error('Message callback not found');
|
||||||
const messageCallback = messageCallEntry[1];
|
|
||||||
messageCallback(JSON.stringify({ type: 'auth_ok' }));
|
messageCallback(JSON.stringify({ type: 'auth_ok' }));
|
||||||
|
|
||||||
await connectPromise;
|
await connectPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle authentication failure', async () => {
|
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();
|
const connectPromise = client.connect();
|
||||||
|
|
||||||
// Get and call the open callback
|
// Get and call the open callback
|
||||||
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
|
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
||||||
if (!openCallEntry) throw new Error('Open callback not found');
|
if (!openCallback) throw new Error('Open callback not found');
|
||||||
const openCallback = openCallEntry[1];
|
|
||||||
openCallback();
|
openCallback();
|
||||||
|
|
||||||
// Get and call the message callback with auth failure
|
// Get and call the message callback with auth failure
|
||||||
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
|
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
||||||
if (!messageCallEntry) throw new Error('Message callback not found');
|
if (!messageCallback) throw new Error('Message callback not found');
|
||||||
const messageCallback = messageCallEntry[1];
|
|
||||||
messageCallback(JSON.stringify({ type: 'auth_invalid' }));
|
messageCallback(JSON.stringify({ type: 'auth_invalid' }));
|
||||||
|
|
||||||
await expect(connectPromise).rejects.toThrow();
|
await expect(connectPromise).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle connection errors', async () => {
|
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();
|
const connectPromise = client.connect();
|
||||||
|
|
||||||
// Get and call the error callback
|
// Get and call the error callback
|
||||||
const errorCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'error');
|
const errorCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'error')?.[1];
|
||||||
if (!errorCallEntry) throw new Error('Error callback not found');
|
if (!errorCallback) throw new Error('Error callback not found');
|
||||||
const errorCallback = errorCallEntry[1];
|
|
||||||
errorCallback(new Error('Connection failed'));
|
errorCallback(new Error('Connection failed'));
|
||||||
|
|
||||||
await expect(connectPromise).rejects.toThrow('Connection failed');
|
await expect(connectPromise).rejects.toThrow('Connection failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle message parsing errors', async () => {
|
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();
|
const connectPromise = client.connect();
|
||||||
|
|
||||||
// Get and call the open callback
|
// Get and call the open callback
|
||||||
const openCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'open');
|
const openCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'open')?.[1];
|
||||||
if (!openCallEntry) throw new Error('Open callback not found');
|
if (!openCallback) throw new Error('Open callback not found');
|
||||||
const openCallback = openCallEntry[1];
|
|
||||||
openCallback();
|
openCallback();
|
||||||
|
|
||||||
// Get and call the message callback with invalid JSON
|
// Get and call the message callback with invalid JSON
|
||||||
const messageCallEntry = mockWs.on.mock.calls.find(call => call[0] === 'message');
|
const messageCallback = mockWebSocket.on.mock.calls.find(call => call[0] === 'message')?.[1];
|
||||||
if (!messageCallEntry) throw new Error('Message callback not found');
|
if (!messageCallback) throw new Error('Message callback not found');
|
||||||
const messageCallback = messageCallEntry[1];
|
|
||||||
|
|
||||||
// Should emit error event
|
// Should emit error event
|
||||||
await expect(new Promise((resolve) => {
|
await expect(new Promise((resolve) => {
|
||||||
@@ -137,105 +126,183 @@ describe('Home Assistant Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('HassInstanceImpl', () => {
|
describe('HassInstanceImpl', () => {
|
||||||
let instance: any;
|
let instance: HassInstanceImpl;
|
||||||
const mockBaseUrl = 'http://localhost:8123';
|
const mockBaseUrl = 'http://localhost:8123';
|
||||||
const mockToken = 'test_token';
|
const mockToken = 'test_token';
|
||||||
|
const mockState: Entity = {
|
||||||
|
entity_id: 'light.test',
|
||||||
|
state: 'on',
|
||||||
|
attributes: {},
|
||||||
|
last_changed: '',
|
||||||
|
last_updated: '',
|
||||||
|
context: {
|
||||||
|
id: '',
|
||||||
|
parent_id: null,
|
||||||
|
user_id: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { HassInstanceImpl } = await import('../../src/hass/index.js');
|
const { HassInstanceImpl } = await import('../../src/hass/index.js');
|
||||||
instance = new HassInstanceImpl(mockBaseUrl, mockToken);
|
instance = new HassInstanceImpl(mockBaseUrl, mockToken);
|
||||||
mockFetch.mockClear();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock successful fetch responses
|
||||||
|
mockFetch.mockImplementation(async (url, init) => {
|
||||||
|
if (url.toString().endsWith('/api/states')) {
|
||||||
|
return new Response(JSON.stringify([mockState]));
|
||||||
|
}
|
||||||
|
if (url.toString().includes('/api/states/')) {
|
||||||
|
return new Response(JSON.stringify(mockState));
|
||||||
|
}
|
||||||
|
if (url.toString().endsWith('/api/services')) {
|
||||||
|
return new Response(JSON.stringify([]));
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create an instance with the provided URL and token', () => {
|
it('should create instance with correct properties', () => {
|
||||||
expect(instance.baseUrl).toBe(mockBaseUrl);
|
expect(instance.baseUrl).toBe(mockBaseUrl);
|
||||||
expect(instance.token).toBe(mockToken);
|
expect(instance.token).toBe(mockToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch states successfully', async () => {
|
it('should fetch states', 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();
|
const states = await instance.fetchStates();
|
||||||
expect(states).toEqual(mockStates);
|
expect(states).toEqual([mockState]);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
`${mockBaseUrl}/api/states`,
|
`${mockBaseUrl}/api/states`,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: {
|
headers: expect.objectContaining({
|
||||||
Authorization: `Bearer ${mockToken}`,
|
Authorization: `Bearer ${mockToken}`
|
||||||
'Content-Type': 'application/json'
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch single entity state successfully', async () => {
|
it('should fetch single state', async () => {
|
||||||
const mockState = {
|
const state = await instance.fetchState('light.test');
|
||||||
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(state).toEqual(mockState);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
`${mockBaseUrl}/api/states/light.living_room`,
|
`${mockBaseUrl}/api/states/light.test`,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: {
|
headers: expect.objectContaining({
|
||||||
Authorization: `Bearer ${mockToken}`,
|
Authorization: `Bearer ${mockToken}`
|
||||||
'Content-Type': 'application/json'
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call service successfully', async () => {
|
it('should call service', async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
await instance.callService('light', 'turn_on', { entity_id: 'light.test' });
|
||||||
ok: true,
|
|
||||||
json: async () => ({})
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await instance.callService('light', 'turn_on', { entity_id: 'light.living_room' });
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
`${mockBaseUrl}/api/services/light/turn_on`,
|
`${mockBaseUrl}/api/services/light/turn_on`,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: expect.objectContaining({
|
||||||
Authorization: `Bearer ${mockToken}`,
|
Authorization: `Bearer ${mockToken}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
}),
|
||||||
body: JSON.stringify({ entity_id: 'light.living_room' })
|
body: JSON.stringify({ entity_id: 'light.test' })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle fetch errors', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
await expect(instance.fetchStates()).rejects.toThrow('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid JSON responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response('invalid json'));
|
||||||
|
await expect(instance.fetchStates()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-200 responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response('Error', { status: 500 }));
|
||||||
|
await expect(instance.fetchStates()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Event Subscription', () => {
|
||||||
|
let eventCallback: (event: Event) => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
eventCallback = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subscribe to events', async () => {
|
||||||
|
const subscriptionId = await instance.subscribeEvents(eventCallback);
|
||||||
|
expect(typeof subscriptionId).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unsubscribe from events', async () => {
|
||||||
|
const subscriptionId = await instance.subscribeEvents(eventCallback);
|
||||||
|
await instance.unsubscribeEvents(subscriptionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get_hass', () => {
|
describe('get_hass', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
let mockBootstrap: jest.Mock;
|
||||||
|
|
||||||
|
const createMockServices = () => ({
|
||||||
|
light: {},
|
||||||
|
climate: {},
|
||||||
|
alarm_control_panel: {},
|
||||||
|
cover: {},
|
||||||
|
switch: {},
|
||||||
|
contact: {},
|
||||||
|
media_player: {},
|
||||||
|
fan: {},
|
||||||
|
lock: {},
|
||||||
|
vacuum: {},
|
||||||
|
scene: {},
|
||||||
|
script: {},
|
||||||
|
camera: {}
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
process.env.HASS_HOST = 'http://localhost:8123';
|
process.env.HASS_HOST = 'http://localhost:8123';
|
||||||
process.env.HASS_TOKEN = 'test_token';
|
process.env.HASS_TOKEN = 'test_token';
|
||||||
|
|
||||||
|
// Mock the MY_APP.bootstrap function
|
||||||
|
mockBootstrap = jest.fn();
|
||||||
|
mockBootstrap.mockImplementation(() => Promise.resolve({
|
||||||
|
baseUrl: process.env.HASS_HOST,
|
||||||
|
token: process.env.HASS_TOKEN,
|
||||||
|
wsClient: undefined,
|
||||||
|
services: createMockServices(),
|
||||||
|
als: {},
|
||||||
|
context: {},
|
||||||
|
event: new EventEmitter(),
|
||||||
|
internal: {},
|
||||||
|
lifecycle: {},
|
||||||
|
logger: {},
|
||||||
|
scheduler: {},
|
||||||
|
config: {},
|
||||||
|
params: {},
|
||||||
|
hass: {},
|
||||||
|
fetchStates: jest.fn(),
|
||||||
|
fetchState: jest.fn(),
|
||||||
|
callService: jest.fn(),
|
||||||
|
subscribeEvents: jest.fn(),
|
||||||
|
unsubscribeEvents: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../src/hass/index.js', () => ({
|
||||||
|
MY_APP: {
|
||||||
|
configuration: {},
|
||||||
|
bootstrap: () => mockBootstrap()
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a development instance by default', async () => {
|
it('should return a development instance by default', async () => {
|
||||||
@@ -243,23 +310,50 @@ describe('Home Assistant Integration', () => {
|
|||||||
const instance = await get_hass();
|
const instance = await get_hass();
|
||||||
expect(instance.baseUrl).toBe('http://localhost:8123');
|
expect(instance.baseUrl).toBe('http://localhost:8123');
|
||||||
expect(instance.token).toBe('test_token');
|
expect(instance.token).toBe('test_token');
|
||||||
|
expect(mockBootstrap).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a test instance when specified', async () => {
|
it('should return a test instance when in test environment', async () => {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
const { get_hass } = await import('../../src/hass/index.js');
|
const { get_hass } = await import('../../src/hass/index.js');
|
||||||
const instance = await get_hass('test');
|
const instance = await get_hass();
|
||||||
expect(instance.baseUrl).toBe('http://localhost:8123');
|
expect(instance.baseUrl).toBe('http://localhost:8123');
|
||||||
expect(instance.token).toBe('test_token');
|
expect(instance.token).toBe('test_token');
|
||||||
|
expect(mockBootstrap).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a production instance when specified', async () => {
|
it('should return a production instance when in production environment', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
process.env.HASS_HOST = 'https://hass.example.com';
|
process.env.HASS_HOST = 'https://hass.example.com';
|
||||||
process.env.HASS_TOKEN = 'prod_token';
|
process.env.HASS_TOKEN = 'prod_token';
|
||||||
|
|
||||||
|
mockBootstrap.mockImplementationOnce(() => Promise.resolve({
|
||||||
|
baseUrl: process.env.HASS_HOST,
|
||||||
|
token: process.env.HASS_TOKEN,
|
||||||
|
wsClient: undefined,
|
||||||
|
services: createMockServices(),
|
||||||
|
als: {},
|
||||||
|
context: {},
|
||||||
|
event: new EventEmitter(),
|
||||||
|
internal: {},
|
||||||
|
lifecycle: {},
|
||||||
|
logger: {},
|
||||||
|
scheduler: {},
|
||||||
|
config: {},
|
||||||
|
params: {},
|
||||||
|
hass: {},
|
||||||
|
fetchStates: jest.fn(),
|
||||||
|
fetchState: jest.fn(),
|
||||||
|
callService: jest.fn(),
|
||||||
|
subscribeEvents: jest.fn(),
|
||||||
|
unsubscribeEvents: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
const { get_hass } = await import('../../src/hass/index.js');
|
const { get_hass } = await import('../../src/hass/index.js');
|
||||||
const instance = await get_hass('production');
|
const instance = await get_hass();
|
||||||
expect(instance.baseUrl).toBe('https://hass.example.com');
|
expect(instance.baseUrl).toBe('https://hass.example.com');
|
||||||
expect(instance.token).toBe('prod_token');
|
expect(instance.token).toBe('prod_token');
|
||||||
|
expect(mockBootstrap).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,18 +16,43 @@ module.exports = (request, options) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle @digital-alchemy packages
|
||||||
|
if (request.startsWith('@digital-alchemy/')) {
|
||||||
|
try {
|
||||||
|
const packagePath = path.resolve(__dirname, 'node_modules', request);
|
||||||
|
return options.defaultResolver(packagePath, {
|
||||||
|
...options,
|
||||||
|
packageFilter: pkg => {
|
||||||
|
if (pkg.type === 'module') {
|
||||||
|
if (pkg.exports && pkg.exports.import) {
|
||||||
|
pkg.main = pkg.exports.import;
|
||||||
|
} else if (pkg.module) {
|
||||||
|
pkg.main = pkg.module;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// If resolution fails, continue with default resolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Call the default resolver
|
// Call the default resolver
|
||||||
return options.defaultResolver(request, {
|
return options.defaultResolver(request, {
|
||||||
...options,
|
...options,
|
||||||
// Handle ESM modules
|
// Handle ESM modules
|
||||||
packageFilter: pkg => {
|
packageFilter: pkg => {
|
||||||
// Preserve ESM modules
|
// Preserve ESM modules
|
||||||
if (pkg.type === 'module' && pkg.exports) {
|
if (pkg.type === 'module') {
|
||||||
// If there's a specific export for the current conditions, use that
|
if (pkg.exports) {
|
||||||
if (pkg.exports.import) {
|
if (pkg.exports.import) {
|
||||||
pkg.main = pkg.exports.import;
|
pkg.main = pkg.exports.import;
|
||||||
} else if (typeof pkg.exports === 'string') {
|
} else if (typeof pkg.exports === 'string') {
|
||||||
pkg.main = pkg.exports;
|
pkg.main = pkg.exports;
|
||||||
|
}
|
||||||
|
} else if (pkg.module) {
|
||||||
|
pkg.main = pkg.module;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pkg;
|
return pkg;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'ts-jest/presets/default-esm',
|
preset: 'ts-jest/presets/default-esm',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts', '.mts'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
'^(\\.{1,2}/.*)\\.ts$': '$1',
|
'^(\\.{1,2}/.*)\\.ts$': '$1',
|
||||||
@@ -11,7 +11,7 @@ module.exports = {
|
|||||||
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
|
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
|
||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.tsx?$': [
|
'^.+\\.(ts|mts|js|mjs)$': [
|
||||||
'ts-jest',
|
'ts-jest',
|
||||||
{
|
{
|
||||||
useESM: true,
|
useESM: true,
|
||||||
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)'
|
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)(?!.*\\.mjs$)'
|
||||||
],
|
],
|
||||||
resolver: '<rootDir>/jest-resolver.cjs',
|
resolver: '<rootDir>/jest-resolver.cjs',
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user