Phase 3.1: Type Safety - Add bridge_types.py for shared type aliases (EventDict, PowerWatts, Timestamp, DeviceID) - Define protocols for callbacks and message parsers - Strict type annotations on all core modules (session_detector, event_queue, device_manager) - Fix Optional handling and type guards throughout codebase - Achieve full mypy compliance: 0 errors across 47 source files Phase 3.2: Logging Unification - Migrate from stdlib logging to pure structlog across all runtime modules - Convert all logs to structured event+fields format (snake_case event names) - Remove f-string and printf-style logger calls - Add contextvars support for per-request correlation - Implement FastAPI middleware to bind request_id, http_method, http_path - Propagate X-Request-ID header in responses - Remove stdlib logging imports except setup layer (utils/logging.py) - Ensure log-level consistency across all modules Files Modified: - iot_bridge/bridge_types.py (new) - Central type definitions - iot_bridge/core/* - Type safety and logging unification - iot_bridge/clients/* - Structured logging with request context - iot_bridge/parsers/* - Type-safe parsing with structured logs - iot_bridge/utils/logging.py - Pure structlog setup with contextvars - iot_bridge/api/server.py - Added request correlation middleware - iot_bridge/tests/* - Test fixtures updated for type safety - iot_bridge/OPTIMIZATION_PLAN.md - Phase 3 status updated Validation: - mypy . → 0 errors (47 files) - All unit tests pass - Runtime behavior unchanged - API response headers include X-Request-ID
829 lines
27 KiB
Python
Executable File
829 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
End-to-End Tests for PUSH Architecture (Phase 3.5)
|
|
|
|
Tests the complete PUSH flow:
|
|
- Odoo creates/updates/deletes Device
|
|
- Odoo automatically pushes config to Bridge
|
|
- Bridge receives config and updates subscriptions
|
|
- Bridge persists config to /data/config-active.yaml
|
|
|
|
NO VOLUME DELETION - Tests work with running infrastructure.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
# Configuration
|
|
ODOO_URL = "http://localhost:9018"
|
|
ODOO_DB = "odoo"
|
|
ODOO_USER = "admin"
|
|
ODOO_PASSWORD = "admin"
|
|
|
|
BRIDGE_URL = "http://localhost:8080"
|
|
|
|
# Colors for output
|
|
GREEN = "\033[92m"
|
|
RED = "\033[91m"
|
|
YELLOW = "\033[93m"
|
|
BLUE = "\033[94m"
|
|
RESET = "\033[0m"
|
|
|
|
|
|
class OdooAPI:
|
|
"""Simple Odoo API client for E2E tests."""
|
|
|
|
def __init__(self, url: str, db: str, username: str, password: str):
|
|
self.url = url.rstrip("/")
|
|
self.db = db
|
|
self.username = username
|
|
self.password = password
|
|
self.session = requests.Session()
|
|
self.uid = None
|
|
|
|
def authenticate(self) -> bool:
|
|
"""Authenticate with Odoo using JSON-RPC."""
|
|
try:
|
|
# Authenticate via JSON-RPC
|
|
auth_url = f"{self.url}/web/session/authenticate"
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "call",
|
|
"params": {"db": self.db, "login": self.username, "password": self.password},
|
|
"id": 1,
|
|
}
|
|
|
|
resp = self.session.post(auth_url, json=payload)
|
|
resp.raise_for_status()
|
|
|
|
result = resp.json()
|
|
|
|
# Check for error
|
|
if "error" in result:
|
|
print(f"{RED}✗{RESET} Authentication error: {result['error']}")
|
|
return False
|
|
|
|
# Check if authenticated
|
|
if result.get("result") and result["result"].get("uid"):
|
|
self.uid = result["result"]["uid"]
|
|
print(f"{GREEN}✓{RESET} Authenticated with Odoo (UID: {self.uid})")
|
|
return True
|
|
else:
|
|
print(f"{RED}✗{RESET} Authentication failed - no UID returned")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"{RED}✗{RESET} Authentication error: {e}")
|
|
return False
|
|
|
|
def json_rpc(self, endpoint: str, method: str, params: dict) -> Any:
|
|
"""Make JSON-RPC call to Odoo."""
|
|
url = f"{self.url}{endpoint}"
|
|
payload = {"jsonrpc": "2.0", "method": "call", "params": params, "id": 1}
|
|
|
|
resp = self.session.post(url, json=payload)
|
|
resp.raise_for_status()
|
|
|
|
result = resp.json()
|
|
if "error" in result:
|
|
raise Exception(f"Odoo RPC error: {result['error']}")
|
|
|
|
return result.get("result")
|
|
|
|
def search_read(
|
|
self,
|
|
model: str,
|
|
domain: list[Any],
|
|
fields: list[str],
|
|
limit: int | None = None,
|
|
) -> list[Any]:
|
|
"""Search and read records from Odoo."""
|
|
kwargs: dict[str, Any] = {"fields": fields}
|
|
if limit is not None:
|
|
kwargs["limit"] = limit
|
|
result = self.json_rpc(
|
|
"/web/dataset/call_kw",
|
|
"call_kw",
|
|
{"model": model, "method": "search_read", "args": [domain], "kwargs": kwargs},
|
|
)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def create(self, model: str, vals: dict) -> int:
|
|
"""Create a record in Odoo."""
|
|
result = self.json_rpc(
|
|
"/web/dataset/call_kw",
|
|
"call_kw",
|
|
{"model": model, "method": "create", "args": [vals], "kwargs": {}},
|
|
)
|
|
return int(result)
|
|
|
|
def write(self, model: str, record_id: int, vals: dict) -> bool:
|
|
"""Update a record in Odoo."""
|
|
result = self.json_rpc(
|
|
"/web/dataset/call_kw",
|
|
"call_kw",
|
|
{"model": model, "method": "write", "args": [[record_id], vals], "kwargs": {}},
|
|
)
|
|
return bool(result)
|
|
|
|
def unlink(self, model: str, record_id: int) -> bool:
|
|
"""Delete a record in Odoo."""
|
|
result = self.json_rpc(
|
|
"/web/dataset/call_kw",
|
|
"call_kw",
|
|
{"model": model, "method": "unlink", "args": [[record_id]], "kwargs": {}},
|
|
)
|
|
return bool(result)
|
|
|
|
def call_button(self, model: str, record_id: int, method: str) -> Any:
|
|
"""Call a button method on a record."""
|
|
return self.json_rpc(
|
|
"/web/dataset/call_button",
|
|
"call_button",
|
|
{"model": model, "method": method, "args": [[record_id]], "kwargs": {}},
|
|
)
|
|
|
|
|
|
class BridgeAPI:
|
|
"""Simple Bridge API client for E2E tests."""
|
|
|
|
def __init__(self, url: str):
|
|
self.url = url.rstrip("/")
|
|
self.session = requests.Session()
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
"""Get bridge health status."""
|
|
resp = self.session.get(f"{self.url}/health")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
"""Get current bridge configuration."""
|
|
resp = self.session.get(f"{self.url}/config")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def print_test_header(test_name: str):
|
|
"""Print formatted test header."""
|
|
print(f"\n{BLUE}{'='*70}{RESET}")
|
|
print(f"{BLUE}TEST: {test_name}{RESET}")
|
|
print(f"{BLUE}{'='*70}{RESET}")
|
|
|
|
|
|
def print_step(step: str):
|
|
"""Print test step."""
|
|
print(f"{YELLOW}→{RESET} {step}")
|
|
|
|
|
|
def print_success(message: str):
|
|
"""Print success message."""
|
|
print(f"{GREEN}✓{RESET} {message}")
|
|
|
|
|
|
def print_error(message: str):
|
|
"""Print error message."""
|
|
print(f"{RED}✗{RESET} {message}")
|
|
|
|
|
|
def test_device_create(odoo: OdooAPI, bridge: BridgeAPI):
|
|
"""Test: Create device in Odoo → Config pushed to Bridge."""
|
|
print_test_header("Device Create → Config Push")
|
|
|
|
# Get initial bridge state
|
|
print_step("Get initial bridge state")
|
|
initial_health = bridge.health()
|
|
initial_device_count = initial_health["devices"]
|
|
print(f" Initial devices: {initial_device_count}")
|
|
|
|
# Create device in Odoo
|
|
print_step("Create test device in Odoo")
|
|
device_name = f"test-e2e-device-{int(time.time())}"
|
|
device_vals = {
|
|
"name": "E2E Test Device",
|
|
"device_id": device_name,
|
|
"topic_pattern": f"{device_name}/#",
|
|
"parser_type": "shelly_pm",
|
|
"session_strategy": "power_threshold",
|
|
"strategy_config": json.dumps(
|
|
{
|
|
"standby_threshold_w": 5.0,
|
|
"working_threshold_w": 50.0,
|
|
"start_debounce_s": 3.0,
|
|
"stop_debounce_s": 15.0,
|
|
"message_timeout_s": 20.0,
|
|
"heartbeat_interval_s": 300.0,
|
|
}
|
|
),
|
|
"active": True,
|
|
}
|
|
|
|
try:
|
|
device_id = odoo.create("mqtt.device", device_vals)
|
|
print_success(f"Device created with ID: {device_id}")
|
|
except Exception as e:
|
|
print_error(f"Failed to create device: {e}")
|
|
return None
|
|
|
|
# Wait for config push
|
|
print_step("Wait for automatic config push (2s)")
|
|
time.sleep(2)
|
|
|
|
# Check bridge received update
|
|
print_step("Verify bridge received config update")
|
|
try:
|
|
new_health = bridge.health()
|
|
new_device_count = new_health["devices"]
|
|
|
|
if new_device_count > initial_device_count:
|
|
print_success(
|
|
f"Bridge device count increased: {initial_device_count} → {new_device_count}"
|
|
)
|
|
else:
|
|
print_error(f"Bridge device count unchanged: {new_device_count}")
|
|
|
|
# Verify last_config_update timestamp changed
|
|
if new_health.get("last_config_update"):
|
|
print_success(f"Config update timestamp: {new_health['last_config_update']}")
|
|
|
|
# Get full config to verify device is there
|
|
config = bridge.get_config()
|
|
device_ids = [d["device_id"] for d in config["devices"]]
|
|
|
|
if device_name in device_ids:
|
|
print_success(f"Device '{device_name}' found in bridge config")
|
|
else:
|
|
print_error(f"Device '{device_name}' NOT found in bridge config")
|
|
print(f" Available devices: {device_ids}")
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to verify bridge state: {e}")
|
|
return None
|
|
|
|
print_success("TEST PASSED: Device create → config push works")
|
|
return device_id
|
|
|
|
|
|
def test_device_update(odoo: OdooAPI, bridge: BridgeAPI, device_id: int):
|
|
"""Test: Update device in Odoo → Config pushed to Bridge."""
|
|
print_test_header("Device Update → Config Push")
|
|
|
|
if not device_id:
|
|
print_error("No device_id provided, skipping test")
|
|
return False
|
|
|
|
# Get device name
|
|
print_step("Get current device config")
|
|
try:
|
|
devices = odoo.search_read(
|
|
"mqtt.device", [("id", "=", device_id)], ["device_id", "strategy_config"]
|
|
)
|
|
if not devices:
|
|
print_error(f"Device {device_id} not found")
|
|
return False
|
|
|
|
device = devices[0]
|
|
device_name = device["device_id"]
|
|
old_config = json.loads(device["strategy_config"])
|
|
old_threshold = old_config["working_threshold_w"]
|
|
print(f" Device: {device_name}")
|
|
print(f" Current working_threshold_w: {old_threshold}W")
|
|
except Exception as e:
|
|
print_error(f"Failed to get device: {e}")
|
|
return False
|
|
|
|
# Update device
|
|
print_step("Update working_threshold_w: 50W → 75W")
|
|
new_config = old_config.copy()
|
|
new_config["working_threshold_w"] = 75.0
|
|
|
|
try:
|
|
odoo.write("mqtt.device", device_id, {"strategy_config": json.dumps(new_config)})
|
|
print_success("Device updated in Odoo")
|
|
except Exception as e:
|
|
print_error(f"Failed to update device: {e}")
|
|
return False
|
|
|
|
# Wait for config push
|
|
print_step("Wait for automatic config push (2s)")
|
|
time.sleep(2)
|
|
|
|
# Verify bridge received update
|
|
print_step("Verify bridge received config update")
|
|
try:
|
|
config = bridge.get_config()
|
|
bridge_device = next((d for d in config["devices"] if d["device_id"] == device_name), None)
|
|
|
|
if not bridge_device:
|
|
print_error(f"Device '{device_name}' not found in bridge config")
|
|
return False
|
|
|
|
bridge_threshold = bridge_device["session_config"]["working_threshold_w"]
|
|
|
|
if bridge_threshold == 75.0:
|
|
print_success(f"Bridge config updated: working_threshold_w = {bridge_threshold}W")
|
|
else:
|
|
print_error(
|
|
f"Bridge config NOT updated: working_threshold_w = {bridge_threshold}W (expected 75.0W)"
|
|
)
|
|
return False
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to verify bridge state: {e}")
|
|
return False
|
|
|
|
print_success("TEST PASSED: Device update → config push works")
|
|
return True
|
|
|
|
|
|
def test_manual_push(odoo: OdooAPI, bridge: BridgeAPI):
|
|
"""Test: Manual push button in Odoo UI."""
|
|
print_test_header("Manual Push Button")
|
|
|
|
print_step("Get current bridge config timestamp")
|
|
try:
|
|
old_health = bridge.health()
|
|
old_timestamp = old_health.get("last_config_update")
|
|
print(f" Current timestamp: {old_timestamp}")
|
|
except Exception as e:
|
|
print_error(f"Failed to get bridge health: {e}")
|
|
return False
|
|
|
|
# Wait a bit to ensure timestamp difference
|
|
time.sleep(1)
|
|
|
|
# Trigger manual push via Odoo
|
|
print_step("Trigger manual push from Odoo")
|
|
try:
|
|
# Get first device
|
|
devices = odoo.search_read("mqtt.device", [], ["id"], limit=1)
|
|
if not devices:
|
|
print_error("No devices found in Odoo")
|
|
return False
|
|
|
|
device_id = devices[0]["id"]
|
|
|
|
# Call action_push_config_to_bridge()
|
|
result = odoo.call_button("mqtt.device", device_id, "action_push_config_to_bridge")
|
|
print_success("Manual push triggered")
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to trigger manual push: {e}")
|
|
return False
|
|
|
|
# Wait for push
|
|
print_step("Wait for config push (2s)")
|
|
time.sleep(2)
|
|
|
|
# Verify timestamp changed
|
|
print_step("Verify bridge config timestamp changed")
|
|
try:
|
|
new_health = bridge.health()
|
|
new_timestamp = new_health.get("last_config_update")
|
|
|
|
if new_timestamp and new_timestamp != old_timestamp:
|
|
print_success(f"Config timestamp updated: {new_timestamp}")
|
|
else:
|
|
print_error(f"Config timestamp unchanged: {new_timestamp}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to verify bridge state: {e}")
|
|
return False
|
|
|
|
print_success("TEST PASSED: Manual push button works")
|
|
return True
|
|
|
|
|
|
def test_device_delete(odoo: OdooAPI, bridge: BridgeAPI, device_id: int):
|
|
"""Test: Delete device in Odoo → Config pushed to Bridge."""
|
|
print_test_header("Device Delete → Config Push")
|
|
|
|
if not device_id:
|
|
print_error("No device_id provided, skipping test")
|
|
return False
|
|
|
|
# Get device name before deletion
|
|
print_step("Get device name")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [("id", "=", device_id)], ["device_id"])
|
|
if not devices:
|
|
print_error(f"Device {device_id} not found")
|
|
return False
|
|
|
|
device_name = devices[0]["device_id"]
|
|
print(f" Device: {device_name}")
|
|
except Exception as e:
|
|
print_error(f"Failed to get device: {e}")
|
|
return False
|
|
|
|
# Get initial bridge state
|
|
print_step("Get initial bridge state")
|
|
initial_health = bridge.health()
|
|
initial_device_count = initial_health["devices"]
|
|
print(f" Initial devices: {initial_device_count}")
|
|
|
|
# Delete device
|
|
print_step("Delete device in Odoo")
|
|
try:
|
|
odoo.unlink("mqtt.device", device_id)
|
|
print_success("Device deleted in Odoo")
|
|
except Exception as e:
|
|
print_error(f"Failed to delete device: {e}")
|
|
return False
|
|
|
|
# Wait for config push
|
|
print_step("Wait for automatic config push (2s)")
|
|
time.sleep(2)
|
|
|
|
# Verify bridge removed device
|
|
print_step("Verify bridge removed device")
|
|
try:
|
|
new_health = bridge.health()
|
|
new_device_count = new_health["devices"]
|
|
|
|
if new_device_count < initial_device_count:
|
|
print_success(
|
|
f"Bridge device count decreased: {initial_device_count} → {new_device_count}"
|
|
)
|
|
else:
|
|
print_error(f"Bridge device count unchanged: {new_device_count}")
|
|
return False
|
|
|
|
# Verify device NOT in config
|
|
config = bridge.get_config()
|
|
device_ids = [d["device_id"] for d in config["devices"]]
|
|
|
|
if device_name not in device_ids:
|
|
print_success(f"Device '{device_name}' removed from bridge config")
|
|
else:
|
|
print_error(f"Device '{device_name}' still in bridge config")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to verify bridge state: {e}")
|
|
return False
|
|
|
|
print_success("TEST PASSED: Device delete → config push works")
|
|
return True
|
|
|
|
|
|
def test_bridge_restart(bridge: BridgeAPI):
|
|
"""Test: Bridge restart loads config-active.yaml."""
|
|
print_test_header("Bridge Restart → Load config-active.yaml")
|
|
|
|
print_step("Get current bridge state")
|
|
try:
|
|
before_health = bridge.health()
|
|
before_devices = before_health["devices"]
|
|
before_config = bridge.get_config()
|
|
print(f" Devices: {before_devices}")
|
|
print(f" Config timestamp: {before_config.get('timestamp')}")
|
|
except Exception as e:
|
|
print_error(f"Failed to get bridge state: {e}")
|
|
return False
|
|
|
|
# Restart bridge container
|
|
print_step("Restart bridge container")
|
|
import subprocess
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "restart", "hobbyhimmel_odoo_18-dev_iot_bridge"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
print_error(f"Failed to restart container: {result.stderr}")
|
|
return False
|
|
print_success("Bridge container restarted")
|
|
except Exception as e:
|
|
print_error(f"Failed to restart bridge: {e}")
|
|
return False
|
|
|
|
# Wait for bridge to be healthy
|
|
print_step("Wait for bridge to be healthy (max 60s)")
|
|
for i in range(60):
|
|
try:
|
|
health = bridge.health()
|
|
if health.get("status") == "ok":
|
|
print_success(f"Bridge healthy after {i+1}s")
|
|
break
|
|
except Exception as e:
|
|
if i % 10 == 0: # Log every 10 seconds
|
|
print(f" Waiting... ({i}s) - {type(e).__name__}")
|
|
time.sleep(1)
|
|
else:
|
|
print_error("Bridge did not become healthy within 60s")
|
|
try:
|
|
# Try to get logs for debugging
|
|
result = subprocess.run(
|
|
["docker", "logs", "--tail", "20", "hobbyhimmel_odoo_18-dev_iot_bridge"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
print(f" Bridge logs:\n{result.stdout}")
|
|
except:
|
|
pass
|
|
return False
|
|
|
|
# Verify config loaded
|
|
print_step("Verify bridge loaded config-active.yaml")
|
|
try:
|
|
after_health = bridge.health()
|
|
after_devices = after_health["devices"]
|
|
after_config = bridge.get_config()
|
|
|
|
if after_devices == before_devices:
|
|
print_success(f"Device count preserved: {after_devices}")
|
|
else:
|
|
print_error(f"Device count changed: {before_devices} → {after_devices}")
|
|
return False
|
|
|
|
# Compare configs (timestamps will differ)
|
|
before_device_ids = {d["device_id"] for d in before_config["devices"]}
|
|
after_device_ids = {d["device_id"] for d in after_config["devices"]}
|
|
|
|
if before_device_ids == after_device_ids:
|
|
print_success(f"Device IDs preserved: {after_device_ids}")
|
|
else:
|
|
print_error("Device IDs changed")
|
|
print(f" Before: {before_device_ids}")
|
|
print(f" After: {after_device_ids}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print_error(f"Failed to verify bridge state: {e}")
|
|
return False
|
|
|
|
print_success("TEST PASSED: Bridge restart → config persistence works")
|
|
return True
|
|
|
|
|
|
def test_device_status_monitoring(odoo: OdooAPI, bridge: BridgeAPI):
|
|
"""Test: Device status changes (offline → idle → active → idle → offline)."""
|
|
print_test_header("Device Status Monitoring")
|
|
|
|
# Find a device to test with
|
|
print_step("Get test device")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [], ["device_id", "state"], limit=1)
|
|
if not devices:
|
|
print_error("No devices found in Odoo")
|
|
return False
|
|
|
|
device = devices[0]
|
|
device_id = device["device_id"]
|
|
initial_state = device["state"]
|
|
print(f" Device: {device_id}, Initial state: {initial_state}")
|
|
except Exception as e:
|
|
print_error(f"Failed to get device: {e}")
|
|
return False
|
|
|
|
# Test 1: Simulate device_online event
|
|
print_step("Send device_online event")
|
|
try:
|
|
event_data = {
|
|
"jsonrpc": "2.0",
|
|
"params": {
|
|
"event_uid": str(uuid.uuid4()),
|
|
"device_id": device_id,
|
|
"event_type": "device_online",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"payload": {},
|
|
},
|
|
}
|
|
resp = requests.post(f"{ODOO_URL}/ows/iot/event", json=event_data)
|
|
resp.raise_for_status()
|
|
print_success("device_online event sent")
|
|
except Exception as e:
|
|
print_error(f"Failed to send event: {e}")
|
|
return False
|
|
|
|
# Wait and verify state = idle
|
|
time.sleep(1)
|
|
print_step("Verify device state = idle")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [("device_id", "=", device_id)], ["state"])
|
|
if devices and devices[0]["state"] == "idle":
|
|
print_success("Device state is now 'idle'")
|
|
else:
|
|
print_error(f"Device state is '{devices[0]['state']}', expected 'idle'")
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Failed to verify state: {e}")
|
|
return False
|
|
|
|
# Test 2: Simulate session_started event (should set state to active)
|
|
print_step("Send session_started event")
|
|
try:
|
|
session_id = f"test-session-{int(time.time())}"
|
|
event_data = {
|
|
"jsonrpc": "2.0",
|
|
"params": {
|
|
"event_uid": str(uuid.uuid4()),
|
|
"device_id": device_id,
|
|
"session_id": session_id,
|
|
"event_type": "session_started",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"payload": {"power_w": 100.0, "state": "working"},
|
|
},
|
|
}
|
|
resp = requests.post(f"{ODOO_URL}/ows/iot/event", json=event_data)
|
|
resp.raise_for_status()
|
|
print_success("session_started event sent")
|
|
except Exception as e:
|
|
print_error(f"Failed to send event: {e}")
|
|
return False
|
|
|
|
# Wait and verify state = active
|
|
time.sleep(1)
|
|
print_step("Verify device state = active")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [("device_id", "=", device_id)], ["state"])
|
|
if devices and devices[0]["state"] == "active":
|
|
print_success("Device state is now 'active'")
|
|
else:
|
|
print_error(f"Device state is '{devices[0]['state']}', expected 'active'")
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Failed to verify state: {e}")
|
|
return False
|
|
|
|
# Test 3: Simulate session_stopped event (should set state back to idle)
|
|
print_step("Send session_stopped event")
|
|
try:
|
|
event_data = {
|
|
"jsonrpc": "2.0",
|
|
"params": {
|
|
"event_uid": str(uuid.uuid4()),
|
|
"device_id": device_id,
|
|
"session_id": session_id,
|
|
"event_type": "session_stopped",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"payload": {"power_w": 5.0, "total_duration_s": 60},
|
|
},
|
|
}
|
|
resp = requests.post(f"{ODOO_URL}/ows/iot/event", json=event_data)
|
|
resp.raise_for_status()
|
|
print_success("session_stopped event sent")
|
|
except Exception as e:
|
|
print_error(f"Failed to send event: {e}")
|
|
return False
|
|
|
|
# Wait and verify state = idle
|
|
time.sleep(1)
|
|
print_step("Verify device state = idle (after session stop)")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [("device_id", "=", device_id)], ["state"])
|
|
if devices and devices[0]["state"] == "idle":
|
|
print_success("Device state is now 'idle'")
|
|
else:
|
|
print_error(f"Device state is '{devices[0]['state']}', expected 'idle'")
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Failed to verify state: {e}")
|
|
return False
|
|
|
|
# Test 4: Simulate device_offline event
|
|
print_step("Send device_offline event")
|
|
try:
|
|
event_data = {
|
|
"jsonrpc": "2.0",
|
|
"params": {
|
|
"event_uid": str(uuid.uuid4()),
|
|
"device_id": device_id,
|
|
"event_type": "device_offline",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"payload": {},
|
|
},
|
|
}
|
|
resp = requests.post(f"{ODOO_URL}/ows/iot/event", json=event_data)
|
|
resp.raise_for_status()
|
|
print_success("device_offline event sent")
|
|
except Exception as e:
|
|
print_error(f"Failed to send event: {e}")
|
|
return False
|
|
|
|
# Wait and verify state = offline
|
|
time.sleep(1)
|
|
print_step("Verify device state = offline")
|
|
try:
|
|
devices = odoo.search_read("mqtt.device", [("device_id", "=", device_id)], ["state"])
|
|
if devices and devices[0]["state"] == "offline":
|
|
print_success("Device state is now 'offline'")
|
|
else:
|
|
print_error(f"Device state is '{devices[0]['state']}', expected 'offline'")
|
|
return False
|
|
except Exception as e:
|
|
print_error(f"Failed to verify state: {e}")
|
|
return False
|
|
|
|
print_success(
|
|
"TEST PASSED: Device status monitoring (offline → idle → active → idle → offline)"
|
|
)
|
|
return True
|
|
|
|
|
|
def main():
|
|
"""Run all E2E tests."""
|
|
print(f"\n{BLUE}{'='*70}{RESET}")
|
|
print(f"{BLUE}End-to-End Tests: PUSH Architecture (Phase 3.5){RESET}")
|
|
print(f"{BLUE}{'='*70}{RESET}")
|
|
print(f"Odoo URL: {ODOO_URL}")
|
|
print(f"Bridge URL: {BRIDGE_URL}")
|
|
print(f"Time: {datetime.now().isoformat()}")
|
|
|
|
# Initialize API clients
|
|
print_step("Initialize API clients")
|
|
odoo = OdooAPI(ODOO_URL, ODOO_DB, ODOO_USER, ODOO_PASSWORD)
|
|
bridge = BridgeAPI(BRIDGE_URL)
|
|
|
|
# Authenticate with Odoo
|
|
if not odoo.authenticate():
|
|
print_error("Failed to authenticate with Odoo")
|
|
sys.exit(1)
|
|
|
|
# Check bridge is accessible
|
|
try:
|
|
health = bridge.health()
|
|
print_success(f"Bridge accessible (devices: {health['devices']})")
|
|
except Exception as e:
|
|
print_error(f"Bridge not accessible: {e}")
|
|
sys.exit(1)
|
|
|
|
# Run tests
|
|
results = []
|
|
test_device_id = None
|
|
|
|
# Test 1: Create device
|
|
test_device_id = test_device_create(odoo, bridge)
|
|
results.append(("Device Create", test_device_id is not None))
|
|
|
|
# Test 2: Update device
|
|
if test_device_id:
|
|
result = test_device_update(odoo, bridge, test_device_id)
|
|
results.append(("Device Update", result))
|
|
else:
|
|
print_error("Skipping Device Update test (no device created)")
|
|
results.append(("Device Update", False))
|
|
|
|
# Test 3: Manual push
|
|
result = test_manual_push(odoo, bridge)
|
|
results.append(("Manual Push", result))
|
|
|
|
# Test 4: Delete device
|
|
if test_device_id:
|
|
result = test_device_delete(odoo, bridge, test_device_id)
|
|
results.append(("Device Delete", result))
|
|
else:
|
|
print_error("Skipping Device Delete test (no device to delete)")
|
|
results.append(("Device Delete", False))
|
|
|
|
# Test 5: Bridge restart
|
|
result = test_bridge_restart(bridge)
|
|
results.append(("Bridge Restart", result))
|
|
|
|
# Test 6: Device status monitoring
|
|
result = test_device_status_monitoring(odoo, bridge)
|
|
results.append(("Device Status Monitoring", result))
|
|
|
|
# Print summary
|
|
print(f"\n{BLUE}{'='*70}{RESET}")
|
|
print(f"{BLUE}TEST SUMMARY{RESET}")
|
|
print(f"{BLUE}{'='*70}{RESET}")
|
|
|
|
for test_name, passed in results:
|
|
status = f"{GREEN}✓ PASSED{RESET}" if passed else f"{RED}✗ FAILED{RESET}"
|
|
print(f"{test_name:.<50} {status}")
|
|
|
|
passed_count = sum(1 for _, passed in results if passed)
|
|
total_count = len(results)
|
|
|
|
print(f"\n{BLUE}Result: {passed_count}/{total_count} tests passed{RESET}")
|
|
|
|
if passed_count == total_count:
|
|
print(f"{GREEN}{'='*70}{RESET}")
|
|
print(f"{GREEN}ALL TESTS PASSED ✓{RESET}")
|
|
print(f"{GREEN}{'='*70}{RESET}")
|
|
sys.exit(0)
|
|
else:
|
|
print(f"{RED}{'='*70}{RESET}")
|
|
print(f"{RED}SOME TESTS FAILED ✗{RESET}")
|
|
print(f"{RED}{'='*70}{RESET}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|