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
478 lines
14 KiB
Python
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"])
|