460 lines
15 KiB
Python
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'])
|