Compare commits

...
8 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 afb47c48d4 Bump version to 1.2.0 and update documentation
Build and Push / build (release) Successful in 39s
Update README.md:
- Reflect current architecture (housekeeping, infrastructure, tools)
- Document all API endpoints
- Add environment variables reference
- Update test instructions

Update CHANGELOG.md:
- Document housekeeping API addition
- Document web scraper removal
- Document test coverage improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:52:25 +01:00
jpmschweitzerandClaude Opus 4.5 a22e168666 Improve test coverage to 65%
Add comprehensive test suites for:
- NPM client (27 tests)
- Ollama client (16 tests)
- AI client and controller (34 tests)
- Static controller (8 tests)
- Tools controller DNS lookup (9 tests)
- OIDC authentication (10 tests)
- Housekeeping endpoints (28 tests)
- Infrastructure endpoints (15 tests)
- Health endpoints (12 tests)
- Portainer client (12 tests)
- Home Assistant client (24 tests)

Total: 285 tests passing with 65% code coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:32:39 +01:00
jpmschweitzerandClaude Opus 4.5 33f31b09a3 Remove obsolete web scraper module
Web scraping functionality is no longer needed. Removes:
- src/web_scraper/ directory (6 files)
- /web-scraper/scrape endpoint from tools controller
- References from health controller endpoints list

Tools controller now only contains DNS lookup functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:28:10 +01:00
jpmschweitzerandClaude Opus 4.5 c45fdcb528 Add housekeeping API for Home Assistant integration
Introduces home automation endpoints for the Tatlock Housekeeper agent:
- Device discovery and control (turn_on, turn_off, toggle, set_brightness)
- Scene activation and script execution
- Automation management (enable/disable)
- State history queries and area discovery

Includes Home Assistant REST client and configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:15:24 +01:00
jpmschweitzerandClaude Opus 4.5 a06fc59e8f Add version to /health endpoint
Build and Push / build (release) Successful in 27s
- Include version in /health endpoint response
- Update test to verify version field
- Bump version to 1.1.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:53:04 +01:00
jpmschweitzer 095ce15f0e Update .gitea/workflows/build.yml
Build and Push / build (release) Successful in 27s
fix port
2025-12-14 12:49:51 +01:00
jpmschweitzerandClaude Opus 4.5 a72041fcb0 Add Watchtower trigger to CI workflow
Build and Push / build (release) Failing after 11s
- Add step to trigger Watchtower update after successful build
- Bump version to 1.1.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:47:00 +01:00
jpmschweitzerandClaude Opus 4.5 ac92607003 Remove Uptime Kuma integration
- Delete kuma_client.py and all Kuma-related code
- Remove /infrastructure/monitors endpoints
- Update service start/stop to use Portainer only
- Simplify service control widget to show container status
- Remove Kuma config and credentials
- Delete stale memory tests (moved to core-ai service)
- Add test infrastructure with pytest
- Add tests for service_groups, config, and health endpoints
- Bump version to 1.1.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:04:05 +01:00
41 changed files with 6044 additions and 2301 deletions
+6
View File
@@ -25,3 +25,9 @@ jobs:
tags: | tags: |
git.schweitz.internal/jpmschweitzer/core-api:latest git.schweitz.internal/jpmschweitzer/core-api:latest
git.schweitz.internal/jpmschweitzer/core-api:${{ github.ref_name }} git.schweitz.internal/jpmschweitzer/core-api:${{ github.ref_name }}
- name: Trigger Watchtower update
if: success()
run: |
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
http://watchtower:8080/v1/update
+65
View File
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2025-12-17
### Added
- **Housekeeping API** - Home Assistant integration for smart home control
- `GET /housekeeping/health` - HA connection status
- `GET /housekeeping/devices` - List controllable devices with optional domain/area filtering
- `GET /housekeeping/devices/{entity_id}` - Get device details
- `POST /housekeeping/devices/{entity_id}/control` - Control devices (turn_on, turn_off, toggle, set_brightness)
- `GET /housekeeping/scenes` - List available scenes
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate a scene
- `GET /housekeeping/scripts` - List available scripts
- `POST /housekeeping/scripts/{script_id}/run` - Run a script
- `GET /housekeeping/automations` - List automations
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
- `GET /housekeeping/history` - Query state history
- `GET /housekeeping/areas` - List rooms/areas
- Home Assistant REST API client (`src/clients/homeassistant_client.py`)
- Home Assistant configuration in credentials and settings
- Comprehensive test suite with 65% code coverage (285 tests)
- Tests for NPM client, Ollama client, AI client, OIDC authentication
- Tests for infrastructure, health, tools, and housekeeping endpoints
### Removed
- **Web Scraper** - Entire web scraping module removed
- `src/web_scraper/` directory deleted
- `/web-scraper/scrape` endpoint removed
- Trafilatura and BeautifulSoup dependencies removed from scraping use
### Changed
- Updated README.md with current architecture and all endpoints
- Tools controller now only contains DNS lookup functionality
- Health controller endpoints list updated to reflect current features
## [1.1.2] - 2024-12-14
### Added
- Version field to /health endpoint response
## [1.1.1] - 2024-12-14
### Added
- Watchtower trigger step in CI workflow for automatic container updates
## [1.1.0] - 2024-12-14
### Removed
- Uptime Kuma integration (kuma_client.py deleted)
- All /infrastructure/monitors endpoints
- Kuma monitor pause/resume from service start/stop operations
- Uptime percentage display from service control widget
- Kuma-related configuration and credentials
- Stale memory tests (memory functionality moved to core-ai service)
### Changed
- Service control widget now uses Portainer container status exclusively
- Simplified widget-data endpoint response (removed monitors field)
- Updated service start/stop to only manage containers via Portainer
## [1.0.0] - 2024-12-14 ## [1.0.0] - 2024-12-14
### Added ### Added
+117 -136
View File
@@ -1,15 +1,26 @@
# Core Code API # Core Code API
OpenAPI-compatible functions for Open WebUI, providing web scraping and data processing capabilities. Central API service providing infrastructure management, home automation, and utility endpoints for the homelab ecosystem.
## Features ## Features
### Web Scraper ### Infrastructure Management
- Intelligent content extraction using Trafilatura - **Portainer Integration**: Stack and container management
- BeautifulSoup fallback for complex pages - **NPM Integration**: Nginx Proxy Manager domain and certificate management
- Configurable content length limits - **Service Control**: Start/stop services with container orchestration
- Optional link extraction
- Perfect for feeding webpage content to LLMs ### Home Automation (Housekeeping API)
- **Device Control**: Turn on/off, toggle, and set brightness for smart devices
- **Scene Activation**: Trigger Home Assistant scenes
- **Script Execution**: Run Home Assistant scripts
- **Automation Management**: Enable/disable automations
- **State History**: Query device state changes over time
- **Area Discovery**: List rooms and areas
### Utilities
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
- **Health Checks**: Comprehensive service health monitoring
- **AI Metrics Proxy**: Forward metrics requests to Core-AI service
## Architecture ## Architecture
@@ -17,17 +28,69 @@ OpenAPI-compatible functions for Open WebUI, providing web scraping and data pro
src/ src/
├── config.py # Global application settings ├── config.py # Global application settings
├── logging_config.py # Logging configuration ├── logging_config.py # Logging configuration
├── base_schema.py # Base Pydantic models ├── base_controller.py # Base controller pattern
├── main.py # FastAPI application entry point ├── main.py # FastAPI application entry point
── web_scraper/ # Web scraper module ── auth/
── __init__.py ── oidc.py # OIDC authentication
├── config.py # Module-specific settings ├── clients/
├── schemas.py # Pydantic request/response models ├── homeassistant_client.py # Home Assistant REST client
├── service.py # Business logic ├── npm_client.py # Nginx Proxy Manager client
├── router.py # API routes ├── ollama_client.py # Ollama LLM client
└── exceptions.py # Custom exceptions └── portainer_client.py # Portainer API client
├── controllers/
│ ├── ai_controller.py # AI metrics proxy
│ ├── health_controller.py # Health endpoints
│ ├── housekeeping_controller.py # Home automation endpoints
│ ├── infrastructure_controller.py # Infrastructure management
│ ├── static_controller.py # Static file serving
│ └── tools_controller.py # DNS and utility tools
└── dns/
├── service.py # DNS lookup service
└── exceptions.py # DNS-specific exceptions
``` ```
## API Endpoints
### Health
- `GET /` - Service info and documentation links
- `GET /health` - Basic health status
- `GET /health/full` - Detailed component health
- `GET /health/diagnostics` - Full diagnostic information
### Infrastructure (`/infrastructure`)
- `GET /infrastructure/health` - Portainer/NPM connection status
- `GET /infrastructure/services` - List all services (stacks)
- `GET /infrastructure/services/{name}` - Get service details
- `GET /infrastructure/services/{name}/status` - Service status
- `POST /infrastructure/services/{name}/start` - Start service
- `POST /infrastructure/services/{name}/stop` - Stop service
- `GET /infrastructure/containers` - List containers
- `GET /infrastructure/containers/{name}` - Container details
- `GET /infrastructure/containers/{name}/logs` - Container logs
- `GET /infrastructure/ports` - List exposed ports
- `GET /infrastructure/domains` - List proxy domains
- `GET /infrastructure/widget-data` - Dashboard widget data
### Housekeeping (`/housekeeping`)
- `GET /housekeeping/health` - Home Assistant connection status
- `GET /housekeeping/devices` - List controllable devices
- `GET /housekeeping/devices/{entity_id}` - Device details
- `POST /housekeeping/devices/{entity_id}/control` - Control device
- `GET /housekeeping/scenes` - List scenes
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
- `GET /housekeeping/scripts` - List scripts
- `POST /housekeeping/scripts/{script_id}/run` - Run script
- `GET /housekeeping/automations` - List automations
- `POST /housekeeping/automations/{automation_id}/toggle` - Toggle automation
- `GET /housekeeping/history` - State history
- `GET /housekeeping/areas` - List areas/rooms
### Tools (`/tools`)
- `POST /tools/dns/lookup` - DNS record lookup
### AI (`/ai`)
- `GET /ai/metrics` - Proxy to Core-AI metrics
## Development ## Development
### Requirements ### Requirements
@@ -40,13 +103,30 @@ src/
# Install dependencies # Install dependencies
pip install -r requirements.txt pip install -r requirements.txt
# Copy credentials template
cp src/credentials.example.py src/credentials.py
# Edit src/credentials.py with your values
# Run locally # Run locally
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083 uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
``` ```
### Running Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=src --cov-report=term-missing
# Run specific test file
pytest tests/test_housekeeping.py -v
```
### Adding New Dependencies ### Adding New Dependencies
**Important**: Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes. Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes.
1. Add package to `requirements.txt` with major version constraint: 1. Add package to `requirements.txt` with major version constraint:
``` ```
@@ -58,14 +138,6 @@ uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
docker restart core-api docker restart core-api
``` ```
The container automatically runs `pip install -r requirements.txt` on every boot, so new dependencies are installed immediately on restart.
**Version Pinning Best Practices**:
- Use `~=` (compatible release) for most packages: `fastapi~=0.115.0`
- Use `>=X,<Y` for complex constraints: `langchain-core>=0.3.17,<0.4.0`
- Allows automatic security patches without breaking changes
- Documented in PEP 440
### Docker Build ### Docker Build
```bash ```bash
@@ -88,130 +160,39 @@ docker run -p 8083:8083 core-code:latest
### Environment Variables ### Environment Variables
See `.env.example` for all available configuration options. | Variable | Description | Default |
|----------|-------------|---------|
| `PORTAINER_URL` | Portainer API URL | `http://localhost:9000` |
| `PORTAINER_API_KEY` | Portainer API key | - |
| `NPM_URL` | Nginx Proxy Manager URL | `http://localhost:81` |
| `NPM_EMAIL` | NPM admin email | - |
| `NPM_PASSWORD` | NPM admin password | - |
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
| `OLLAMA_URL` | Ollama API URL | `http://localhost:11434` |
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
| `OIDC_ISSUER` | OIDC issuer URL | - |
| `OIDC_AUDIENCE` | OIDC audience | - |
## API Documentation ## API Documentation
Once deployed, access documentation at: Once deployed, access documentation at:
- **Swagger UI**: http://192.168.86.149:8083/docs - **Swagger UI**: http://localhost:8083/docs
- **ReDoc**: http://192.168.86.149:8083/redoc - **ReDoc**: http://localhost:8083/redoc
- **OpenAPI Spec**: http://192.168.86.149:8083/openapi.json - **OpenAPI Spec**: http://localhost:8083/openapi.json
## Integration with Open WebUI
### Method 1: Functions (OpenAPI Import)
1. In Open WebUI, navigate to Functions
2. Import from OpenAPI spec: `http://192.168.86.149:8083/openapi.json`
3. Use functions directly in chat
### Method 2: Pipelines
1. Create a pipeline that calls Core Code API endpoints
2. Use as data source for LLM workflows
### Method 3: Direct API Calls
```python
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"http://192.168.86.149:8083/web-scraper/scrape",
json={
"url": "https://example.com",
"extract_main_content": True
}
)
data = response.json()
```
## API Endpoints
### Web Scraper
**POST /web-scraper/scrape**
Scrape and extract content from a website.
Request:
```json
{
"url": "https://example.com/article",
"extract_main_content": true,
"include_links": false,
"max_length": 10000
}
```
Response:
```json
{
"url": "https://example.com/article",
"title": "Article Title",
"content": "Extracted article content...",
"extracted_at": "2025-11-12T19:30:00Z",
"content_length": 5432,
"links": null
}
```
## Logging
Logs are written to:
- **Console**: stdout (captured by Docker)
- **File**: `/app/logs/app.log` (persisted via volume mount)
Log format:
```
2025-11-12 19:30:00 | INFO | src.web_scraper.service:scrape_url:45 | Starting scrape for URL: https://example.com
```
## Health Checks ## Health Checks
- **Endpoint**: `GET /health` - **Basic**: `GET /health` - Returns status and Ollama connection
- **Docker**: Automatic health checks configured - **Full**: `GET /health/full` - Returns all component statuses (503 if unhealthy)
- **Response**: `{"status": "healthy"}` - **Diagnostics**: `GET /health/diagnostics` - Detailed service information
## Security ## Security
- Runs as non-root user (uid 1000) - Runs as non-root user (uid 1000)
- No authentication required (internal network only) - OIDC authentication support via Authentik
- CORS configured for same-network access - CORS configured for same-network access
- Rate limiting: Not implemented (internal use only) - Admin endpoints require authentication when OIDC enabled
## Future Modules
The architecture supports adding new modules:
- Data transformation functions
- API integrations
- File processing
- Database queries
Each module follows the same structure:
```
src/
└── module_name/
├── config.py
├── schemas.py
├── service.py
├── router.py
└── exceptions.py
```
## Troubleshooting
### Container won't start
```bash
docker logs core-code
```
### API not responding
```bash
curl http://192.168.86.149:8083/health
```
### Check OpenAPI spec
```bash
curl http://192.168.86.149:8083/openapi.json | jq
```
## License ## License
+8
View File
@@ -0,0 +1,8 @@
# Development and testing dependencies
-r requirements.txt
# Testing
pytest>=9.0.0
pytest-asyncio>=0.24.0
pytest-cov>=6.0.0
httpx>=0.28.0 # For TestClient
+7 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.0.0" version = "1.2.0"
description = "Core Code API - Infrastructure management and tools API" description = "Core Code API - Infrastructure management and tools API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -8,3 +8,9 @@ license = {text = "MIT"}
[tool.setuptools] [tool.setuptools]
packages = ["src"] packages = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "auto"
addopts = "-v"
+409
View File
@@ -0,0 +1,409 @@
"""
Home Assistant REST API Client
Provides interface to Home Assistant REST API for home automation control.
Uses long-lived access token authentication.
API Reference: https://developers.home-assistant.io/docs/api/rest/
"""
import httpx
import json
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta, timezone
from src.logging_config import get_logger
from src.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class HomeAssistantClient:
"""
HTTP client for Home Assistant REST API
Uses long-lived access token authentication via Bearer token.
"""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Home Assistant client
Args:
base_url: Home Assistant base URL (default from settings)
token: Long-lived access token (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
self.token = token or settings.homeassistant_token
self.timeout = timeout
if not self.token:
logger.warning("Home Assistant token not configured")
def _get_headers(self) -> Dict[str, str]:
"""Get request headers with Bearer token authentication"""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ========================================================================
# Health & Discovery
# ========================================================================
async def health_check(self) -> Dict[str, Any]:
"""
Check Home Assistant API connectivity and get version info
HA Endpoint: GET /api/
Returns:
Dict with connected status, platform name, and version
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/",
headers=self._get_headers()
)
if response.status_code == 200:
data = response.json()
return {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": data.get("version", "unknown")
}
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": f"HTTP {response.status_code}"
}
except Exception as e:
logger.error(f"Home Assistant health check failed: {e}")
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": str(e)
}
async def get_states(self) -> List[Dict[str, Any]]:
"""
Get all entity states
HA Endpoint: GET /api/states
Returns:
List of all entity states
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
"""
Get state of a specific entity
HA Endpoint: GET /api/states/<entity_id>
Args:
entity_id: Entity ID (e.g., "light.living_room")
Returns:
Entity state dict or None if not found
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states/{entity_id}",
headers=self._get_headers()
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
async def get_config(self) -> Dict[str, Any]:
"""
Get Home Assistant configuration (includes areas)
HA Endpoint: GET /api/config
Returns:
Configuration dict including components, location, etc.
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/config",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
# ========================================================================
# Device Control
# ========================================================================
async def call_service(
self,
domain: str,
service: str,
entity_id: Optional[str] = None,
service_data: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Call a Home Assistant service
HA Endpoint: POST /api/services/<domain>/<service>
Args:
domain: Service domain (e.g., "light", "switch", "scene")
service: Service name (e.g., "turn_on", "turn_off", "toggle")
entity_id: Target entity ID (optional for some services)
service_data: Additional service data/attributes
Returns:
List of changed states
"""
payload = service_data.copy() if service_data else {}
if entity_id:
payload["entity_id"] = entity_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/services/{domain}/{service}",
headers=self._get_headers(),
json=payload
)
response.raise_for_status()
return response.json()
async def turn_on(
self,
entity_id: str,
**attributes
) -> List[Dict[str, Any]]:
"""
Turn on an entity with optional attributes
Args:
entity_id: Entity ID (e.g., "light.living_room")
**attributes: Additional attributes (brightness, color_temp, etc.)
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_on",
entity_id=entity_id,
service_data=attributes if attributes else None
)
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Turn off an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_off",
entity_id=entity_id
)
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Toggle an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="toggle",
entity_id=entity_id
)
# ========================================================================
# Scenes
# ========================================================================
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., "scene.movie_night")
Returns:
List of changed states
"""
return await self.call_service(
domain="scene",
service="turn_on",
entity_id=scene_id
)
# ========================================================================
# Scripts
# ========================================================================
async def run_script(
self,
script_id: str,
variables: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., "script.bedtime_routine")
variables: Script variables
Returns:
List of changed states
"""
service_data = {"variables": variables} if variables else None
return await self.call_service(
domain="script",
service="turn_on",
entity_id=script_id,
service_data=service_data
)
# ========================================================================
# Automations
# ========================================================================
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Enable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_on",
entity_id=automation_id
)
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Disable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_off",
entity_id=automation_id
)
# ========================================================================
# History
# ========================================================================
async def get_history(
self,
entity_id: str,
hours: int = 24
) -> List[List[Dict[str, Any]]]:
"""
Get state history for an entity
HA Endpoint: GET /api/history/period/<timestamp>
Args:
entity_id: Entity ID to get history for
hours: Number of hours of history (default 24)
Returns:
List of state history entries
"""
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
timestamp = start_time.isoformat()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/history/period/{timestamp}",
headers=self._get_headers(),
params={
"filter_entity_id": entity_id,
"minimal_response": "true"
}
)
response.raise_for_status()
return response.json()
# ========================================================================
# Areas (via template API)
# ========================================================================
async def get_areas(self) -> List[Dict[str, str]]:
"""
Get all areas/rooms
Note: The REST API doesn't have a direct areas endpoint.
This uses the template API to render area data.
HA Endpoint: POST /api/template
Returns:
List of area dicts with id and name
"""
template = """
{% set areas_list = [] %}
{% for area in areas() %}
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
{% endfor %}
{{ areas_list | tojson }}
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/template",
headers=self._get_headers(),
json={"template": template}
)
response.raise_for_status()
# Response is rendered template as string
return json.loads(response.text)
# Singleton instance
_homeassistant_client: Optional[HomeAssistantClient] = None
def get_homeassistant_client() -> HomeAssistantClient:
"""Get singleton Home Assistant client instance"""
global _homeassistant_client
if _homeassistant_client is None:
_homeassistant_client = HomeAssistantClient()
return _homeassistant_client
-561
View File
@@ -1,561 +0,0 @@
"""
Uptime Kuma Socket.IO Client
Provides interface to Uptime Kuma via Socket.IO for monitor management.
Also provides metrics API access for real-time status data.
"""
import socketio
import asyncio
import httpx
import re
from typing import Optional, Dict, List, Any
from src.logging_config import get_logger
from src.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class KumaClient:
"""
Socket.IO client for Uptime Kuma
Uses Socket.IO for real-time communication with Uptime Kuma.
"""
def __init__(
self,
base_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Kuma client
Args:
base_url: Kuma base URL (default from settings)
username: Kuma username (default from settings)
password: Kuma password (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.kuma_url).rstrip("/")
self.username = username or settings.kuma_username
self.password = password or settings.kuma_password
self.timeout = timeout
self.sio = socketio.AsyncClient(
reconnection=True,
reconnection_attempts=3,
reconnection_delay=1,
)
self._connected = False
self._authenticated = False
self._monitors_cache: Dict[int, Dict[str, Any]] = {}
if not self.username or not self.password:
logger.warning("Uptime Kuma credentials not configured")
async def _ensure_connected(self):
"""Ensure we have an active connection and authentication"""
if not self._connected:
await self.connect()
if not self._authenticated:
await self.login()
async def connect(self):
"""Connect to Uptime Kuma Socket.IO server"""
if self._connected:
return
try:
await self.sio.connect(self.base_url, transports=['websocket'])
self._connected = True
logger.info(f"Connected to Uptime Kuma at {self.base_url}")
except Exception as e:
logger.error(f"Failed to connect to Uptime Kuma: {e}")
raise
async def disconnect(self):
"""Disconnect from Uptime Kuma"""
if self._connected:
await self.sio.disconnect()
self._connected = False
self._authenticated = False
logger.info("Disconnected from Uptime Kuma")
async def login(self):
"""Authenticate with Uptime Kuma"""
if not self._connected:
await self.connect()
try:
# Uptime Kuma login event
login_response = await self.sio.call(
'login',
{
'username': self.username,
'password': self.password,
'token': None
},
timeout=self.timeout
)
if login_response and login_response.get('ok'):
self._authenticated = True
logger.info("Successfully authenticated with Uptime Kuma")
else:
error_msg = login_response.get('msg', 'Unknown error') if login_response else 'No response'
raise Exception(f"Login failed: {error_msg}")
except Exception as e:
logger.error(f"Failed to authenticate with Uptime Kuma: {e}")
raise
async def health_check(self) -> bool:
"""
Check if Uptime Kuma is accessible
Returns:
True if accessible, False otherwise
"""
try:
await self._ensure_connected()
return self._authenticated
except Exception as e:
logger.error(f"Uptime Kuma health check failed: {e}")
return False
async def get_monitors(self) -> List[Dict[str, Any]]:
"""
List all monitors with uptime data
Returns:
List of monitor configurations with uptime_24h field
"""
await self._ensure_connected()
try:
# Storage for monitor list and uptime data received via events
monitor_list_data = {}
uptime_list_data = {}
monitor_event_received = asyncio.Event()
uptime_event_received = asyncio.Event()
# Register event handler for monitorList
@self.sio.event
async def monitorList(data):
nonlocal monitor_list_data
monitor_list_data = data
monitor_event_received.set()
# Register event handler for uptimeList (24h uptime percentages)
@self.sio.event
async def uptimeList(monitor_id, uptime_data):
nonlocal uptime_list_data
# uptime_data is typically a dict with time periods: {"24": 99.5, "720": 98.2, ...}
uptime_list_data[str(monitor_id)] = uptime_data
# Don't set event here as we'll get multiple calls
# Request monitor list - this triggers the server to send monitorList event
response = await self.sio.call('getMonitorList', timeout=self.timeout)
logger.info(f"getMonitorList call response: {response}")
# Wait for the monitorList event (with timeout)
try:
await asyncio.wait_for(monitor_event_received.wait(), timeout=5.0)
logger.info(f"Received monitorList event with {len(monitor_list_data)} items")
# Give time for uptimeList events to arrive
await asyncio.sleep(0.5)
logger.info(f"Received uptime data for {len(uptime_list_data)} monitors")
except asyncio.TimeoutError:
logger.warning("Timeout waiting for monitorList event")
# Process the monitor list data
if monitor_list_data and isinstance(monitor_list_data, dict):
monitors = []
for monitor_id, monitor_data in monitor_list_data.items():
if isinstance(monitor_data, dict):
monitor_data['id'] = int(monitor_id)
# Add uptime data if available
uptime_info = uptime_list_data.get(str(monitor_id), {})
if isinstance(uptime_info, dict):
# Uptime Kuma provides 24h uptime as key "24"
monitor_data['uptime_24h'] = float(uptime_info.get('24', 0))
else:
monitor_data['uptime_24h'] = 0.0
monitors.append(monitor_data)
self._monitors_cache[int(monitor_id)] = monitor_data
logger.info(f"Found {len(monitors)} monitors total")
return monitors
logger.warning(f"No valid monitor data received")
return []
except Exception as e:
logger.error(f"Failed to get monitors: {e}", exc_info=True)
raise
async def get_monitor(self, monitor_id: int) -> Dict[str, Any]:
"""
Get details of a specific monitor
Args:
monitor_id: Monitor identifier
Returns:
Monitor configuration details
"""
await self._ensure_connected()
try:
response = await self.sio.call('getMonitor', monitor_id, timeout=self.timeout)
if response:
self._monitors_cache[monitor_id] = response
return response
raise Exception(f"Monitor {monitor_id} not found")
except Exception as e:
logger.error(f"Failed to get monitor {monitor_id}: {e}")
raise
async def find_monitor_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
Find a monitor by its name (case-insensitive)
Args:
name: Monitor name to search for
Returns:
Monitor object if found, None otherwise
"""
monitors = await self.get_monitors()
name_lower = name.lower()
for monitor in monitors:
if monitor.get("name", "").lower() == name_lower:
return monitor
return None
async def find_monitors_by_tag(self, tag: str) -> List[Dict[str, Any]]:
"""
Find all monitors with a specific tag
Args:
tag: Tag name to search for
Returns:
List of monitors with the tag
"""
monitors = await self.get_monitors()
tagged_monitors = []
for monitor in monitors:
monitor_tags = monitor.get("tags", [])
if any(t.get("name", "").lower() == tag.lower() for t in monitor_tags):
tagged_monitors.append(monitor)
return tagged_monitors
async def pause_monitor(self, monitor_id: int) -> bool:
"""
Pause a monitor (disable monitoring)
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma pause event
response = await self.sio.call('pauseMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Paused monitor {monitor_id}")
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to pause monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to pause monitor {monitor_id}: {e}")
raise
async def resume_monitor(self, monitor_id: int) -> bool:
"""
Resume a monitor (enable monitoring)
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma resume event
response = await self.sio.call('resumeMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Resumed monitor {monitor_id}")
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to resume monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to resume monitor {monitor_id}: {e}")
raise
async def pause_monitor_by_name(self, name: str) -> bool:
"""
Pause a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.pause_monitor(monitor["id"])
return True
async def resume_monitor_by_name(self, name: str) -> bool:
"""
Resume a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.resume_monitor(monitor["id"])
return True
async def add_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a new monitor
Args:
monitor_config: Monitor configuration dict
Returns:
Created monitor details including ID
"""
await self._ensure_connected()
try:
# Uptime Kuma add monitor event
response = await self.sio.call('add', monitor_config, timeout=self.timeout)
if response and response.get('ok'):
monitor_id = response.get('monitorID')
logger.info(f"Created monitor '{monitor_config.get('name')}' with ID {monitor_id}")
# Get full monitor details
monitor = await self.get_monitor(monitor_id)
return monitor
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to create monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to create monitor '{monitor_config.get('name')}': {e}")
raise
async def update_monitor(self, monitor_id: int, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Update an existing monitor
Args:
monitor_id: Monitor identifier
monitor_config: Updated monitor configuration
Returns:
Updated monitor details
"""
await self._ensure_connected()
try:
# Ensure ID is in the config
monitor_config['id'] = monitor_id
# Uptime Kuma edit monitor event
response = await self.sio.call('editMonitor', monitor_config, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Updated monitor {monitor_id}")
# Get updated monitor details
monitor = await self.get_monitor(monitor_id)
return monitor
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to update monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to update monitor {monitor_id}: {e}")
raise
async def delete_monitor(self, monitor_id: int) -> bool:
"""
Delete a monitor
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma delete monitor event
response = await self.sio.call('deleteMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Deleted monitor {monitor_id}")
# Remove from cache
self._monitors_cache.pop(monitor_id, None)
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to delete monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
raise
async def delete_monitor_by_name(self, name: str) -> bool:
"""
Delete a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.delete_monitor(monitor["id"])
return True
async def get_metrics_status(self) -> Dict[str, Dict[str, Any]]:
"""
Get monitor status from Prometheus metrics endpoint
This is simpler and more reliable than Socket.IO for getting current status.
Returns real-time UP/DOWN status but not historical uptime percentages.
Returns:
Dict mapping monitor names to status info:
{
"Portainer": {
"status": 1, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
"response_time": 5, # ms
"monitor_type": "http",
"url": "http://192.168.86.149:8001"
},
...
}
"""
try:
# Use API key authentication
api_key = settings.kuma_api_key
if not api_key:
logger.warning("Kuma API key not configured")
return {}
# Fetch metrics with HTTP Basic Auth (empty username, API key as password)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{self.base_url}/metrics",
auth=("", api_key)
)
response.raise_for_status()
metrics_text = response.text
# Parse Prometheus format metrics
# Format: metric_name{label1="value1",label2="value2"} value
monitor_data = {}
# Parse monitor_status lines
status_pattern = r'monitor_status\{monitor_name="([^"]+)",.*?\} (\d+)'
for match in re.finditer(status_pattern, metrics_text):
monitor_name = match.group(1)
status = int(match.group(2))
if monitor_name not in monitor_data:
monitor_data[monitor_name] = {}
monitor_data[monitor_name]['status'] = status
# Parse monitor_response_time lines
response_pattern = r'monitor_response_time\{monitor_name="([^"]+)",monitor_type="([^"]+)",monitor_url="([^"]+)",.*?\} ([\d.]+)'
for match in re.finditer(response_pattern, metrics_text):
monitor_name = match.group(1)
monitor_type = match.group(2)
monitor_url = match.group(3)
response_time = float(match.group(4))
if monitor_name not in monitor_data:
monitor_data[monitor_name] = {}
monitor_data[monitor_name].update({
'response_time': response_time,
'monitor_type': monitor_type,
'url': monitor_url
})
logger.info(f"Fetched metrics for {len(monitor_data)} monitors")
return monitor_data
except Exception as e:
logger.error(f"Failed to fetch metrics: {e}")
return {}
async def __aenter__(self):
"""Async context manager entry"""
await self._ensure_connected()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.disconnect()
# Singleton instance
_kuma_client: Optional[KumaClient] = None
def get_kuma_client() -> KumaClient:
"""Get singleton Kuma client instance"""
global _kuma_client
if _kuma_client is None:
_kuma_client = KumaClient()
return _kuma_client
+8 -10
View File
@@ -25,9 +25,9 @@ try:
from src.credentials import ( from src.credentials import (
PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_URL, PORTAINER_API_KEY,
NPM_URL, NPM_EMAIL, NPM_PASSWORD, NPM_URL, NPM_EMAIL, NPM_PASSWORD,
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD, KUMA_API_KEY,
BRAVE_SEARCH_API_KEY, BRAVE_SEARCH_API_KEY,
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID,
HOMEASSISTANT_URL, HOMEASSISTANT_TOKEN
) )
except ImportError: except ImportError:
# Fallback to empty strings if credentials.py doesn't exist # Fallback to empty strings if credentials.py doesn't exist
@@ -37,13 +37,11 @@ except ImportError:
NPM_URL = "http://localhost:81" NPM_URL = "http://localhost:81"
NPM_EMAIL = "" NPM_EMAIL = ""
NPM_PASSWORD = "" NPM_PASSWORD = ""
KUMA_URL = "http://localhost:3001"
KUMA_USERNAME = ""
KUMA_PASSWORD = ""
KUMA_API_KEY = ""
BRAVE_SEARCH_API_KEY = "" BRAVE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_API_KEY = "" GOOGLE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_ENGINE_ID = "" GOOGLE_SEARCH_ENGINE_ID = ""
HOMEASSISTANT_URL = "http://localhost:8123"
HOMEASSISTANT_TOKEN = ""
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -127,10 +125,10 @@ class Settings(BaseSettings):
npm_email: str = NPM_EMAIL npm_email: str = NPM_EMAIL
npm_password: str = NPM_PASSWORD npm_password: str = NPM_PASSWORD
kuma_url: str = KUMA_URL # Home Assistant Configuration
kuma_username: str = KUMA_USERNAME homeassistant_url: str = HOMEASSISTANT_URL
kuma_password: str = KUMA_PASSWORD homeassistant_token: str = HOMEASSISTANT_TOKEN
kuma_api_key: str = KUMA_API_KEY homeassistant_timeout: int = 30
# Core-AI Service (AI performance metrics) # Core-AI Service (AI performance metrics)
core_ai_base_url: str = "http://core-ai:8086" core_ai_base_url: str = "http://core-ai:8086"
+2 -1
View File
@@ -61,7 +61,7 @@ class HealthController(BaseController):
"chat_completions": "/v1/chat/completions", "chat_completions": "/v1/chat/completions",
"models": "/v1/models", "models": "/v1/models",
"conversations": "/v1/conversations", "conversations": "/v1/conversations",
"web_scraper": "/web-scraper/scrape", "dns_lookup": "/tools/dns/lookup",
"infrastructure": "/infrastructure", "infrastructure": "/infrastructure",
"health": "/health", "health": "/health",
"health_full": "/health/full" "health_full": "/health/full"
@@ -85,6 +85,7 @@ class HealthController(BaseController):
return { return {
"status": "healthy", "status": "healthy",
"version": settings.app_version,
"ollama_connected": ollama_healthy "ollama_connected": ollama_healthy
} }
+744
View File
@@ -0,0 +1,744 @@
"""
Housekeeping Controller
Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers.
"""
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from src.controllers.base import BaseController
from src.clients.homeassistant_client import get_homeassistant_client
from src.logging_config import get_logger
from src.auth.oidc import get_admin_user
logger = get_logger(__name__)
# ========================================================================
# Pydantic Schemas
# ========================================================================
class Device(BaseModel):
"""Device/entity information"""
entity_id: str
name: str
domain: str
area: Optional[str] = None
state: str
attributes: Dict[str, Any] = {}
last_changed: Optional[str] = None
class DeviceListResponse(BaseModel):
"""Response for device listing"""
devices: List[Device]
class DeviceDetailResponse(Device):
"""Detailed device response"""
pass
class Area(BaseModel):
"""Area/room information"""
id: str
name: str
class AreaListResponse(BaseModel):
"""Response for area listing"""
areas: List[Area]
class DeviceControlRequest(BaseModel):
"""Request to control a device"""
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
brightness: Optional[int] = Field(None, ge=0, le=255)
color_temp: Optional[int] = None
rgb_color: Optional[List[int]] = None
class Config:
extra = "allow" # Allow additional attributes
class DeviceControlResponse(BaseModel):
"""Response from device control"""
success: bool
entity_id: str
new_state: Optional[str] = None
message: str
class Scene(BaseModel):
"""Scene information"""
id: str
name: str
class SceneListResponse(BaseModel):
"""Response for scene listing"""
scenes: List[Scene]
class SceneActivateResponse(BaseModel):
"""Response from scene activation"""
success: bool
scene_id: str
message: str
class Script(BaseModel):
"""Script information"""
id: str
name: str
class ScriptListResponse(BaseModel):
"""Response for script listing"""
scripts: List[Script]
class ScriptRunRequest(BaseModel):
"""Request to run a script"""
variables: Optional[Dict[str, Any]] = None
class ScriptRunResponse(BaseModel):
"""Response from script execution"""
success: bool
script_id: str
message: str
class Automation(BaseModel):
"""Automation information"""
id: str
name: str
enabled: bool
class AutomationListResponse(BaseModel):
"""Response for automation listing"""
automations: List[Automation]
class AutomationToggleRequest(BaseModel):
"""Request to toggle automation"""
enabled: bool
class AutomationToggleResponse(BaseModel):
"""Response from automation toggle"""
success: bool
automation_id: str
enabled: bool
message: str
class HistoryEntry(BaseModel):
"""Single history entry"""
state: str
timestamp: str
attributes: Dict[str, Any] = {}
class HistoryResponse(BaseModel):
"""Response for history query"""
entity_id: str
history: List[HistoryEntry]
class HealthResponse(BaseModel):
"""Health check response"""
status: str
connected: bool
platform: str
version: Optional[str] = None
error: Optional[str] = None
class ErrorResponse(BaseModel):
"""Standard error response"""
error: bool = True
code: str
message: str
# ========================================================================
# Controller
# ========================================================================
class HousekeepingController(BaseController):
"""
Controller for home automation operations
Provides endpoints for:
- Device discovery and control
- Scene activation
- Script execution
- Automation management
- State history
"""
def __init__(self):
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
# ====================================================================
# Health
# ====================================================================
@router.get(
"/health",
response_model=HealthResponse,
summary="Home automation health check"
)
async def get_health():
"""
Check Home Assistant connection health
Returns connection status and HA version.
"""
ha = get_homeassistant_client()
return await ha.health_check()
# ====================================================================
# Device Discovery
# ====================================================================
@router.get(
"/devices",
response_model=DeviceListResponse,
summary="List available devices"
)
async def list_devices(
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
area: Optional[str] = Query(None, description="Filter by area/room name")
):
"""
List all available devices with optional filtering
Query Parameters:
- domain: Filter by device type (light, switch, climate, media_player, etc.)
- area: Filter by area/room name
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
# Non-controllable domains to filter out
excluded_domains = {
"zone", "person", "device_tracker", "sun", "weather",
"persistent_notification", "update", "binary_sensor", "sensor",
"conversation", "calendar", "button", "number", "select",
"text", "time", "date", "datetime", "image", "tts", "stt"
}
devices = []
for state in states:
entity_id = state.get("entity_id", "")
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
# Skip non-controllable entities
if entity_domain in excluded_domains:
continue
# Apply domain filter
if domain and entity_domain != domain:
continue
# Get area from attributes
device_area = state.get("attributes", {}).get("area_id")
# Apply area filter
if area and device_area and area.lower() not in device_area.lower():
continue
device = Device(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=device_area,
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
devices.append(device)
return DeviceListResponse(devices=devices)
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/devices/{entity_id:path}",
response_model=DeviceDetailResponse,
responses={404: {"model": ErrorResponse}}
)
async def get_device(entity_id: str):
"""
Get detailed state of a specific device
Args:
entity_id: Entity ID (e.g., light.living_room)
"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(entity_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
return DeviceDetailResponse(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=state.get("attributes", {}).get("area_id"),
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/areas",
response_model=AreaListResponse,
summary="List areas/rooms"
)
async def list_areas():
"""
List all configured areas/rooms in Home Assistant
"""
ha = get_homeassistant_client()
try:
areas = await ha.get_areas()
return AreaListResponse(
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
)
except Exception as e:
logger.error(f"Failed to list areas: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Device Control
# ====================================================================
@router.post(
"/devices/{entity_id:path}/control",
response_model=DeviceControlResponse,
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
)
async def control_device(
entity_id: str,
request: DeviceControlRequest,
user: Dict = Depends(get_admin_user)
):
"""
Control a device (turn on, turn off, toggle, or set attributes)
Args:
entity_id: Entity ID (e.g., light.living_room)
request: Control request with action and optional attributes
"""
ha = get_homeassistant_client()
# Validate action
valid_actions = ["turn_on", "turn_off", "toggle"]
if request.action not in valid_actions:
raise HTTPException(
status_code=400,
detail={"error": True, "code": "INVALID_ACTION",
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
)
try:
# Check device exists first
current_state = await ha.get_state(entity_id)
if not current_state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
# Build attributes dict from request
attributes = {}
if request.brightness is not None:
attributes["brightness"] = request.brightness
if request.color_temp is not None:
attributes["color_temp"] = request.color_temp
if request.rgb_color is not None:
attributes["rgb_color"] = request.rgb_color
# Add any extra attributes from request
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
for key, value in extra_fields.items():
if value is not None:
attributes[key] = value
# Execute action
if request.action == "turn_on":
await ha.turn_on(entity_id, **attributes)
elif request.action == "turn_off":
await ha.turn_off(entity_id)
else: # toggle
await ha.toggle(entity_id)
# Get new state
new_state = await ha.get_state(entity_id)
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
return DeviceControlResponse(
success=True,
entity_id=entity_id,
new_state=new_state.get("state") if new_state else None,
message=f"Device {request.action} successful"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to control device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scenes
# ====================================================================
@router.get(
"/scenes",
response_model=SceneListResponse,
summary="List available scenes"
)
async def list_scenes():
"""
List all available scenes in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scenes = [
Scene(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("scene.")
]
return SceneListResponse(scenes=scenes)
except Exception as e:
logger.error(f"Failed to list scenes: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scenes/{scene_id:path}/activate",
response_model=SceneActivateResponse,
responses={404: {"model": ErrorResponse}}
)
async def activate_scene(
scene_id: str,
user: Dict = Depends(get_admin_user)
):
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., scene.movie_night)
"""
ha = get_homeassistant_client()
try:
# Verify scene exists
state = await ha.get_state(scene_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCENE_NOT_FOUND",
"message": f"Scene {scene_id} not found"}
)
await ha.activate_scene(scene_id)
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
return SceneActivateResponse(
success=True,
scene_id=scene_id,
message="Scene activated"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to activate scene {scene_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scripts
# ====================================================================
@router.get(
"/scripts",
response_model=ScriptListResponse,
summary="List available scripts"
)
async def list_scripts():
"""
List all available scripts/sequences in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scripts = [
Script(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("script.")
]
return ScriptListResponse(scripts=scripts)
except Exception as e:
logger.error(f"Failed to list scripts: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scripts/{script_id:path}/run",
response_model=ScriptRunResponse,
responses={404: {"model": ErrorResponse}}
)
async def run_script(
script_id: str,
request: Optional[ScriptRunRequest] = None,
user: Dict = Depends(get_admin_user)
):
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., script.bedtime_routine)
request: Optional variables for the script
"""
ha = get_homeassistant_client()
try:
# Verify script exists
state = await ha.get_state(script_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
"message": f"Script {script_id} not found"}
)
variables = request.variables if request else None
await ha.run_script(script_id, variables)
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
return ScriptRunResponse(
success=True,
script_id=script_id,
message="Script executed"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to run script {script_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Automations
# ====================================================================
@router.get(
"/automations",
response_model=AutomationListResponse,
summary="List automations"
)
async def list_automations():
"""
List all automations with their enabled/disabled status
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
automations = [
Automation(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
enabled=s.get("state") == "on"
)
for s in states
if s["entity_id"].startswith("automation.")
]
return AutomationListResponse(automations=automations)
except Exception as e:
logger.error(f"Failed to list automations: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/automations/{automation_id:path}/toggle",
response_model=AutomationToggleResponse,
responses={404: {"model": ErrorResponse}}
)
async def toggle_automation(
automation_id: str,
request: AutomationToggleRequest,
user: Dict = Depends(get_admin_user)
):
"""
Enable or disable an automation
Args:
automation_id: Automation entity ID (e.g., automation.motion_lights)
request: Contains enabled boolean
"""
ha = get_homeassistant_client()
try:
# Verify automation exists
state = await ha.get_state(automation_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
"message": f"Automation {automation_id} not found"}
)
if request.enabled:
await ha.enable_automation(automation_id)
else:
await ha.disable_automation(automation_id)
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
return AutomationToggleResponse(
success=True,
automation_id=automation_id,
enabled=request.enabled,
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to toggle automation {automation_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# History
# ====================================================================
@router.get(
"/history",
response_model=HistoryResponse,
responses={400: {"model": ErrorResponse}}
)
async def get_history(
entity_id: str = Query(..., description="Entity ID to get history for"),
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
):
"""
Get state history for a device
Query Parameters:
- entity_id: Device entity ID (required)
- hours: Number of hours of history (default 24, max 168/1 week)
"""
ha = get_homeassistant_client()
try:
history_data = await ha.get_history(entity_id, hours)
# Transform HA history format to our format
history_entries = []
if history_data and len(history_data) > 0:
for entry in history_data[0]: # First array is our entity
history_entries.append(HistoryEntry(
state=entry.get("state", "unknown"),
timestamp=entry.get("last_changed", ""),
attributes=entry.get("attributes", {})
))
return HistoryResponse(
entity_id=entity_id,
history=history_entries
)
except Exception as e:
logger.error(f"Failed to get history for {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
return router
# Create controller instance
housekeeping_controller = HousekeepingController()
+7 -233
View File
@@ -11,7 +11,6 @@ from pydantic import BaseModel, field_validator
from src.controllers.base import BaseController from src.controllers.base import BaseController
from src.clients.portainer_client import get_portainer_client from src.clients.portainer_client import get_portainer_client
from src.clients.npm_client import get_npm_client from src.clients.npm_client import get_npm_client
from src.clients.kuma_client import get_kuma_client
from src.logging_config import get_logger from src.logging_config import get_logger
from src import service_groups from src import service_groups
from src.auth.oidc import get_admin_user, get_forward_auth_admin from src.auth.oidc import get_admin_user, get_forward_auth_admin
@@ -963,7 +962,7 @@ class InfrastructureController(BaseController):
"/services/{name}/stop", "/services/{name}/stop",
response_model=OperationResult, response_model=OperationResult,
summary="Stop a service or service group", summary="Stop a service or service group",
description="Stop a service or service group by pausing monitors and stopping containers. Requires admin authentication when accessed externally via api.schweitz.net." description="Stop a service or service group by stopping containers. Requires admin authentication when accessed externally via api.schweitz.net."
) )
async def stop_service( async def stop_service(
name: str, name: str,
@@ -974,8 +973,7 @@ class InfrastructureController(BaseController):
This will: This will:
1. Validate service can be stopped (not always-on) 1. Validate service can be stopped (not always-on)
2. Pause Uptime Kuma monitors for all services in group 2. Stop the Portainer stack containers
3. Stop the Portainer stack(s)
Args: Args:
name: Service or group name name: Service or group name
@@ -984,7 +982,6 @@ class InfrastructureController(BaseController):
Operation result with details Operation result with details
""" """
portainer = get_portainer_client() portainer = get_portainer_client()
kuma = get_kuma_client()
try: try:
# Get all services in the group # Get all services in the group
@@ -997,24 +994,13 @@ class InfrastructureController(BaseController):
results = { results = {
"stopped_services": [], "stopped_services": [],
"paused_monitors": [],
"errors": [] "errors": []
} }
# Stop each service # Stop each service
for service_name in services: for service_name in services:
try: try:
# 1. Pause Uptime Kuma monitor # Stop Portainer stack containers
try:
monitor_paused = await kuma.pause_monitor_by_name(service_name)
if monitor_paused:
results["paused_monitors"].append(service_name)
logger.info(f"Paused Kuma monitor for {service_name}")
except Exception as e:
logger.warning(f"Failed to pause Kuma monitor for {service_name}: {e}")
results["errors"].append(f"Kuma pause failed for {service_name}: {str(e)}")
# 2. Stop Portainer stack
stacks = await portainer.get_stacks() stacks = await portainer.get_stacks()
stack = next( stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()), (s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
@@ -1025,9 +1011,6 @@ class InfrastructureController(BaseController):
stack_id = stack.get("Id") stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId") endpoint_id = stack.get("EndpointId")
# Stop stack by deleting it (Portainer doesn't have a "stop" operation)
# Note: This is destructive. For a gentler approach, we'd need to use docker compose stop
# Let's use docker API instead
logger.info(f"Stopping containers for stack: {service_name}") logger.info(f"Stopping containers for stack: {service_name}")
# Get containers for this stack # Get containers for this stack
@@ -1078,7 +1061,7 @@ class InfrastructureController(BaseController):
"/services/{name}/start", "/services/{name}/start",
response_model=OperationResult, response_model=OperationResult,
summary="Start a service or service group", summary="Start a service or service group",
description="Start a service or service group by starting containers and resuming monitors. Requires admin authentication when accessed externally via api.schweitz.net." description="Start a service or service group by starting containers. Requires admin authentication when accessed externally via api.schweitz.net."
) )
async def start_service( async def start_service(
name: str, name: str,
@@ -1088,8 +1071,7 @@ class InfrastructureController(BaseController):
Start a service or service group Start a service or service group
This will: This will:
1. Start the Portainer stack(s) 1. Start the Portainer stack containers
2. Resume Uptime Kuma monitors for all services in group
Args: Args:
name: Service or group name name: Service or group name
@@ -1098,7 +1080,6 @@ class InfrastructureController(BaseController):
Operation result with details Operation result with details
""" """
portainer = get_portainer_client() portainer = get_portainer_client()
kuma = get_kuma_client()
try: try:
# Get all services in the group # Get all services in the group
@@ -1106,14 +1087,13 @@ class InfrastructureController(BaseController):
results = { results = {
"started_services": [], "started_services": [],
"resumed_monitors": [],
"errors": [] "errors": []
} }
# Start each service # Start each service
for service_name in services: for service_name in services:
try: try:
# 1. Start Portainer stack (start containers) # Start Portainer stack containers
stacks = await portainer.get_stacks() stacks = await portainer.get_stacks()
stack = next( stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()), (s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
@@ -1147,16 +1127,6 @@ class InfrastructureController(BaseController):
}) })
logger.info(f"Started service: {service_name}") logger.info(f"Started service: {service_name}")
# 2. Resume Uptime Kuma monitor
try:
monitor_resumed = await kuma.resume_monitor_by_name(service_name)
if monitor_resumed:
results["resumed_monitors"].append(service_name)
logger.info(f"Resumed Kuma monitor for {service_name}")
except Exception as e:
logger.warning(f"Failed to resume Kuma monitor for {service_name}: {e}")
results["errors"].append(f"Kuma resume failed for {service_name}: {str(e)}")
else: else:
results["errors"].append(f"Stack not found: {service_name}") results["errors"].append(f"Stack not found: {service_name}")
@@ -1181,8 +1151,6 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to start service group '{name}': {e}") logger.error(f"Failed to start service group '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ===== Monitoring Endpoints =====
@router.get( @router.get(
"/widget-data", "/widget-data",
summary="Get combined data for service control widget", summary="Get combined data for service control widget",
@@ -1190,11 +1158,10 @@ class InfrastructureController(BaseController):
) )
async def get_widget_data(): async def get_widget_data():
""" """
Get combined service and monitor data for the widget Get combined service data for the widget
Returns all data needed by service-control widget in a single call: Returns all data needed by service-control widget in a single call:
- Service list with status and container counts - Service list with status and container counts
- Monitor list with uptime percentages
- Service groups and always-on list - Service groups and always-on list
This endpoint is designed for browser-based widgets to avoid This endpoint is designed for browser-based widgets to avoid
@@ -1202,7 +1169,6 @@ class InfrastructureController(BaseController):
""" """
try: try:
portainer = get_portainer_client() portainer = get_portainer_client()
kuma = get_kuma_client()
npm = get_npm_client() npm = get_npm_client()
# Fetch services (same logic as /services endpoint) # Fetch services (same logic as /services endpoint)
@@ -1252,38 +1218,9 @@ class InfrastructureController(BaseController):
"containers_total": containers_total "containers_total": containers_total
}) })
# Fetch monitors with real-time status from metrics endpoint
monitors_list = []
try:
# Get real-time status from Prometheus metrics
metrics_data = await kuma.get_metrics_status()
for monitor_name, monitor_info in metrics_data.items():
# Status: 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
status = monitor_info.get('status', 0)
# Convert status to simple up/down for widget
# Treat UP (1) as 100%, anything else as 0%
status_percentage = 100.0 if status == 1 else 0.0
monitors_list.append({
"id": None, # Not available from metrics
"name": monitor_name,
"uptime_24h": status_percentage, # Current status as percentage
"active": True, # Assume active if in metrics
"status": status, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
"response_time": monitor_info.get('response_time', 0)
})
logger.info(f"Fetched status for {len(monitors_list)} monitors from metrics")
except Exception as e:
logger.warning(f"Failed to fetch monitors: {e}")
# Continue without monitor data rather than failing
return { return {
"success": True, "success": True,
"services": services, "services": services,
"monitors": monitors_list,
"service_groups": { "service_groups": {
"groups": service_groups.list_service_groups(), "groups": service_groups.list_service_groups(),
"always_on": list(service_groups.ALWAYS_ON_SERVICES), "always_on": list(service_groups.ALWAYS_ON_SERVICES),
@@ -1295,169 +1232,6 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to fetch widget data: {e}") logger.error(f"Failed to fetch widget data: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch widget data: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to fetch widget data: {str(e)}")
@router.get(
"/monitors",
summary="List all monitors",
response_model=Dict[str, Any]
)
async def list_monitors():
"""
List all Uptime Kuma monitors
Returns:
List of monitors with their configurations
"""
try:
kuma = get_kuma_client()
monitors = await kuma.get_monitors()
return {
"success": True,
"monitors": monitors,
"total": len(monitors)
}
except Exception as e:
logger.error(f"Failed to list monitors: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list monitors: {str(e)}")
@router.post(
"/monitors",
summary="Create a new monitor",
description="Create a new Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def create_monitor(
monitor_config: Dict[str, Any],
user: Dict = Depends(get_admin_user)
):
"""
Create a new Uptime Kuma monitor
Args:
monitor_config: Monitor configuration (name, type, hostname, port, etc.)
Returns:
Created monitor details including ID
"""
try:
kuma = get_kuma_client()
created_monitor = await kuma.add_monitor(monitor_config)
return {
"success": True,
"message": f"Monitor '{monitor_config.get('name')}' created successfully",
"monitor": created_monitor
}
except Exception as e:
logger.error(f"Failed to create monitor: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create monitor: {str(e)}")
@router.get(
"/monitors/{monitor_id}",
summary="Get monitor details",
response_model=Dict[str, Any]
)
async def get_monitor(monitor_id: int):
"""
Get details of a specific monitor
Args:
monitor_id: Monitor identifier
Returns:
Monitor configuration and status
"""
try:
kuma = get_kuma_client()
monitor = await kuma.get_monitor(monitor_id)
return {
"success": True,
"monitor": monitor
}
except Exception as e:
logger.error(f"Failed to get monitor {monitor_id}: {e}")
raise HTTPException(status_code=404, detail=f"Monitor {monitor_id} not found: {str(e)}")
@router.put(
"/monitors/{monitor_id}",
summary="Update a monitor",
description="Update an existing Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def update_monitor(
monitor_id: int,
updates: Dict[str, Any],
user: Dict = Depends(get_admin_user)
):
"""
Update an existing monitor
Args:
monitor_id: Monitor identifier
updates: Fields to update
Returns:
Updated monitor details
"""
try:
kuma = get_kuma_client()
# Get existing monitor
existing = await kuma.get_monitor(monitor_id)
# Merge updates
monitor_config = existing.copy()
monitor_config.update(updates)
# Update monitor
updated_monitor = await kuma.update_monitor(monitor_id, monitor_config)
return {
"success": True,
"message": f"Monitor {monitor_id} updated successfully",
"monitor": updated_monitor
}
except Exception as e:
logger.error(f"Failed to update monitor {monitor_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update monitor: {str(e)}")
@router.delete(
"/monitors/{monitor_id}",
summary="Delete a monitor",
description="Delete an Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def delete_monitor(
monitor_id: int,
user: Dict = Depends(get_admin_user)
):
"""
Delete a monitor
Args:
monitor_id: Monitor identifier
Returns:
Success confirmation
"""
try:
kuma = get_kuma_client()
await kuma.delete_monitor(monitor_id)
return {
"success": True,
"message": f"Monitor {monitor_id} deleted successfully"
}
except Exception as e:
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete monitor: {str(e)}")
# ======================================================================== # ========================================================================
# Container Management Endpoints (for core-ai infrastructure tools) # Container Management Endpoints (for core-ai infrastructure tools)
# ======================================================================== # ========================================================================
-66
View File
@@ -2,16 +2,12 @@
Tools Controller Tools Controller
Provides utility tool endpoints including: Provides utility tool endpoints including:
- Web scraping and content extraction
- DNS lookups - DNS lookups
""" """
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from src.controllers.base import BaseController from src.controllers.base import BaseController
from src.logging_config import get_logger from src.logging_config import get_logger
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
from src.web_scraper.exceptions import FetchError, ScrapingError
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
from src.dns.service import DNSService from src.dns.service import DNSService
from src.dns.exceptions import DNSQueryError from src.dns.exceptions import DNSQueryError
@@ -24,79 +20,17 @@ class ToolsController(BaseController):
Controller for utility tools Controller for utility tools
Provides endpoints for: Provides endpoints for:
- Web scraping and content extraction
- DNS lookups - DNS lookups
""" """
def __init__(self): def __init__(self):
super().__init__(prefix="/tools", tags=["Tools"]) super().__init__(prefix="/tools", tags=["Tools"])
# Initialize services (could be dependency injected for testing)
self.scraper_service = WebScraperService()
self.dns_service = DNSService() self.dns_service = DNSService()
def create_router(self) -> APIRouter: def create_router(self) -> APIRouter:
"""Create and configure the router""" """Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags) router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/scrape",
response_model=WebScraperResponse,
status_code=status.HTTP_200_OK,
summary="Scrape website content",
description="""
Scrape and extract main content from a website.
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
**Features:**
- Intelligent main content extraction
- Removes navigation, ads, footers
- Optional link extraction
- Configurable content length limits
**Rate Limiting:** None (internal network use only)
"""
)
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape a website and extract its main content
Args:
request: Scraping request with URL and options
Returns:
Extracted content with metadata
Raises:
HTTPException: 400 for fetch errors, 500 for processing errors
"""
try:
logger.info(f"Received scrape request for: {request.url}")
result = await self.scraper_service.scrape_url(request)
return result
except FetchError as e:
logger.warning(f"Fetch failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to fetch URL: {str(e)}"
)
except ScrapingError as e:
logger.error(f"Scraping failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract content: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)
@router.post( @router.post(
"/dns/lookup", "/dns/lookup",
response_model=DNSLookupResponse, response_model=DNSLookupResponse,
+3 -4
View File
@@ -18,7 +18,6 @@ NPM_URL = "http://localhost:81"
NPM_EMAIL = "admin@example.com" NPM_EMAIL = "admin@example.com"
NPM_PASSWORD = "your_password_here" NPM_PASSWORD = "your_password_here"
# Uptime Kuma Configuration # Home Assistant Configuration
KUMA_URL = "http://localhost:3001" HOMEASSISTANT_URL = "http://192.168.86.149:8123" # Or http://home-assistant:8123 in Docker
KUMA_USERNAME = "admin" HOMEASSISTANT_TOKEN = "your_long_lived_access_token_here" # Create in HA: Profile → Long-Lived Access Tokens
KUMA_PASSWORD = "your_password_here"
+22 -1
View File
@@ -14,6 +14,7 @@ from src.controllers.tools_controller import tools_controller
from src.controllers.health_controller import health_controller from src.controllers.health_controller import health_controller
from src.controllers.static_controller import static_controller from src.controllers.static_controller import static_controller
from src.controllers.ai_controller import router as ai_router from src.controllers.ai_controller import router as ai_router
from src.controllers.housekeeping_controller import housekeeping_controller
from src.security import initialize_oidc from src.security import initialize_oidc
# Initialize settings # Initialize settings
@@ -100,6 +101,25 @@ app = FastAPI(
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs. Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
### Home Automation (Housekeeping)
**Read Endpoints:**
- `GET /housekeeping/health` - Check Home Assistant connectivity
- `GET /housekeeping/devices` - List devices (filter by domain, area)
- `GET /housekeeping/devices/{entity_id}` - Get device details
- `GET /housekeeping/areas` - List areas/rooms
- `GET /housekeeping/scenes` - List scenes
- `GET /housekeeping/scripts` - List scripts
- `GET /housekeeping/automations` - List automations
- `GET /housekeeping/history` - Get state history
**Write Endpoints (Admin Only):**
- `POST /housekeeping/devices/{entity_id}/control` - Control device
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
- `POST /housekeeping/scripts/{script_id}/run` - Run script
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
Abstracts Home Assistant for the Tatlock Housekeeper agent and other consumers.
### Web Scraper ### Web Scraper
Intelligent web scraping with main content extraction. Intelligent web scraping with main content extraction.
Perfect for extracting articles, documentation, and blog posts for LLM consumption. Perfect for extracting articles, documentation, and blog posts for LLM consumption.
@@ -148,8 +168,9 @@ app.add_middleware(
# Include controller routers # Include controller routers
app.include_router(health_controller.router) # / and /health app.include_router(health_controller.router) # / and /health
app.include_router(tools_controller.router) # /web-scraper/scrape app.include_router(tools_controller.router) # /tools/*
app.include_router(infrastructure_controller.router) # /infrastructure/* app.include_router(infrastructure_controller.router) # /infrastructure/*
app.include_router(housekeeping_controller.router) # /housekeeping/*
app.include_router(static_controller.router) # /static/* app.include_router(static_controller.router) # /static/*
app.include_router(ai_router) # /ai/* app.include_router(ai_router) # /ai/*
-1
View File
@@ -10,7 +10,6 @@ ALWAYS_ON_SERVICES: Set[str] = {
"portainer", "portainer",
"nginx-proxy-manager", "nginx-proxy-manager",
"core-api", "core-api",
"uptime-kuma",
"organizr", "organizr",
"headscale", "headscale",
"watchtower", "watchtower",
-13
View File
@@ -1,13 +0,0 @@
"""
Web scraper module for extracting content from websites
"""
from src.web_scraper.router import router
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
__all__ = [
"router",
"WebScraperRequest",
"WebScraperResponse",
"WebScraperService",
]
-32
View File
@@ -1,32 +0,0 @@
"""
Configuration for web scraper module
"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class WebScraperSettings(BaseSettings):
"""Web scraper specific settings"""
# HTTP client configuration
request_timeout: int = 30
max_redirects: int = 5
user_agent: str = "Mozilla/5.0 (compatible; CoreCode/1.0)"
# Content extraction
default_max_length: int = 10000
max_links_to_extract: int = 50
# Rate limiting (future use)
rate_limit_enabled: bool = False
requests_per_minute: int = 60
class Config:
env_prefix = "WEB_SCRAPER_"
case_sensitive = False
@lru_cache()
def get_web_scraper_settings() -> WebScraperSettings:
"""Cached web scraper settings instance"""
return WebScraperSettings()
-18
View File
@@ -1,18 +0,0 @@
"""
Custom exceptions for web scraper module
"""
class WebScraperException(Exception):
"""Base exception for web scraper module"""
pass
class FetchError(WebScraperException):
"""Raised when URL fetch fails"""
pass
class ScrapingError(WebScraperException):
"""Raised when content extraction fails"""
pass
-79
View File
@@ -1,79 +0,0 @@
"""
API routes for web scraper module
"""
from fastapi import APIRouter, HTTPException, status
from src.logging_config import get_logger
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
from src.web_scraper.exceptions import FetchError, ScrapingError
logger = get_logger(__name__)
router = APIRouter(
prefix="/web-scraper",
tags=["Web Scraper"]
)
# Initialize service (could be dependency injected for testing)
scraper_service = WebScraperService()
@router.post(
"/scrape",
response_model=WebScraperResponse,
status_code=status.HTTP_200_OK,
summary="Scrape website content",
description="""
Scrape and extract main content from a website.
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
**Features:**
- Intelligent main content extraction
- Removes navigation, ads, footers
- Optional link extraction
- Configurable content length limits
**Rate Limiting:** None (internal network use only)
"""
)
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape a website and extract its main content
Args:
request: Scraping request with URL and options
Returns:
Extracted content with metadata
Raises:
HTTPException: 400 for fetch errors, 500 for processing errors
"""
try:
logger.info(f"Received scrape request for: {request.url}")
result = await scraper_service.scrape_url(request)
return result
except FetchError as e:
logger.warning(f"Fetch failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to fetch URL: {str(e)}"
)
except ScrapingError as e:
logger.error(f"Scraping failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract content: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)
-69
View File
@@ -1,69 +0,0 @@
"""
Pydantic schemas for web scraper module
"""
from pydantic import HttpUrl, Field
from typing import Optional
from datetime import datetime
from src.base_schema import BaseSchema
class WebScraperRequest(BaseSchema):
"""Request model for web scraping"""
url: HttpUrl = Field(
...,
description="The URL to scrape",
examples=["https://example.com/article"]
)
extract_main_content: bool = Field(
default=True,
description="Use intelligent content extraction (trafilatura) vs raw HTML parsing"
)
include_links: bool = Field(
default=False,
description="Include list of links found on the page"
)
max_length: Optional[int] = Field(
default=10000,
ge=100,
le=100000,
description="Maximum content length to return (100-100000 chars)"
)
class WebScraperResponse(BaseSchema):
"""Response model for web scraping"""
url: str = Field(
...,
description="The scraped URL"
)
title: Optional[str] = Field(
default=None,
description="Page title extracted from <title> tag"
)
content: str = Field(
...,
description="Extracted page content"
)
extracted_at: datetime = Field(
...,
description="UTC timestamp when content was extracted"
)
content_length: int = Field(
...,
ge=0,
description="Length of extracted content in characters"
)
links: Optional[list[str]] = Field(
default=None,
description="List of HTTP(S) links found on the page (max 50)"
)
-213
View File
@@ -1,213 +0,0 @@
"""
Business logic for web scraper module
"""
import httpx
from bs4 import BeautifulSoup
import trafilatura
from datetime import datetime, timezone
from typing import Optional
from src.logging_config import get_logger
from src.web_scraper.config import get_web_scraper_settings
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.exceptions import ScrapingError, FetchError
logger = get_logger(__name__)
class WebScraperService:
"""Service class for web scraping operations"""
def __init__(self):
self.settings = get_web_scraper_settings()
async def scrape_url(self, request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape and extract content from a URL
Args:
request: Scraping request parameters
Returns:
Extracted content with metadata
Raises:
FetchError: If URL cannot be fetched
ScrapingError: If content extraction fails
"""
url_str = str(request.url)
logger.info(f"Starting scrape for URL: {url_str}")
try:
# Fetch the webpage
html_content = await self._fetch_url(url_str)
# Extract content based on settings
if request.extract_main_content:
content = self._extract_main_content(html_content, request.include_links)
else:
content = self._extract_basic_content(html_content)
# Extract metadata
title = self._extract_title(html_content)
links = self._extract_links(html_content) if request.include_links else None
# Clean and truncate content
content = self._clean_content(content)
if request.max_length and len(content) > request.max_length:
content = content[:request.max_length] + "\n\n[Content truncated...]"
logger.debug(f"Content truncated to {request.max_length} characters")
logger.info(f"Successfully scraped {len(content)} characters from {url_str}")
return WebScraperResponse(
url=url_str,
title=title,
content=content,
extracted_at=datetime.now(timezone.utc),
content_length=len(content),
links=links
)
except FetchError:
raise
except Exception as e:
logger.error(f"Scraping failed for {url_str}: {str(e)}", exc_info=True)
raise ScrapingError(f"Failed to scrape content: {str(e)}")
async def _fetch_url(self, url: str) -> str:
"""
Fetch HTML content from URL
Args:
url: URL to fetch
Returns:
HTML content as string
Raises:
FetchError: If fetch fails
"""
try:
async with httpx.AsyncClient(
timeout=self.settings.request_timeout,
follow_redirects=True,
max_redirects=self.settings.max_redirects
) as client:
logger.debug(f"Fetching URL: {url}")
response = await client.get(
url,
headers={"User-Agent": self.settings.user_agent}
)
response.raise_for_status()
logger.debug(f"Fetched {len(response.text)} bytes from {url}")
return response.text
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error {e.response.status_code} for {url}")
raise FetchError(f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
except httpx.RequestError as e:
logger.error(f"Request error for {url}: {str(e)}")
raise FetchError(f"Failed to fetch URL: {str(e)}")
def _extract_main_content(self, html: str, include_links: bool = False) -> str:
"""
Extract main content using trafilatura (intelligent extraction)
Args:
html: Raw HTML content
include_links: Whether to preserve links in output
Returns:
Extracted content
"""
logger.debug("Extracting main content with trafilatura")
content = trafilatura.extract(
html,
include_links=include_links,
include_images=False,
output_format='txt',
no_fallback=False
)
# Fallback to BeautifulSoup if trafilatura fails
if not content:
logger.debug("Trafilatura extraction failed, falling back to BeautifulSoup")
content = self._extract_basic_content(html)
return content
def _extract_basic_content(self, html: str) -> str:
"""
Extract content using basic BeautifulSoup parsing
Args:
html: Raw HTML content
Returns:
Extracted text content
"""
logger.debug("Extracting content with BeautifulSoup")
soup = BeautifulSoup(html, 'html.parser')
# Remove unwanted elements
for element in soup(["script", "style", "nav", "footer", "header", "aside"]):
element.decompose()
# Extract text
text = soup.get_text(separator='\n', strip=True)
return text
def _extract_title(self, html: str) -> Optional[str]:
"""
Extract page title from HTML
Args:
html: Raw HTML content
Returns:
Page title or None
"""
soup = BeautifulSoup(html, 'html.parser')
title = soup.title.string if soup.title else None
if title:
title = title.strip()
logger.debug(f"Extracted title: {title}")
return title
def _extract_links(self, html: str) -> list[str]:
"""
Extract HTTP(S) links from HTML
Args:
html: Raw HTML content
Returns:
List of absolute HTTP(S) URLs
"""
soup = BeautifulSoup(html, 'html.parser')
links = [
a.get('href')
for a in soup.find_all('a', href=True)
if a.get('href', '').startswith('http')
]
# Limit number of links
links = links[:self.settings.max_links_to_extract]
logger.debug(f"Extracted {len(links)} links")
return links
def _clean_content(self, content: str) -> str:
"""
Clean and normalize extracted content
Args:
content: Raw extracted content
Returns:
Cleaned content
"""
# Remove empty lines and normalize whitespace
lines = [line.strip() for line in content.split('\n') if line.strip()]
cleaned = '\n'.join(lines)
return cleaned
+5 -111
View File
@@ -71,14 +71,14 @@
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
text-transform: capitalize; text-transform: capitalize;
min-width: 300px; min-width: 200px;
} }
.service-status { .service-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 120px; min-width: 180px;
} }
.status-indicator { .status-indicator {
@@ -103,54 +103,6 @@
color: #a0a0a0; color: #a0a0a0;
} }
.uptime-status {
display: flex;
align-items: center;
gap: 8px;
min-width: 150px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
color: inherit;
}
.uptime-status:hover {
background: rgba(0, 0, 0, 0.4);
}
.uptime-percentage {
font-size: 13px;
font-weight: 600;
}
.uptime-percentage.excellent {
color: #48bb78;
}
.uptime-percentage.good {
color: #68d391;
}
.uptime-percentage.warning {
color: #ed8936;
}
.uptime-percentage.critical {
color: #f56565;
}
.uptime-percentage.unknown {
color: #718096;
}
.uptime-icon {
font-size: 11px;
color: #a0a0a0;
}
.service-right { .service-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -248,10 +200,6 @@
min-width: 100px; min-width: 100px;
} }
.uptime-status {
min-width: 100px;
}
.service-right { .service-right {
width: 100%; width: 100%;
justify-content: flex-end; justify-content: flex-end;
@@ -264,12 +212,12 @@
<div id="error-container"></div> <div id="error-container"></div>
<div class="section"> <div class="section">
<div class="section-header">🎛️ Stoppable Services</div> <div class="section-header">Stoppable Services</div>
<div id="stoppable-container" class="loading">Loading services...</div> <div id="stoppable-container" class="loading">Loading services...</div>
</div> </div>
<div class="section"> <div class="section">
<div class="section-header">🔒 Always-On Infrastructure</div> <div class="section-header">Always-On Infrastructure</div>
<div id="always-on-container" class="loading">Loading infrastructure...</div> <div id="always-on-container" class="loading">Loading infrastructure...</div>
</div> </div>
</div> </div>
@@ -277,11 +225,9 @@
<script> <script>
// Use relative URL to work in any context (iframe, direct access, etc.) // Use relative URL to work in any context (iframe, direct access, etc.)
const API_BASE = ''; const API_BASE = '';
const KUMA_BASE = window.location.protocol + '//' + window.location.hostname + ':3001';
let services = []; let services = [];
let alwaysOnServices = []; let alwaysOnServices = [];
let monitors = {};
async function fetchData() { async function fetchData() {
try { try {
@@ -306,27 +252,13 @@
alwaysOnServices = data.service_groups.always_on; alwaysOnServices = data.service_groups.always_on;
} }
// Build monitors map
const monitorsMap = {};
if (data.monitors) {
data.monitors.forEach(monitor => {
const name = monitor.name.toLowerCase().replace(/[^a-z0-9]/g, '-');
monitorsMap[name] = {
id: monitor.id,
uptime_24h: monitor.uptime_24h || 0,
active: monitor.active !== false
};
});
}
monitors = monitorsMap;
renderServices(); renderServices();
document.getElementById('error-container').innerHTML = ''; document.getElementById('error-container').innerHTML = '';
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
document.getElementById('error-container').innerHTML = document.getElementById('error-container').innerHTML =
`<div class="error">Failed to connect to API: ${error.message}</div>`; `<div class="error">Failed to connect to API: ${error.message}</div>`;
} }
} }
@@ -334,43 +266,9 @@
return alwaysOnServices.includes(serviceName.toLowerCase()); return alwaysOnServices.includes(serviceName.toLowerCase());
} }
function getUptimeInfo(serviceName) {
const monitorKey = serviceName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const monitor = monitors[monitorKey];
if (!monitor) {
return {
percentage: 0,
class: 'unknown',
text: 'No monitor',
id: null
};
}
const uptime = monitor.uptime_24h;
let className = 'unknown';
if (uptime >= 99.5) className = 'excellent';
else if (uptime >= 95) className = 'good';
else if (uptime >= 90) className = 'warning';
else if (uptime > 0) className = 'critical';
return {
percentage: uptime,
class: className,
text: uptime > 0 ? `${uptime.toFixed(1)}% ↑` : 'Down',
id: monitor.id
};
}
function renderServiceRow(service) { function renderServiceRow(service) {
const isRunning = service.containers_running > 0; const isRunning = service.containers_running > 0;
const alwaysOn = isAlwaysOn(service.name); const alwaysOn = isAlwaysOn(service.name);
const uptime = getUptimeInfo(service.name);
const kumaLink = uptime.id ?
`${KUMA_BASE}/dashboard/${uptime.id}` :
KUMA_BASE;
return ` return `
<div class="service-row" data-service="${service.name}"> <div class="service-row" data-service="${service.name}">
@@ -385,10 +283,6 @@
${isRunning ? `Running (${service.containers_running}/${service.containers_total})` : 'Stopped'} ${isRunning ? `Running (${service.containers_running}/${service.containers_total})` : 'Stopped'}
</span> </span>
</div> </div>
<a href="${kumaLink}" target="_blank" class="uptime-status" title="View in Uptime Kuma">
<span class="uptime-icon">📊</span>
<span class="uptime-percentage ${uptime.class}">${uptime.text}</span>
</a>
</div> </div>
<div class="service-right"> <div class="service-right">
<button class="btn btn-start" <button class="btn btn-start"
+1
View File
@@ -0,0 +1 @@
# Tests package
+300
View File
@@ -0,0 +1,300 @@
"""Tests for Core-AI client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import httpx
from src.clients.ai_client import CoreAIClient, get_ai_client
class TestCoreAIClientInit:
"""Test CoreAIClient initialization."""
@patch("src.clients.ai_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.core_ai_base_url = "http://core-ai:8086"
client = CoreAIClient()
assert client.base_url == "http://core-ai:8086"
assert client.timeout == 10
def test_accepts_custom_url(self):
"""Client should accept custom URL."""
client = CoreAIClient(base_url="http://custom:9000")
assert client.base_url == "http://custom:9000"
def test_accepts_custom_timeout(self):
"""Client should accept custom timeout."""
client = CoreAIClient(base_url="http://test:8086", timeout=30)
assert client.timeout == 30
def test_strips_trailing_slash_from_url(self):
"""Client should strip trailing slash from URL."""
client = CoreAIClient(base_url="http://core-ai:8086/")
assert client.base_url == "http://core-ai:8086"
def test_creates_http_client(self):
"""Client should create httpx AsyncClient."""
client = CoreAIClient(base_url="http://test:8086")
assert client.client is not None
class TestCoreAIClientClose:
"""Test client close functionality."""
@pytest.mark.asyncio
async def test_close_closes_client(self):
"""close should close the HTTP client."""
client = CoreAIClient(base_url="http://test:8086")
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
await client.close()
mock_close.assert_called_once()
class TestCoreAIClientContextManager:
"""Test async context manager."""
@pytest.mark.asyncio
async def test_context_manager_enters(self):
"""Context manager should return client on enter."""
client = CoreAIClient(base_url="http://test:8086")
with patch.object(client.client, "aclose", new_callable=AsyncMock):
async with client as ctx:
assert ctx is client
@pytest.mark.asyncio
async def test_context_manager_closes_on_exit(self):
"""Context manager should close client on exit."""
client = CoreAIClient(base_url="http://test:8086")
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
async with client:
pass
mock_close.assert_called_once()
class TestCoreAIClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
async def test_health_check_returns_true_on_200(self):
"""Health check should return True when service responds 200."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 200
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_returns_false_on_error(self):
"""Health check should return False on connection error."""
client = CoreAIClient(base_url="http://test:8086")
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = Exception("Connection refused")
result = await client.health_check()
assert result is False
@pytest.mark.asyncio
async def test_health_check_returns_false_on_non_200(self):
"""Health check should return False on non-200 status."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 500
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.health_check()
assert result is False
class TestCoreAIClientGetMetrics:
"""Test get metrics functionality."""
@pytest.mark.asyncio
async def test_get_metrics_returns_dict(self):
"""get_metrics should return metrics dict."""
client = CoreAIClient(base_url="http://test:8086")
metrics_data = {
"uptime_seconds": 3600,
"agent": {"total_requests": 100},
"tools": {"total_calls": 250}
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = metrics_data
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.get_metrics()
assert result == metrics_data
assert result["uptime_seconds"] == 3600
@pytest.mark.asyncio
async def test_get_metrics_raises_on_http_error(self):
"""get_metrics should raise on HTTP error."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=mock_response
)
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(httpx.HTTPStatusError):
await client.get_metrics()
class TestCoreAIClientGetRecentErrors:
"""Test get recent errors functionality."""
@pytest.mark.asyncio
async def test_get_recent_errors_returns_list(self):
"""get_recent_errors should return list of errors."""
client = CoreAIClient(base_url="http://test:8086")
errors_data = {
"errors": [
{"timestamp": "2025-12-03T19:45:12Z", "error": "Timeout"},
{"timestamp": "2025-12-03T19:46:00Z", "error": "Connection refused"}
]
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = errors_data
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.get_recent_errors()
assert len(result) == 2
assert result[0]["error"] == "Timeout"
@pytest.mark.asyncio
async def test_get_recent_errors_passes_limit(self):
"""get_recent_errors should pass limit parameter."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"errors": []}
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
await client.get_recent_errors(limit=5)
call_args = mock_get.call_args
assert call_args[1]["params"]["limit"] == 5
class TestCoreAIClientGetToolFailures:
"""Test get tool failures functionality."""
@pytest.mark.asyncio
async def test_get_tool_failures_returns_list(self):
"""get_tool_failures should return list of failures."""
client = CoreAIClient(base_url="http://test:8086")
failures_data = {
"failures": [
{"tool_name": "list_containers", "error": "Connection refused"}
]
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = failures_data
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.get_tool_failures()
assert len(result) == 1
assert result[0]["tool_name"] == "list_containers"
@pytest.mark.asyncio
async def test_get_tool_failures_passes_limit(self):
"""get_tool_failures should pass limit parameter."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"failures": []}
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
await client.get_tool_failures(limit=10)
call_args = mock_get.call_args
assert call_args[1]["params"]["limit"] == 10
class TestCoreAIClientResetMetrics:
"""Test reset metrics functionality."""
@pytest.mark.asyncio
async def test_reset_metrics_returns_true_on_success(self):
"""reset_metrics should return True on success."""
client = CoreAIClient(base_url="http://test:8086")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_response
result = await client.reset_metrics()
assert result is True
@pytest.mark.asyncio
async def test_reset_metrics_raises_on_error(self):
"""reset_metrics should raise on error."""
client = CoreAIClient(base_url="http://test:8086")
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
mock_post.side_effect = Exception("Connection refused")
with pytest.raises(Exception):
await client.reset_metrics()
class TestCoreAIClientSingleton:
"""Test singleton pattern."""
def test_get_ai_client_returns_same_instance(self):
"""get_ai_client should return singleton."""
import src.clients.ai_client as module
module._ai_client = None
client1 = get_ai_client()
client2 = get_ai_client()
assert client1 is client2
+264
View File
@@ -0,0 +1,264 @@
"""Tests for AI controller."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock, MagicMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
@pytest.fixture
def mock_ai_client():
"""Create a mock AI client."""
mock = AsyncMock()
return mock
class TestAIHealth:
"""Test /ai/health endpoint."""
@patch("src.controllers.ai_controller.get_ai_client")
def test_health_returns_200(self, mock_get_client, client):
"""AI health should return 200."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_client.return_value = mock_client
response = client.get("/ai/health")
assert response.status_code == 200
@patch("src.controllers.ai_controller.get_ai_client")
def test_health_returns_healthy_status(self, mock_get_client, client):
"""AI health should return healthy status when service is up."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_client.return_value = mock_client
response = client.get("/ai/health")
data = response.json()
assert data["service"] == "core-ai"
assert data["status"] == "healthy"
assert data["accessible"] is True
@patch("src.controllers.ai_controller.get_ai_client")
def test_health_returns_unhealthy_status(self, mock_get_client, client):
"""AI health should return unhealthy status when service is down."""
mock_client = AsyncMock()
mock_client.health_check.return_value = False
mock_get_client.return_value = mock_client
response = client.get("/ai/health")
data = response.json()
assert data["status"] == "unhealthy"
assert data["accessible"] is False
@patch("src.controllers.ai_controller.get_ai_client")
def test_health_handles_exception(self, mock_get_client, client):
"""AI health should handle exceptions gracefully."""
mock_client = AsyncMock()
mock_client.health_check.side_effect = Exception("Connection refused")
mock_get_client.return_value = mock_client
response = client.get("/ai/health")
data = response.json()
assert data["status"] == "error"
assert data["accessible"] is False
assert "error" in data
class TestAIMetrics:
"""Test /ai/metrics endpoint."""
@patch("src.controllers.ai_controller.get_ai_client")
def test_metrics_returns_200(self, mock_get_client, client):
"""AI metrics should return 200."""
mock_client = AsyncMock()
mock_client.get_metrics.return_value = {
"uptime_seconds": 3600,
"agent": {"total_requests": 100}
}
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics")
assert response.status_code == 200
@patch("src.controllers.ai_controller.get_ai_client")
def test_metrics_returns_data(self, mock_get_client, client):
"""AI metrics should return metrics data."""
metrics_data = {
"uptime_seconds": 3600,
"agent": {"total_requests": 100},
"tools": {"total_calls": 250}
}
mock_client = AsyncMock()
mock_client.get_metrics.return_value = metrics_data
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics")
data = response.json()
assert data["uptime_seconds"] == 3600
assert data["agent"]["total_requests"] == 100
@patch("src.controllers.ai_controller.get_ai_client")
def test_metrics_returns_503_on_error(self, mock_get_client, client):
"""AI metrics should return 503 when service unavailable."""
mock_client = AsyncMock()
mock_client.get_metrics.side_effect = Exception("Service unavailable")
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics")
assert response.status_code == 503
class TestAIErrors:
"""Test /ai/metrics/errors endpoint."""
@patch("src.controllers.ai_controller.get_ai_client")
def test_errors_returns_200(self, mock_get_client, client):
"""AI errors should return 200."""
mock_client = AsyncMock()
mock_client.get_recent_errors.return_value = []
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/errors")
assert response.status_code == 200
@patch("src.controllers.ai_controller.get_ai_client")
def test_errors_returns_error_list(self, mock_get_client, client):
"""AI errors should return list of errors."""
errors = [
{"timestamp": "2025-12-03T19:45:12Z", "error": "Timeout"},
{"timestamp": "2025-12-03T19:46:00Z", "error": "Connection refused"}
]
mock_client = AsyncMock()
mock_client.get_recent_errors.return_value = errors
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/errors")
data = response.json()
assert "errors" in data
assert "total" in data
assert data["total"] == 2
@patch("src.controllers.ai_controller.get_ai_client")
def test_errors_accepts_limit_parameter(self, mock_get_client, client):
"""AI errors should accept limit parameter."""
mock_client = AsyncMock()
mock_client.get_recent_errors.return_value = []
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/errors?limit=5")
assert response.status_code == 200
mock_client.get_recent_errors.assert_called_with(limit=5)
@patch("src.controllers.ai_controller.get_ai_client")
def test_errors_returns_503_on_error(self, mock_get_client, client):
"""AI errors should return 503 when service unavailable."""
mock_client = AsyncMock()
mock_client.get_recent_errors.side_effect = Exception("Service unavailable")
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/errors")
assert response.status_code == 503
class TestAIToolFailures:
"""Test /ai/metrics/tool-failures endpoint."""
@patch("src.controllers.ai_controller.get_ai_client")
def test_tool_failures_returns_200(self, mock_get_client, client):
"""Tool failures should return 200."""
mock_client = AsyncMock()
mock_client.get_tool_failures.return_value = []
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/tool-failures")
assert response.status_code == 200
@patch("src.controllers.ai_controller.get_ai_client")
def test_tool_failures_returns_failure_list(self, mock_get_client, client):
"""Tool failures should return list of failures."""
failures = [
{"tool_name": "list_containers", "error": "Connection refused"}
]
mock_client = AsyncMock()
mock_client.get_tool_failures.return_value = failures
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/tool-failures")
data = response.json()
assert "failures" in data
assert "total" in data
assert data["total"] == 1
@patch("src.controllers.ai_controller.get_ai_client")
def test_tool_failures_accepts_limit_parameter(self, mock_get_client, client):
"""Tool failures should accept limit parameter."""
mock_client = AsyncMock()
mock_client.get_tool_failures.return_value = []
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/tool-failures?limit=10")
assert response.status_code == 200
mock_client.get_tool_failures.assert_called_with(limit=10)
@patch("src.controllers.ai_controller.get_ai_client")
def test_tool_failures_returns_503_on_error(self, mock_get_client, client):
"""Tool failures should return 503 when service unavailable."""
mock_client = AsyncMock()
mock_client.get_tool_failures.side_effect = Exception("Service unavailable")
mock_get_client.return_value = mock_client
response = client.get("/ai/metrics/tool-failures")
assert response.status_code == 503
class TestAIMetricsReset:
"""Test /ai/metrics/reset endpoint."""
@patch("src.controllers.ai_controller.get_ai_client")
def test_reset_returns_200(self, mock_get_client, client):
"""Reset metrics should return 200."""
mock_client = AsyncMock()
mock_client.reset_metrics.return_value = True
mock_get_client.return_value = mock_client
response = client.post("/ai/metrics/reset")
assert response.status_code == 200
@patch("src.controllers.ai_controller.get_ai_client")
def test_reset_returns_success_message(self, mock_get_client, client):
"""Reset metrics should return success message."""
mock_client = AsyncMock()
mock_client.reset_metrics.return_value = True
mock_get_client.return_value = mock_client
response = client.post("/ai/metrics/reset")
data = response.json()
assert data["success"] is True
assert "message" in data
@patch("src.controllers.ai_controller.get_ai_client")
def test_reset_returns_503_on_error(self, mock_get_client, client):
"""Reset metrics should return 503 when service unavailable."""
mock_client = AsyncMock()
mock_client.reset_metrics.side_effect = Exception("Service unavailable")
mock_get_client.return_value = mock_client
response = client.post("/ai/metrics/reset")
assert response.status_code == 503
+98
View File
@@ -0,0 +1,98 @@
"""Tests for config module."""
import pytest
from src.config import (
__version__,
Settings,
get_settings,
_get_version_from_pyproject,
)
class TestVersion:
"""Test version loading from pyproject.toml."""
def test_version_is_loaded(self):
"""Version should be loaded from pyproject.toml."""
assert __version__ is not None
assert isinstance(__version__, str)
def test_version_format(self):
"""Version should follow semver format."""
parts = __version__.split(".")
assert len(parts) >= 2, "Version should have at least major.minor"
assert all(p.isdigit() for p in parts), "Version parts should be numeric"
def test_version_matches_settings(self):
"""Settings app_version should match module version."""
settings = get_settings()
assert settings.app_version == __version__
class TestGetVersionFromPyproject:
"""Test the version loading function."""
def test_returns_string(self):
"""Should return a string version."""
version = _get_version_from_pyproject()
assert isinstance(version, str)
def test_returns_valid_version(self):
"""Should return a valid version (not 0.0.0 if file exists)."""
version = _get_version_from_pyproject()
# Since pyproject.toml exists, version should not be fallback
assert version != "0.0.0"
class TestSettings:
"""Test Settings configuration class."""
def test_settings_has_app_name(self):
"""Settings should have app_name."""
settings = get_settings()
assert settings.app_name == "Core Code API"
def test_settings_has_version(self):
"""Settings should have app_version."""
settings = get_settings()
assert settings.app_version is not None
def test_settings_default_host(self):
"""Settings should have default host."""
settings = get_settings()
assert settings.host == "0.0.0.0"
def test_settings_default_port(self):
"""Settings should have default port."""
settings = get_settings()
assert settings.port == 8083
def test_no_kuma_settings(self):
"""Settings should not have Kuma-related attributes."""
settings = get_settings()
assert not hasattr(settings, "kuma_url")
assert not hasattr(settings, "kuma_username")
assert not hasattr(settings, "kuma_password")
assert not hasattr(settings, "kuma_api_key")
class TestGetSettings:
"""Test get_settings function."""
def test_returns_settings_instance(self):
"""Should return a Settings instance."""
settings = get_settings()
assert isinstance(settings, Settings)
def test_returns_cached_instance(self):
"""Should return the same cached instance."""
settings1 = get_settings()
settings2 = get_settings()
assert settings1 is settings2
def test_model_aliases_property(self):
"""Model aliases property should return dict."""
settings = get_settings()
aliases = settings.model_aliases
assert isinstance(aliases, dict)
assert "gpt-3.5-turbo" in aliases
assert "gpt-4" in aliases
+331
View File
@@ -0,0 +1,331 @@
"""Tests for DNS service."""
import pytest
from unittest.mock import patch, MagicMock
import dns.resolver
import dns.exception
from src.dns.service import DNSService
from src.dns.schemas import DNSLookupRequest, DNSRecord
from src.dns.exceptions import DNSQueryError
@pytest.fixture
def dns_service():
"""Create a DNSService instance."""
return DNSService()
class TestDNSServiceInit:
"""Test DNSService initialization."""
def test_service_has_resolver(self, dns_service):
"""Service should have resolver configured."""
assert dns_service.resolver is not None
def test_service_has_timeout(self, dns_service):
"""Service should have timeout configured."""
assert dns_service.resolver.timeout == 5.0
assert dns_service.resolver.lifetime == 10.0
def test_supported_record_types(self, dns_service):
"""Service should have supported record types."""
assert "A" in dns_service.SUPPORTED_RECORD_TYPES
assert "AAAA" in dns_service.SUPPORTED_RECORD_TYPES
assert "MX" in dns_service.SUPPORTED_RECORD_TYPES
assert "TXT" in dns_service.SUPPORTED_RECORD_TYPES
assert "CNAME" in dns_service.SUPPORTED_RECORD_TYPES
assert "NS" in dns_service.SUPPORTED_RECORD_TYPES
class TestDNSServiceLookup:
"""Test DNS lookup functionality."""
@pytest.mark.asyncio
async def test_lookup_validates_record_type(self, dns_service):
"""lookup should raise error for unsupported record type."""
request = DNSLookupRequest(domain="example.com", record_type="INVALID")
with pytest.raises(DNSQueryError) as exc_info:
await dns_service.lookup(request)
assert "Unsupported record type" in str(exc_info.value)
@pytest.mark.asyncio
async def test_lookup_returns_response_on_success(self, dns_service):
"""lookup should return DNSLookupResponse on success."""
request = DNSLookupRequest(domain="example.com", record_type="A")
# Mock the resolver
mock_answer = MagicMock()
mock_rdata = MagicMock()
mock_rdata.__str__ = MagicMock(return_value="93.184.216.34")
mock_answer.__iter__ = MagicMock(return_value=iter([mock_rdata]))
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.return_value = mock_answer
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is True
assert response.domain == "example.com"
assert response.record_type == "A"
assert len(response.records) > 0
@pytest.mark.asyncio
async def test_lookup_uses_custom_nameserver(self, dns_service):
"""lookup should use custom nameserver when specified."""
request = DNSLookupRequest(
domain="example.com",
record_type="A",
nameserver="1.1.1.1"
)
mock_answer = MagicMock()
mock_answer.__iter__ = MagicMock(return_value=iter([]))
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.return_value = mock_answer
mock_resolver.nameservers = []
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
# Verify nameserver was set
assert mock_resolver.nameservers == ["1.1.1.1"]
assert response.nameserver_used == "1.1.1.1"
@pytest.mark.asyncio
async def test_lookup_handles_nxdomain(self, dns_service):
"""lookup should handle NXDOMAIN (domain not found)."""
request = DNSLookupRequest(domain="nonexistent.invalid", record_type="A")
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.side_effect = dns.resolver.NXDOMAIN()
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is False
assert "Domain not found" in response.error_message
@pytest.mark.asyncio
async def test_lookup_handles_no_answer(self, dns_service):
"""lookup should handle NoAnswer (no records of type)."""
request = DNSLookupRequest(domain="example.com", record_type="AAAA")
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.side_effect = dns.resolver.NoAnswer()
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is False
assert "No AAAA records found" in response.error_message
@pytest.mark.asyncio
async def test_lookup_handles_timeout(self, dns_service):
"""lookup should handle DNS timeout."""
request = DNSLookupRequest(domain="example.com", record_type="A")
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.side_effect = dns.resolver.Timeout()
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is False
assert "timeout" in response.error_message.lower()
@pytest.mark.asyncio
async def test_lookup_handles_dns_exception(self, dns_service):
"""lookup should handle generic DNS exceptions."""
request = DNSLookupRequest(domain="example.com", record_type="A")
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.side_effect = dns.exception.DNSException("DNS error")
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is False
assert "DNS error" in response.error_message
@pytest.mark.asyncio
async def test_lookup_handles_unexpected_exception(self, dns_service):
"""lookup should handle unexpected exceptions."""
request = DNSLookupRequest(domain="example.com", record_type="A")
with patch("dns.resolver.Resolver") as mock_resolver_class:
mock_resolver = MagicMock()
mock_resolver.resolve.side_effect = Exception("Unexpected error")
mock_resolver.nameservers = ["8.8.8.8"]
mock_resolver_class.return_value = mock_resolver
response = await dns_service.lookup(request)
assert response.success is False
assert "Unexpected error" in response.error_message
class TestDNSServiceParseRecord:
"""Test record parsing."""
def test_parse_a_record(self, dns_service):
"""_parse_record should parse A record."""
mock_rdata = MagicMock()
mock_rdata.__str__ = MagicMock(return_value="192.168.1.1")
record = dns_service._parse_record(mock_rdata, "A")
assert record is not None
assert record.value == "192.168.1.1"
def test_parse_aaaa_record(self, dns_service):
"""_parse_record should parse AAAA record."""
mock_rdata = MagicMock()
mock_rdata.__str__ = MagicMock(return_value="2001:db8::1")
record = dns_service._parse_record(mock_rdata, "AAAA")
assert record is not None
assert record.value == "2001:db8::1"
def test_parse_mx_record(self, dns_service):
"""_parse_record should parse MX record with priority."""
mock_rdata = MagicMock()
mock_rdata.exchange = "mail.example.com"
mock_rdata.preference = 10
record = dns_service._parse_record(mock_rdata, "MX")
assert record is not None
assert "mail.example.com" in record.value
assert record.priority == 10
def test_parse_txt_record(self, dns_service):
"""_parse_record should parse TXT record."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"v=spf1 include:_spf.google.com ~all"]
record = dns_service._parse_record(mock_rdata, "TXT")
assert record is not None
assert "spf1" in record.value
def test_parse_cname_record(self, dns_service):
"""_parse_record should parse CNAME record."""
mock_rdata = MagicMock()
mock_rdata.target = "alias.example.com"
record = dns_service._parse_record(mock_rdata, "CNAME")
assert record is not None
assert "alias.example.com" in record.value
def test_parse_ns_record(self, dns_service):
"""_parse_record should parse NS record."""
mock_rdata = MagicMock()
mock_rdata.target = "ns1.example.com"
record = dns_service._parse_record(mock_rdata, "NS")
assert record is not None
assert "ns1.example.com" in record.value
def test_parse_soa_record(self, dns_service):
"""_parse_record should parse SOA record."""
mock_rdata = MagicMock()
mock_rdata.mname = "ns1.example.com"
mock_rdata.rname = "admin.example.com"
mock_rdata.serial = 2024010101
record = dns_service._parse_record(mock_rdata, "SOA")
assert record is not None
assert "ns1.example.com" in record.value
assert "2024010101" in record.value
def test_parse_srv_record(self, dns_service):
"""_parse_record should parse SRV record."""
mock_rdata = MagicMock()
mock_rdata.target = "server.example.com"
mock_rdata.port = 443
mock_rdata.priority = 10
mock_rdata.weight = 100
record = dns_service._parse_record(mock_rdata, "SRV")
assert record is not None
assert "server.example.com" in record.value
assert "port=443" in record.value
assert record.priority == 10
def test_parse_record_returns_none_on_error(self, dns_service):
"""_parse_record should return None on parsing error."""
mock_rdata = MagicMock()
mock_rdata.__str__ = MagicMock(side_effect=Exception("Parse error"))
record = dns_service._parse_record(mock_rdata, "A")
assert record is None
class TestDNSServiceErrorResponse:
"""Test error response generation."""
def test_error_response_includes_domain(self, dns_service):
"""_error_response should include domain."""
import time
request = DNSLookupRequest(domain="test.example.com", record_type="A")
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
assert response.domain == "test.example.com"
def test_error_response_includes_record_type(self, dns_service):
"""_error_response should include record type."""
import time
request = DNSLookupRequest(domain="test.example.com", record_type="mx")
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
assert response.record_type == "MX" # Should be uppercase
def test_error_response_has_empty_records(self, dns_service):
"""_error_response should have empty records list."""
import time
request = DNSLookupRequest(domain="test.example.com", record_type="A")
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
assert response.records == []
def test_error_response_has_success_false(self, dns_service):
"""_error_response should have success=False."""
import time
request = DNSLookupRequest(domain="test.example.com", record_type="A")
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Test error")
assert response.success is False
def test_error_response_includes_error_message(self, dns_service):
"""_error_response should include error message."""
import time
request = DNSLookupRequest(domain="test.example.com", record_type="A")
response = dns_service._error_response(request, "8.8.8.8", time.time(), "Specific error")
assert response.error_message == "Specific error"
+263
View File
@@ -0,0 +1,263 @@
"""Tests for health endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
class TestRootEndpoint:
"""Test root endpoint."""
def test_root_returns_200(self, client):
"""Root endpoint should return 200."""
response = client.get("/")
assert response.status_code == 200
def test_root_returns_service_info(self, client):
"""Root endpoint should return service information."""
response = client.get("/")
data = response.json()
assert "service" in data
assert "version" in data
assert "status" in data
assert data["service"] == "Core Code API"
assert data["status"] == "healthy"
def test_root_returns_documentation_links(self, client):
"""Root endpoint should return documentation links."""
response = client.get("/")
data = response.json()
assert "documentation" in data
assert "swagger_ui" in data["documentation"]
assert "redoc" in data["documentation"]
def test_root_returns_endpoints(self, client):
"""Root endpoint should return available endpoints."""
response = client.get("/")
data = response.json()
assert "endpoints" in data
assert "health" in data["endpoints"]
class TestHealthEndpoint:
"""Test /health endpoint."""
@patch("src.controllers.health_controller.get_ollama_client")
def test_health_returns_200_when_ollama_healthy(self, mock_get_ollama, client):
"""Health endpoint should return 200 when Ollama is healthy."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health")
assert response.status_code == 200
@patch("src.controllers.health_controller.get_ollama_client")
def test_health_returns_status(self, mock_get_ollama, client):
"""Health endpoint should return status information."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health")
data = response.json()
assert "status" in data
assert "version" in data
assert "ollama_connected" in data
@patch("src.controllers.health_controller.get_ollama_client")
def test_health_returns_ollama_connected_true(self, mock_get_ollama, client):
"""Health should report Ollama connected when healthy."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health")
data = response.json()
assert data["ollama_connected"] is True
@patch("src.controllers.health_controller.get_ollama_client")
def test_health_returns_ollama_connected_false(self, mock_get_ollama, client):
"""Health should report Ollama disconnected when unhealthy."""
mock_client = AsyncMock()
mock_client.health_check.return_value = False
mock_get_ollama.return_value = mock_client
response = client.get("/health")
data = response.json()
assert data["ollama_connected"] is False
class TestOpenAPIEndpoint:
"""Test OpenAPI documentation endpoints."""
def test_openapi_spec_available(self, client):
"""OpenAPI spec should be available."""
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert "openapi" in data
assert "info" in data
def test_swagger_ui_available(self, client):
"""Swagger UI should be available."""
response = client.get("/docs")
assert response.status_code == 200
def test_redoc_available(self, client):
"""ReDoc should be available."""
response = client.get("/redoc")
assert response.status_code == 200
class TestFullHealthCheck:
"""Test /health/full endpoint."""
@patch("src.controllers.health_controller.get_ollama_client")
def test_full_health_returns_503_when_unhealthy(self, mock_get_ollama, client):
"""Full health should return 503 when Ollama unhealthy."""
mock_client = AsyncMock()
mock_client.health_check.return_value = False
mock_get_ollama.return_value = mock_client
response = client.get("/health/full")
assert response.status_code == 503
@patch("src.controllers.health_controller.get_ollama_client")
def test_full_health_returns_components_status(self, mock_get_ollama, client):
"""Full health should return component status."""
mock_client = AsyncMock()
mock_client.health_check.return_value = False
mock_get_ollama.return_value = mock_client
response = client.get("/health/full")
data = response.json()
assert "status" in data
assert "components" in data
assert "ollama" in data["components"]
assert "response_time_ms" in data
@patch("src.controllers.health_controller.get_ollama_client")
def test_full_health_handles_list_models_error(self, mock_get_ollama, client):
"""Full health should handle list_models errors."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_client.list_models.side_effect = Exception("Connection error")
mock_get_ollama.return_value = mock_client
response = client.get("/health/full")
data = response.json()
# Should report error in component status
assert "ollama" in data["components"]
@patch("src.controllers.health_controller.get_ollama_client")
def test_full_health_handles_health_check_exception(self, mock_get_ollama, client):
"""Full health should handle health check exceptions gracefully."""
mock_client = AsyncMock()
# Return False instead of raising exception to test unhealthy path
mock_client.health_check.return_value = False
mock_get_ollama.return_value = mock_client
response = client.get("/health/full")
# Should return 503 for unhealthy
assert response.status_code == 503
data = response.json()
assert data["status"] == "unhealthy"
class TestDiagnosticsEndpoint:
"""Test /health/diagnostics endpoint."""
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_returns_200(self, mock_get_ollama, client):
"""Diagnostics should return 200."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
assert response.status_code == 200
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_returns_service_info(self, mock_get_ollama, client):
"""Diagnostics should return service information."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
data = response.json()
assert "service" in data
assert "name" in data["service"]
assert "version" in data["service"]
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_returns_components(self, mock_get_ollama, client):
"""Diagnostics should return component details."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
data = response.json()
assert "components" in data
assert "ollama" in data["components"]
assert "agent" in data["components"]
assert "qdrant" in data["components"]
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_returns_configuration(self, mock_get_ollama, client):
"""Diagnostics should return configuration info."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
data = response.json()
assert "configuration" in data
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_returns_response_time(self, mock_get_ollama, client):
"""Diagnostics should return response time."""
mock_client = AsyncMock()
mock_client.health_check.return_value = True
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
data = response.json()
assert "response_time_ms" in data
assert isinstance(data["response_time_ms"], int)
@patch("src.controllers.health_controller.get_ollama_client")
def test_diagnostics_handles_ollama_error(self, mock_get_ollama, client):
"""Diagnostics should handle Ollama connection errors."""
mock_client = AsyncMock()
mock_client.health_check.side_effect = Exception("Connection refused")
mock_get_ollama.return_value = mock_client
response = client.get("/health/diagnostics")
data = response.json()
# Should still return 200 with error info
assert response.status_code == 200
assert "error" in data["components"]["ollama"]
+471
View File
@@ -0,0 +1,471 @@
"""Tests for Home Assistant client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import httpx
from src.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
class TestHomeAssistantClientInit:
"""Test HomeAssistantClient initialization."""
@patch("src.clients.homeassistant_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.homeassistant_url = "http://ha.local:8123"
mock_settings.homeassistant_token = "test_token"
client = HomeAssistantClient()
assert client.base_url == "http://ha.local:8123"
assert client.token == "test_token"
def test_accepts_custom_url_and_token(self):
"""Client should accept custom URL and token."""
client = HomeAssistantClient(
base_url="http://custom:8123",
token="custom_token"
)
assert client.base_url == "http://custom:8123"
assert client.token == "custom_token"
def test_strips_trailing_slash_from_url(self):
"""Client should strip trailing slash from URL."""
client = HomeAssistantClient(
base_url="http://custom:8123/",
token="token"
)
assert client.base_url == "http://custom:8123"
@patch("src.clients.homeassistant_client.logger")
@patch("src.clients.homeassistant_client.settings")
def test_warns_when_token_missing(self, mock_settings, mock_logger):
"""Client should warn when token is not configured."""
mock_settings.homeassistant_url = "http://ha.local:8123"
mock_settings.homeassistant_token = ""
HomeAssistantClient()
mock_logger.warning.assert_called_once()
class TestHomeAssistantClientHeaders:
"""Test header generation."""
def test_get_headers_includes_bearer_token(self):
"""Headers should include Bearer token."""
client = HomeAssistantClient(
base_url="http://ha:8123",
token="my_token"
)
headers = client._get_headers()
assert headers["Authorization"] == "Bearer my_token"
assert headers["Content-Type"] == "application/json"
class TestHomeAssistantClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
async def test_health_check_returns_healthy(self):
"""Health check should return healthy when HA responds."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"version": "2024.12.0"}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "healthy"
assert result["connected"] is True
assert result["platform"] == "home_assistant"
assert result["version"] == "2024.12.0"
@pytest.mark.asyncio
async def test_health_check_returns_unhealthy_on_error(self):
"""Health check should return unhealthy on connection error."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection refused")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "unhealthy"
assert result["connected"] is False
assert "error" in result
@pytest.mark.asyncio
async def test_health_check_returns_unhealthy_on_non_200(self):
"""Health check should return unhealthy on non-200 status."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "unhealthy"
assert result["connected"] is False
class TestHomeAssistantClientStates:
"""Test state retrieval methods."""
@pytest.mark.asyncio
async def test_get_states_returns_list(self):
"""get_states should return list of states."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
states = [
{"entity_id": "light.test", "state": "on"},
{"entity_id": "switch.test", "state": "off"}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = states
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_states()
assert result == states
assert len(result) == 2
@pytest.mark.asyncio
async def test_get_state_returns_single_entity(self):
"""get_state should return single entity state."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
state = {"entity_id": "light.test", "state": "on", "attributes": {}}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = state
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_state("light.test")
assert result == state
@pytest.mark.asyncio
async def test_get_state_returns_none_for_404(self):
"""get_state should return None for non-existent entity."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 404
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_state("light.nonexistent")
assert result is None
class TestHomeAssistantClientServices:
"""Test service call methods."""
@pytest.mark.asyncio
async def test_call_service_posts_to_correct_endpoint(self):
"""call_service should POST to /api/services/{domain}/{service}."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.call_service("light", "turn_on", "light.test")
# Verify the correct URL was called
call_args = mock_client.post.call_args
assert "/api/services/light/turn_on" in call_args[0][0]
@pytest.mark.asyncio
async def test_turn_on_calls_correct_service(self):
"""turn_on should call the turn_on service with attributes."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.turn_on("light.test", brightness=128)
call_args = mock_client.post.call_args
assert "/api/services/light/turn_on" in call_args[0][0]
# Check that brightness was passed in the payload
payload = call_args[1]["json"]
assert payload["entity_id"] == "light.test"
assert payload["brightness"] == 128
@pytest.mark.asyncio
async def test_turn_off_calls_correct_service(self):
"""turn_off should call the turn_off service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.turn_off("switch.test")
call_args = mock_client.post.call_args
assert "/api/services/switch/turn_off" in call_args[0][0]
@pytest.mark.asyncio
async def test_toggle_calls_correct_service(self):
"""toggle should call the toggle service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.toggle("light.test")
call_args = mock_client.post.call_args
assert "/api/services/light/toggle" in call_args[0][0]
class TestHomeAssistantClientScenes:
"""Test scene methods."""
@pytest.mark.asyncio
async def test_activate_scene_calls_scene_turn_on(self):
"""activate_scene should call scene.turn_on service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.activate_scene("scene.movie_night")
call_args = mock_client.post.call_args
assert "/api/services/scene/turn_on" in call_args[0][0]
class TestHomeAssistantClientScripts:
"""Test script methods."""
@pytest.mark.asyncio
async def test_run_script_calls_script_turn_on(self):
"""run_script should call script.turn_on service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.run_script("script.bedtime", {"delay": 5})
call_args = mock_client.post.call_args
assert "/api/services/script/turn_on" in call_args[0][0]
class TestHomeAssistantClientAutomations:
"""Test automation methods."""
@pytest.mark.asyncio
async def test_enable_automation_calls_turn_on(self):
"""enable_automation should call automation.turn_on."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.enable_automation("automation.motion")
call_args = mock_client.post.call_args
assert "/api/services/automation/turn_on" in call_args[0][0]
@pytest.mark.asyncio
async def test_disable_automation_calls_turn_off(self):
"""disable_automation should call automation.turn_off."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.disable_automation("automation.motion")
call_args = mock_client.post.call_args
assert "/api/services/automation/turn_off" in call_args[0][0]
class TestHomeAssistantClientHistory:
"""Test history methods."""
@pytest.mark.asyncio
async def test_get_history_calls_correct_endpoint(self):
"""get_history should call /api/history/period endpoint."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [[]]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.get_history("light.test", hours=24)
call_args = mock_client.get.call_args
assert "/api/history/period/" in call_args[0][0]
assert call_args[1]["params"]["filter_entity_id"] == "light.test"
class TestHomeAssistantClientAreas:
"""Test areas method."""
@pytest.mark.asyncio
async def test_get_areas_uses_template_api(self):
"""get_areas should use the template API."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = '[{"id": "living_room", "name": "Living Room"}]'
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_areas()
call_args = mock_client.post.call_args
assert "/api/template" in call_args[0][0]
assert result == [{"id": "living_room", "name": "Living Room"}]
class TestGetHomeAssistantClientSingleton:
"""Test singleton pattern."""
def test_returns_same_instance(self):
"""get_homeassistant_client should return singleton."""
# Reset singleton
import src.clients.homeassistant_client as module
module._homeassistant_client = None
client1 = get_homeassistant_client()
client2 = get_homeassistant_client()
assert client1 is client2
+655
View File
@@ -0,0 +1,655 @@
"""Tests for housekeeping (home automation) endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
@pytest.fixture
def mock_ha_client():
"""Create a mock Home Assistant client."""
mock = AsyncMock()
return mock
@pytest.fixture
def sample_states():
"""Sample Home Assistant states for testing."""
return [
{
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"friendly_name": "Living Room Light",
"brightness": 255,
"area_id": "living_room"
},
"last_changed": "2025-01-01T12:00:00Z"
},
{
"entity_id": "light.bedroom",
"state": "off",
"attributes": {
"friendly_name": "Bedroom Light",
"area_id": "bedroom"
},
"last_changed": "2025-01-01T11:00:00Z"
},
{
"entity_id": "switch.garage",
"state": "off",
"attributes": {
"friendly_name": "Garage Switch"
},
"last_changed": "2025-01-01T10:00:00Z"
},
{
"entity_id": "scene.movie_night",
"state": "scening",
"attributes": {
"friendly_name": "Movie Night"
},
"last_changed": "2025-01-01T09:00:00Z"
},
{
"entity_id": "script.bedtime",
"state": "off",
"attributes": {
"friendly_name": "Bedtime Routine"
},
"last_changed": "2025-01-01T08:00:00Z"
},
{
"entity_id": "automation.motion_lights",
"state": "on",
"attributes": {
"friendly_name": "Motion Lights"
},
"last_changed": "2025-01-01T07:00:00Z"
},
{
"entity_id": "sensor.temperature",
"state": "22.5",
"attributes": {
"friendly_name": "Temperature",
"unit_of_measurement": "°C"
},
"last_changed": "2025-01-01T06:00:00Z"
}
]
class TestHousekeepingHealth:
"""Test /housekeeping/health endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_200(self, mock_get_ha, client):
"""Health endpoint should return 200."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": "2024.12.0"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_connection_status(self, mock_get_ha, client):
"""Health endpoint should return connection status."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": "2024.12.0"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
data = response.json()
assert "status" in data
assert "connected" in data
assert "platform" in data
assert data["platform"] == "home_assistant"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_unhealthy_when_disconnected(self, mock_get_ha, client):
"""Health should report unhealthy when HA is disconnected."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": "Connection refused"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
data = response.json()
assert data["connected"] is False
assert data["status"] == "unhealthy"
class TestHousekeepingDevices:
"""Test /housekeeping/devices endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_returns_200(self, mock_get_ha, client, sample_states):
"""List devices should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_returns_controllable_only(self, mock_get_ha, client, sample_states):
"""List devices should filter out non-controllable entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
data = response.json()
# Should include lights, switches, scenes, scripts, automations
# Should NOT include sensors
entity_ids = [d["entity_id"] for d in data["devices"]]
assert "light.living_room" in entity_ids
assert "switch.garage" in entity_ids
assert "sensor.temperature" not in entity_ids
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_filter_by_domain(self, mock_get_ha, client, sample_states):
"""List devices should filter by domain parameter."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices?domain=light")
data = response.json()
# Should only return lights
assert len(data["devices"]) == 2
for device in data["devices"]:
assert device["domain"] == "light"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_includes_attributes(self, mock_get_ha, client, sample_states):
"""List devices should include device attributes."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
data = response.json()
# Find the living room light
living_room = next(d for d in data["devices"] if d["entity_id"] == "light.living_room")
assert living_room["name"] == "Living Room Light"
assert living_room["state"] == "on"
assert "brightness" in living_room["attributes"]
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_get_device_returns_200(self, mock_get_ha, client):
"""Get device should return 200 for existing device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"friendly_name": "Living Room Light",
"brightness": 255
},
"last_changed": "2025-01-01T12:00:00Z"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices/light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_get_device_returns_404_for_missing(self, mock_get_ha, client):
"""Get device should return 404 for non-existent device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = None
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices/light.nonexistent")
assert response.status_code == 404
data = response.json()
assert data["detail"]["code"] == "DEVICE_NOT_FOUND"
class TestHousekeepingAreas:
"""Test /housekeeping/areas endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_areas_returns_200(self, mock_get_ha, client):
"""List areas should return 200."""
mock_client = AsyncMock()
mock_client.get_areas.return_value = [
{"id": "living_room", "name": "Living Room"},
{"id": "bedroom", "name": "Bedroom"}
]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/areas")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_areas_returns_area_data(self, mock_get_ha, client):
"""List areas should return area id and name."""
mock_client = AsyncMock()
mock_client.get_areas.return_value = [
{"id": "living_room", "name": "Living Room"},
{"id": "bedroom", "name": "Bedroom"}
]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/areas")
data = response.json()
assert "areas" in data
assert len(data["areas"]) == 2
assert data["areas"][0]["id"] == "living_room"
assert data["areas"][0]["name"] == "Living Room"
class TestHousekeepingScenes:
"""Test /housekeeping/scenes endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scenes_returns_200(self, mock_get_ha, client, sample_states):
"""List scenes should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scenes")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scenes_returns_only_scenes(self, mock_get_ha, client, sample_states):
"""List scenes should only return scene entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scenes")
data = response.json()
assert "scenes" in data
assert len(data["scenes"]) == 1
assert data["scenes"][0]["id"] == "scene.movie_night"
assert data["scenes"][0]["name"] == "Movie Night"
class TestHousekeepingScripts:
"""Test /housekeeping/scripts endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scripts_returns_200(self, mock_get_ha, client, sample_states):
"""List scripts should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scripts")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scripts_returns_only_scripts(self, mock_get_ha, client, sample_states):
"""List scripts should only return script entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scripts")
data = response.json()
assert "scripts" in data
assert len(data["scripts"]) == 1
assert data["scripts"][0]["id"] == "script.bedtime"
class TestHousekeepingAutomations:
"""Test /housekeeping/automations endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_automations_returns_200(self, mock_get_ha, client, sample_states):
"""List automations should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/automations")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_automations_includes_enabled_status(self, mock_get_ha, client, sample_states):
"""List automations should include enabled status."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/automations")
data = response.json()
assert "automations" in data
assert len(data["automations"]) == 1
assert data["automations"][0]["id"] == "automation.motion_lights"
assert data["automations"][0]["enabled"] is True
class TestHousekeepingHistory:
"""Test /housekeeping/history endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_returns_200(self, mock_get_ha, client):
"""History endpoint should return 200."""
mock_client = AsyncMock()
mock_client.get_history.return_value = [[
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {}},
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
]]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/history?entity_id=light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_returns_entries(self, mock_get_ha, client):
"""History endpoint should return history entries."""
mock_client = AsyncMock()
mock_client.get_history.return_value = [[
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {"brightness": 255}},
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
]]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/history?entity_id=light.living_room&hours=24")
data = response.json()
assert "entity_id" in data
assert "history" in data
assert data["entity_id"] == "light.living_room"
assert len(data["history"]) == 2
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_requires_entity_id(self, mock_get_ha, client):
"""History endpoint should require entity_id parameter."""
response = client.get("/housekeeping/history")
assert response.status_code == 422 # Validation error
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_validates_hours_range(self, mock_get_ha, client):
"""History endpoint should validate hours range (1-168)."""
mock_client = AsyncMock()
mock_get_ha.return_value = mock_client
# Too high
response = client.get("/housekeeping/history?entity_id=light.test&hours=200")
assert response.status_code == 422
# Too low
response = client.get("/housekeeping/history?entity_id=light.test&hours=0")
assert response.status_code == 422
class TestHousekeepingDeviceControl:
"""Test /housekeeping/devices/{entity_id}/control endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_turn_on(self, mock_get_ha, client):
"""Control should turn on device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {"brightness": 255}
}
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_turn_off(self, mock_get_ha, client):
"""Control should turn off device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "off",
"attributes": {}
}
mock_client.turn_off.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_off"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_toggle(self, mock_get_ha, client):
"""Control should toggle device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {}
}
mock_client.toggle.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "toggle"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_with_brightness(self, mock_get_ha, client):
"""Control should set brightness."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {"brightness": 128}
}
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on", "brightness": 128}
)
assert response.status_code == 200
mock_client.turn_on.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_returns_404_for_missing(self, mock_get_ha, client):
"""Control should return 404 for non-existent device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = None
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.nonexistent/control",
json={"action": "turn_on"}
)
assert response.status_code == 404
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_returns_error_response(self, mock_get_ha, client):
"""Control should return proper error response."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {}
}
mock_client.turn_on.side_effect = Exception("Service unavailable")
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on"}
)
assert response.status_code == 500
class TestHousekeepingSceneActivation:
"""Test /housekeeping/scenes/{scene_id}/activate endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_returns_200(self, mock_get_ha, client):
"""Activate scene should return 200."""
mock_client = AsyncMock()
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_returns_success_response(self, mock_get_ha, client):
"""Activate scene should return success response."""
mock_client = AsyncMock()
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
data = response.json()
assert data["success"] is True
assert data["scene_id"] == "scene.movie_night"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_handles_error(self, mock_get_ha, client):
"""Activate scene should handle errors."""
mock_client = AsyncMock()
mock_client.activate_scene.side_effect = Exception("Service error")
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
assert response.status_code == 500
class TestHousekeepingScriptRun:
"""Test /housekeeping/scripts/{script_id}/run endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_returns_200(self, mock_get_ha, client):
"""Run script should return 200."""
mock_client = AsyncMock()
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_returns_success_response(self, mock_get_ha, client):
"""Run script should return success response."""
mock_client = AsyncMock()
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
data = response.json()
assert data["success"] is True
assert data["script_id"] == "script.bedtime"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_handles_error(self, mock_get_ha, client):
"""Run script should handle errors."""
mock_client = AsyncMock()
mock_client.run_script.side_effect = Exception("Script error")
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
assert response.status_code == 500
class TestHousekeepingAutomationToggle:
"""Test /housekeeping/automations/{automation_id}/toggle endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_enable(self, mock_get_ha, client):
"""Toggle automation should enable when requested."""
mock_client = AsyncMock()
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_disable(self, mock_get_ha, client):
"""Toggle automation should disable when requested."""
mock_client = AsyncMock()
mock_client.disable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": False}
)
assert response.status_code == 200
mock_client.disable_automation.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_returns_new_state(self, mock_get_ha, client):
"""Toggle automation should return new enabled state."""
mock_client = AsyncMock()
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
data = response.json()
assert data["success"] is True
assert data["automation_id"] == "automation.motion_lights"
assert data["enabled"] is True
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_handles_error(self, mock_get_ha, client):
"""Toggle automation should handle errors."""
mock_client = AsyncMock()
mock_client.enable_automation.side_effect = Exception("Automation error")
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
assert response.status_code == 500
+404
View File
@@ -0,0 +1,404 @@
"""Tests for infrastructure endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock, MagicMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
@pytest.fixture
def mock_portainer():
"""Create a mock Portainer client."""
mock = AsyncMock()
return mock
@pytest.fixture
def mock_npm():
"""Create a mock NPM client."""
mock = AsyncMock()
return mock
class TestInfrastructureHealth:
"""Test /infrastructure/health endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_health_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Health endpoint should return 200."""
mock_portainer = AsyncMock()
mock_portainer.health_check.return_value = True
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.health_check.return_value = True
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/health")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_health_returns_connection_status(self, mock_get_npm, mock_get_portainer, client):
"""Health endpoint should return connection status for both services."""
mock_portainer = AsyncMock()
mock_portainer.health_check.return_value = True
mock_portainer.get_stacks.return_value = [{"Id": 1}, {"Id": 2}]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.health_check.return_value = True
mock_npm.get_proxy_hosts.return_value = [{"id": 1}]
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/health")
data = response.json()
assert "portainer_connected" in data
assert "npm_connected" in data
assert "total_stacks" in data
assert "total_proxy_hosts" in data
assert data["portainer_connected"] is True
assert data["npm_connected"] is True
assert data["total_stacks"] == 2
assert data["total_proxy_hosts"] == 1
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_health_handles_disconnected_services(self, mock_get_npm, mock_get_portainer, client):
"""Health should handle when services are disconnected."""
mock_portainer = AsyncMock()
mock_portainer.health_check.return_value = False
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.health_check.return_value = False
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/health")
data = response.json()
assert data["portainer_connected"] is False
assert data["npm_connected"] is False
assert data["total_stacks"] == 0
assert data["total_proxy_hosts"] == 0
class TestInfrastructureServices:
"""Test /infrastructure/services endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_services_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""List services should return 200."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/services")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_services_returns_stack_info(self, mock_get_npm, mock_get_portainer, client):
"""List services should return stack information."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "stack1", "Status": 1, "EndpointId": 1},
{"Id": 2, "Name": "stack2", "Status": 2, "EndpointId": 1}
]
mock_portainer.get_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/services")
data = response.json()
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["name"] == "stack1"
assert data[1]["name"] == "stack2"
class TestInfrastructurePorts:
"""Test /infrastructure/ports endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_ports_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""List ports should return 200."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.get_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/ports")
assert response.status_code == 200
class TestInfrastructureDomains:
"""Test /infrastructure/domains endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_domains_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""List domains should return 200."""
mock_portainer = AsyncMock()
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/domains")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_domains_returns_domain_info(self, mock_get_npm, mock_get_portainer, client):
"""List domains should return domain information."""
mock_portainer = AsyncMock()
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = [
{
"id": 1,
"domain_names": ["example.com", "www.example.com"],
"forward_host": "app",
"forward_port": 8080,
"ssl_certificate_id": 1
}
]
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/domains")
data = response.json()
assert isinstance(data, list)
# Each domain name should be a separate entry
assert len(data) >= 1
class TestInfrastructureContainers:
"""Test /infrastructure/containers endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_list_containers_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""List containers should return 200."""
mock_portainer = AsyncMock()
mock_portainer.list_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/containers")
assert response.status_code == 200
class TestInfrastructureWidgetData:
"""Test /infrastructure/widget-data endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_widget_data_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Widget data should return 200."""
mock_portainer = AsyncMock()
mock_portainer.health_check.return_value = True
mock_portainer.get_stacks.return_value = []
mock_portainer.list_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.health_check.return_value = True
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/widget-data")
assert response.status_code == 200
class TestInfrastructureServiceGroups:
"""Test /infrastructure/service-groups endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_service_groups_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Service groups should return 200."""
mock_portainer = AsyncMock()
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/service-groups")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_service_groups_returns_group_data(self, mock_get_npm, mock_get_portainer, client):
"""Service groups should return group data."""
mock_portainer = AsyncMock()
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/service-groups")
data = response.json()
# Should return a dict or list of service groups
assert isinstance(data, (dict, list))
class TestInfrastructureResources:
"""Test /infrastructure/resources endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_system_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""System resources should return 200."""
mock_portainer = AsyncMock()
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/resources/system")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_container_resources_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Container resources should return 200."""
mock_portainer = AsyncMock()
mock_portainer.list_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/resources/containers")
assert response.status_code == 200
class TestInfrastructureServiceStatus:
"""Test /infrastructure/services/{service}/status endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_service_status_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Service status should return 200."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
]
mock_portainer.get_containers.return_value = [
{"Names": ["/testservice_app_1"], "State": "running"}
]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/services/testservice/status")
assert response.status_code == 200
class TestInfrastructureContainerActions:
"""Test container action endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_container_logs_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Get container logs should return 200."""
mock_portainer = AsyncMock()
mock_portainer.list_containers.return_value = [
{"Names": ["/testcontainer"], "Id": "abc123"}
]
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/containers/testcontainer/logs")
# Response depends on container existence
assert response.status_code in [200, 404]
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_single_container_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Get single container should return 200."""
mock_portainer = AsyncMock()
mock_portainer.inspect_container.return_value = {
"Id": "abc123",
"Name": "/testcontainer",
"State": {"Status": "running"}
}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/containers/testcontainer")
assert response.status_code == 200
class TestInfrastructureGetService:
"""Test /infrastructure/services/{name} endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_service_returns_200(self, mock_get_npm, mock_get_portainer, client):
"""Get service should return 200."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "testservice", "Status": 1, "EndpointId": 1}
]
mock_portainer.get_containers.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_npm.get_proxy_hosts.return_value = []
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/services/testservice")
assert response.status_code == 200
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_service_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get service should return 404 for non-existent service."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/services/nonexistent")
assert response.status_code == 404
-269
View File
@@ -1,269 +0,0 @@
"""
Integration tests for Phase 2 Memory System
Tests the complete memory stack:
- Tier 1: ConversationBufferMemory
- Tier 2/3: QdrantConversationMemory
- Embedding Client
"""
import asyncio
import pytest
from datetime import datetime
from src.memory import (
ConversationBufferMemory,
QdrantConversationMemory,
ConversationTurn,
MessageRole,
TokenUsage,
get_buffer_memory,
get_qdrant_memory
)
from src.models.embeddings import get_embedding_client
class TestEmbeddingClient:
"""Test embedding generation"""
def test_embedding_client_init(self):
"""Test embedding client initialization"""
client = get_embedding_client()
assert client is not None
assert client.dimension == 384
print(f"✓ Embedding client initialized: {client.model_name}")
def test_single_embedding(self):
"""Test single text embedding"""
client = get_embedding_client()
text = "Hello, this is a test message for embedding generation"
embedding = client.embed_text(text)
assert isinstance(embedding, list)
assert len(embedding) == 384
assert all(isinstance(x, float) for x in embedding)
print(f"✓ Single embedding generated: {len(embedding)} dimensions")
def test_batch_embedding(self):
"""Test batch text embedding"""
client = get_embedding_client()
texts = [
"First message about Python programming",
"Second message about machine learning",
"Third message about data science"
]
embeddings = client.embed_batch(texts)
assert len(embeddings) == 3
assert all(len(emb) == 384 for emb in embeddings)
print(f"✓ Batch embeddings generated: {len(embeddings)} texts")
class TestQdrantMemory:
"""Test Qdrant memory storage and retrieval"""
@pytest.fixture
def qdrant_memory(self):
"""Get Qdrant memory instance"""
return get_qdrant_memory()
@pytest.fixture
def test_conversation_id(self):
"""Generate unique test conversation ID"""
return f"test_conv_{int(datetime.utcnow().timestamp())}"
@pytest.mark.asyncio
async def test_qdrant_connection(self, qdrant_memory):
"""Test Qdrant connection and collection"""
assert qdrant_memory.client is not None
assert qdrant_memory.collection_name == "core_api_conversations"
print(f"✓ Connected to Qdrant: {qdrant_memory.host}:{qdrant_memory.port}")
@pytest.mark.asyncio
async def test_add_turn(self, qdrant_memory, test_conversation_id):
"""Test adding a turn to Qdrant"""
turn = ConversationTurn(
role=MessageRole.USER,
content="What is Python?",
turn_number=1,
tokens=TokenUsage(prompt=10, completion=0, total=10)
)
await qdrant_memory.add_turn(test_conversation_id, turn)
# Verify it was stored
exists = await qdrant_memory.conversation_exists(test_conversation_id)
assert exists is True
print(f"✓ Turn stored in Qdrant: {test_conversation_id}")
@pytest.mark.asyncio
async def test_chronological_retrieval(self, qdrant_memory, test_conversation_id):
"""Test Tier 2 mode: chronological retrieval"""
# Add multiple turns
turns = [
ConversationTurn(role=MessageRole.USER, content="What is Python?", turn_number=1),
ConversationTurn(role=MessageRole.ASSISTANT, content="Python is a programming language", turn_number=2),
ConversationTurn(role=MessageRole.USER, content="How do I learn it?", turn_number=3),
]
for turn in turns:
await qdrant_memory.add_turn(test_conversation_id, turn)
# Retrieve turns chronologically
retrieved = await qdrant_memory.get_turns(test_conversation_id)
assert len(retrieved) == 3
assert retrieved[0].turn_number == 1
assert retrieved[1].turn_number == 2
assert retrieved[2].turn_number == 3
assert retrieved[0].content == "What is Python?"
print(f"✓ Chronological retrieval works: {len(retrieved)} turns")
@pytest.mark.asyncio
async def test_semantic_search(self, qdrant_memory, test_conversation_id):
"""Test Tier 3 mode: semantic search"""
# Add turns with distinct topics
turns = [
ConversationTurn(role=MessageRole.USER, content="I love machine learning and neural networks", turn_number=10),
ConversationTurn(role=MessageRole.USER, content="Pizza is my favorite food", turn_number=11),
ConversationTurn(role=MessageRole.USER, content="Deep learning models are fascinating", turn_number=12),
]
for turn in turns:
await qdrant_memory.add_turn(test_conversation_id, turn)
# Search for AI-related content
results = await qdrant_memory.similarity_search(
query="artificial intelligence and AI",
conversation_id=test_conversation_id,
limit=3
)
assert len(results) > 0
# Top results should be about ML/AI, not pizza
top_result = results[0]
assert "machine learning" in top_result["content"] or "Deep learning" in top_result["content"]
assert top_result["score"] > 0.5 # Reasonable similarity score
print(f"✓ Semantic search works: {len(results)} matches, top score: {results[0]['score']:.3f}")
@pytest.mark.asyncio
async def test_conversation_stats(self, qdrant_memory, test_conversation_id):
"""Test conversation statistics"""
stats = await qdrant_memory.get_conversation_stats(test_conversation_id)
assert stats["conversation_id"] == test_conversation_id
assert stats["total_turns"] >= 0
assert "total_tokens" in stats
print(f"✓ Stats retrieved: {stats['total_turns']} turns, {stats['total_tokens']} tokens")
@pytest.mark.asyncio
async def test_clear_conversation(self, qdrant_memory, test_conversation_id):
"""Test clearing a conversation"""
# Add a turn
turn = ConversationTurn(role=MessageRole.USER, content="Test message", turn_number=99)
await qdrant_memory.add_turn(test_conversation_id, turn)
# Clear it
await qdrant_memory.clear_conversation(test_conversation_id)
# Verify it's gone
exists = await qdrant_memory.conversation_exists(test_conversation_id)
assert exists is False
print(f"✓ Conversation cleared: {test_conversation_id}")
class TestIntegration:
"""Test full integration: Tier 1 + Qdrant + Embeddings"""
@pytest.mark.asyncio
async def test_full_memory_flow(self):
"""Test complete memory flow: Buffer → Qdrant"""
conversation_id = f"integration_test_{int(datetime.utcnow().timestamp())}"
# Initialize both tiers
buffer_memory = get_buffer_memory()
qdrant_memory = get_qdrant_memory()
# 1. Add turns to buffer (Tier 1)
turns = [
ConversationTurn(role=MessageRole.USER, content="Hello!", turn_number=1),
ConversationTurn(role=MessageRole.ASSISTANT, content="Hi there!", turn_number=2),
ConversationTurn(role=MessageRole.USER, content="How are you?", turn_number=3),
]
for turn in turns:
await buffer_memory.add_turn(conversation_id, turn)
# Verify buffer has them
buffer = await buffer_memory.get_buffer(conversation_id)
assert len(buffer.turns) == 3
print(f"✓ Tier 1 buffer: {len(buffer.turns)} turns")
# 2. Move to Qdrant (Tier 2/3)
for turn in buffer.turns:
await qdrant_memory.add_turn(conversation_id, turn)
# Verify Qdrant has them
qdrant_turns = await qdrant_memory.get_turns(conversation_id)
assert len(qdrant_turns) == 3
print(f"✓ Tier 2/3 Qdrant: {len(qdrant_turns)} turns")
# 3. Test semantic search across both
search_results = await qdrant_memory.similarity_search(
query="greeting",
conversation_id=conversation_id,
limit=2
)
assert len(search_results) > 0
print(f"✓ Semantic search: {len(search_results)} matches")
# Cleanup
await qdrant_memory.clear_conversation(conversation_id)
await buffer_memory.clear_conversation(conversation_id)
print(f"✓ Full memory flow complete!")
def run_tests():
"""Run all tests"""
print("\n" + "="*60)
print("Phase 2 Memory System Integration Tests")
print("="*60 + "\n")
# Test 1: Embedding Client
print("Test 1: Embedding Client")
print("-" * 40)
test_embed = TestEmbeddingClient()
test_embed.test_embedding_client_init()
test_embed.test_single_embedding()
test_embed.test_batch_embedding()
print()
# Test 2: Qdrant Memory
print("Test 2: Qdrant Memory Storage")
print("-" * 40)
test_qdrant = TestQdrantMemory()
qdrant_memory = get_qdrant_memory()
test_conv_id = f"test_conv_{int(datetime.utcnow().timestamp())}"
asyncio.run(test_qdrant.test_qdrant_connection(qdrant_memory))
asyncio.run(test_qdrant.test_add_turn(qdrant_memory, test_conv_id))
asyncio.run(test_qdrant.test_chronological_retrieval(qdrant_memory, test_conv_id))
asyncio.run(test_qdrant.test_semantic_search(qdrant_memory, test_conv_id))
asyncio.run(test_qdrant.test_conversation_stats(qdrant_memory, test_conv_id))
asyncio.run(test_qdrant.test_clear_conversation(qdrant_memory, test_conv_id))
print()
# Test 3: Full Integration
print("Test 3: Full Integration (Tier 1 + Tier 2/3)")
print("-" * 40)
test_integration = TestIntegration()
asyncio.run(test_integration.test_full_memory_flow())
print()
print("="*60)
print("✅ All Memory System Tests Passed!")
print("="*60)
if __name__ == "__main__":
run_tests()
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env python3
"""
Test MemoryManager orchestration
Verifies unified memory interface works correctly.
"""
import asyncio
import sys
from datetime import datetime
sys.path.insert(0, '/app')
from src.memory import MemoryManager, get_memory_manager, MessageRole, TokenUsage
async def test_memory_manager():
"""Test MemoryManager orchestration"""
print("\n" + "="*60)
print("MEMORY MANAGER TEST")
print("="*60)
test_conv_id = f"manager_test_{int(datetime.utcnow().timestamp())}"
try:
# Initialize manager
manager = get_memory_manager()
print(f"✓ MemoryManager initialized")
# Test 1: Add turns through manager
print("\n1. Adding turns via MemoryManager...")
turn1 = await manager.add_turn(
conversation_id=test_conv_id,
role=MessageRole.USER,
content="Hello, how are you?",
tokens=TokenUsage(prompt=5, completion=0, total=5)
)
assert turn1.turn_number == 1
print(f" ✓ Turn 1 added: {turn1.content[:30]}...")
turn2 = await manager.add_turn(
conversation_id=test_conv_id,
role=MessageRole.ASSISTANT,
content="I'm doing great! How can I help you today?",
tokens=TokenUsage(prompt=5, completion=10, total=15)
)
assert turn2.turn_number == 2
print(f" ✓ Turn 2 added: {turn2.content[:30]}...")
# Test 2: Get recent turns (from buffer)
print("\n2. Getting recent turns from buffer...")
recent = await manager.get_recent_turns(test_conv_id, limit=10)
assert len(recent) == 2
assert recent[0].turn_number == 1
assert recent[1].turn_number == 2
print(f" ✓ Retrieved {len(recent)} recent turns from buffer")
# Test 3: Add more turns to trigger consolidation (threshold = 10)
print("\n3. Adding turns to trigger auto-consolidation...")
for i in range(3, 11): # Add turns 3-10
await manager.add_turn(
conversation_id=test_conv_id,
role=MessageRole.USER if i % 2 == 1 else MessageRole.ASSISTANT,
content=f"Test message number {i}",
tokens=TokenUsage(prompt=5, completion=5, total=10)
)
print(f" ✓ Added 8 more turns (total: 10)")
# Check if consolidation happened (turn 10 should trigger it)
print("\n4. Verifying auto-consolidation...")
stats = await manager.get_conversation_stats(test_conv_id)
print(f" Buffer turns: {stats['buffer_turns']}")
print(f" Qdrant turns: {stats['qdrant_turns']}")
print(f" Exists in buffer: {stats['exists_in_buffer']}")
print(f" Exists in Qdrant: {stats['exists_in_qdrant']}")
if stats['qdrant_turns'] > 0:
print(f" ✓ Auto-consolidation triggered! {stats['qdrant_turns']} turns in Qdrant")
else:
print(f" ⚠ No auto-consolidation yet (threshold may not be reached)")
# Test 4: Manual consolidation
print("\n5. Testing manual consolidation...")
consolidated = await manager.consolidate(test_conv_id)
print(f" ✓ Manually consolidated {consolidated} turns")
# Test 5: Get full history (buffer + Qdrant)
print("\n6. Getting full conversation history...")
full_history = await manager.get_full_history(test_conv_id)
print(f" ✓ Retrieved {len(full_history)} total turns")
assert len(full_history) == 10, f"Expected 10 turns, got {len(full_history)}"
print(f" ✓ Full history verified (10 turns)")
# Test 6: Semantic search
print("\n7. Testing semantic search...")
search_results = await manager.search_conversations(
query="greeting hello",
conversation_id=test_conv_id,
limit=3
)
if len(search_results) > 0:
print(f" ✓ Semantic search found {len(search_results)} matches")
print(f" Top: '{search_results[0]['content'][:40]}...' (score: {search_results[0]['score']:.3f})")
else:
print(f" ⚠ No semantic search results (may need more data)")
# Test 7: Clear conversation
print("\n8. Clearing conversation...")
await manager.clear_conversation(test_conv_id)
stats_after = await manager.get_conversation_stats(test_conv_id)
assert stats_after['buffer_turns'] == 0
assert stats_after['qdrant_turns'] == 0
print(f" ✓ Conversation cleared from all tiers")
print("\n" + "="*60)
print("✅ MEMORY MANAGER TEST: PASSED")
print("="*60)
print("\nMemoryManager verified:")
print(" ✓ Add turns with auto turn numbering")
print(" ✓ Get recent turns from buffer")
print(" ✓ Auto-consolidation (when threshold reached)")
print(" ✓ Manual consolidation")
print(" ✓ Get full history (buffer + Qdrant)")
print(" ✓ Semantic search")
print(" ✓ Clear conversation")
print(" ✓ Conversation stats")
return True
except Exception as e:
print(f"\n❌ MEMORY MANAGER TEST: FAILED")
print(f"Error: {e}")
import traceback
traceback.print_exc()
# Cleanup on error
try:
await manager.clear_conversation(test_conv_id)
except:
pass
return False
def main():
"""Run the test"""
success = asyncio.run(test_memory_manager())
return 0 if success else 1
if __name__ == "__main__":
exit(main())
-330
View File
@@ -1,330 +0,0 @@
#!/usr/bin/env python3
"""
Simple integration tests for Phase 2 Memory System
No external dependencies beyond the memory system itself
"""
import asyncio
import sys
from datetime import datetime
# Add src to path
sys.path.insert(0, '/app')
from src.memory import (
ConversationBufferMemory,
QdrantConversationMemory,
ConversationTurn,
MessageRole,
TokenUsage,
get_buffer_memory,
get_qdrant_memory
)
from src.models.embeddings import get_embedding_client
def test_embedding_client():
"""Test 1: Embedding Client"""
print("\n" + "="*60)
print("Test 1: Embedding Client")
print("="*60)
try:
# Initialize
client = get_embedding_client()
assert client is not None
assert client.dimension == 384
print(f"✓ Embedding client initialized: {client.model_name}")
print(f"✓ Embedding dimension: {client.dimension}")
# Single embedding
text = "Hello, this is a test message for embedding generation"
embedding = client.embed_text(text)
assert isinstance(embedding, list)
assert len(embedding) == 384
assert all(isinstance(x, float) for x in embedding)
print(f"✓ Single embedding generated: {len(embedding)} dimensions")
print(f" Sample values: [{embedding[0]:.4f}, {embedding[1]:.4f}, {embedding[2]:.4f}, ...]")
# Batch embedding
texts = [
"First message about Python programming",
"Second message about machine learning",
"Third message about data science"
]
embeddings = client.embed_batch(texts)
assert len(embeddings) == 3
assert all(len(emb) == 384 for emb in embeddings)
print(f"✓ Batch embeddings generated: {len(embeddings)} texts")
print("\n✅ Embedding Client Tests: PASSED")
return True
except Exception as e:
print(f"\n❌ Embedding Client Tests: FAILED")
print(f"Error: {e}")
import traceback
traceback.print_exc()
return False
async def test_qdrant_memory():
"""Test 2: Qdrant Memory Storage"""
print("\n" + "="*60)
print("Test 2: Qdrant Memory Storage")
print("="*60)
test_conv_id = f"test_conv_{int(datetime.utcnow().timestamp())}"
try:
# Initialize
qdrant_memory = get_qdrant_memory()
assert qdrant_memory.client is not None
assert qdrant_memory.collection_name == "core_api_conversations"
print(f"✓ Connected to Qdrant: {qdrant_memory.host}:{qdrant_memory.port}")
print(f"✓ Collection: {qdrant_memory.collection_name}")
# Add single turn
turn1 = ConversationTurn(
role=MessageRole.USER,
content="What is Python?",
turn_number=1,
tokens=TokenUsage(prompt=10, completion=0, total=10)
)
await qdrant_memory.add_turn(test_conv_id, turn1)
print(f"✓ Turn 1 stored in Qdrant")
# Verify it exists
exists = await qdrant_memory.conversation_exists(test_conv_id)
assert exists is True
print(f"✓ Conversation exists: {test_conv_id}")
# Add more turns for chronological test
turn2 = ConversationTurn(
role=MessageRole.ASSISTANT,
content="Python is a high-level programming language known for simplicity and readability",
turn_number=2
)
turn3 = ConversationTurn(
role=MessageRole.USER,
content="How do I learn Python programming?",
turn_number=3
)
await qdrant_memory.add_turn(test_conv_id, turn2)
await qdrant_memory.add_turn(test_conv_id, turn3)
print(f"✓ Turns 2-3 stored in Qdrant")
# Test chronological retrieval (Tier 2 mode)
retrieved = await qdrant_memory.get_turns(test_conv_id)
assert len(retrieved) == 3
assert retrieved[0].turn_number == 1
assert retrieved[1].turn_number == 2
assert retrieved[2].turn_number == 3
assert retrieved[0].content == "What is Python?"
print(f"✓ Chronological retrieval works: {len(retrieved)} turns")
for i, turn in enumerate(retrieved, 1):
print(f" Turn {turn.turn_number}: {turn.role.value} - {turn.content[:50]}...")
# Add turns with distinct topics for semantic search
turn10 = ConversationTurn(
role=MessageRole.USER,
content="I love machine learning and neural networks and artificial intelligence",
turn_number=10
)
turn11 = ConversationTurn(
role=MessageRole.USER,
content="Pizza is my favorite food and I enjoy eating pasta",
turn_number=11
)
turn12 = ConversationTurn(
role=MessageRole.USER,
content="Deep learning models and transformers are fascinating AI technologies",
turn_number=12
)
await qdrant_memory.add_turn(test_conv_id, turn10)
await qdrant_memory.add_turn(test_conv_id, turn11)
await qdrant_memory.add_turn(test_conv_id, turn12)
print(f"✓ Added 3 more turns for semantic search test")
# Test semantic search (Tier 3 mode)
search_results = await qdrant_memory.similarity_search(
query="artificial intelligence and deep learning",
conversation_id=test_conv_id,
limit=3
)
assert len(search_results) > 0
print(f"✓ Semantic search works: {len(search_results)} matches")
# Top result should be about AI/ML, not food
top_result = search_results[0]
print(f" Top match (score: {top_result['score']:.3f}): {top_result['content'][:60]}...")
assert top_result["score"] > 0.5, "Semantic similarity score too low"
# Verify top matches are AI-related
ai_keywords = ["machine learning", "neural networks", "Deep learning", "AI", "artificial intelligence"]
top_content = search_results[0]["content"]
assert any(keyword in top_content for keyword in ai_keywords), "Top result not AI-related"
print(f"✓ Semantic relevance verified (AI-related content ranked higher)")
# Test conversation stats
stats = await qdrant_memory.get_conversation_stats(test_conv_id)
assert stats["conversation_id"] == test_conv_id
assert stats["total_turns"] == 6
print(f"✓ Stats retrieved: {stats['total_turns']} turns, {stats['total_tokens']} tokens")
# Cleanup
await qdrant_memory.clear_conversation(test_conv_id)
exists_after = await qdrant_memory.conversation_exists(test_conv_id)
assert exists_after is False
print(f"✓ Conversation cleared successfully")
print("\n✅ Qdrant Memory Tests: PASSED")
return True
except Exception as e:
print(f"\n❌ Qdrant Memory Tests: FAILED")
print(f"Error: {e}")
import traceback
traceback.print_exc()
# Cleanup on error
try:
await qdrant_memory.clear_conversation(test_conv_id)
except:
pass
return False
async def test_full_integration():
"""Test 3: Full Integration (Tier 1 + Tier 2/3)"""
print("\n" + "="*60)
print("Test 3: Full Integration (Tier 1 + Tier 2/3)")
print("="*60)
test_conv_id = f"integration_test_{int(datetime.utcnow().timestamp())}"
try:
# Initialize both tiers
buffer_memory = get_buffer_memory()
qdrant_memory = get_qdrant_memory()
print(f"✓ Initialized Tier 1 (Buffer) and Tier 2/3 (Qdrant)")
# 1. Add turns to buffer (Tier 1)
turns = [
ConversationTurn(role=MessageRole.USER, content="Hello!", turn_number=1),
ConversationTurn(role=MessageRole.ASSISTANT, content="Hi there! How can I help?", turn_number=2),
ConversationTurn(role=MessageRole.USER, content="How are you?", turn_number=3),
ConversationTurn(role=MessageRole.ASSISTANT, content="I'm doing great, thanks!", turn_number=4),
]
for turn in turns:
await buffer_memory.add_turn(test_conv_id, turn)
# Verify buffer has them
buffer = await buffer_memory.get_buffer(test_conv_id)
assert len(buffer.turns) == 4
print(f"✓ Tier 1 buffer: {len(buffer.turns)} turns stored")
# 2. Move to Qdrant (Tier 2/3) - simulating consolidation
for turn in buffer.turns:
await qdrant_memory.add_turn(test_conv_id, turn)
# Verify Qdrant has them
qdrant_turns = await qdrant_memory.get_turns(test_conv_id)
assert len(qdrant_turns) == 4
print(f"✓ Tier 2/3 Qdrant: {len(qdrant_turns)} turns stored")
# 3. Test semantic search across consolidated data
search_results = await qdrant_memory.similarity_search(
query="greeting hello",
conversation_id=test_conv_id,
limit=2
)
assert len(search_results) > 0
print(f"✓ Semantic search: {len(search_results)} matches found")
print(f" Best match: '{search_results[0]['content']}' (score: {search_results[0]['score']:.3f})")
# 4. Test data consistency
buffer_content = [t.content for t in buffer.turns]
qdrant_content = [t.content for t in qdrant_turns]
assert buffer_content == qdrant_content
print(f"✓ Data consistency verified (Buffer ↔ Qdrant)")
# Cleanup
await qdrant_memory.clear_conversation(test_conv_id)
await buffer_memory.clear_conversation(test_conv_id)
print(f"✓ Cleanup complete")
print("\n✅ Full Integration Tests: PASSED")
return True
except Exception as e:
print(f"\n❌ Full Integration Tests: FAILED")
print(f"Error: {e}")
import traceback
traceback.print_exc()
# Cleanup on error
try:
await qdrant_memory.clear_conversation(test_conv_id)
await buffer_memory.clear_conversation(test_conv_id)
except:
pass
return False
def main():
"""Run all tests"""
print("\n" + "="*60)
print("PHASE 2 MEMORY SYSTEM - INTEGRATION TESTS")
print("="*60)
print(f"Start time: {datetime.utcnow().isoformat()}")
results = []
# Test 1: Embedding Client
results.append(("Embedding Client", test_embedding_client()))
# Test 2: Qdrant Memory
results.append(("Qdrant Memory", asyncio.run(test_qdrant_memory())))
# Test 3: Full Integration
results.append(("Full Integration", asyncio.run(test_full_integration())))
# Summary
print("\n" + "="*60)
print("TEST SUMMARY")
print("="*60)
for test_name, passed in results:
status = "✅ PASSED" if passed else "❌ FAILED"
print(f"{test_name:.<40} {status}")
total = len(results)
passed = sum(1 for _, p in results if p)
failed = total - passed
print(f"\nTotal: {total} | Passed: {passed} | Failed: {failed}")
print(f"Success rate: {(passed/total)*100:.1f}%")
if all(p for _, p in results):
print("\n" + "="*60)
print("🎉 ALL TESTS PASSED!")
print("="*60)
print("\nPhase 2 Memory System Status: ✅ FUNCTIONAL")
print("- Embedding client working (384d vectors)")
print("- Qdrant storage working (chronological + semantic)")
print("- Full integration working (Tier 1 ↔ Tier 2/3)")
return 0
else:
print("\n" + "="*60)
print("❌ SOME TESTS FAILED")
print("="*60)
return 1
if __name__ == "__main__":
exit(main())
+398
View File
@@ -0,0 +1,398 @@
"""Tests for NPM client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from datetime import datetime, timedelta
from src.clients.npm_client import NPMClient, get_npm_client
class TestNPMClientInit:
"""Test NPMClient initialization."""
@patch("src.clients.npm_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.npm_url = "http://npm:81"
mock_settings.npm_email = "admin@example.com"
mock_settings.npm_password = "password123"
client = NPMClient()
assert client.base_url == "http://npm:81"
assert client.email == "admin@example.com"
assert client.password == "password123"
def test_accepts_custom_credentials(self):
"""Client should accept custom credentials."""
client = NPMClient(
base_url="http://custom:81",
email="custom@example.com",
password="custom_pass"
)
assert client.base_url == "http://custom:81"
assert client.email == "custom@example.com"
assert client.password == "custom_pass"
def test_strips_trailing_slash_from_url(self):
"""Client should strip trailing slash from URL."""
client = NPMClient(
base_url="http://npm:81/",
email="test@test.com",
password="pass"
)
assert client.base_url == "http://npm:81"
@patch("src.clients.npm_client.logger")
@patch("src.clients.npm_client.settings")
def test_warns_when_credentials_missing(self, mock_settings, mock_logger):
"""Client should warn when credentials are not configured."""
mock_settings.npm_url = "http://npm:81"
mock_settings.npm_email = ""
mock_settings.npm_password = ""
NPMClient()
mock_logger.warning.assert_called_once()
def test_initializes_token_as_none(self):
"""Client should initialize token as None."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
assert client._token is None
assert client._token_expires is None
class TestNPMClientHeaders:
"""Test header generation."""
def test_get_headers_raises_without_token(self):
"""Headers should raise if no token available."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
with pytest.raises(RuntimeError, match="No NPM token available"):
client._get_headers()
def test_get_headers_includes_bearer_token(self):
"""Headers should include Bearer token when available."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
client._token = "test_token_123"
headers = client._get_headers()
assert headers["Authorization"] == "Bearer test_token_123"
assert headers["Content-Type"] == "application/json"
class TestNPMClientToken:
"""Test token management."""
@pytest.mark.asyncio
async def test_refresh_token_stores_token(self):
"""_refresh_token should store token from response."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"token": "new_token_abc"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client._refresh_token()
assert client._token == "new_token_abc"
assert client._token_expires is not None
@pytest.mark.asyncio
async def test_ensure_token_refreshes_when_none(self):
"""_ensure_token should refresh when no token."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
with patch.object(client, "_refresh_token", new_callable=AsyncMock) as mock_refresh:
await client._ensure_token()
mock_refresh.assert_called_once()
@pytest.mark.asyncio
async def test_ensure_token_skips_refresh_when_valid(self):
"""_ensure_token should skip refresh when token is valid."""
client = NPMClient(
base_url="http://npm:81",
email="test@test.com",
password="pass"
)
client._token = "valid_token"
client._token_expires = datetime.now() + timedelta(hours=12)
with patch.object(client, "_refresh_token", new_callable=AsyncMock) as mock_refresh:
await client._ensure_token()
mock_refresh.assert_not_called()
class TestNPMClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
async def test_health_check_returns_true_on_200(self):
"""Health check should return True when NPM responds 200."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_returns_true_on_redirect(self):
"""Health check should return True on redirect (3xx)."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 302
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_returns_false_on_error(self):
"""Health check should return False on connection error."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection refused")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is False
class TestNPMClientProxyHosts:
"""Test proxy host operations."""
@pytest.mark.asyncio
async def test_get_proxy_hosts_returns_list(self):
"""get_proxy_hosts should return list of proxy hosts."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
proxy_hosts = [
{"id": 1, "domain_names": ["example.com"]},
{"id": 2, "domain_names": ["test.com"]}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = proxy_hosts
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_proxy_hosts()
assert result == proxy_hosts
assert len(result) == 2
@pytest.mark.asyncio
async def test_get_proxy_host_returns_single_host(self):
"""get_proxy_host should return single proxy host."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
proxy_host = {"id": 1, "domain_names": ["example.com"], "forward_host": "app"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = proxy_host
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_proxy_host(1)
assert result == proxy_host
@pytest.mark.asyncio
async def test_create_proxy_host_posts_correct_data(self):
"""create_proxy_host should POST with correct payload."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "domain_names": ["new.com"]}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.create_proxy_host(
domain_names=["new.com"],
forward_host="backend",
forward_port=8080
)
call_args = mock_client.post.call_args
assert call_args[1]["json"]["domain_names"] == ["new.com"]
assert call_args[1]["json"]["forward_host"] == "backend"
assert call_args[1]["json"]["forward_port"] == 8080
@pytest.mark.asyncio
async def test_update_proxy_host_puts_correct_data(self):
"""update_proxy_host should PUT with correct payload."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
config = {"domain_names": ["updated.com"], "forward_host": "new-backend"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = config
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.update_proxy_host(1, config)
assert result == config
class TestNPMClientCertificates:
"""Test certificate operations."""
@pytest.mark.asyncio
async def test_get_certificates_returns_list(self):
"""get_certificates should return list of certificates."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
certificates = [
{"id": 1, "domain_names": ["example.com"]},
{"id": 2, "domain_names": ["test.com"]}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = certificates
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_certificates()
assert result == certificates
@pytest.mark.asyncio
async def test_create_certificate_posts_correct_data(self):
"""create_certificate should POST with correct payload."""
client = NPMClient(base_url="http://npm:81", email="test@test.com", password="pass")
client._token = "test_token"
client._token_expires = datetime.now() + timedelta(hours=12)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "domain_names": ["secure.com"]}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.create_certificate(domain_names=["secure.com"])
call_args = mock_client.post.call_args
assert call_args[1]["json"]["domain_names"] == ["secure.com"]
assert call_args[1]["json"]["provider"] == "letsencrypt"
class TestNPMClientSingleton:
"""Test singleton pattern."""
def test_returns_same_instance(self):
"""get_npm_client should return singleton."""
import src.clients.npm_client as module
module._npm_client = None
client1 = get_npm_client()
client2 = get_npm_client()
assert client1 is client2
+167
View File
@@ -0,0 +1,167 @@
"""Tests for OIDC authentication module."""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi import HTTPException
from src.auth.oidc import OIDCConfig, oidc_config, get_jwks, get_current_user
class TestOIDCConfig:
"""Test OIDCConfig class."""
def test_init_defaults(self):
"""Config should initialize with disabled state."""
config = OIDCConfig()
assert config.enabled is False
assert config.issuer == ""
assert config.audience == ""
assert config.jwks_uri == ""
def test_configure_sets_values(self):
"""configure should set all values."""
config = OIDCConfig()
config.configure(
enabled=True,
issuer="https://auth.example.com",
audience="core-api"
)
assert config.enabled is True
assert config.issuer == "https://auth.example.com"
assert config.audience == "core-api"
assert config.jwks_uri == "https://auth.example.com/jwks/"
def test_configure_strips_trailing_slash(self):
"""configure should handle trailing slash in issuer."""
config = OIDCConfig()
config.configure(
enabled=True,
issuer="https://auth.example.com/",
audience="core-api"
)
assert config.jwks_uri == "https://auth.example.com/jwks/"
class TestGetJWKS:
"""Test get_jwks function."""
def test_returns_empty_when_disabled(self):
"""get_jwks should return empty dict when OIDC disabled."""
# Save original state
original_enabled = oidc_config.enabled
try:
oidc_config.enabled = False
# Clear the cache
get_jwks.cache_clear()
result = get_jwks()
assert result == {}
finally:
# Restore original state
oidc_config.enabled = original_enabled
get_jwks.cache_clear()
@patch("src.auth.oidc.httpx.get")
def test_fetches_jwks_when_enabled(self, mock_get):
"""get_jwks should fetch JWKS when enabled."""
# Save original state
original_enabled = oidc_config.enabled
original_jwks_uri = oidc_config.jwks_uri
try:
oidc_config.enabled = True
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
get_jwks.cache_clear()
mock_response = MagicMock()
mock_response.json.return_value = {"keys": [{"kid": "test"}]}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = get_jwks()
assert "keys" in result
mock_get.assert_called_once()
finally:
oidc_config.enabled = original_enabled
oidc_config.jwks_uri = original_jwks_uri
get_jwks.cache_clear()
@patch("src.auth.oidc.httpx.get")
def test_raises_exception_on_error(self, mock_get):
"""get_jwks should raise HTTPException on fetch error."""
# Save original state
original_enabled = oidc_config.enabled
original_jwks_uri = oidc_config.jwks_uri
try:
oidc_config.enabled = True
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
get_jwks.cache_clear()
mock_get.side_effect = Exception("Connection error")
with pytest.raises(HTTPException) as exc_info:
get_jwks()
assert exc_info.value.status_code == 503
finally:
oidc_config.enabled = original_enabled
oidc_config.jwks_uri = original_jwks_uri
get_jwks.cache_clear()
class TestGetCurrentUser:
"""Test get_current_user function."""
@pytest.mark.asyncio
async def test_returns_none_when_disabled(self):
"""get_current_user should return None when OIDC disabled."""
# Save original state
original_enabled = oidc_config.enabled
try:
oidc_config.enabled = False
result = await get_current_user(credentials=None)
assert result is None
finally:
oidc_config.enabled = original_enabled
@pytest.mark.asyncio
async def test_raises_401_when_enabled_without_token(self):
"""get_current_user should raise 401 when enabled but no token."""
# Save original state
original_enabled = oidc_config.enabled
try:
oidc_config.enabled = True
with pytest.raises(HTTPException) as exc_info:
await get_current_user(credentials=None)
assert exc_info.value.status_code == 401
finally:
oidc_config.enabled = original_enabled
class TestOIDCGlobalConfig:
"""Test global OIDC config."""
def test_global_config_exists(self):
"""oidc_config should be an OIDCConfig instance."""
assert isinstance(oidc_config, OIDCConfig)
def test_global_config_starts_disabled(self):
"""oidc_config should start disabled by default."""
# This tests the initial state before any configure() is called
# The actual state depends on app configuration
assert hasattr(oidc_config, 'enabled')
assert hasattr(oidc_config, 'issuer')
assert hasattr(oidc_config, 'audience')
assert hasattr(oidc_config, 'jwks_uri')
+307
View File
@@ -0,0 +1,307 @@
"""Tests for Ollama client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import json
from src.models.ollama_client import OllamaClient, get_ollama_client, close_ollama_client
class TestOllamaClientInit:
"""Test OllamaClient initialization."""
@patch("src.models.ollama_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 60
client = OllamaClient()
assert client.base_url == "http://ollama:11434"
assert client.timeout == 60
@patch("src.models.ollama_client.settings")
def test_creates_http_client(self, mock_settings):
"""Client should create httpx AsyncClient."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
assert client.client is not None
class TestOllamaClientClose:
"""Test client close functionality."""
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_close_closes_client(self, mock_settings):
"""close should close the HTTP client."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
await client.close()
mock_close.assert_called_once()
class TestOllamaClientResolveModel:
"""Test model resolution."""
@patch("src.models.ollama_client.settings")
def test_resolves_aliased_model(self, mock_settings):
"""resolve_model should map alias to actual model."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
mock_settings.model_aliases = {"gpt-3.5-turbo": "gemma:7b"}
client = OllamaClient()
result = client.resolve_model("gpt-3.5-turbo")
assert result == "gemma:7b"
@patch("src.models.ollama_client.settings")
def test_returns_original_if_no_alias(self, mock_settings):
"""resolve_model should return original if no alias found."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
mock_settings.model_aliases = {}
client = OllamaClient()
result = client.resolve_model("llama2")
assert result == "llama2"
class TestOllamaClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_health_check_returns_true_on_200(self, mock_settings):
"""Health check should return True when Ollama responds 200."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
mock_response = MagicMock()
mock_response.status_code = 200
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.health_check()
assert result is True
mock_get.assert_called_once()
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_health_check_returns_false_on_error(self, mock_settings):
"""Health check should return False on connection error."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = Exception("Connection refused")
result = await client.health_check()
assert result is False
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_health_check_returns_false_on_non_200(self, mock_settings):
"""Health check should return False on non-200 status."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
mock_response = MagicMock()
mock_response.status_code = 500
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.health_check()
assert result is False
class TestOllamaClientListModels:
"""Test list models functionality."""
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_list_models_returns_dict(self, mock_settings):
"""list_models should return dict with models."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
models_data = {
"models": [
{"name": "llama2", "size": 1000000},
{"name": "gemma:7b", "size": 2000000}
]
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = models_data
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await client.list_models()
assert result == models_data
assert len(result["models"]) == 2
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_list_models_raises_on_error(self, mock_settings):
"""list_models should raise on error."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
client = OllamaClient()
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = Exception("Connection error")
with pytest.raises(Exception):
await client.list_models()
class TestOllamaClientGenerateNonStreaming:
"""Test non-streaming generation."""
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_generate_non_streaming_returns_response(self, mock_settings):
"""generate_non_streaming should return response dict."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
mock_settings.model_aliases = {}
client = OllamaClient()
response_data = {
"message": {"content": "Hello! How can I help?"},
"prompt_eval_count": 10,
"eval_count": 20
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = response_data
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_response
result = await client.generate_non_streaming("llama2", "Hello")
assert result["response"] == "Hello! How can I help?"
assert result["tokens"]["prompt"] == 10
assert result["tokens"]["completion"] == 20
assert result["tokens"]["total"] == 30
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_generate_non_streaming_includes_max_tokens(self, mock_settings):
"""generate_non_streaming should include max_tokens in payload."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
mock_settings.model_aliases = {}
client = OllamaClient()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": {"content": "Hi"}}
mock_response.raise_for_status = MagicMock()
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_response
await client.generate_non_streaming("llama2", "Hello", max_tokens=100)
call_args = mock_post.call_args
assert call_args[1]["json"]["options"]["num_predict"] == 100
class TestOllamaClientGenerateStreaming:
"""Test streaming generation."""
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_generate_streaming_yields_content(self, mock_settings):
"""generate_streaming should yield content chunks."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
mock_settings.model_aliases = {}
client = OllamaClient()
# Create mock streaming response
async def mock_aiter_lines():
yield json.dumps({"message": {"content": "Hello"}})
yield json.dumps({"message": {"content": " world"}})
yield json.dumps({"done": True})
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.aiter_lines = mock_aiter_lines
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response
mock_stream_context.__aexit__.return_value = None
with patch.object(client.client, "stream", return_value=mock_stream_context):
chunks = []
async for chunk in client.generate_streaming("llama2", "Hi"):
chunks.append(chunk)
assert "Hello" in chunks
assert " world" in chunks
class TestOllamaClientSingleton:
"""Test singleton pattern."""
@patch("src.models.ollama_client.settings")
def test_get_ollama_client_returns_same_instance(self, mock_settings):
"""get_ollama_client should return singleton."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
import src.models.ollama_client as module
module._ollama_client = None
client1 = get_ollama_client()
client2 = get_ollama_client()
assert client1 is client2
@pytest.mark.asyncio
@patch("src.models.ollama_client.settings")
async def test_close_ollama_client_clears_singleton(self, mock_settings):
"""close_ollama_client should clear the singleton."""
mock_settings.ollama_base_url = "http://ollama:11434"
mock_settings.ollama_timeout = 30
import src.models.ollama_client as module
module._ollama_client = None
client = get_ollama_client()
with patch.object(client.client, "aclose", new_callable=AsyncMock):
await close_ollama_client()
assert module._ollama_client is None
+576
View File
@@ -0,0 +1,576 @@
"""Tests for Portainer client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import httpx
from src.clients.portainer_client import PortainerClient, get_portainer_client
class TestPortainerClientInit:
"""Test PortainerClient initialization."""
@patch("src.clients.portainer_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.portainer_url = "http://portainer:9000"
mock_settings.portainer_api_key = "test_key"
client = PortainerClient()
assert client.base_url == "http://portainer:9000"
assert client.api_key == "test_key"
def test_accepts_custom_url_and_key(self):
"""Client should accept custom URL and API key."""
client = PortainerClient(
base_url="http://custom:9000",
api_key="custom_key"
)
assert client.base_url == "http://custom:9000"
assert client.api_key == "custom_key"
def test_strips_trailing_slash_from_url(self):
"""Client should strip trailing slash from URL."""
client = PortainerClient(
base_url="http://custom:9000/",
api_key="key"
)
assert client.base_url == "http://custom:9000"
@patch("src.clients.portainer_client.logger")
@patch("src.clients.portainer_client.settings")
def test_warns_when_api_key_missing(self, mock_settings, mock_logger):
"""Client should warn when API key is not configured."""
mock_settings.portainer_url = "http://portainer:9000"
mock_settings.portainer_api_key = ""
PortainerClient()
mock_logger.warning.assert_called_once()
class TestPortainerClientHeaders:
"""Test header generation."""
def test_get_headers_includes_api_key(self):
"""Headers should include X-API-Key."""
client = PortainerClient(
base_url="http://portainer:9000",
api_key="my_api_key"
)
headers = client._get_headers()
assert headers["X-API-Key"] == "my_api_key"
assert headers["Content-Type"] == "application/json"
class TestPortainerClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
async def test_health_check_returns_true_on_200(self):
"""Health check should return True when Portainer responds 200."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_returns_false_on_error(self):
"""Health check should return False on connection error."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection refused")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is False
@pytest.mark.asyncio
async def test_health_check_returns_false_on_non_200(self):
"""Health check should return False on non-200 status."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result is False
class TestPortainerClientEndpoints:
"""Test endpoint retrieval."""
@pytest.mark.asyncio
async def test_get_endpoints_returns_list(self):
"""get_endpoints should return list of endpoints."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
endpoints = [
{"Id": 1, "Name": "local"},
{"Id": 2, "Name": "remote"}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = endpoints
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_endpoints()
assert result == endpoints
assert len(result) == 2
class TestPortainerClientStacks:
"""Test stack operations."""
@pytest.mark.asyncio
async def test_get_stacks_returns_list(self):
"""get_stacks should return list of stacks."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
stacks = [
{"Id": 1, "Name": "stack1", "Status": 1},
{"Id": 2, "Name": "stack2", "Status": 1}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = stacks
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stacks()
assert result == stacks
@pytest.mark.asyncio
async def test_get_stacks_with_endpoint_filter(self):
"""get_stacks should filter by endpoint_id when provided."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.get_stacks(endpoint_id=3)
# Verify params include endpoint_id
call_args = mock_client.get.call_args
assert call_args[1]["params"]["endpointId"] == 3
@pytest.mark.asyncio
async def test_get_stack_returns_single_stack(self):
"""get_stack should return a single stack."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
stack = {"Id": 1, "Name": "mystack", "Status": 1}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = stack
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stack(1)
assert result == stack
@pytest.mark.asyncio
async def test_create_stack_posts_correct_data(self):
"""create_stack should POST with correct payload."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1, "Name": "newstack"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.create_stack("newstack", "version: '3'\nservices:", 1)
call_args = mock_client.post.call_args
assert call_args[1]["json"]["name"] == "newstack"
assert "stackFileContent" in call_args[1]["json"]
@pytest.mark.asyncio
async def test_update_stack_puts_correct_data(self):
"""update_stack should PUT with correct payload."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1, "Name": "stack"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.update_stack(1, "version: '3'", 1, prune=True, pull_image=True)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["prune"] is True
assert call_args[1]["json"]["pullImage"] is True
@pytest.mark.asyncio
async def test_delete_stack_returns_true(self):
"""delete_stack should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.delete_stack(1, 1)
assert result is True
class TestPortainerClientContainers:
"""Test container operations."""
@pytest.mark.asyncio
async def test_get_containers_returns_list(self):
"""get_containers should return list of containers."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
containers = [
{"Id": "abc123", "Names": ["/container1"], "State": "running"},
{"Id": "def456", "Names": ["/container2"], "State": "exited"}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = containers
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_containers(1)
assert result == containers
@pytest.mark.asyncio
async def test_get_container_returns_details(self):
"""get_container should return container details."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
container = {"Id": "abc123", "Name": "/container1", "State": {"Status": "running"}}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = container
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_container(1, "abc123")
assert result == container
@pytest.mark.asyncio
async def test_stop_container_returns_true(self):
"""stop_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.stop_container(1, "abc123")
assert result is True
@pytest.mark.asyncio
async def test_start_container_returns_true(self):
"""start_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.start_container(1, "abc123")
assert result is True
class TestPortainerClientDockerSocketFallback:
"""Test Docker socket fallback methods."""
@pytest.mark.asyncio
async def test_list_containers_via_socket_returns_empty_on_error(self):
"""_list_containers_via_socket should return empty list on error."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncHTTPTransport") as mock_transport:
mock_transport.side_effect = Exception("Socket not available")
result = await client._list_containers_via_socket()
assert result == []
@pytest.mark.asyncio
async def test_inspect_container_via_socket_returns_none_on_error(self):
"""_inspect_container_via_socket should return None on error."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncHTTPTransport") as mock_transport:
mock_transport.side_effect = Exception("Socket not available")
result = await client._inspect_container_via_socket("container_name")
assert result is None
class TestPortainerClientWrapperMethods:
"""Test convenience wrapper methods."""
@pytest.mark.asyncio
async def test_list_containers_uses_portainer_first(self):
"""list_containers should try Portainer first."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
containers = [{"Id": "abc123", "Names": ["/test"]}]
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}]
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers:
mock_containers.return_value = containers
result = await client.list_containers()
assert result == containers
mock_endpoints.assert_called_once()
mock_containers.assert_called_once()
@pytest.mark.asyncio
async def test_list_containers_falls_back_to_socket(self):
"""list_containers should fallback to Docker socket if Portainer returns empty."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}]
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers:
mock_containers.return_value = []
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = [{"Id": "from_socket"}]
result = await client.list_containers()
assert result == [{"Id": "from_socket"}]
mock_socket.assert_called_once()
@pytest.mark.asyncio
async def test_list_containers_handles_exception_with_fallback(self):
"""list_containers should try fallback even on exception."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = [{"Id": "fallback"}]
result = await client.list_containers()
assert result == [{"Id": "fallback"}]
@pytest.mark.asyncio
async def test_list_containers_returns_empty_when_all_fails(self):
"""list_containers should return empty list when everything fails."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.side_effect = Exception("Socket error")
result = await client.list_containers()
assert result == []
@pytest.mark.asyncio
async def test_inspect_container_uses_portainer_first(self):
"""inspect_container should try Portainer first."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}]
container_detail = {"Id": "abc123", "Name": "/mycontainer", "State": {"Status": "running"}}
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}]
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list:
mock_list.return_value = container_list
with patch.object(client, "get_container", new_callable=AsyncMock) as mock_detail:
mock_detail.return_value = container_detail
result = await client.inspect_container("mycontainer")
assert result == container_detail
@pytest.mark.asyncio
async def test_inspect_container_falls_back_to_socket(self):
"""inspect_container should fallback if not found in Portainer."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.return_value = [{"Id": 1}]
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list:
mock_list.return_value = [] # Container not found
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = {"Id": "from_socket"}
result = await client.inspect_container("missing_container")
assert result == {"Id": "from_socket"}
mock_socket.assert_called_once()
@pytest.mark.asyncio
async def test_inspect_container_handles_exception_with_fallback(self):
"""inspect_container should try fallback even on exception."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.return_value = {"Id": "fallback"}
result = await client.inspect_container("container")
assert result == {"Id": "fallback"}
@pytest.mark.asyncio
async def test_inspect_container_returns_none_when_all_fails(self):
"""inspect_container should return None when everything fails."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
mock_endpoints.side_effect = Exception("API error")
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
mock_socket.side_effect = Exception("Socket error")
result = await client.inspect_container("container")
assert result is None
class TestPortainerClientSingleton:
"""Test singleton pattern."""
def test_returns_same_instance(self):
"""get_portainer_client should return singleton."""
# Reset singleton
import src.clients.portainer_client as module
module._portainer_client = None
client1 = get_portainer_client()
client2 = get_portainer_client()
assert client1 is client2
+146
View File
@@ -0,0 +1,146 @@
"""Tests for service_groups module."""
import pytest
from src.service_groups import (
ALWAYS_ON_SERVICES,
SERVICE_GROUPS,
is_always_on,
get_service_group,
get_group_name,
list_service_groups,
list_stoppable_services,
validate_stop_request,
)
class TestAlwaysOnServices:
"""Test always-on service configuration."""
def test_always_on_services_includes_infrastructure(self):
"""Critical infrastructure services should be in always-on list."""
assert "portainer" in ALWAYS_ON_SERVICES
assert "nginx-proxy-manager" in ALWAYS_ON_SERVICES
assert "core-api" in ALWAYS_ON_SERVICES
def test_uptime_kuma_not_in_always_on(self):
"""Uptime Kuma was removed from always-on list."""
assert "uptime-kuma" not in ALWAYS_ON_SERVICES
class TestIsAlwaysOn:
"""Test is_always_on function."""
def test_is_always_on_returns_true_for_infrastructure(self):
"""Infrastructure services should return True."""
assert is_always_on("portainer") is True
assert is_always_on("nginx-proxy-manager") is True
assert is_always_on("core-api") is True
def test_is_always_on_case_insensitive(self):
"""Function should be case-insensitive."""
assert is_always_on("PORTAINER") is True
assert is_always_on("Portainer") is True
assert is_always_on("PoRtAiNeR") is True
def test_is_always_on_returns_false_for_stoppable(self):
"""Stoppable services should return False."""
assert is_always_on("jellyfin") is False
assert is_always_on("nextcloud") is False
assert is_always_on("unknown-service") is False
class TestGetServiceGroup:
"""Test get_service_group function."""
def test_returns_group_members_for_grouped_service(self):
"""Should return all services in the group."""
# Assuming jellyfin is defined in SERVICE_GROUPS
if "jellyfin" in SERVICE_GROUPS:
result = get_service_group("jellyfin")
assert "jellyfin" in result
def test_returns_single_item_for_ungrouped_service(self):
"""Ungrouped services should return themselves."""
result = get_service_group("some-random-service")
assert result == ["some-random-service"]
def test_returns_copy_not_reference(self):
"""Should return a copy to prevent modification."""
if SERVICE_GROUPS:
group_name = list(SERVICE_GROUPS.keys())[0]
result1 = get_service_group(group_name)
result2 = get_service_group(group_name)
assert result1 is not result2
class TestGetGroupName:
"""Test get_group_name function."""
def test_returns_group_name_for_grouped_service(self):
"""Should return group name for services in groups."""
# If ai-stack group exists with ollama
if "ai-stack" in SERVICE_GROUPS and "ollama" in SERVICE_GROUPS["ai-stack"]:
assert get_group_name("ollama") == "ai-stack"
def test_returns_service_name_for_ungrouped(self):
"""Ungrouped services should return their own name."""
assert get_group_name("random-service") == "random-service"
class TestListServiceGroups:
"""Test list_service_groups function."""
def test_returns_all_groups(self):
"""Should return all defined service groups."""
result = list_service_groups()
assert isinstance(result, dict)
assert result == SERVICE_GROUPS
def test_returns_copy(self):
"""Should return a copy to prevent modification."""
result = list_service_groups()
assert result is not SERVICE_GROUPS
class TestListStoppableServices:
"""Test list_stoppable_services function."""
def test_returns_list(self):
"""Should return a list."""
result = list_stoppable_services()
assert isinstance(result, list)
def test_excludes_always_on_services(self):
"""Should not include always-on services."""
result = list_stoppable_services()
for service in result:
assert not is_always_on(service), f"{service} is always-on but in stoppable list"
class TestValidateStopRequest:
"""Test validate_stop_request function."""
def test_valid_for_stoppable_services(self):
"""Should return valid for stoppable services."""
stoppable = list_stoppable_services()
if stoppable:
is_valid, error = validate_stop_request([stoppable[0]])
assert is_valid is True
assert error == ""
def test_invalid_for_always_on_services(self):
"""Should return invalid for always-on services."""
is_valid, error = validate_stop_request(["portainer"])
assert is_valid is False
assert "always-on" in error.lower()
assert "portainer" in error
def test_invalid_if_any_service_is_always_on(self):
"""Should fail if any service in list is always-on."""
is_valid, error = validate_stop_request(["jellyfin", "portainer"])
assert is_valid is False
def test_valid_for_empty_list(self):
"""Empty list should be valid."""
is_valid, error = validate_stop_request([])
assert is_valid is True
assert error == ""
+115
View File
@@ -0,0 +1,115 @@
"""Tests for static controller."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from pathlib import Path
import tempfile
import os
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
class TestListWidgets:
"""Test /static/widgets endpoint."""
def test_list_widgets_returns_200(self, client):
"""List widgets should return 200."""
response = client.get("/static/widgets")
assert response.status_code == 200
def test_list_widgets_returns_widgets_list(self, client):
"""List widgets should return widgets array."""
response = client.get("/static/widgets")
data = response.json()
assert "widgets" in data
assert "count" in data
assert isinstance(data["widgets"], list)
@patch("src.controllers.static_controller.StaticController")
def test_list_widgets_handles_missing_directory(self, mock_controller_class, client):
"""List widgets should handle missing widgets directory."""
# Create a mock controller with non-existent static dir
with tempfile.TemporaryDirectory() as tmpdir:
mock_static_dir = Path(tmpdir) / "nonexistent"
with patch.object(
client.app.state if hasattr(client.app, 'state') else client.app,
'static_dir',
mock_static_dir,
create=True
):
# The actual endpoint handles this case gracefully
response = client.get("/static/widgets")
# Should still return 200 with empty list or message
assert response.status_code == 200
class TestGetWidget:
"""Test /static/widgets/{filename} endpoint."""
def test_get_widget_returns_404_for_nonexistent(self, client):
"""Get widget should return 404 for non-existent file."""
response = client.get("/static/widgets/nonexistent-widget.html")
assert response.status_code == 404
def test_get_widget_returns_html_content_type(self, client):
"""Get widget should return HTML content type for existing file."""
# First check if any widgets exist
list_response = client.get("/static/widgets")
widgets = list_response.json().get("widgets", [])
if widgets:
# Test with first available widget
widget_name = widgets[0]["name"]
response = client.get(f"/static/widgets/{widget_name}")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
def test_get_widget_prevents_path_traversal(self, client):
"""Get widget should prevent path traversal attacks."""
# Attempt path traversal
response = client.get("/static/widgets/../../../etc/passwd")
# Should either return 404 or 403, not the actual file
assert response.status_code in [400, 403, 404]
def test_get_widget_includes_cache_headers(self, client):
"""Get widget should include no-cache headers."""
list_response = client.get("/static/widgets")
widgets = list_response.json().get("widgets", [])
if widgets:
widget_name = widgets[0]["name"]
response = client.get(f"/static/widgets/{widget_name}")
if response.status_code == 200:
assert "no-cache" in response.headers.get("cache-control", "")
class TestStaticControllerInit:
"""Test StaticController initialization."""
def test_controller_has_static_dir(self):
"""Controller should have static directory configured."""
from src.controllers.static_controller import static_controller
assert static_controller.static_dir is not None
assert isinstance(static_controller.static_dir, Path)
def test_controller_has_correct_prefix(self):
"""Controller should have /static prefix."""
from src.controllers.static_controller import static_controller
assert static_controller.prefix == "/static"
def test_controller_has_correct_tags(self):
"""Controller should have Static tag."""
from src.controllers.static_controller import static_controller
assert "Static" in static_controller.tags
+142
View File
@@ -0,0 +1,142 @@
"""Tests for tools controller."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock, MagicMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
class TestDNSLookup:
"""Test /tools/dns/lookup endpoint."""
@patch("src.controllers.tools_controller.DNSService")
def test_dns_lookup_returns_200(self, mock_dns_class, client):
"""DNS lookup should return 200 for valid request."""
mock_service = MagicMock()
mock_service.lookup = AsyncMock(return_value=MagicMock(
success=True,
domain="example.com",
record_type="A",
records=[{"value": "93.184.216.34"}],
nameserver_used="8.8.8.8",
query_time_ms=50,
error_message=None
))
mock_dns_class.return_value = mock_service
response = client.post(
"/tools/dns/lookup",
json={"domain": "example.com", "record_type": "A"}
)
assert response.status_code == 200
@patch("src.controllers.tools_controller.DNSService")
def test_dns_lookup_returns_result(self, mock_dns_class, client):
"""DNS lookup should return lookup results."""
mock_response = MagicMock()
mock_response.success = True
mock_response.domain = "example.com"
mock_response.record_type = "A"
mock_response.records = [{"value": "93.184.216.34"}]
mock_response.nameserver_used = "8.8.8.8"
mock_response.query_time_ms = 50
mock_response.error_message = None
mock_response.model_dump = MagicMock(return_value={
"success": True,
"domain": "example.com",
"record_type": "A",
"records": [{"value": "93.184.216.34"}],
"nameserver_used": "8.8.8.8",
"query_time_ms": 50,
"error_message": None
})
mock_service = MagicMock()
mock_service.lookup = AsyncMock(return_value=mock_response)
mock_dns_class.return_value = mock_service
response = client.post(
"/tools/dns/lookup",
json={"domain": "example.com", "record_type": "A"}
)
data = response.json()
assert data["success"] is True
assert data["domain"] == "example.com"
def test_dns_lookup_requires_domain(self, client):
"""DNS lookup should require domain parameter."""
response = client.post(
"/tools/dns/lookup",
json={"record_type": "A"}
)
assert response.status_code == 422
@patch("src.controllers.tools_controller.DNSService")
def test_dns_lookup_handles_dns_query_error(self, mock_dns_class, client):
"""DNS lookup should handle DNSQueryError."""
from src.dns.exceptions import DNSQueryError
mock_service = MagicMock()
mock_service.lookup = AsyncMock(side_effect=DNSQueryError("Unsupported record type"))
mock_dns_class.return_value = mock_service
response = client.post(
"/tools/dns/lookup",
json={"domain": "example.com", "record_type": "INVALID"}
)
assert response.status_code == 400
@patch("src.controllers.tools_controller.DNSService")
def test_dns_lookup_accepts_custom_nameserver(self, mock_dns_class, client):
"""DNS lookup should accept custom nameserver."""
mock_response = MagicMock()
mock_response.success = True
mock_response.domain = "example.com"
mock_response.record_type = "A"
mock_response.records = []
mock_response.nameserver_used = "1.1.1.1"
mock_response.query_time_ms = 30
mock_response.error_message = None
mock_response.model_dump = MagicMock(return_value={
"success": True,
"domain": "example.com",
"record_type": "A",
"records": [],
"nameserver_used": "1.1.1.1",
"query_time_ms": 30,
"error_message": None
})
mock_service = MagicMock()
mock_service.lookup = AsyncMock(return_value=mock_response)
mock_dns_class.return_value = mock_service
response = client.post(
"/tools/dns/lookup",
json={"domain": "example.com", "record_type": "A", "nameserver": "1.1.1.1"}
)
assert response.status_code == 200
class TestToolsControllerInit:
"""Test ToolsController initialization."""
def test_controller_has_correct_prefix(self):
"""Controller should have /tools prefix."""
from src.controllers.tools_controller import tools_controller
assert tools_controller.prefix == "/tools"
def test_controller_has_correct_tags(self):
"""Controller should have Tools tag."""
from src.controllers.tools_controller import tools_controller
assert "Tools" in tools_controller.tags