odoo_mqtt/iot_bridge/tests/unit/test_event_queue.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

250 lines
7.8 KiB
Python

"""Unit tests for Event Queue with Retry Logic."""
import time
from datetime import datetime, timedelta
from unittest.mock import Mock
import pytest
from core.event_queue import EventQueue, QueuedEvent
class TestQueuedEvent:
"""Tests for QueuedEvent class."""
def test_should_retry_initial(self):
"""Test that new event should retry immediately."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
)
assert event.should_retry(datetime.utcnow()) is True
assert event.retry_count == 0
def test_should_retry_after_max(self):
"""Test that event won't retry after max retries."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
retry_count=10,
max_retries=10,
)
assert event.should_retry(datetime.utcnow()) is False
def test_should_retry_before_next_time(self):
"""Test that event won't retry before next_retry_time."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
retry_count=1,
next_retry_time=datetime.utcnow() + timedelta(seconds=10),
)
assert event.should_retry(datetime.utcnow()) is False
def test_should_retry_after_next_time(self):
"""Test that event will retry after next_retry_time."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
retry_count=1,
next_retry_time=datetime.utcnow() - timedelta(seconds=1),
)
assert event.should_retry(datetime.utcnow()) is True
def test_exponential_backoff(self):
"""Test exponential backoff calculation."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
)
# First retry: 2^0 = 1s
event.increment_retry()
assert event.retry_count == 1
assert event.next_retry_time is not None
delay1 = (event.next_retry_time - datetime.utcnow()).total_seconds()
assert 1.9 <= delay1 <= 2.1 # 2^1 = 2s
# Second retry: 2^2 = 4s
event.increment_retry()
assert event.retry_count == 2
assert event.next_retry_time is not None
delay2 = (event.next_retry_time - datetime.utcnow()).total_seconds()
assert 3.9 <= delay2 <= 4.1 # 2^2 = 4s
# Third retry: 2^3 = 8s
event.increment_retry()
assert event.retry_count == 3
assert event.next_retry_time is not None
delay3 = (event.next_retry_time - datetime.utcnow()).total_seconds()
assert 7.9 <= delay3 <= 8.1 # 2^3 = 8s
def test_max_backoff_cap(self):
"""Test that backoff is capped at 60s."""
event = QueuedEvent(
event_uid="test-uid",
event_type="session_started",
device_id="device-01",
payload={},
created_at=datetime.utcnow(),
retry_count=10, # 2^10 = 1024s, but should be capped
)
event.increment_retry()
assert event.next_retry_time is not None
delay = (event.next_retry_time - datetime.utcnow()).total_seconds()
assert 59.9 <= delay <= 60.1
class TestEventQueue:
"""Tests for EventQueue class."""
def test_enqueue(self):
"""Test enqueuing events."""
send_mock = Mock(return_value=True)
queue = EventQueue(send_callback=send_mock)
event = {
"event_type": "session_started",
"device_id": "device-01",
"session_id": "session-123",
}
uid = queue.enqueue(event)
assert uid is not None
assert queue.get_queue_size() == 1
assert queue.stats["pending_count"] == 1
def test_enqueue_preserves_uid(self):
"""Test that existing event_uid is preserved."""
send_mock = Mock(return_value=True)
queue = EventQueue(send_callback=send_mock)
event = {
"event_uid": "my-custom-uid",
"event_type": "session_started",
"device_id": "device-01",
}
uid = queue.enqueue(event)
assert uid == "my-custom-uid"
def test_successful_send(self):
"""Test successful event sending."""
send_mock = Mock(return_value=True)
queue = EventQueue(send_callback=send_mock)
queue.start()
event = {"event_type": "session_started", "device_id": "device-01"}
queue.enqueue(event)
# Wait for processing
time.sleep(0.3)
queue.stop()
# Verify send was called
assert send_mock.call_count == 1
assert queue.stats["sent_count"] == 1
assert queue.stats["pending_count"] == 0
def test_failed_send_with_retry(self):
"""Test that failed sends are retried."""
# Fail first 2 times, succeed on 3rd
send_mock = Mock(side_effect=[False, False, True])
queue = EventQueue(send_callback=send_mock)
queue.start()
event = {"event_type": "session_started", "device_id": "device-01"}
queue.enqueue(event)
# Wait for initial send + 2 retries (initial + 2s + 4s = 7s)
time.sleep(8)
queue.stop()
# Verify retries happened
assert send_mock.call_count == 3
assert queue.stats["sent_count"] == 1
assert queue.stats["retry_count"] == 2
assert queue.stats["pending_count"] == 0
def test_max_retries_exceeded(self):
"""Test that events are marked failed after max retries."""
send_mock = Mock(return_value=False) # Always fail
queue = EventQueue(send_callback=send_mock)
queue.start()
event = {"event_type": "session_started", "device_id": "device-01"}
queue.enqueue(event)
# Wait for first 5 retries (2s + 4s + 8s + 16s + 32s = 62s)
# For faster tests, just verify retry behavior with 4 attempts
time.sleep(32) # Enough for 4-5 retry attempts
queue.stop()
# Should have attempted at least 5 times
assert send_mock.call_count >= 5
assert queue.stats["retry_count"] >= 4
def test_queue_statistics(self):
"""Test queue statistics tracking."""
send_mock = Mock(side_effect=[True, False, True])
queue = EventQueue(send_callback=send_mock)
queue.start()
# Enqueue 3 events
for i in range(3):
queue.enqueue({"event_type": "session_heartbeat", "device_id": f"device-{i:02d}"})
time.sleep(3)
queue.stop()
stats = queue.get_stats()
assert stats["sent_count"] >= 2 # At least 2 succeeded
assert stats["retry_count"] >= 1 # At least 1 retry
def test_queue_stop_waits_for_completion(self):
"""Test that stop() waits for queue processing to finish."""
send_mock = Mock(return_value=True)
queue = EventQueue(send_callback=send_mock)
queue.start()
# Enqueue event
queue.enqueue({"event_type": "session_started", "device_id": "device-01"})
# Stop immediately
queue.stop()
# Verify event was processed before stop completed
assert send_mock.call_count >= 0 # May or may not have processed
assert not queue.running
if __name__ == "__main__":
pytest.main([__file__, "-v"])