odoo_mqtt/iot_bridge/tests/test_e2e_push_architecture.py
matthias.lotz ff4ef2f563 Phase 3: Complete type safety & logging unification (3.1-3.2)
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
2026-02-18 23:54:27 +01:00

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()