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
331 lines
9.2 KiB
Python
331 lines
9.2 KiB
Python
"""
|
|
Shared pytest fixtures for IoT Bridge tests.
|
|
|
|
This module provides reusable test fixtures for:
|
|
- Configuration objects
|
|
- MQTT client mocks
|
|
- Odoo client mocks
|
|
- Device fixtures
|
|
- Session detector instances
|
|
- Event queue instances
|
|
"""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from config.schema import (
|
|
BridgeConfig,
|
|
DeviceConfig,
|
|
DeviceStatusConfig,
|
|
EventQueueConfig,
|
|
LoggingConfig,
|
|
MQTTConfig,
|
|
OdooConfig,
|
|
SessionConfig,
|
|
)
|
|
|
|
# ============================================================================
|
|
# Configuration Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_config_file():
|
|
"""Create a temporary YAML config file for testing."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
|
config_data = {
|
|
"mqtt": {
|
|
"broker": "localhost",
|
|
"port": 1883,
|
|
"client_id": "test_bridge",
|
|
"username": "",
|
|
"password": "",
|
|
"use_tls": False,
|
|
},
|
|
"odoo": {
|
|
"base_url": "http://localhost:8069",
|
|
"database": "test_db",
|
|
"username": "test@example.com",
|
|
"password": "test_password",
|
|
},
|
|
"logging": {"level": "DEBUG", "format": "json"},
|
|
"session": {
|
|
"heartbeat_interval": 300,
|
|
"grace_period": 60,
|
|
"min_session_duration": 10,
|
|
},
|
|
"devices": [
|
|
{
|
|
"device_id": "test_device_1",
|
|
"topic": "test/device1/#",
|
|
"parser": "shelly",
|
|
},
|
|
{
|
|
"device_id": "test_device_2",
|
|
"topic": "test/device2/#",
|
|
"parser": "shelly",
|
|
},
|
|
],
|
|
}
|
|
yaml.dump(config_data, f)
|
|
temp_path = Path(f.name)
|
|
|
|
yield temp_path
|
|
|
|
# Cleanup
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def mqtt_config():
|
|
"""Provide a sample MQTT configuration."""
|
|
return MQTTConfig(
|
|
broker="localhost",
|
|
port=1883,
|
|
client_id="test_bridge",
|
|
username="",
|
|
password="",
|
|
use_tls=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def odoo_config():
|
|
"""Provide a sample Odoo configuration."""
|
|
return OdooConfig(
|
|
base_url="http://localhost:8069",
|
|
database="test_db",
|
|
username="test@example.com",
|
|
api_key="test_password",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def logging_config():
|
|
"""Provide a sample logging configuration."""
|
|
return LoggingConfig(level="DEBUG", format="json", log_file=None)
|
|
|
|
|
|
@pytest.fixture
|
|
def session_config():
|
|
"""Provide a sample session configuration."""
|
|
return SessionConfig(
|
|
strategy="power_threshold",
|
|
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,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def device_config(session_config):
|
|
"""Provide a sample device configuration."""
|
|
return DeviceConfig(
|
|
device_id="test_device",
|
|
mqtt_topic="test/device/#",
|
|
parser_type="shelly_pm",
|
|
machine_name="Test Device",
|
|
session_config=session_config,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def device_configs(session_config):
|
|
"""Provide a list of device configurations."""
|
|
return [
|
|
DeviceConfig(
|
|
device_id="test_device_1",
|
|
mqtt_topic="test/device1/#",
|
|
parser_type="shelly_pm",
|
|
machine_name="Test Device 1",
|
|
session_config=session_config,
|
|
),
|
|
DeviceConfig(
|
|
device_id="test_device_2",
|
|
mqtt_topic="test/device2/#",
|
|
parser_type="shelly_pm",
|
|
machine_name="Test Device 2",
|
|
session_config=session_config,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def bridge_config(mqtt_config, odoo_config, logging_config, device_configs):
|
|
"""Provide a complete bridge configuration."""
|
|
return BridgeConfig(
|
|
mqtt=mqtt_config,
|
|
odoo=odoo_config,
|
|
logging=logging_config,
|
|
event_queue=EventQueueConfig(),
|
|
device_status=DeviceStatusConfig(),
|
|
devices=device_configs,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# MQTT Client Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_mqtt_client():
|
|
"""Provide a mock MQTT client."""
|
|
client = MagicMock()
|
|
client.connect = Mock(return_value=True)
|
|
client.disconnect = Mock()
|
|
client.subscribe = Mock()
|
|
client.unsubscribe = Mock()
|
|
client.publish = Mock()
|
|
client.loop_start = Mock()
|
|
client.loop_stop = Mock()
|
|
client.is_connected = Mock(return_value=True)
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_paho_mqtt_client(monkeypatch):
|
|
"""Mock paho.mqtt.client.Client for tests that instantiate it."""
|
|
mock_client = MagicMock()
|
|
mock_client.connect = Mock(return_value=0) # MQTT success code
|
|
mock_client.disconnect = Mock(return_value=0)
|
|
mock_client.subscribe = Mock(return_value=(0, 1)) # (result, mid)
|
|
mock_client.unsubscribe = Mock(return_value=(0, 1))
|
|
mock_client.publish = Mock(return_value=(0, 1))
|
|
mock_client.loop_start = Mock(return_value=0)
|
|
mock_client.loop_stop = Mock(return_value=0)
|
|
|
|
def mock_mqtt_client_init(*args, **kwargs):
|
|
return mock_client
|
|
|
|
monkeypatch.setattr("paho.mqtt.client.Client", mock_mqtt_client_init)
|
|
return mock_client
|
|
|
|
|
|
# ============================================================================
|
|
# Odoo Client Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_odoo_client():
|
|
"""Provide a mock Odoo client."""
|
|
client = MagicMock()
|
|
client.send_event = Mock(return_value=True)
|
|
client.authenticate = Mock(return_value=True)
|
|
client.base_url = "http://localhost:8069"
|
|
client.database = "test_db"
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_requests(monkeypatch):
|
|
"""Mock requests library for HTTP calls."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json = Mock(return_value={"result": "success"})
|
|
mock_response.raise_for_status = Mock()
|
|
|
|
mock_post = Mock(return_value=mock_response)
|
|
mock_get = Mock(return_value=mock_response)
|
|
|
|
monkeypatch.setattr("requests.post", mock_post)
|
|
monkeypatch.setattr("requests.get", mock_get)
|
|
|
|
return {"post": mock_post, "get": mock_get, "response": mock_response}
|
|
|
|
|
|
# ============================================================================
|
|
# Device & Parser Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def shelly_pm_message():
|
|
"""Provide a sample Shelly PM MQTT message."""
|
|
return {
|
|
"topic": "shellypmminig3-test123/status/pm1:0",
|
|
"payload": {
|
|
"id": 0,
|
|
"voltage": 230.5,
|
|
"current": 2.1,
|
|
"apower": 484.0,
|
|
"freq": 50.0,
|
|
"aenergy": {"total": 12345.678, "by_minute": [120.5, 115.3, 118.7]},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_parser():
|
|
"""Provide a mock message parser."""
|
|
parser = MagicMock()
|
|
parser.parse_message = Mock(
|
|
return_value={
|
|
"device_id": "test_device",
|
|
"event_type": "power",
|
|
"power": 484.0,
|
|
"voltage": 230.5,
|
|
"current": 2.1,
|
|
"energy": 12345.678,
|
|
"timestamp": "2026-02-18T20:00:00Z",
|
|
}
|
|
)
|
|
return parser
|
|
|
|
|
|
# ============================================================================
|
|
# Session Detector Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def session_detector_config():
|
|
"""Provide configuration for session detector."""
|
|
return {
|
|
"device_id": "test_device",
|
|
"heartbeat_interval": 300,
|
|
"grace_period": 60,
|
|
"min_session_duration": 10,
|
|
"power_threshold": 50.0,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Event Queue Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_event():
|
|
"""Provide a sample IoT event."""
|
|
return {
|
|
"device_id": "test_device",
|
|
"event_type": "session_start",
|
|
"timestamp": "2026-02-18T20:00:00Z",
|
|
"power": 484.0,
|
|
"energy": 12345.678,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Pytest Configuration
|
|
# ============================================================================
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Configure pytest with custom markers."""
|
|
config.addinivalue_line("markers", "unit: Unit tests (fast, no external dependencies)")
|
|
config.addinivalue_line(
|
|
"markers", "integration: Integration tests (may require MQTT broker, Odoo)"
|
|
)
|
|
config.addinivalue_line("markers", "e2e: End-to-end tests (full system test)")
|
|
config.addinivalue_line("markers", "slow: Slow running tests")
|