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
250 lines
7.8 KiB
Python
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"])
|