odoo_mqtt/iot_bridge/tests/integration/test_bridge_integration.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

478 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Integration Tests für IoT Bridge
Testet komplette Pipeline:
MQTT Simulator → Bridge → SessionDetector → Event Callback
Usage:
cd iot_bridge
source venv/bin/activate
pytest tests/integration/test_bridge_integration.py -v -s
"""
import os
import signal
import subprocess
import time
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def workspace_dir():
"""iot_bridge root directory"""
return Path(__file__).parent.parent.parent
@pytest.fixture(scope="session")
def test_config_file(workspace_dir):
"""Erstellt temporäre test_config.yaml für Integration Tests"""
# Use local Mosquitto container (no auth needed)
# If running outside docker-compose, fallback to cloud broker
import socket
# Try localhost first (docker-compose.dev.yaml mosquitto)
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("localhost", 1883))
sock.close()
if result == 0:
# Local Mosquitto available
mqtt_broker = "localhost"
mqtt_port = 1883
mqtt_username = None
mqtt_password = None
use_tls = False
print("\n✅ Using local Mosquitto container (localhost:1883)")
else:
raise ConnectionError("Local broker not available")
except:
# Fallback to cloud broker with credentials from config.yaml
import yaml
config_path = workspace_dir / "config.yaml"
if not config_path.exists():
pytest.skip("config.yaml nicht gefunden und kein lokaler Mosquitto Container.")
with open(config_path) as f:
real_config = yaml.safe_load(f)
mqtt_config = real_config.get("mqtt", {})
mqtt_broker = mqtt_config.get("broker", "mqtt.majufilo.eu")
mqtt_port = mqtt_config.get("port", 8883)
mqtt_username = mqtt_config.get("username")
mqtt_password = mqtt_config.get("password")
use_tls = mqtt_config.get("use_tls", True)
print(f"\n⚠️ Using cloud broker {mqtt_broker}:{mqtt_port}")
# Config mit MQTT Broker (lokal oder cloud)
username_line = f' username: "{mqtt_username}"' if mqtt_username else ' # username: ""'
password_line = f' password: "{mqtt_password}"' if mqtt_password else ' # password: ""'
config_content = f"""
mqtt:
broker: "{mqtt_broker}"
port: {mqtt_port}
{username_line}
{password_line}
use_tls: {str(use_tls).lower()}
client_id: "iot_bridge_pytest"
keepalive: 60
odoo:
url: "http://localhost:8069"
token: "dummy-token-for-tests"
use_mock: true
logging:
level: "INFO"
format: "json"
devices:
- device_id: "pytest-device-01"
machine_name: "PyTest Machine 1"
mqtt_topic: "testshelly-m1/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20.0
working_threshold_w: 100.0
start_debounce_s: 3.0
stop_debounce_s: 15.0
message_timeout_s: 20.0
heartbeat_interval_s: 10.0
- device_id: "pytest-device-02"
machine_name: "PyTest Machine 2"
mqtt_topic: "testshelly-m2/status/pm1:0"
parser_type: "shelly_pm_mini_g3"
session_config:
strategy: "power_threshold"
standby_threshold_w: 20.0
working_threshold_w: 100.0
start_debounce_s: 3.0
stop_debounce_s: 15.0
message_timeout_s: 20.0
heartbeat_interval_s: 10.0
"""
config_path = workspace_dir / "test_config.yaml"
config_path.write_text(config_content)
yield config_path
# Cleanup
if config_path.exists():
config_path.unlink()
@pytest.fixture(scope="session")
def test_events_log(workspace_dir):
"""Log-Datei für emittierte Events"""
log_file = workspace_dir / "test_events.log"
# Löschen wenn vorhanden
if log_file.exists():
log_file.unlink()
yield log_file
# Cleanup optional (zum Debugging behalten)
# if log_file.exists():
# log_file.unlink()
@pytest.fixture(scope="module")
def bridge_process(workspace_dir, test_config_file):
"""Startet die IoT Bridge als Subprocess"""
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
# Python aus venv
python_exe = workspace_dir / "venv" / "bin" / "python"
if not python_exe.exists():
pytest.skip("Kein venv gefunden. Bitte 'python -m venv venv' ausführen.")
bridge_log = workspace_dir / "bridge_integration_test.log"
with open(bridge_log, "w") as log_file:
process = subprocess.Popen(
[str(python_exe), "main.py", "--config", str(test_config_file)],
cwd=workspace_dir,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
)
# Warten bis Bridge connected ist
print("\n⏳ Warte auf Bridge Start...")
time.sleep(5)
# Prüfen ob Prozess läuft
if process.poll() is not None:
with open(bridge_log) as f:
output = f.read()
pytest.fail(f"Bridge konnte nicht gestartet werden:\n{output}")
print("✅ Bridge gestartet")
yield process
# Bridge sauber beenden
print("\n🛑 Beende Bridge...")
process.send_signal(signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
@pytest.fixture
def simulator_process(workspace_dir, test_config_file):
"""Startet Shelly Simulator als Subprocess"""
python_exe = workspace_dir / "venv" / "bin" / "python"
simulator_path = workspace_dir / "tests" / "tools" / "shelly_simulator.py"
if not simulator_path.exists():
pytest.skip("shelly_simulator.py nicht gefunden")
# Load MQTT config from test_config (same as bridge uses)
import yaml
with open(test_config_file) as f:
config = yaml.safe_load(f)
mqtt_config = config.get("mqtt", {})
broker = mqtt_config.get("broker", "localhost")
port = mqtt_config.get("port", 1883)
username = mqtt_config.get("username")
password = mqtt_config.get("password")
use_tls = mqtt_config.get("use_tls", False)
def run_scenario(
scenario: str,
topic_prefix: str,
duration: int = 30,
power: float | None = None,
):
"""Startet Simulator mit Szenario"""
cmd = [
str(python_exe),
str(simulator_path),
"--broker",
broker,
"--port",
str(port),
"--topic-prefix",
topic_prefix,
"--scenario",
scenario,
"--duration",
str(duration),
]
# Add credentials only if present
if username:
cmd.extend(["--username", username])
if password:
cmd.extend(["--password", password])
# Add --no-tls if TLS is disabled
if not use_tls:
cmd.append("--no-tls")
if power is not None:
cmd.extend(["--power", str(power)])
print(f"\n🔧 Starte Simulator: {scenario} (topic={topic_prefix})")
print(f" Command: {' '.join(cmd)}")
# Simulator im Hintergrund starten - STDOUT auf console für Debugging
process = subprocess.Popen(
cmd,
cwd=workspace_dir,
stdout=None, # Inherit stdout (zeigt auf console)
stderr=subprocess.STDOUT,
text=True,
)
# Kurz warten damit Simulator sich connecten kann
time.sleep(2)
return process
yield run_scenario
def wait_for_log_line(log_file: Path, search_string: str, timeout: int = 15):
"""Wartet bis search_string im Log auftaucht"""
start_time = time.time()
while time.time() - start_time < timeout:
if log_file.exists():
with open(log_file) as f:
content = f.read()
if search_string in content:
return True
time.sleep(0.5)
return False
def count_event_type_in_log(log_file: Path, event_type: str):
"""Zählt wie oft event_type im Bridge-Log vorkommt"""
if not log_file.exists():
return 0
count = 0
with open(log_file) as f:
for line in f:
if event_type in line and '"event_type"' in line:
count += 1
return count
class TestBridgeIntegration:
"""Integration Tests mit Simulator"""
def test_bridge_is_running(self, bridge_process):
"""Bridge läuft erfolgreich"""
assert bridge_process.poll() is None, "Bridge Prozess ist abgestürzt"
def test_session_start_standby(self, bridge_process, simulator_process, workspace_dir):
"""Session Start → STANDBY via Simulator"""
log_file = workspace_dir / "bridge_integration_test.log"
# Simulator: standby Szenario (40-60W)
sim = simulator_process("standby", "testshelly-m1", duration=15)
# Warten auf session_started Event
print("⏳ Warte auf session_started Event...")
found = wait_for_log_line(log_file, "session_started", timeout=20)
# Simulator beenden
sim.terminate()
sim.wait()
assert found, "session_started Event wurde nicht geloggt"
# Log ausgeben
with open(log_file) as f:
lines = f.readlines()
for line in lines[-20:]: # Letzte 20 Zeilen
if "session_started" in line:
print(f"\n✅ Event gefunden: {line.strip()}")
def test_session_heartbeat(self, bridge_process, simulator_process, workspace_dir):
"""Session Heartbeat nach 10s"""
log_file = workspace_dir / "bridge_integration_test.log"
# Simulator: standby für 20s (heartbeat_interval_s = 10s)
sim = simulator_process("standby", "testshelly-m1", duration=20)
# Warten auf session_heartbeat Event
print("⏳ Warte auf session_heartbeat Event (10s)...")
found = wait_for_log_line(log_file, "session_heartbeat", timeout=25)
sim.terminate()
sim.wait()
assert found, "session_heartbeat Event wurde nicht geloggt"
# Log ausgeben
with open(log_file) as f:
lines = f.readlines()
for line in lines[-20:]:
if "session_heartbeat" in line:
print(f"\n✅ Heartbeat gefunden: {line.strip()}")
def test_full_session_cycle(self, bridge_process, simulator_process, workspace_dir):
"""Kompletter Session-Zyklus: Start → Working → Heartbeat → End"""
log_file = workspace_dir / "bridge_integration_test.log"
# Simulator: full_session Szenario (dauert ~80s)
print("\n🔄 Starte Full Session Szenario...")
sim = simulator_process("full_session", "testshelly-m1", duration=90)
# Warten auf Events
time.sleep(5)
assert wait_for_log_line(log_file, "session_started", timeout=10), "session_started fehlt"
time.sleep(12) # Warten auf Heartbeat (10s interval + buffer)
assert wait_for_log_line(
log_file, "session_heartbeat", timeout=5
), "session_heartbeat fehlt"
# Warten bis Simulator fertig ist (80s Szenario)
sim.wait(timeout=90)
time.sleep(3) # Buffer für session_ended
assert wait_for_log_line(log_file, "session_ended", timeout=5), "session_ended fehlt"
print("✅ Full Session Cycle erfolgreich")
def test_standby_to_working_transition(self, bridge_process, simulator_process, workspace_dir):
"""STANDBY → WORKING Transition"""
log_file = workspace_dir / "bridge_integration_test.log"
# Simulator: working Szenario (>100W)
sim = simulator_process("working", "testshelly-m1", duration=15)
# Warten auf session_started
time.sleep(5)
found_start = wait_for_log_line(log_file, "session_started", timeout=10)
sim.terminate()
sim.wait()
assert found_start, "session_started Event fehlt"
# Prüfen ob WORKING State erreicht wurde
with open(log_file) as f:
content = f.read()
# Suche nach State-Wechsel Logs
assert (
"working" in content.lower() or "standby" in content.lower()
), "Keine State-Logs gefunden"
print("✅ State Transition Test erfolgreich")
def test_multi_device_parallel_sessions(self, bridge_process, simulator_process, workspace_dir):
"""Zwei Devices parallel"""
log_file = workspace_dir / "bridge_integration_test.log"
# Log-Marker setzen vor Test
start_marker = f"=== TEST START {time.time()} ==="
# Zwei Simulatoren parallel starten
print(f"\n🔄 Starte 2 Devices parallel... {start_marker}")
sim1 = simulator_process("standby", "testshelly-m1", duration=25)
time.sleep(2) # Mehr Zeit zwischen Starts
sim2 = simulator_process("standby", "testshelly-m2", duration=25)
# Warten auf beide session_started Events
time.sleep(10) # Mehr Zeit für beide Sessions
# Prüfe ob beide Devices session_started haben
found_device1 = wait_for_log_line(
log_file, "session_started device=pytest-device-01", timeout=5
)
found_device2 = wait_for_log_line(
log_file, "session_started device=pytest-device-02", timeout=5
)
sim1.terminate()
sim2.terminate()
sim1.wait()
sim2.wait()
# Beide Sessions sollten gestartet sein
assert found_device1, "Device 1 Session nicht gestartet"
assert found_device2, "Device 2 Session nicht gestartet"
print("✅ 2 parallele Sessions erfolgreich")
def test_session_timeout(self, bridge_process, simulator_process, workspace_dir):
"""Session Timeout nach 20s ohne Messages"""
log_file = workspace_dir / "bridge_integration_test.log"
# Simulator für 10s, dann stoppen
sim = simulator_process("standby", "testshelly-m1", duration=10)
# Session starten
time.sleep(8)
assert wait_for_log_line(log_file, "session_started", timeout=10)
sim.wait()
# Jetzt 25s warten (> 20s timeout)
print("\n⏱️ Warte 25s für Timeout Detection...")
time.sleep(25)
# Prüfe auf session_timeout Event
found_timeout = wait_for_log_line(log_file, "session_timeout", timeout=5)
assert found_timeout, "session_timeout Event wurde nicht geloggt"
print("✅ Timeout Detection funktioniert")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])