diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b28efc8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Build output +dist + +# Environment files +.env* +!.env.example + +# Git +.git +.gitignore + +# IDE +.vscode +.idea + +# Test files +coverage +__tests__ +jest.config.* +*.test.ts + +# Misc +*.md +.DS_Store +*.log \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d865436 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +NODE_ENV=development +HASS_HOST=http://homeassistant.local:8123 +HASS_TOKEN=your_home_assistant_token +PORT=3000 +HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket +LOG_LEVEL=debug \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..65880d6 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,49 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "parserOptions": { + "project": "./tsconfig.json", + "ecmaVersion": "latest", + "sourceType": "module" + }, + "rules": { + "no-console": "warn", + "@typescript-eslint/explicit-function-return-type": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/strict-boolean-expressions": "warn", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-misused-promises": [ + "warn", + { + "checksVoidReturn": { + "attributes": false + } + } + ], + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-return": "warn" + }, + "ignorePatterns": [ + "dist/", + "node_modules/", + "*.js", + "*.d.ts" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a0d218e..10051d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,70 @@ +# Dependencies node_modules dist -.env \ No newline at end of file +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.cursorrules +# Environment variables +.env +.env.local +.env.development +.env.production +.env.test +venv/ +ENV/ +env/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Docker +docker-compose.yml +docker-compose.yaml +docker-compose.override.yml +docker-compose.override.yaml + +# IDEs and editors +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Home Assistant +.storage +.cloud +.google.token +home-assistant.log* +home-assistant_v2.db +home-assistant_v2.db-* +. + +package-lock.json +yarn.lock +pnpm-lock.yaml +bun.lockb + +coverage/* +coverage/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20e8c12 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use Node.js 20 as the base image +FROM node:20-slim + +# Install curl for healthcheck +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy source code first +COPY . . + +# Install dependencies +RUN npm install + +# Build TypeScript +RUN npm run build + +# Expose the port the app runs on +EXPOSE 3000 + +# Start the application +CMD ["node", "dist/src/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index e7895cb..e02c638 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,734 @@ -# A Model Context Protocol Server for Home Assistant - -[![smithery badge](https://smithery.ai/badge/@strandbrown/homeassistant-mcp)](https://smithery.ai/server/@strandbrown/homeassistant-mcp) +# Model Context Protocol Server for Home Assistant The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application. -More about MCP here: https://modelcontextprotocol.io/introduction +A powerful bridge between your Home Assistant instance and Language Learning Models (LLMs), enabling natural language control and monitoring of your smart home devices through the Model Context Protocol (MCP). This server provides a comprehensive API for managing your entire Home Assistant ecosystem, from device control to system administration. -More about Home Assistant here: https://www.home-assistant.io +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Node.js](https://img.shields.io/badge/node-%3E%3D20.10.0-green.svg) +![Docker Compose](https://img.shields.io/badge/docker-compose-%3E%3D1.27.0-blue.svg) +![NPM](https://img.shields.io/badge/npm-%3E%3D7.0.0-orange.svg) +![TypeScript](https://img.shields.io/badge/typescript-%5E5.0.0-blue.svg) +![Test Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg) -## Usage +## Features -### Installing via Smithery +- ๐ŸŽฎ **Device Control**: Control any Home Assistant device through natural language +- ๐Ÿ”„ **Real-time Updates**: Get instant updates through Server-Sent Events (SSE) +- ๐Ÿค– **Automation Management**: Create, update, and manage automations +- ๐Ÿ“Š **State Monitoring**: Track and query device states +- ๐Ÿ” **Secure**: Token-based authentication and rate limiting +- ๐Ÿ“ฑ **Mobile Ready**: Works with any HTTP-capable client -To install Home Assistant Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@strandbrown/homeassistant-mcp): +## Real-time Updates with SSE + +The server includes a powerful Server-Sent Events (SSE) system that provides real-time updates from your Home Assistant instance. This allows you to: + +- ๐Ÿ”„ Get instant state changes for any device +- ๐Ÿ“ก Monitor automation triggers and executions +- ๐ŸŽฏ Subscribe to specific domains or entities +- ๐Ÿ“Š Track service calls and script executions + +### Quick SSE Example + +```javascript +const eventSource = new EventSource( + 'http://localhost:3000/subscribe_events?token=YOUR_TOKEN&domain=light' +); + +eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log('Update received:', data); +}; +``` + +See [SSE_API.md](docs/SSE_API.md) for complete documentation of the SSE system. + +## Table of Contents + +- [Key Features](#key-features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Basic Setup](#basic-setup) + - [Docker Setup (Recommended)](#docker-setup-recommended) +- [Configuration](#configuration) +- [Development](#development) +- [API Reference](#api-reference) + - [Device Control](#device-control) + - [Add-on Management](#add-on-management) + - [Package Management](#package-management) + - [Automation Management](#automation-management) +- [Natural Language Integration](#natural-language-integration) +- [Troubleshooting](#troubleshooting) +- [Project Status](#project-status) +- [Contributing](#contributing) +- [Resources](#resources) +- [License](#license) + +## Key Features + +### Core Functionality ๐ŸŽฎ +- **Smart Device Control** + - ๐Ÿ’ก **Lights**: Brightness, color temperature, RGB color + - ๐ŸŒก๏ธ **Climate**: Temperature, HVAC modes, fan modes, humidity + - ๐Ÿšช **Covers**: Position and tilt control + - ๐Ÿ”Œ **Switches**: On/off control + - ๐Ÿšจ **Sensors & Contacts**: State monitoring + - ๐ŸŽต **Media Players**: Playback control, volume, source selection + - ๐ŸŒช๏ธ **Fans**: Speed, oscillation, direction + - ๐Ÿ”’ **Locks**: Lock/unlock control + - ๐Ÿงน **Vacuums**: Start, stop, return to base + - ๐Ÿ“น **Cameras**: Motion detection, snapshots + +### System Management ๐Ÿ› ๏ธ +- **Add-on Management** + - Browse available add-ons + - Install/uninstall add-ons + - Start/stop/restart add-ons + - Version management + - Configuration access + +- **Package Management (HACS)** + - Integration with Home Assistant Community Store + - Multiple package types support: + - Custom integrations + - Frontend themes + - Python scripts + - AppDaemon apps + - NetDaemon apps + - Version control and updates + - Repository management + +- **Automation Management** + - Create and edit automations + - Advanced configuration options: + - Multiple trigger types + - Complex conditions + - Action sequences + - Execution modes + - Duplicate and modify existing automations + - Enable/disable automation rules + - Trigger automation manually + +### Architecture Features ๐Ÿ—๏ธ +- **Intelligent Organization** + - Area and floor-based device grouping + - State monitoring and querying + - Smart context awareness + - Historical data access + +- **Robust Architecture** + - Comprehensive error handling + - State validation + - Secure API integration + - TypeScript type safety + - Extensive test coverage + +## Prerequisites + +- **Node.js** 20.10.0 or higher +- **NPM** package manager +- **Docker Compose** for containerization +- Running **Home Assistant** instance +- Home Assistant long-lived access token ([How to get token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159)) +- **HACS** installed for package management features +- **Supervisor** access for add-on management + +## Installation + +### Basic Setup ```bash -npx -y @smithery/cli install @strandbrown/homeassistant-mcp --client claude +# Clone the repository +git clone https://github.com/jango-blockchained/homeassistant-mcp.git +cd homeassistant-mcp + +# Install dependencies +npm install + +# Build the project +npm run build ``` -### Manual Installation +### Docker Setup (Recommended) -First build the server +The project includes Docker support for easy deployment and consistent environments across different platforms. -``` -yarn build +1. **Clone the repository:** + ```bash + git clone https://github.com/jango-blockchained/homeassistant-mcp.git + cd homeassistant-mcp + ``` + +2. **Configure environment:** + ```bash + cp .env.example .env + ``` + Edit the `.env` file with your Home Assistant configuration: + ```env + # Home Assistant Configuration + HASS_HOST=http://homeassistant.local:8123 + HASS_TOKEN=your_home_assistant_token + HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket + + # Server Configuration + PORT=3000 + NODE_ENV=production + DEBUG=false + ``` + +3. **Build and run with Docker Compose:** + ```bash + # Build and start the containers + docker compose up -d + + # View logs + docker compose logs -f + + # Stop the service + docker compose down + ``` + +4. **Verify the installation:** + The server should now be running at `http://localhost:3000`. You can check the health endpoint at `http://localhost:3000/health`. + +5. **Update the application:** + ```bash + # Pull the latest changes + git pull + + # Rebuild and restart the containers + docker compose up -d --build + ``` + +#### Docker Configuration + +The Docker setup includes: +- Multi-stage build for optimal image size +- Health checks for container monitoring +- Volume mounting for environment configuration +- Automatic container restart on failure +- Exposed port 3000 for API access + +#### Docker Compose Environment Variables + +All environment variables can be configured in the `.env` file. The following variables are supported: +- `HASS_HOST`: Your Home Assistant instance URL +- `HASS_TOKEN`: Long-lived access token for Home Assistant +- `HASS_SOCKET_URL`: WebSocket URL for Home Assistant +- `PORT`: Server port (default: 3000) +- `NODE_ENV`: Environment (production/development) +- `DEBUG`: Enable debug mode (true/false) + +## Configuration + +### Environment Variables + +```env +# Home Assistant Configuration +HASS_HOST=http://homeassistant.local:8123 # Your Home Assistant instance URL +HASS_TOKEN=your_home_assistant_token # Long-lived access token +HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket # WebSocket URL + +# Server Configuration +PORT=3000 # Server port (default: 3000) +NODE_ENV=production # Environment (production/development) +DEBUG=false # Enable debug mode + +# Test Configuration +TEST_HASS_HOST=http://localhost:8123 # Test instance URL +TEST_HASS_TOKEN=test_token # Test token ``` -Then configure your application (like Claude Desktop) to use it. +### Configuration Files -``` +1. **Development**: Copy `.env.example` to `.env.development` +2. **Production**: Copy `.env.example` to `.env.production` +3. **Testing**: Copy `.env.example` to `.env.test` + +## API Reference + +### Device Control + +#### Common Entity Controls +```json { - "mcpServers": { - "homeassistant": { - "command": "node", - "args": [ - "/Users/tevonsb/Desktop/mcp/dist/index.js" - ], - "env": { - "TOKEN": , - "BASE_URL": - } - } - } + "tool": "control", + "command": "turn_on", // or "turn_off", "toggle" + "entity_id": "light.living_room" } ``` -You'll need a personal access token from home assistant. +#### Light Control +```json +{ + "tool": "control", + "command": "turn_on", + "entity_id": "light.living_room", + "brightness": 128, + "color_temp": 4000, + "rgb_color": [255, 0, 0] +} +``` -Get one using this guide: https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159 +### Add-on Management -## In Progress +#### List Available Add-ons +```json +{ + "tool": "addon", + "action": "list" +} +``` -- [x] Access to entities -- [x] Access to Floors -- [x] Access to Areas -- [x] Control for entities - - [x] Lights - - [x] Thermostats - - [x] Covers -- [ ] Testing / writing custom prompts -- [ ] Testing using resources for high-level context -- [ ] Test varying tool organization +#### Install Add-on +```json +{ + "tool": "addon", + "action": "install", + "slug": "core_configurator", + "version": "5.6.0" +} +``` + +#### Manage Add-on State +```json +{ + "tool": "addon", + "action": "start", // or "stop", "restart" + "slug": "core_configurator" +} +``` + +### Package Management + +#### List HACS Packages +```json +{ + "tool": "package", + "action": "list", + "category": "integration" // or "plugin", "theme", "python_script", "appdaemon", "netdaemon" +} +``` + +#### Install Package +```json +{ + "tool": "package", + "action": "install", + "category": "integration", + "repository": "hacs/integration", + "version": "1.32.0" +} +``` + +### Automation Management + +#### Create Automation +```json +{ + "tool": "automation_config", + "action": "create", + "config": { + "alias": "Motion Light", + "description": "Turn on light when motion detected", + "mode": "single", + "trigger": [ + { + "platform": "state", + "entity_id": "binary_sensor.motion", + "to": "on" + } + ], + "action": [ + { + "service": "light.turn_on", + "target": { + "entity_id": "light.living_room" + } + } + ] + } +} +``` + +#### Duplicate Automation +```json +{ + "tool": "automation_config", + "action": "duplicate", + "automation_id": "automation.motion_light" +} +``` + +### Core Functions + +#### State Management +```http +GET /api/state +POST /api/state +``` + +Manages the current state of the system. + +**Example Request:** +```json +POST /api/state +{ + "context": "living_room", + "state": { + "lights": "on", + "temperature": 22 + } +} +``` + +#### Context Updates +```http +POST /api/context +``` + +Updates the current context with new information. + +**Example Request:** +```json +POST /api/context +{ + "user": "john", + "location": "kitchen", + "time": "morning", + "activity": "cooking" +} +``` + +### Action Endpoints + +#### Execute Action +```http +POST /api/action +``` + +Executes a specified action with given parameters. + +**Example Request:** +```json +POST /api/action +{ + "action": "turn_on_lights", + "parameters": { + "room": "living_room", + "brightness": 80 + } +} +``` + +#### Batch Actions +```http +POST /api/actions/batch +``` + +Executes multiple actions in sequence. + +**Example Request:** +```json +POST /api/actions/batch +{ + "actions": [ + { + "action": "turn_on_lights", + "parameters": { + "room": "living_room" + } + }, + { + "action": "set_temperature", + "parameters": { + "temperature": 22 + } + } + ] +} +``` + +### Query Functions + +#### Get Available Actions +```http +GET /api/actions +``` + +Returns a list of all available actions. + +**Example Response:** +```json +{ + "actions": [ + { + "name": "turn_on_lights", + "parameters": ["room", "brightness"], + "description": "Turns on lights in specified room" + }, + { + "name": "set_temperature", + "parameters": ["temperature"], + "description": "Sets temperature in current context" + } + ] +} +``` + +#### Context Query +```http +GET /api/context?type=current +``` + +Retrieves context information. + +**Example Response:** +```json +{ + "current_context": { + "user": "john", + "location": "kitchen", + "time": "morning", + "activity": "cooking" + } +} +``` + +### WebSocket Events + +The server supports real-time updates via WebSocket connections. + +```javascript +// Client-side connection example +const ws = new WebSocket('ws://localhost:3000/ws'); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log('Received update:', data); +}; +``` + +#### Supported Events + +- `state_change`: Emitted when system state changes +- `context_update`: Emitted when context is updated +- `action_executed`: Emitted when an action is completed +- `error`: Emitted when an error occurs + +**Example Event Data:** +```json +{ + "event": "state_change", + "data": { + "previous_state": { + "lights": "off" + }, + "current_state": { + "lights": "on" + }, + "timestamp": "2024-03-20T10:30:00Z" + } +} +``` + +### Error Handling + +All endpoints return standard HTTP status codes: + +- 200: Success +- 400: Bad Request +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found +- 500: Internal Server Error + +**Error Response Format:** +```json +{ + "error": { + "code": "INVALID_PARAMETERS", + "message": "Missing required parameter: room", + "details": { + "missing_fields": ["room"] + } + } +} +``` + +### Rate Limiting + +The API implements rate limiting to prevent abuse: + +- 100 requests per minute per IP for regular endpoints +- 1000 requests per minute per IP for WebSocket connections + +When rate limit is exceeded, the server returns: + +```json +{ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Too many requests", + "reset_time": "2024-03-20T10:31:00Z" + } +} +``` + +### Example Usage + +#### Using curl +```bash +# Get current state +curl -X GET \ + http://localhost:3000/api/state \ + -H 'Authorization: ApiKey your_api_key_here' + +# Execute action +curl -X POST \ + http://localhost:3000/api/action \ + -H 'Authorization: ApiKey your_api_key_here' \ + -H 'Content-Type: application/json' \ + -d '{ + "action": "turn_on_lights", + "parameters": { + "room": "living_room", + "brightness": 80 + } + }' +``` + +#### Using JavaScript +```javascript +// Execute action +async function executeAction() { + const response = await fetch('http://localhost:3000/api/action', { + method: 'POST', + headers: { + 'Authorization': 'ApiKey your_api_key_here', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'turn_on_lights', + parameters: { + room: 'living_room', + brightness: 80 + } + }) + }); + + const data = await response.json(); + console.log('Action result:', data); +} +``` + +## Development + +```bash +# Development mode with hot reload +npm run dev + +# Build project +npm run build + +# Production mode +npm run start + +# Run tests +npx jest --config=jest.config.cjs + +# Run tests with coverage +npx jest --coverage + +# Lint code +npm run lint + +# Format code +npm run format +``` + +## Troubleshooting + +### Common Issues + +1. **Node.js Version (`toSorted is not a function`)** + - **Solution:** Update to Node.js 20.10.0+ + ```bash + nvm install 20.10.0 + nvm use 20.10.0 + ``` + +2. **Connection Issues** + - Verify Home Assistant is running + - Check `HASS_HOST` accessibility + - Validate token permissions + - Ensure WebSocket connection for real-time updates + +3. **Add-on Management Issues** + - Verify Supervisor access + - Check add-on compatibility + - Validate system resources + +4. **HACS Integration Issues** + - Verify HACS installation + - Check HACS integration status + - Validate repository access + +5. **Automation Issues** + - Verify entity availability + - Check trigger conditions + - Validate service calls + - Monitor execution logs + +## Project Status + +โœ… **Complete** +- Entity, Floor, and Area access +- Device control (Lights, Climate, Covers, Switches, Contacts) +- Add-on management system +- Package management through HACS +- Advanced automation configuration +- Basic state management +- Error handling and validation +- Docker containerization +- Jest testing setup +- TypeScript integration +- Environment variable management +- Home Assistant API integration +- Project documentation + +๐Ÿšง **In Progress** +- WebSocket implementation for real-time updates +- Enhanced security features +- Tool organization optimization +- Performance optimization +- Resource context integration +- API documentation generation +- Multi-platform desktop integration +- Advanced error recovery +- Custom prompt testing +- Enhanced macOS integration +- Type safety improvements +- Testing coverage expansion + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Implement your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +## Resources + +- [MCP Documentation](https://modelcontextprotocol.io/introduction) +- [Home Assistant Docs](https://www.home-assistant.io) +- [HA REST API](https://developers.home-assistant.io/docs/api/rest) +- [HACS Documentation](https://hacs.xyz) +- [TypeScript Documentation](https://www.typescriptlang.org/docs) + +## License + +MIT License - See [LICENSE](LICENSE) file diff --git a/__tests__/ai/nlp/intent-classifier.test.ts b/__tests__/ai/nlp/intent-classifier.test.ts new file mode 100644 index 0000000..91a068b --- /dev/null +++ b/__tests__/ai/nlp/intent-classifier.test.ts @@ -0,0 +1,204 @@ +import { IntentClassifier } from '../../../src/ai/nlp/intent-classifier.js'; + +describe('IntentClassifier', () => { + let classifier: IntentClassifier; + + beforeEach(() => { + classifier = new IntentClassifier(); + }); + + describe('Basic Intent Classification', () => { + it('should classify turn_on commands', async () => { + const testCases = [ + { + input: 'turn on the living room light', + entities: { parameters: {}, primary_target: 'light.living_room' }, + expectedAction: 'turn_on' + }, + { + input: 'switch on the kitchen lights', + entities: { parameters: {}, primary_target: 'light.kitchen' }, + expectedAction: 'turn_on' + }, + { + input: 'enable the bedroom lamp', + entities: { parameters: {}, primary_target: 'light.bedroom' }, + expectedAction: 'turn_on' + } + ]; + + for (const test of testCases) { + const result = await classifier.classify(test.input, test.entities); + expect(result.action).toBe(test.expectedAction); + expect(result.target).toBe(test.entities.primary_target); + expect(result.confidence).toBeGreaterThan(0.5); + } + }); + + it('should classify turn_off commands', async () => { + const testCases = [ + { + input: 'turn off the living room light', + entities: { parameters: {}, primary_target: 'light.living_room' }, + expectedAction: 'turn_off' + }, + { + input: 'switch off the kitchen lights', + entities: { parameters: {}, primary_target: 'light.kitchen' }, + expectedAction: 'turn_off' + }, + { + input: 'disable the bedroom lamp', + entities: { parameters: {}, primary_target: 'light.bedroom' }, + expectedAction: 'turn_off' + } + ]; + + for (const test of testCases) { + const result = await classifier.classify(test.input, test.entities); + expect(result.action).toBe(test.expectedAction); + expect(result.target).toBe(test.entities.primary_target); + expect(result.confidence).toBeGreaterThan(0.5); + } + }); + + it('should classify set commands with parameters', async () => { + const testCases = [ + { + input: 'set the living room light brightness to 50', + entities: { + parameters: { brightness: 50 }, + primary_target: 'light.living_room' + }, + expectedAction: 'set' + }, + { + input: 'change the temperature to 72', + entities: { + parameters: { temperature: 72 }, + primary_target: 'climate.living_room' + }, + expectedAction: 'set' + }, + { + input: 'adjust the kitchen light color to red', + entities: { + parameters: { color: 'red' }, + primary_target: 'light.kitchen' + }, + expectedAction: 'set' + } + ]; + + for (const test of testCases) { + const result = await classifier.classify(test.input, test.entities); + expect(result.action).toBe(test.expectedAction); + expect(result.target).toBe(test.entities.primary_target); + expect(result.parameters).toEqual(test.entities.parameters); + expect(result.confidence).toBeGreaterThan(0.5); + } + }); + + it('should classify query commands', async () => { + const testCases = [ + { + input: 'what is the living room temperature', + entities: { parameters: {}, primary_target: 'sensor.living_room_temperature' }, + expectedAction: 'query' + }, + { + input: 'get the kitchen light status', + entities: { parameters: {}, primary_target: 'light.kitchen' }, + expectedAction: 'query' + }, + { + input: 'show me the front door camera', + entities: { parameters: {}, primary_target: 'camera.front_door' }, + expectedAction: 'query' + } + ]; + + for (const test of testCases) { + const result = await classifier.classify(test.input, test.entities); + expect(result.action).toBe(test.expectedAction); + expect(result.target).toBe(test.entities.primary_target); + expect(result.confidence).toBeGreaterThan(0.5); + } + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle empty input gracefully', async () => { + const result = await classifier.classify('', { parameters: {}, primary_target: '' }); + expect(result.action).toBe('unknown'); + expect(result.confidence).toBeLessThan(0.5); + }); + + it('should handle unknown commands with low confidence', async () => { + const result = await classifier.classify( + 'do something random', + { parameters: {}, primary_target: 'light.living_room' } + ); + expect(result.action).toBe('unknown'); + expect(result.confidence).toBeLessThan(0.5); + }); + + it('should handle missing entities gracefully', async () => { + const result = await classifier.classify( + 'turn on the lights', + { parameters: {}, primary_target: '' } + ); + expect(result.action).toBe('turn_on'); + expect(result.target).toBe(''); + }); + }); + + describe('Confidence Calculation', () => { + it('should assign higher confidence to exact matches', async () => { + const exactMatch = await classifier.classify( + 'turn on', + { parameters: {}, primary_target: 'light.living_room' } + ); + const partialMatch = await classifier.classify( + 'please turn on the lights if possible', + { parameters: {}, primary_target: 'light.living_room' } + ); + expect(exactMatch.confidence).toBeGreaterThan(partialMatch.confidence); + }); + + it('should boost confidence for polite phrases', async () => { + const politeRequest = await classifier.classify( + 'please turn on the lights', + { parameters: {}, primary_target: 'light.living_room' } + ); + const basicRequest = await classifier.classify( + 'turn on the lights', + { parameters: {}, primary_target: 'light.living_room' } + ); + expect(politeRequest.confidence).toBeGreaterThan(basicRequest.confidence); + }); + }); + + describe('Context Inference', () => { + it('should infer set action when parameters are present', async () => { + const result = await classifier.classify( + 'lights at 50%', + { + parameters: { brightness: 50 }, + primary_target: 'light.living_room' + } + ); + expect(result.action).toBe('set'); + expect(result.parameters).toHaveProperty('brightness', 50); + }); + + it('should infer query action for question-like inputs', async () => { + const result = await classifier.classify( + 'how warm is it', + { parameters: {}, primary_target: 'sensor.temperature' } + ); + expect(result.action).toBe('query'); + expect(result.confidence).toBeGreaterThan(0.5); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/context/context.test.ts b/__tests__/context/context.test.ts new file mode 100644 index 0000000..817fbc1 --- /dev/null +++ b/__tests__/context/context.test.ts @@ -0,0 +1,87 @@ +import { jest, describe, beforeEach, it, expect } from '@jest/globals'; +import { z } from 'zod'; +import { DomainSchema } from '../../src/schemas.js'; + +type MockResponse = { success: boolean }; + +// Define types for tool and server +interface Tool { + name: string; + description: string; + execute: (params: any) => Promise; + parameters: z.ZodType; +} + +interface MockService { + [key: string]: jest.Mock>; +} + +interface MockServices { + light: { + turn_on: jest.Mock>; + turn_off: jest.Mock>; + }; + climate: { + set_temperature: jest.Mock>; + }; +} + +interface MockHassInstance { + services: MockServices; +} + +// Mock LiteMCP class +class MockLiteMCP { + private tools: Tool[] = []; + + constructor(public name: string, public version: string) { } + + addTool(tool: Tool) { + this.tools.push(tool); + } + + getTools() { + return this.tools; + } +} + +const createMockFn = (): jest.Mock> => { + return jest.fn<() => Promise>().mockResolvedValue({ success: true }); +}; + +// Mock the Home Assistant instance +const mockHassServices: MockHassInstance = { + services: { + light: { + turn_on: createMockFn(), + turn_off: createMockFn(), + }, + climate: { + set_temperature: createMockFn(), + }, + }, +}; + +// Mock get_hass function +const get_hass = jest.fn<() => Promise>().mockResolvedValue(mockHassServices); + +describe('Context Tests', () => { + let mockTool: Tool; + + beforeEach(() => { + mockTool = { + name: 'test_tool', + description: 'A test tool', + execute: jest.fn<(params: any) => Promise>().mockResolvedValue({ success: true }), + parameters: z.object({ + test: z.string() + }) + }; + }); + + // Add your test cases here + it('should execute tool successfully', async () => { + const result = await mockTool.execute({ test: 'value' }); + expect(result.success).toBe(true); + }); +}); \ No newline at end of file diff --git a/__tests__/context/index.test.ts b/__tests__/context/index.test.ts new file mode 100644 index 0000000..fb710b7 --- /dev/null +++ b/__tests__/context/index.test.ts @@ -0,0 +1,215 @@ +import { jest, describe, it, expect } from '@jest/globals'; +import { ContextManager, ResourceType, RelationType, ResourceState } from '../../src/context/index.js'; + +describe('Context Manager', () => { + describe('Resource Management', () => { + const contextManager = new ContextManager(); + + it('should add resources', () => { + const resource: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(resource); + const retrievedResource = contextManager.getResource(resource.id); + expect(retrievedResource).toEqual(resource); + }); + + it('should update resources', () => { + const resource: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'off', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + contextManager.updateResource(resource.id, { state: 'off' }); + const retrievedResource = contextManager.getResource(resource.id); + expect(retrievedResource?.state).toBe('off'); + }); + + it('should remove resources', () => { + const resourceId = 'light.living_room'; + contextManager.removeResource(resourceId); + const retrievedResource = contextManager.getResource(resourceId); + expect(retrievedResource).toBeUndefined(); + }); + + it('should get resources by type', () => { + const light1: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + const light2: ResourceState = { + id: 'light.kitchen', + type: ResourceType.DEVICE, + state: 'off', + attributes: { + name: 'Kitchen Light' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(light1); + contextManager.addResource(light2); + const lights = contextManager.getResourcesByType(ResourceType.DEVICE); + expect(lights).toHaveLength(2); + expect(lights).toContainEqual(light1); + expect(lights).toContainEqual(light2); + }); + }); + + describe('Relationship Management', () => { + const contextManager = new ContextManager(); + + it('should add relationships', () => { + const light: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + const room: ResourceState = { + id: 'room.living_room', + type: ResourceType.AREA, + state: 'active', + attributes: { + name: 'Living Room' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(light); + contextManager.addResource(room); + + const relationship = { + sourceId: light.id, + targetId: room.id, + type: RelationType.CONTAINS + }; + contextManager.addRelationship(relationship); + const related = contextManager.getRelatedResources(relationship.sourceId); + expect(related.length).toBeGreaterThan(0); + expect(related[0]).toEqual(room); + }); + + it('should remove relationships', () => { + const sourceId = 'light.living_room'; + const targetId = 'room.living_room'; + contextManager.removeRelationship(sourceId, targetId, RelationType.CONTAINS); + const related = contextManager.getRelatedResources(sourceId); + expect(related).toHaveLength(0); + }); + + it('should get related resources with depth', () => { + const light: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + const room: ResourceState = { + id: 'room.living_room', + type: ResourceType.AREA, + state: 'active', + attributes: { + name: 'Living Room' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(light); + contextManager.addResource(room); + contextManager.addRelationship({ + sourceId: light.id, + targetId: room.id, + type: RelationType.CONTAINS + }); + const relatedResources = contextManager.getRelatedResources(light.id, undefined, 1); + expect(relatedResources).toContainEqual(room); + }); + }); + + describe('Resource Analysis', () => { + const contextManager = new ContextManager(); + + it('should analyze resource usage', () => { + const light: ResourceState = { + id: 'light.living_room', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light', + brightness: 255, + color_temp: 400 + }, + lastUpdated: Date.now() + }; + contextManager.addResource(light); + const analysis = contextManager.analyzeResourceUsage(light.id); + expect(analysis).toBeDefined(); + expect(analysis.dependencies).toBeDefined(); + expect(analysis.usage).toBeDefined(); + }); + }); + + describe('Event Subscriptions', () => { + const contextManager = new ContextManager(); + + it('should handle resource subscriptions', () => { + const callback = jest.fn(); + const resourceId = 'light.living_room'; + const resource: ResourceState = { + id: resourceId, + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Living Room Light' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(resource); + contextManager.subscribeToResource(resourceId, callback); + contextManager.updateResource(resourceId, { state: 'off' }); + expect(callback).toHaveBeenCalled(); + }); + + it('should handle type subscriptions', () => { + const callback = jest.fn(); + const type = ResourceType.DEVICE; + + const unsubscribe = contextManager.subscribeToType(type, callback); + + const resource: ResourceState = { + id: 'light.kitchen', + type: ResourceType.DEVICE, + state: 'on', + attributes: { + name: 'Kitchen Light' + }, + lastUpdated: Date.now() + }; + contextManager.addResource(resource); + + contextManager.updateResource(resource.id, { state: 'off' }); + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/hass/api.test.ts b/__tests__/hass/api.test.ts new file mode 100644 index 0000000..6cc1d68 --- /dev/null +++ b/__tests__/hass/api.test.ts @@ -0,0 +1,227 @@ +import { HassInstanceImpl } from '../../src/hass/index.js'; +import * as HomeAssistant from '../../src/types/hass.js'; +import { HassWebSocketClient } from '../../src/websocket/client.js'; + +// Add DOM types for WebSocket and events +type CloseEvent = { + code: number; + reason: string; + wasClean: boolean; +}; + +type MessageEvent = { + data: any; + type: string; + lastEventId: string; +}; + +type Event = { + type: string; +}; + +interface WebSocketLike { + send(data: string): void; + close(): void; + addEventListener(type: string, listener: (event: any) => void): void; + removeEventListener(type: string, listener: (event: any) => void): void; + dispatchEvent(event: Event): boolean; + onopen: ((event: Event) => void) | null; + onclose: ((event: CloseEvent) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: Event) => void) | null; + url: string; + readyState: number; + bufferedAmount: number; + extensions: string; + protocol: string; + binaryType: string; +} + +interface MockWebSocketInstance extends WebSocketLike { + send: jest.Mock; + close: jest.Mock; + addEventListener: jest.Mock; + removeEventListener: jest.Mock; + dispatchEvent: jest.Mock; +} + +interface MockWebSocketConstructor extends jest.Mock { + CONNECTING: 0; + OPEN: 1; + CLOSING: 2; + CLOSED: 3; + prototype: WebSocketLike; +} + +// Mock the entire hass module +jest.mock('../../src/hass/index.js', () => ({ + get_hass: jest.fn() +})); + +describe('Home Assistant API', () => { + let hass: HassInstanceImpl; + let mockWs: MockWebSocketInstance; + let MockWebSocket: MockWebSocketConstructor; + + beforeEach(() => { + hass = new HassInstanceImpl('http://localhost:8123', 'test_token'); + mockWs = { + send: jest.fn(), + close: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + onopen: null, + onclose: null, + onmessage: null, + onerror: null, + url: '', + readyState: 1, + bufferedAmount: 0, + extensions: '', + protocol: '', + binaryType: 'blob' + } as MockWebSocketInstance; + + // Create a mock WebSocket constructor + MockWebSocket = jest.fn().mockImplementation(() => mockWs) as MockWebSocketConstructor; + MockWebSocket.CONNECTING = 0; + MockWebSocket.OPEN = 1; + MockWebSocket.CLOSING = 2; + MockWebSocket.CLOSED = 3; + MockWebSocket.prototype = {} as WebSocketLike; + + // Mock WebSocket globally + (global as any).WebSocket = MockWebSocket; + }); + + describe('State Management', () => { + it('should fetch all states', async () => { + const mockStates: HomeAssistant.Entity[] = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 }, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { id: '123', parent_id: null, user_id: null } + } + ]; + + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockStates) + }); + + const states = await hass.fetchStates(); + expect(states).toEqual(mockStates); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/states', + expect.any(Object) + ); + }); + + it('should fetch single state', async () => { + const mockState: HomeAssistant.Entity = { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 }, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { id: '123', parent_id: null, user_id: null } + }; + + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockState) + }); + + const state = await hass.fetchState('light.living_room'); + expect(state).toEqual(mockState); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8123/api/states/light.living_room', + expect.any(Object) + ); + }); + + it('should handle state fetch errors', async () => { + global.fetch = jest.fn().mockRejectedValueOnce(new Error('Failed to fetch states')); + + await expect(hass.fetchStates()).rejects.toThrow('Failed to fetch states'); + }); + }); + + describe('Service Calls', () => { + it('should call service', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }); + + await hass.callService('light', 'turn_on', { + entity_id: 'light.living_room', + brightness: 255 + }); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8123/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 () => { + global.fetch = jest.fn().mockRejectedValueOnce(new Error('Service call failed')); + + await expect( + hass.callService('invalid_domain', 'invalid_service', {}) + ).rejects.toThrow('Service call failed'); + }); + }); + + describe('Event Subscription', () => { + it('should subscribe to events', async () => { + const callback = jest.fn(); + await hass.subscribeEvents(callback, 'state_changed'); + + expect(MockWebSocket).toHaveBeenCalledWith( + 'ws://localhost:8123/api/websocket' + ); + }); + + it('should handle subscription errors', async () => { + const callback = jest.fn(); + MockWebSocket.mockImplementation(() => { + throw new Error('WebSocket connection failed'); + }); + + await expect( + hass.subscribeEvents(callback, 'state_changed') + ).rejects.toThrow('WebSocket connection failed'); + }); + }); + + describe('WebSocket connection', () => { + it('should connect to WebSocket endpoint', async () => { + await hass.subscribeEvents(() => { }); + expect(MockWebSocket).toHaveBeenCalledWith( + 'ws://localhost:8123/api/websocket' + ); + }); + + it('should handle connection errors', async () => { + MockWebSocket.mockImplementation(() => { + throw new Error('Connection failed'); + }); + + await expect(hass.subscribeEvents(() => { })).rejects.toThrow( + 'Connection failed' + ); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/hass/hass.test.ts b/__tests__/hass/hass.test.ts new file mode 100644 index 0000000..1511ed4 --- /dev/null +++ b/__tests__/hass/hass.test.ts @@ -0,0 +1,99 @@ +import { jest, describe, beforeEach, afterAll, it, expect } from '@jest/globals'; +import type { Mock } from 'jest-mock'; + +// Define types +interface MockResponse { + success: boolean; +} + +type MockFn = () => Promise; + +interface MockService { + [key: string]: Mock; +} + +interface MockServices { + light: { + turn_on: Mock; + turn_off: Mock; + }; + climate: { + set_temperature: Mock; + }; +} + +interface MockHassInstance { + services: MockServices; +} + +// Mock instance +let mockInstance: MockHassInstance | null = null; + +const createMockFn = (): Mock => { + return jest.fn().mockImplementation(async () => ({ success: true })); +}; + +// Mock the digital-alchemy modules before tests +jest.unstable_mockModule('@digital-alchemy/core', () => ({ + CreateApplication: jest.fn(() => ({ + configuration: {}, + bootstrap: async () => mockInstance, + services: {} + })), + TServiceParams: jest.fn() +})); + +jest.unstable_mockModule('@digital-alchemy/hass', () => ({ + LIB_HASS: { + configuration: {}, + services: {} + } +})); + +describe('Home Assistant Connection', () => { + // Backup the original environment + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + // Initialize mock instance + mockInstance = { + services: { + light: { + turn_on: createMockFn(), + turn_off: createMockFn(), + }, + climate: { + set_temperature: createMockFn(), + }, + }, + }; + // Reset environment variables + process.env = { ...originalEnv }; + }); + + afterAll(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('should return a Home Assistant instance with services', async () => { + const { get_hass } = await import('../../src/hass/index.js'); + const hass = await get_hass(); + + expect(hass).toBeDefined(); + expect(hass.services).toBeDefined(); + expect(typeof hass.services.light.turn_on).toBe('function'); + expect(typeof hass.services.light.turn_off).toBe('function'); + expect(typeof hass.services.climate.set_temperature).toBe('function'); + }); + + it('should reuse the same instance on subsequent calls', async () => { + const { get_hass } = await import('../../src/hass/index.js'); + const firstInstance = await get_hass(); + const secondInstance = await get_hass(); + + expect(firstInstance).toBe(secondInstance); + }); +}); \ No newline at end of file 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/__tests__/helpers.test.ts b/__tests__/helpers.test.ts new file mode 100644 index 0000000..faeaaa4 --- /dev/null +++ b/__tests__/helpers.test.ts @@ -0,0 +1,45 @@ +import { jest, describe, it, expect } from '@jest/globals'; +import { formatToolCall } from '../src/helpers.js'; + +describe('helpers', () => { + describe('formatToolCall', () => { + it('should format an object into the correct structure', () => { + const testObj = { name: 'test', value: 123 }; + const result = formatToolCall(testObj); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: JSON.stringify(testObj, null, 2), + isError: false + }] + }); + }); + + it('should handle error cases correctly', () => { + const testObj = { error: 'test error' }; + const result = formatToolCall(testObj, true); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: JSON.stringify(testObj, null, 2), + isError: true + }] + }); + }); + + it('should handle empty objects', () => { + const testObj = {}; + const result = formatToolCall(testObj); + + expect(result).toEqual({ + content: [{ + type: 'text', + text: '{}', + isError: false + }] + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts new file mode 100644 index 0000000..e6ce733 --- /dev/null +++ b/__tests__/index.test.ts @@ -0,0 +1,1130 @@ +import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; +import { LiteMCP } from 'litemcp'; +import { get_hass } from '../src/hass/index.js'; +import type { WebSocket } from 'ws'; + +// Load test environment variables with defaults +const TEST_HASS_HOST = process.env.TEST_HASS_HOST || 'http://localhost:8123'; +const TEST_HASS_TOKEN = process.env.TEST_HASS_TOKEN || 'test_token'; +const TEST_HASS_SOCKET_URL = process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket'; + +// Set environment variables for testing +process.env.HASS_HOST = TEST_HASS_HOST; +process.env.HASS_TOKEN = TEST_HASS_TOKEN; +process.env.HASS_SOCKET_URL = TEST_HASS_SOCKET_URL; + +// Mock fetch +const mockFetchResponse = { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ automation_id: 'test_automation' }), + text: async () => '{"automation_id":"test_automation"}', + headers: new Headers(), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob([]), + formData: async () => new FormData(), + clone: function () { return { ...this }; }, + type: 'default', + url: '', + redirected: false, + redirect: () => Promise.resolve(new Response()) +} as Response; + +const mockFetch = jest.fn(async (_input: string | URL | Request, _init?: RequestInit) => mockFetchResponse); +(global as any).fetch = mockFetch; + +// Mock LiteMCP +interface Tool { + name: string; + description: string; + parameters: Record; + execute: (params: Record) => Promise; +} + +type MockFunction = jest.Mock, any[]>; + +interface MockLiteMCPInstance { + addTool: ReturnType; + start: ReturnType; +} + +const mockLiteMCPInstance: MockLiteMCPInstance = { + addTool: jest.fn(), + start: jest.fn().mockResolvedValue(undefined) +}; + +jest.mock('litemcp', () => ({ + LiteMCP: jest.fn(() => mockLiteMCPInstance) +})); + +// Mock get_hass +interface MockServices { + light: { + turn_on: jest.Mock; + turn_off: jest.Mock; + }; + climate: { + set_temperature: jest.Mock; + }; +} + +interface MockHassInstance { + services: MockServices; +} + +// Create mock services +const mockServices: MockServices = { + light: { + turn_on: jest.fn().mockResolvedValue({ success: true }), + turn_off: jest.fn().mockResolvedValue({ success: true }) + }, + climate: { + set_temperature: jest.fn().mockResolvedValue({ success: true }) + } +}; + +jest.unstable_mockModule('../src/hass/index.js', () => ({ + get_hass: jest.fn().mockResolvedValue({ services: mockServices }) +})); + +interface TestResponse { + success: boolean; + message?: string; + devices?: Record; + history?: unknown[]; + scenes?: unknown[]; + automations?: unknown[]; + addons?: unknown[]; + packages?: unknown[]; + automation_id?: string; + new_automation_id?: string; +} + +type WebSocketEventMap = { + message: MessageEvent; + open: Event; + close: Event; + error: Event; +}; + +type WebSocketEventListener = (event: Event) => void; +type WebSocketMessageListener = (event: MessageEvent) => void; + +interface MockWebSocketInstance { + addEventListener: jest.Mock; + removeEventListener: jest.Mock; + send: jest.Mock; + close: jest.Mock; + readyState: number; + binaryType: 'blob' | 'arraybuffer'; + bufferedAmount: number; + extensions: string; + protocol: string; + url: string; + onopen: WebSocketEventListener | null; + onerror: WebSocketEventListener | null; + onclose: WebSocketEventListener | null; + onmessage: WebSocketMessageListener | null; + CONNECTING: number; + OPEN: number; + CLOSING: number; + CLOSED: number; +} + +const createMockWebSocket = (): MockWebSocketInstance => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + send: jest.fn(), + close: jest.fn(), + readyState: 0, + binaryType: 'blob', + bufferedAmount: 0, + extensions: '', + protocol: '', + url: '', + onopen: null, + onerror: null, + onclose: null, + onmessage: null, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +}); + +describe('Home Assistant MCP Server', () => { + let mockHass: MockHassInstance; + let liteMcpInstance: MockLiteMCPInstance; + let addToolCalls: Array<[Tool]>; + + beforeEach(async () => { + mockHass = { + services: mockServices + }; + + // Reset all mocks + jest.clearAllMocks(); + mockFetch.mockClear(); + + // Import the module which will execute the main function + await import('../src/index.js'); + + // Mock WebSocket + const mockWs = createMockWebSocket(); + (global as any).WebSocket = jest.fn(() => mockWs); + + // Get the mock instance + liteMcpInstance = mockLiteMCPInstance; + addToolCalls = liteMcpInstance.addTool.mock.calls as Array<[Tool]>; + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should connect to Home Assistant', async () => { + const hass = await get_hass(); + expect(hass).toBeDefined(); + expect(hass.services).toBeDefined(); + expect(typeof hass.services.light.turn_on).toBe('function'); + }); + + it('should reuse the same instance on subsequent calls', async () => { + const firstInstance = await get_hass(); + const secondInstance = await get_hass(); + expect(firstInstance).toBe(secondInstance); + }); + + describe('list_devices tool', () => { + it('should successfully list devices', async () => { + // Mock the fetch response for listing devices + const mockDevices = [ + { + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + }, + { + entity_id: 'climate.bedroom', + state: 'heat', + attributes: { temperature: 22 } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockDevices + } as Response); + + // Get the tool registration + const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0]; + expect(listDevicesTool).toBeDefined(); + + if (!listDevicesTool) { + throw new Error('list_devices tool not found'); + } + + // Execute the tool + const result = (await listDevicesTool.execute({})) as TestResponse; + + // Verify the results + expect(result.success).toBe(true); + expect(result.devices).toEqual({ + light: [{ + entity_id: 'light.living_room', + state: 'on', + attributes: { brightness: 255 } + }], + climate: [{ + entity_id: 'climate.bedroom', + state: 'heat', + attributes: { temperature: 22 } + }] + }); + }); + + it('should handle fetch errors', async () => { + // Mock a fetch error + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + // Get the tool registration + const listDevicesTool = addToolCalls.find(call => call[0].name === 'list_devices')?.[0]; + expect(listDevicesTool).toBeDefined(); + + if (!listDevicesTool) { + throw new Error('list_devices tool not found'); + } + + // Execute the tool + const result = (await listDevicesTool.execute({})) as TestResponse; + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toBe('Network error'); + }); + }); + + describe('control tool', () => { + it('should successfully control a light device', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + // Execute the tool + const result = (await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + brightness: 255 + })) as TestResponse; + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed turn_on for light.living_room'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/light/turn_on`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'light.living_room', + brightness: 255 + }) + } + ); + }); + + it('should handle unsupported domains', async () => { + // Get the tool registration + const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + // Execute the tool with an unsupported domain + const result = (await controlTool.execute({ + command: 'turn_on', + entity_id: 'unsupported.device' + })) as TestResponse; + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toBe('Unsupported domain: unsupported'); + }); + + it('should handle service call errors', async () => { + // Mock a failed service call + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Service unavailable' + } as Response); + + // Get the tool registration + const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + // Execute the tool + const result = (await controlTool.execute({ + command: 'turn_on', + entity_id: 'light.living_room' + })) as TestResponse; + + // Verify error handling + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to execute turn_on for light.living_room'); + }); + + it('should handle climate device controls', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + // Execute the tool + const result = (await controlTool.execute({ + command: 'set_temperature', + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + })) as TestResponse; + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed set_temperature for climate.bedroom'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/climate/set_temperature`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'climate.bedroom', + temperature: 22, + target_temp_high: 24, + target_temp_low: 20 + }) + } + ); + }); + + it('should handle cover device controls', async () => { + // Mock successful service call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + // Get the tool registration + const controlTool = addToolCalls.find(call => call[0].name === 'control')?.[0]; + expect(controlTool).toBeDefined(); + + if (!controlTool) { + throw new Error('control tool not found'); + } + + // Execute the tool + const result = (await controlTool.execute({ + command: 'set_position', + entity_id: 'cover.living_room', + position: 50 + })) as TestResponse; + + // Verify the results + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully executed set_position for cover.living_room'); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/cover/set_position`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'cover.living_room', + position: 50 + }) + } + ); + }); + }); + + describe('get_history tool', () => { + it('should successfully fetch history', async () => { + const mockHistory = [ + { + entity_id: 'light.living_room', + state: 'on', + last_changed: '2024-01-01T00:00:00Z', + attributes: { brightness: 255 } + }, + { + entity_id: 'light.living_room', + state: 'off', + last_changed: '2024-01-01T01:00:00Z', + attributes: { brightness: 0 } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockHistory + } as Response); + + // Get the tool registration + const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0]; + expect(historyTool).toBeDefined(); + + if (!historyTool) { + throw new Error('get_history tool not found'); + } + + // Execute the tool + const result = (await historyTool.execute({ + entity_id: 'light.living_room', + start_time: '2024-01-01T00:00:00Z', + end_time: '2024-01-01T02:00:00Z', + minimal_response: true, + significant_changes_only: true + })) as TestResponse; + + // Verify the results + expect(result.success).toBe(true); + expect(result.history).toEqual(mockHistory); + + // Verify the fetch call + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'), + expect.objectContaining({ + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + } + }) + ); + + // Verify query parameters + const url = mockFetch.mock.calls[0][0] as string; + const queryParams = new URL(url).searchParams; + expect(queryParams.get('filter_entity_id')).toBe('light.living_room'); + expect(queryParams.get('minimal_response')).toBe('true'); + expect(queryParams.get('significant_changes_only')).toBe('true'); + }); + + it('should handle fetch errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const historyTool = addToolCalls.find(call => call[0].name === 'get_history')?.[0]; + expect(historyTool).toBeDefined(); + + if (!historyTool) { + throw new Error('get_history tool not found'); + } + + const result = (await historyTool.execute({ + entity_id: 'light.living_room' + })) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Network error'); + }); + }); + + describe('scene tool', () => { + it('should successfully list scenes', async () => { + const mockScenes = [ + { + entity_id: 'scene.movie_time', + state: 'on', + attributes: { + friendly_name: 'Movie Time', + description: 'Perfect lighting for movies' + } + }, + { + entity_id: 'scene.good_morning', + state: 'on', + attributes: { + friendly_name: 'Good Morning', + description: 'Bright lights to start the day' + } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockScenes + } as Response); + + const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0]; + expect(sceneTool).toBeDefined(); + + if (!sceneTool) { + throw new Error('scene tool not found'); + } + + const result = (await sceneTool.execute({ + action: 'list' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.scenes).toEqual([ + { + entity_id: 'scene.movie_time', + name: 'Movie Time', + description: 'Perfect lighting for movies' + }, + { + entity_id: 'scene.good_morning', + name: 'Good Morning', + description: 'Bright lights to start the day' + } + ]); + }); + + it('should successfully activate a scene', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const sceneTool = addToolCalls.find(call => call[0].name === 'scene')?.[0]; + expect(sceneTool).toBeDefined(); + + if (!sceneTool) { + throw new Error('scene tool not found'); + } + + const result = (await sceneTool.execute({ + action: 'activate', + scene_id: 'scene.movie_time' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully activated scene scene.movie_time'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/scene/turn_on`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'scene.movie_time' + }) + } + ); + }); + }); + + describe('notify tool', () => { + it('should successfully send a notification', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0]; + expect(notifyTool).toBeDefined(); + + if (!notifyTool) { + throw new Error('notify tool not found'); + } + + const result = (await notifyTool.execute({ + message: 'Test notification', + title: 'Test Title', + target: 'mobile_app_phone', + data: { priority: 'high' } + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Notification sent successfully'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/notify/mobile_app_phone`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Test notification', + title: 'Test Title', + data: { priority: 'high' } + }) + } + ); + }); + + it('should use default notification service when no target is specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const notifyTool = addToolCalls.find(call => call[0].name === 'notify')?.[0]; + expect(notifyTool).toBeDefined(); + + if (!notifyTool) { + throw new Error('notify tool not found'); + } + + await notifyTool.execute({ + message: 'Test notification' + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/notify/notify`, + expect.any(Object) + ); + }); + }); + + describe('automation tool', () => { + it('should successfully list automations', async () => { + const mockAutomations = [ + { + entity_id: 'automation.morning_routine', + state: 'on', + attributes: { + friendly_name: 'Morning Routine', + last_triggered: '2024-01-01T07:00:00Z' + } + }, + { + entity_id: 'automation.night_mode', + state: 'off', + attributes: { + friendly_name: 'Night Mode', + last_triggered: '2024-01-01T22:00:00Z' + } + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAutomations + } as Response); + + const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = (await automationTool.execute({ + action: 'list' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.automations).toEqual([ + { + entity_id: 'automation.morning_routine', + name: 'Morning Routine', + state: 'on', + last_triggered: '2024-01-01T07:00:00Z' + }, + { + entity_id: 'automation.night_mode', + name: 'Night Mode', + state: 'off', + last_triggered: '2024-01-01T22:00:00Z' + } + ]); + }); + + it('should successfully toggle an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = (await automationTool.execute({ + action: 'toggle', + automation_id: 'automation.morning_routine' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully toggled automation automation.morning_routine'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/automation/toggle`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + } + ); + }); + + it('should successfully trigger an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = (await automationTool.execute({ + action: 'trigger', + automation_id: 'automation.morning_routine' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully triggered automation automation.morning_routine'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/services/automation/trigger`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + entity_id: 'automation.morning_routine' + }) + } + ); + }); + + it('should require automation_id for toggle and trigger actions', async () => { + const automationTool = addToolCalls.find(call => call[0].name === 'automation')?.[0]; + expect(automationTool).toBeDefined(); + + if (!automationTool) { + throw new Error('automation tool not found'); + } + + const result = (await automationTool.execute({ + action: 'toggle' + })) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID is required for toggle and trigger actions'); + }); + }); + + describe('addon tool', () => { + it('should successfully list add-ons', async () => { + const mockAddons = { + data: { + addons: [ + { + name: 'File Editor', + slug: 'core_configurator', + description: 'Simple browser-based file editor', + version: '5.6.0', + installed: true, + available: true, + state: 'started' + }, + { + name: 'Terminal & SSH', + slug: 'ssh', + description: 'Terminal access to your Home Assistant', + version: '9.6.1', + installed: false, + available: true, + state: 'not_installed' + } + ] + } + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockAddons + } as Response); + + const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0]; + expect(addonTool).toBeDefined(); + + if (!addonTool) { + throw new Error('addon tool not found'); + } + + const result = (await addonTool.execute({ + action: 'list' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.addons).toEqual(mockAddons.data.addons); + }); + + it('should successfully install an add-on', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { state: 'installing' } }) + } as Response); + + const addonTool = addToolCalls.find(call => call[0].name === 'addon')?.[0]; + expect(addonTool).toBeDefined(); + + if (!addonTool) { + throw new Error('addon tool not found'); + } + + const result = (await addonTool.execute({ + action: 'install', + slug: 'core_configurator', + version: '5.6.0' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully installed add-on core_configurator'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/hassio/addons/core_configurator/install`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ version: '5.6.0' }) + } + ); + }); + }); + + describe('package tool', () => { + it('should successfully list packages', async () => { + const mockPackages = { + repositories: [ + { + name: 'HACS', + description: 'Home Assistant Community Store', + category: 'integration', + installed: true, + version_installed: '1.32.0', + available_version: '1.32.0', + authors: ['ludeeus'], + domain: 'hacs' + } + ] + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackages + } as Response); + + const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0]; + expect(packageTool).toBeDefined(); + + if (!packageTool) { + throw new Error('package tool not found'); + } + + const result = (await packageTool.execute({ + action: 'list', + category: 'integration' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.packages).toEqual(mockPackages.repositories); + }); + + it('should successfully install a package', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + } as Response); + + const packageTool = addToolCalls.find(call => call[0].name === 'package')?.[0]; + expect(packageTool).toBeDefined(); + + if (!packageTool) { + throw new Error('package tool not found'); + } + + const result = (await packageTool.execute({ + action: 'install', + category: 'integration', + repository: 'hacs/integration', + version: '1.32.0' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully installed package hacs/integration'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/hacs/repository/install`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + category: 'integration', + repository: 'hacs/integration', + version: '1.32.0' + }) + } + ); + }); + }); + + describe('automation_config tool', () => { + const mockAutomationConfig = { + alias: 'Test Automation', + description: 'Test automation description', + mode: 'single', + trigger: [ + { + platform: 'state', + entity_id: 'binary_sensor.motion', + to: 'on' + } + ], + action: [ + { + service: 'light.turn_on', + target: { + entity_id: 'light.living_room' + } + } + ] + }; + + it('should successfully create an automation', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ automation_id: 'new_automation_1' }) + } as Response); + + const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = (await automationConfigTool.execute({ + action: 'create', + config: mockAutomationConfig + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully created automation'); + expect(result.automation_id).toBe('new_automation_1'); + + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/config/automation/config`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(mockAutomationConfig) + } + ); + }); + + it('should successfully duplicate an automation', async () => { + // Mock get existing automation + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => mockAutomationConfig + } as Response) + // Mock create new automation + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ automation_id: 'new_automation_2' }) + } as Response); + + const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = (await automationConfigTool.execute({ + action: 'duplicate', + automation_id: 'automation.test' + })) as TestResponse; + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully duplicated automation automation.test'); + expect(result.new_automation_id).toBe('new_automation_2'); + + // Verify both API calls + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/config/automation/config/automation.test`, + expect.any(Object) + ); + + const duplicateConfig = { ...mockAutomationConfig, alias: 'Test Automation (Copy)' }; + expect(mockFetch).toHaveBeenCalledWith( + `${TEST_HASS_HOST}/api/config/automation/config`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${TEST_HASS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(duplicateConfig) + } + ); + }); + + it('should require config for create action', async () => { + const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = (await automationConfigTool.execute({ + action: 'create' + })) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Configuration is required for creating automation'); + }); + + it('should require automation_id for update action', async () => { + const automationConfigTool = addToolCalls.find(call => call[0].name === 'automation_config')?.[0]; + expect(automationConfigTool).toBeDefined(); + + if (!automationConfigTool) { + throw new Error('automation_config tool not found'); + } + + const result = (await automationConfigTool.execute({ + action: 'update', + config: mockAutomationConfig + })) as TestResponse; + + expect(result.success).toBe(false); + expect(result.message).toBe('Automation ID and configuration are required for updating automation'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/performance/index.test.ts b/__tests__/performance/index.test.ts new file mode 100644 index 0000000..047e5bd --- /dev/null +++ b/__tests__/performance/index.test.ts @@ -0,0 +1,195 @@ +import { PerformanceMonitor, PerformanceOptimizer, Metric } from '../../src/performance/index.js'; + +describe('Performance Module', () => { + describe('PerformanceMonitor', () => { + let monitor: PerformanceMonitor; + + beforeEach(() => { + monitor = new PerformanceMonitor({ + responseTime: 500, + memoryUsage: 1024 * 1024 * 512, // 512MB + cpuUsage: 70 + }); + }); + + afterEach(() => { + monitor.stop(); + }); + + it('should collect metrics', () => { + const metricHandler = jest.fn(); + monitor.on('metric', metricHandler); + + monitor.start(); + + // Wait for first collection + return new Promise(resolve => setTimeout(() => { + expect(metricHandler).toHaveBeenCalled(); + const calls = metricHandler.mock.calls; + + // Verify memory metrics + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.heapUsed' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.heapTotal' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.rss' + )).toBe(true); + + // Verify CPU metrics + expect(calls.some(([metric]: [Metric]) => + metric.name === 'cpu.user' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'cpu.system' + )).toBe(true); + + resolve(true); + }, 100)); + }); + + it('should emit threshold exceeded events', () => { + const thresholdHandler = jest.fn(); + monitor = new PerformanceMonitor({ + memoryUsage: 1, // Ensure threshold is exceeded + cpuUsage: 1 + }); + monitor.on('threshold_exceeded', thresholdHandler); + + monitor.start(); + + return new Promise(resolve => setTimeout(() => { + expect(thresholdHandler).toHaveBeenCalled(); + resolve(true); + }, 100)); + }); + + it('should clean old metrics', () => { + const now = Date.now(); + const oldMetric: Metric = { + name: 'test', + value: 1, + timestamp: now - 25 * 60 * 60 * 1000 // 25 hours old + }; + const newMetric: Metric = { + name: 'test', + value: 2, + timestamp: now - 1000 // 1 second old + }; + + monitor.addMetric(oldMetric); + monitor.addMetric(newMetric); + + const metrics = monitor.getMetrics(now - 24 * 60 * 60 * 1000); + expect(metrics).toHaveLength(1); + expect(metrics[0]).toEqual(newMetric); + }); + + it('should calculate metric averages', () => { + const now = Date.now(); + const metrics: Metric[] = [ + { name: 'test', value: 1, timestamp: now - 3000 }, + { name: 'test', value: 2, timestamp: now - 2000 }, + { name: 'test', value: 3, timestamp: now - 1000 } + ]; + + metrics.forEach(metric => monitor.addMetric(metric)); + + const average = monitor.calculateAverage( + 'test', + now - 5000, + now + ); + expect(average).toBe(2); + }); + }); + + describe('PerformanceOptimizer', () => { + it('should process batches correctly', async () => { + const items = [1, 2, 3, 4, 5]; + const batchSize = 2; + const processor = jest.fn(async (batch: number[]) => + batch.map(n => n * 2) + ); + + const results = await PerformanceOptimizer.processBatch( + items, + batchSize, + processor + ); + + expect(results).toEqual([2, 4, 6, 8, 10]); + expect(processor).toHaveBeenCalledTimes(3); // 2 + 2 + 1 items + }); + + it('should debounce function calls', (done) => { + const fn = jest.fn(); + const debounced = PerformanceOptimizer.debounce(fn, 100); + + debounced(); + debounced(); + debounced(); + + setTimeout(() => { + expect(fn).not.toHaveBeenCalled(); + }, 50); + + setTimeout(() => { + expect(fn).toHaveBeenCalledTimes(1); + done(); + }, 150); + }); + + it('should throttle function calls', (done) => { + const fn = jest.fn(); + const throttled = PerformanceOptimizer.throttle(fn, 100); + + throttled(); + throttled(); + throttled(); + + expect(fn).toHaveBeenCalledTimes(1); + + setTimeout(() => { + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + done(); + }, 150); + }); + + it('should optimize memory when threshold is exceeded', async () => { + const originalGc = global.gc; + global.gc = jest.fn(); + + const memoryUsage = process.memoryUsage; + const mockMemoryUsage = () => ({ + heapUsed: 900, + heapTotal: 1000, + rss: 2000, + external: 0, + arrayBuffers: 0 + }); + Object.defineProperty(process, 'memoryUsage', { + value: mockMemoryUsage, + writable: true + }); + + await PerformanceOptimizer.optimizeMemory(); + + expect(global.gc).toHaveBeenCalled(); + + // Cleanup + Object.defineProperty(process, 'memoryUsage', { + value: memoryUsage, + writable: true + }); + if (originalGc) { + global.gc = originalGc; + } else { + delete global.gc; + } + }); + }); +}); \ No newline at end of file diff --git a/__tests__/schemas/devices.test.js b/__tests__/schemas/devices.test.js new file mode 100644 index 0000000..bfc9eda --- /dev/null +++ b/__tests__/schemas/devices.test.js @@ -0,0 +1,186 @@ +import { MediaPlayerSchema, FanSchema, LockSchema, VacuumSchema, SceneSchema, ScriptSchema, CameraSchema, ListMediaPlayersResponseSchema, ListFansResponseSchema, ListLocksResponseSchema, ListVacuumsResponseSchema, ListScenesResponseSchema, ListScriptsResponseSchema, ListCamerasResponseSchema, } from '../../src/schemas'; +describe('Device Schemas', () => { + describe('MediaPlayer Schema', () => { + it('should validate a valid media player entity', () => { + const mediaPlayer = { + entity_id: 'media_player.living_room', + state: 'playing', + state_attributes: { + volume_level: 0.5, + is_volume_muted: false, + media_content_id: 'spotify:playlist:xyz', + media_content_type: 'playlist', + media_title: 'My Playlist', + source: 'Spotify', + source_list: ['Spotify', 'Radio', 'TV'], + supported_features: 12345 + } + }; + expect(() => MediaPlayerSchema.parse(mediaPlayer)).not.toThrow(); + }); + it('should validate media player list response', () => { + const response = { + media_players: [{ + entity_id: 'media_player.living_room', + state: 'playing', + state_attributes: {} + }] + }; + expect(() => ListMediaPlayersResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Fan Schema', () => { + it('should validate a valid fan entity', () => { + const fan = { + entity_id: 'fan.bedroom', + state: 'on', + state_attributes: { + percentage: 50, + preset_mode: 'auto', + preset_modes: ['auto', 'low', 'medium', 'high'], + oscillating: true, + direction: 'forward', + supported_features: 12345 + } + }; + expect(() => FanSchema.parse(fan)).not.toThrow(); + }); + it('should validate fan list response', () => { + const response = { + fans: [{ + entity_id: 'fan.bedroom', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListFansResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Lock Schema', () => { + it('should validate a valid lock entity', () => { + const lock = { + entity_id: 'lock.front_door', + state: 'locked', + state_attributes: { + code_format: 'number', + changed_by: 'User', + locked: true, + supported_features: 12345 + } + }; + expect(() => LockSchema.parse(lock)).not.toThrow(); + }); + it('should validate lock list response', () => { + const response = { + locks: [{ + entity_id: 'lock.front_door', + state: 'locked', + state_attributes: { locked: true } + }] + }; + expect(() => ListLocksResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Vacuum Schema', () => { + it('should validate a valid vacuum entity', () => { + const vacuum = { + entity_id: 'vacuum.robot', + state: 'cleaning', + state_attributes: { + battery_level: 80, + fan_speed: 'medium', + fan_speed_list: ['low', 'medium', 'high'], + status: 'cleaning', + supported_features: 12345 + } + }; + expect(() => VacuumSchema.parse(vacuum)).not.toThrow(); + }); + it('should validate vacuum list response', () => { + const response = { + vacuums: [{ + entity_id: 'vacuum.robot', + state: 'cleaning', + state_attributes: {} + }] + }; + expect(() => ListVacuumsResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Scene Schema', () => { + it('should validate a valid scene entity', () => { + const scene = { + entity_id: 'scene.movie_night', + state: 'on', + state_attributes: { + entity_id: ['light.living_room', 'media_player.tv'], + supported_features: 12345 + } + }; + expect(() => SceneSchema.parse(scene)).not.toThrow(); + }); + it('should validate scene list response', () => { + const response = { + scenes: [{ + entity_id: 'scene.movie_night', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListScenesResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Script Schema', () => { + it('should validate a valid script entity', () => { + const script = { + entity_id: 'script.welcome_home', + state: 'on', + state_attributes: { + last_triggered: '2023-12-25T12:00:00Z', + mode: 'single', + variables: { + brightness: 100, + color: 'red' + }, + supported_features: 12345 + } + }; + expect(() => ScriptSchema.parse(script)).not.toThrow(); + }); + it('should validate script list response', () => { + const response = { + scripts: [{ + entity_id: 'script.welcome_home', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListScriptsResponseSchema.parse(response)).not.toThrow(); + }); + }); + describe('Camera Schema', () => { + it('should validate a valid camera entity', () => { + const camera = { + entity_id: 'camera.front_door', + state: 'recording', + state_attributes: { + motion_detection: true, + frontend_stream_type: 'hls', + supported_features: 12345 + } + }; + expect(() => CameraSchema.parse(camera)).not.toThrow(); + }); + it('should validate camera list response', () => { + const response = { + cameras: [{ + entity_id: 'camera.front_door', + state: 'recording', + state_attributes: {} + }] + }; + expect(() => ListCamerasResponseSchema.parse(response)).not.toThrow(); + }); + }); +}); +//# sourceMappingURL=devices.test.js.map \ No newline at end of file diff --git a/__tests__/schemas/devices.test.js.map b/__tests__/schemas/devices.test.js.map new file mode 100644 index 0000000..37b57d5 --- /dev/null +++ b/__tests__/schemas/devices.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"devices.test.js","sourceRoot":"","sources":["devices.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/__tests__/schemas/devices.test.ts b/__tests__/schemas/devices.test.ts new file mode 100644 index 0000000..521cc40 --- /dev/null +++ b/__tests__/schemas/devices.test.ts @@ -0,0 +1,214 @@ +import { + MediaPlayerSchema, + FanSchema, + LockSchema, + VacuumSchema, + SceneSchema, + ScriptSchema, + CameraSchema, + ListMediaPlayersResponseSchema, + ListFansResponseSchema, + ListLocksResponseSchema, + ListVacuumsResponseSchema, + ListScenesResponseSchema, + ListScriptsResponseSchema, + ListCamerasResponseSchema, +} from '../../src/schemas.js'; + +describe('Device Schemas', () => { + describe('Media Player Schema', () => { + it('should validate a valid media player entity', () => { + const mediaPlayer = { + entity_id: 'media_player.living_room', + state: 'playing', + state_attributes: { + volume_level: 0.5, + is_volume_muted: false, + media_content_id: 'spotify:playlist:xyz', + media_content_type: 'playlist', + media_title: 'My Playlist', + source: 'Spotify', + source_list: ['Spotify', 'Radio', 'TV'], + supported_features: 12345 + } + }; + expect(() => MediaPlayerSchema.parse(mediaPlayer)).not.toThrow(); + }); + + it('should validate media player list response', () => { + const response = { + media_players: [{ + entity_id: 'media_player.living_room', + state: 'playing', + state_attributes: {} + }] + }; + expect(() => ListMediaPlayersResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Fan Schema', () => { + it('should validate a valid fan entity', () => { + const fan = { + entity_id: 'fan.bedroom', + state: 'on', + state_attributes: { + percentage: 50, + preset_mode: 'auto', + preset_modes: ['auto', 'low', 'medium', 'high'], + oscillating: true, + direction: 'forward', + supported_features: 12345 + } + }; + expect(() => FanSchema.parse(fan)).not.toThrow(); + }); + + it('should validate fan list response', () => { + const response = { + fans: [{ + entity_id: 'fan.bedroom', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListFansResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Lock Schema', () => { + it('should validate a valid lock entity', () => { + const lock = { + entity_id: 'lock.front_door', + state: 'locked', + state_attributes: { + code_format: 'number', + changed_by: 'User', + locked: true, + supported_features: 12345 + } + }; + expect(() => LockSchema.parse(lock)).not.toThrow(); + }); + + it('should validate lock list response', () => { + const response = { + locks: [{ + entity_id: 'lock.front_door', + state: 'locked', + state_attributes: { locked: true } + }] + }; + expect(() => ListLocksResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Vacuum Schema', () => { + it('should validate a valid vacuum entity', () => { + const vacuum = { + entity_id: 'vacuum.robot', + state: 'cleaning', + state_attributes: { + battery_level: 80, + fan_speed: 'medium', + fan_speed_list: ['low', 'medium', 'high'], + status: 'cleaning', + supported_features: 12345 + } + }; + expect(() => VacuumSchema.parse(vacuum)).not.toThrow(); + }); + + it('should validate vacuum list response', () => { + const response = { + vacuums: [{ + entity_id: 'vacuum.robot', + state: 'cleaning', + state_attributes: {} + }] + }; + expect(() => ListVacuumsResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Scene Schema', () => { + it('should validate a valid scene entity', () => { + const scene = { + entity_id: 'scene.movie_night', + state: 'on', + state_attributes: { + entity_id: ['light.living_room', 'media_player.tv'], + supported_features: 12345 + } + }; + expect(() => SceneSchema.parse(scene)).not.toThrow(); + }); + + it('should validate scene list response', () => { + const response = { + scenes: [{ + entity_id: 'scene.movie_night', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListScenesResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Script Schema', () => { + it('should validate a valid script entity', () => { + const script = { + entity_id: 'script.welcome_home', + state: 'on', + state_attributes: { + last_triggered: '2023-12-25T12:00:00Z', + mode: 'single', + variables: { + brightness: 100, + color: 'red' + }, + supported_features: 12345 + } + }; + expect(() => ScriptSchema.parse(script)).not.toThrow(); + }); + + it('should validate script list response', () => { + const response = { + scripts: [{ + entity_id: 'script.welcome_home', + state: 'on', + state_attributes: {} + }] + }; + expect(() => ListScriptsResponseSchema.parse(response)).not.toThrow(); + }); + }); + + describe('Camera Schema', () => { + it('should validate a valid camera entity', () => { + const camera = { + entity_id: 'camera.front_door', + state: 'recording', + state_attributes: { + motion_detection: true, + frontend_stream_type: 'hls', + supported_features: 12345 + } + }; + expect(() => CameraSchema.parse(camera)).not.toThrow(); + }); + + it('should validate camera list response', () => { + const response = { + cameras: [{ + entity_id: 'camera.front_door', + state: 'recording', + state_attributes: {} + }] + }; + expect(() => ListCamerasResponseSchema.parse(response)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/schemas/hass.test.ts b/__tests__/schemas/hass.test.ts new file mode 100644 index 0000000..1ebdd84 --- /dev/null +++ b/__tests__/schemas/hass.test.ts @@ -0,0 +1,532 @@ +import { entitySchema, serviceSchema, stateChangedEventSchema, configSchema, automationSchema, deviceControlSchema } from '../../src/schemas/hass.js'; +import AjvModule from 'ajv'; +const Ajv = AjvModule.default || AjvModule; + +describe('Home Assistant Schemas', () => { + const ajv = new Ajv({ allErrors: true }); + + describe('Entity Schema', () => { + const validate = ajv.compile(entitySchema); + + it('should validate a valid entity', () => { + const validEntity = { + entity_id: 'light.living_room', + state: 'on', + attributes: { + brightness: 255, + friendly_name: 'Living Room Light' + }, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(validEntity)).toBe(true); + }); + + it('should reject entity with missing required fields', () => { + const invalidEntity = { + entity_id: 'light.living_room', + state: 'on' + // missing attributes, last_changed, last_updated, context + }; + expect(validate(invalidEntity)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + + it('should validate entity with additional attributes', () => { + const entityWithExtraAttrs = { + entity_id: 'climate.living_room', + state: '22', + attributes: { + temperature: 22, + humidity: 45, + mode: 'auto', + custom_attr: 'value' + }, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(entityWithExtraAttrs)).toBe(true); + }); + + it('should reject invalid entity_id format', () => { + const invalidEntityId = { + entity_id: 'invalid_format', + state: 'on', + attributes: {}, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(invalidEntityId)).toBe(false); + }); + }); + + describe('Service Schema', () => { + const validate = ajv.compile(serviceSchema); + + it('should validate a basic service call', () => { + const basicService = { + domain: 'light', + service: 'turn_on', + target: { + entity_id: ['light.living_room'] + } + }; + expect(validate(basicService)).toBe(true); + }); + + it('should validate service call with multiple targets', () => { + const multiTargetService = { + domain: 'light', + service: 'turn_on', + target: { + entity_id: ['light.living_room', 'light.kitchen'], + device_id: ['device123', 'device456'], + area_id: ['living_room', 'kitchen'] + }, + service_data: { + brightness_pct: 100 + } + }; + expect(validate(multiTargetService)).toBe(true); + }); + + it('should validate service call without targets', () => { + const noTargetService = { + domain: 'homeassistant', + service: 'restart' + }; + expect(validate(noTargetService)).toBe(true); + }); + + it('should reject service call with invalid target type', () => { + const invalidService = { + domain: 'light', + service: 'turn_on', + target: { + entity_id: 'not_an_array' // should be an array + } + }; + expect(validate(invalidService)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + }); + + describe('State Changed Event Schema', () => { + const validate = ajv.compile(stateChangedEventSchema); + + it('should validate a valid state changed event', () => { + const validEvent = { + event_type: 'state_changed', + data: { + entity_id: 'light.living_room', + new_state: { + entity_id: 'light.living_room', + state: 'on', + attributes: { + brightness: 255 + }, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }, + old_state: { + entity_id: 'light.living_room', + state: 'off', + attributes: {}, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + } + }, + origin: 'LOCAL', + time_fired: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(validEvent)).toBe(true); + }); + + it('should validate event with null old_state', () => { + const newEntityEvent = { + event_type: 'state_changed', + data: { + entity_id: 'light.living_room', + new_state: { + entity_id: 'light.living_room', + state: 'on', + attributes: {}, + last_changed: '2024-01-01T00:00:00Z', + last_updated: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }, + old_state: null + }, + origin: 'LOCAL', + time_fired: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(newEntityEvent)).toBe(true); + }); + + it('should reject event with invalid event_type', () => { + const invalidEvent = { + event_type: 'wrong_type', + data: { + entity_id: 'light.living_room', + new_state: null, + old_state: null + }, + origin: 'LOCAL', + time_fired: '2024-01-01T00:00:00Z', + context: { + id: '123456', + parent_id: null, + user_id: null + } + }; + expect(validate(invalidEvent)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + }); + + describe('Config Schema', () => { + const validate = ajv.compile(configSchema); + + it('should validate a minimal config', () => { + const minimalConfig = { + latitude: 52.3731, + longitude: 4.8922, + elevation: 0, + unit_system: { + length: 'km', + mass: 'kg', + temperature: 'ยฐC', + volume: 'L' + }, + location_name: 'Home', + time_zone: 'Europe/Amsterdam', + components: ['homeassistant'], + version: '2024.1.0' + }; + expect(validate(minimalConfig)).toBe(true); + }); + + it('should reject config with missing required fields', () => { + const invalidConfig = { + latitude: 52.3731, + longitude: 4.8922 + // missing other required fields + }; + expect(validate(invalidConfig)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + + it('should reject config with invalid types', () => { + const invalidConfig = { + latitude: '52.3731', // should be number + longitude: 4.8922, + elevation: 0, + unit_system: { + length: 'km', + mass: 'kg', + temperature: 'ยฐC', + volume: 'L' + }, + location_name: 'Home', + time_zone: 'Europe/Amsterdam', + components: ['homeassistant'], + version: '2024.1.0' + }; + expect(validate(invalidConfig)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + }); + + describe('Automation Schema', () => { + const validate = ajv.compile(automationSchema); + + it('should validate a basic automation', () => { + const basicAutomation = { + alias: 'Turn on lights at sunset', + description: 'Automatically turn on lights when the sun sets', + trigger: [{ + platform: 'sun', + event: 'sunset', + offset: '+00:30:00' + }], + action: [{ + service: 'light.turn_on', + target: { + entity_id: ['light.living_room', 'light.kitchen'] + }, + data: { + brightness_pct: 70 + } + }] + }; + expect(validate(basicAutomation)).toBe(true); + }); + + it('should validate automation with conditions', () => { + const automationWithConditions = { + alias: 'Conditional Light Control', + mode: 'single', + trigger: [{ + platform: 'state', + entity_id: 'binary_sensor.motion', + to: 'on' + }], + condition: [{ + condition: 'and', + conditions: [ + { + condition: 'time', + after: '22:00:00', + before: '06:00:00' + }, + { + condition: 'state', + entity_id: 'input_boolean.guest_mode', + state: 'off' + } + ] + }], + action: [{ + service: 'light.turn_on', + target: { + entity_id: 'light.hallway' + } + }] + }; + expect(validate(automationWithConditions)).toBe(true); + }); + + it('should validate automation with multiple triggers and actions', () => { + const complexAutomation = { + alias: 'Complex Automation', + mode: 'parallel', + trigger: [ + { + platform: 'state', + entity_id: 'binary_sensor.door', + to: 'on' + }, + { + platform: 'state', + entity_id: 'binary_sensor.window', + to: 'on' + } + ], + condition: [{ + condition: 'state', + entity_id: 'alarm_control_panel.home', + state: 'armed_away' + }], + action: [ + { + service: 'notify.mobile_app', + data: { + message: 'Security alert: Movement detected!' + } + }, + { + service: 'light.turn_on', + target: { + entity_id: 'light.all_lights' + } + }, + { + service: 'camera.snapshot', + target: { + entity_id: 'camera.front_door' + } + } + ] + }; + expect(validate(complexAutomation)).toBe(true); + }); + + it('should reject automation without required fields', () => { + const invalidAutomation = { + description: 'Missing required fields' + // missing alias, trigger, and action + }; + expect(validate(invalidAutomation)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + + it('should validate all automation modes', () => { + const modes = ['single', 'parallel', 'queued', 'restart']; + modes.forEach(mode => { + const automation = { + alias: `Test ${mode} mode`, + mode, + trigger: [{ + platform: 'state', + entity_id: 'input_boolean.test', + to: 'on' + }], + action: [{ + service: 'light.turn_on', + target: { + entity_id: 'light.test' + } + }] + }; + expect(validate(automation)).toBe(true); + }); + }); + }); + + describe('Device Control Schema', () => { + const validate = ajv.compile(deviceControlSchema); + + it('should validate light control command', () => { + const lightCommand = { + domain: 'light', + command: 'turn_on', + entity_id: 'light.living_room', + parameters: { + brightness: 255, + color_temp: 400, + transition: 2 + } + }; + expect(validate(lightCommand)).toBe(true); + }); + + it('should validate climate control command', () => { + const climateCommand = { + domain: 'climate', + command: 'set_temperature', + entity_id: 'climate.living_room', + parameters: { + temperature: 22.5, + hvac_mode: 'heat', + target_temp_high: 24, + target_temp_low: 20 + } + }; + expect(validate(climateCommand)).toBe(true); + }); + + it('should validate cover control command', () => { + const coverCommand = { + domain: 'cover', + command: 'set_position', + entity_id: 'cover.garage_door', + parameters: { + position: 50, + tilt_position: 45 + } + }; + expect(validate(coverCommand)).toBe(true); + }); + + it('should validate fan control command', () => { + const fanCommand = { + domain: 'fan', + command: 'set_speed', + entity_id: 'fan.bedroom', + parameters: { + speed: 'medium', + oscillating: true, + direction: 'forward' + } + }; + expect(validate(fanCommand)).toBe(true); + }); + + it('should reject command with invalid domain', () => { + const invalidCommand = { + domain: 'invalid_domain', + command: 'turn_on', + entity_id: 'light.living_room' + }; + expect(validate(invalidCommand)).toBe(false); + expect(validate.errors).toBeDefined(); + }); + + it('should reject command with mismatched domain and entity_id', () => { + const mismatchedCommand = { + domain: 'light', + command: 'turn_on', + entity_id: 'switch.living_room' // mismatched domain + }; + expect(validate(mismatchedCommand)).toBe(false); + }); + + it('should validate command with array of entity_ids', () => { + const multiEntityCommand = { + domain: 'light', + command: 'turn_on', + entity_id: ['light.living_room', 'light.kitchen'], + parameters: { + brightness: 255 + } + }; + expect(validate(multiEntityCommand)).toBe(true); + }); + + it('should validate scene activation command', () => { + const sceneCommand = { + domain: 'scene', + command: 'turn_on', + entity_id: 'scene.movie_night', + parameters: { + transition: 2 + } + }; + expect(validate(sceneCommand)).toBe(true); + }); + + it('should validate script execution command', () => { + const scriptCommand = { + domain: 'script', + command: 'turn_on', + entity_id: 'script.welcome_home', + parameters: { + variables: { + user: 'John', + delay: 5 + } + } + }; + expect(validate(scriptCommand)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/security/index.test.ts b/__tests__/security/index.test.ts new file mode 100644 index 0000000..4a00a53 --- /dev/null +++ b/__tests__/security/index.test.ts @@ -0,0 +1,212 @@ +import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security/index.js'; +import { Request, Response } from 'express'; + +describe('Security Module', () => { + describe('TokenManager', () => { + const testToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const encryptionKey = 'test_encryption_key'; + + it('should encrypt and decrypt tokens', () => { + const encrypted = TokenManager.encryptToken(testToken, encryptionKey); + const decrypted = TokenManager.decryptToken(encrypted, encryptionKey); + + expect(decrypted).toBe(testToken); + }); + + it('should validate tokens correctly', () => { + expect(TokenManager.validateToken(testToken)).toBe(true); + expect(TokenManager.validateToken('invalid_token')).toBe(false); + expect(TokenManager.validateToken('')).toBe(false); + }); + + it('should handle expired tokens', () => { + const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + expect(TokenManager.validateToken(expiredToken)).toBe(false); + }); + }); + + describe('Request Validation', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + mockRequest = { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer validToken' + }, + is: jest.fn().mockReturnValue(true), + body: { test: 'data' } + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + mockNext = jest.fn(); + }); + + it('should pass valid requests', () => { + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should reject invalid content type', () => { + mockRequest.is = jest.fn().mockReturnValue(false); + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(415); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Unsupported Media Type - Content-Type must be application/json' + }); + }); + + it('should reject missing token', () => { + mockRequest.headers = {}; + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid or expired token' + }); + }); + + it('should reject invalid request body', () => { + mockRequest.body = null; + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid request body' + }); + }); + }); + + describe('Input Sanitization', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + mockRequest = { + body: {} + }; + mockResponse = {}; + mockNext = jest.fn(); + }); + + it('should sanitize HTML tags from request body', () => { + mockRequest.body = { + text: 'Test ', + nested: { + html: '' + } + }; + + sanitizeInput( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockRequest.body).toEqual({ + text: 'Test alert("xss")', + nested: { + html: 'img src="x" onerror="alert(1)"' + } + }); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should handle non-object body', () => { + mockRequest.body = 'string body'; + + sanitizeInput( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockRequest.body).toBe('string body'); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('Error Handler', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + mockRequest = {}; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + mockNext = jest.fn(); + }); + + afterAll(() => { + process.env.NODE_ENV = originalEnv; + }); + + it('should handle errors in production mode', () => { + process.env.NODE_ENV = 'production'; + const error = new Error('Test error'); + + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Internal Server Error', + message: undefined + }); + }); + + it('should include error message in development mode', () => { + process.env.NODE_ENV = 'development'; + const error = new Error('Test error'); + + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Internal Server Error', + message: 'Test error' + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/security/middleware.test.ts b/__tests__/security/middleware.test.ts new file mode 100644 index 0000000..e05fc57 --- /dev/null +++ b/__tests__/security/middleware.test.ts @@ -0,0 +1,248 @@ +import { Request, Response } from 'express'; +import { + validateRequest, + sanitizeInput, + errorHandler, + rateLimiter, + securityHeaders +} from '../../src/security/index.js'; + +interface MockRequest extends Partial { + headers: Record; + is: jest.Mock; +} + +describe('Security Middleware', () => { + let mockRequest: MockRequest; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + mockRequest = { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer validToken' + }, + is: jest.fn().mockReturnValue(true), + body: { test: 'data' } + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + setHeader: jest.fn(), + set: jest.fn() + }; + mockNext = jest.fn(); + }); + + describe('Request Validation', () => { + it('should pass valid requests', () => { + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + expect(mockNext).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should reject requests with invalid content type', () => { + mockRequest.is = jest.fn().mockReturnValue(false); + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + expect(mockResponse.status).toHaveBeenCalledWith(415); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Unsupported Media Type - Content-Type must be application/json' + }); + }); + + it('should reject requests without authorization', () => { + mockRequest.headers = {}; + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid or expired token' + }); + }); + + it('should reject requests with invalid token', () => { + mockRequest.headers.authorization = 'Bearer invalid.token.format'; + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('should handle GET requests without body validation', () => { + mockRequest.method = 'GET'; + mockRequest.body = undefined; + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('Input Sanitization', () => { + it('should remove HTML tags from request body', () => { + mockRequest.body = { + text: '', + nested: { + html: '' + } + }; + + sanitizeInput( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockRequest.body.text).not.toContain('