odoo_mqtt/iot_bridge/tests/integration/test_bridge_integration.py
2026-02-10 20:00:27 +01:00

460 lines
15 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 pytest
import subprocess
import time
import json
import signal
import os
from pathlib import Path
from datetime import datetime
import sys
@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, 'r') 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):
"""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, 'r') 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, 'r') 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, 'r') 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, 'r') 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, 'r') 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'])