"""Tests for the Hub Telemetry service.""" import asyncio from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.hub_telemetry import HubTelemetryService class TestHubTelemetryServiceStart: """Tests for the start/stop lifecycle of HubTelemetryService.""" def _reset_service(self): """Reset class state between tests.""" HubTelemetryService._task = None HubTelemetryService._shutdown_event = None HubTelemetryService._start_time = None HubTelemetryService._last_sent_at = None HubTelemetryService._client = None HubTelemetryService._consecutive_failures = 0 @pytest.mark.asyncio async def test_start_skips_when_telemetry_disabled(self): """Verify start() does nothing when HUB_TELEMETRY_ENABLED is False.""" self._reset_service() with patch("app.services.hub_telemetry.settings") as mock_settings: mock_settings.HUB_TELEMETRY_ENABLED = False await HubTelemetryService.start() assert HubTelemetryService._task is None assert HubTelemetryService._client is None @pytest.mark.asyncio async def test_start_skips_when_hub_url_missing(self): """Verify start() does nothing when HUB_URL is not set.""" self._reset_service() with patch("app.services.hub_telemetry.settings") as mock_settings: mock_settings.HUB_TELEMETRY_ENABLED = True mock_settings.HUB_URL = None await HubTelemetryService.start() assert HubTelemetryService._task is None @pytest.mark.asyncio async def test_start_skips_when_hub_api_key_missing(self): """Verify start() does nothing when HUB_API_KEY is not set.""" self._reset_service() with patch("app.services.hub_telemetry.settings") as mock_settings: mock_settings.HUB_TELEMETRY_ENABLED = True mock_settings.HUB_URL = "https://hub.example.com" mock_settings.HUB_API_KEY = None await HubTelemetryService.start() assert HubTelemetryService._task is None @pytest.mark.asyncio async def test_start_skips_when_instance_id_missing(self): """Verify start() does nothing when INSTANCE_ID is not set.""" self._reset_service() with patch("app.services.hub_telemetry.settings") as mock_settings: mock_settings.HUB_TELEMETRY_ENABLED = True mock_settings.HUB_URL = "https://hub.example.com" mock_settings.HUB_API_KEY = "test-key" mock_settings.INSTANCE_ID = None await HubTelemetryService.start() assert HubTelemetryService._task is None @pytest.mark.asyncio async def test_stop_without_start(self): """Verify stop() handles gracefully when service was never started.""" self._reset_service() # Should not raise await HubTelemetryService.stop() class TestHubTelemetryFormatters: """Tests for the metric formatting class methods.""" def test_format_agent_counts_empty(self): """Verify _format_agent_counts handles empty rows.""" result = HubTelemetryService._format_agent_counts([]) assert result == { "online_count": 0, "offline_count": 0, "total_count": 0, } def test_format_agent_counts_with_online_agents(self): """Verify _format_agent_counts counts online agents correctly.""" # Create mock rows that mimic SQLAlchemy result rows from enum import Enum class MockAgentStatus(str, Enum): ONLINE = "online" OFFLINE = "offline" # Patch AgentStatus for comparison with patch("app.services.hub_telemetry.AgentStatus") as mock_status: mock_status.ONLINE = MockAgentStatus.ONLINE mock_status.OFFLINE = MockAgentStatus.OFFLINE online_row = MagicMock() online_row.status = MockAgentStatus.ONLINE online_row.count = 3 offline_row = MagicMock() offline_row.status = MockAgentStatus.OFFLINE offline_row.count = 1 result = HubTelemetryService._format_agent_counts([online_row, offline_row]) assert result["online_count"] == 3 assert result["offline_count"] == 1 assert result["total_count"] == 4 def test_format_task_counts_empty(self): """Verify _format_task_counts handles empty rows.""" result = HubTelemetryService._format_task_counts([]) assert result == { "by_status": {}, "by_type": {}, } def test_format_task_counts_with_data(self): """Verify _format_task_counts aggregates correctly.""" row1 = MagicMock() row1.status = "completed" row1.type = "SHELL" row1.count = 5 row1.avg_duration_ms = 1500.0 row2 = MagicMock() row2.status = "failed" row2.type = "SHELL" row2.count = 2 row2.avg_duration_ms = 3000.0 row3 = MagicMock() row3.status = "completed" row3.type = "DOCKER_RELOAD" row3.count = 3 row3.avg_duration_ms = 5000.0 result = HubTelemetryService._format_task_counts([row1, row2, row3]) # Check by_status aggregation assert result["by_status"]["completed"] == 8 # 5 + 3 assert result["by_status"]["failed"] == 2 # Check by_type aggregation assert result["by_type"]["SHELL"]["count"] == 7 # 5 + 2 assert result["by_type"]["DOCKER_RELOAD"]["count"] == 3 def test_format_task_counts_handles_none_duration(self): """Verify _format_task_counts handles None avg_duration_ms.""" row = MagicMock() row.status = "pending" row.type = "ECHO" row.count = 1 row.avg_duration_ms = None result = HubTelemetryService._format_task_counts([row]) assert result["by_type"]["ECHO"]["count"] == 1 assert result["by_type"]["ECHO"]["avg_duration_ms"] == 0 def test_format_task_counts_rounds_durations(self): """Verify _format_task_counts rounds avg_duration_ms to 2 decimals.""" row = MagicMock() row.status = "completed" row.type = "SHELL" row.count = 1 row.avg_duration_ms = 1234.56789 result = HubTelemetryService._format_task_counts([row]) assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1234.57 def test_format_task_counts_weighted_average(self): """Verify _format_task_counts computes weighted average across same type.""" # Two rows for same type: SHELL completed (count=2, avg=1000) and SHELL failed (count=3, avg=2000) row1 = MagicMock() row1.status = "completed" row1.type = "SHELL" row1.count = 2 row1.avg_duration_ms = 1000.0 row2 = MagicMock() row2.status = "failed" row2.type = "SHELL" row2.count = 3 row2.avg_duration_ms = 2000.0 result = HubTelemetryService._format_task_counts([row1, row2]) # Weighted avg: (2*1000 + 3*2000) / (2+3) = 8000/5 = 1600.0 assert result["by_type"]["SHELL"]["count"] == 5 assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1600.0 def test_format_task_counts_with_enum_values(self): """Verify _format_task_counts handles status/type with .value attribute.""" from enum import Enum class MockStatus(Enum): COMPLETED = "completed" class MockType(Enum): SHELL = "SHELL" row = MagicMock() row.status = MockStatus.COMPLETED row.type = MockType.SHELL row.count = 1 row.avg_duration_ms = 500.0 result = HubTelemetryService._format_task_counts([row]) assert "completed" in result["by_status"] assert "SHELL" in result["by_type"] class TestHubTelemetryBackoff: """Tests for backoff and jitter behavior.""" def test_consecutive_failures_reset_on_init(self): """Verify consecutive_failures starts at 0.""" HubTelemetryService._consecutive_failures = 5 HubTelemetryService._consecutive_failures = 0 assert HubTelemetryService._consecutive_failures == 0 def test_backoff_calculation(self): """Verify exponential backoff formula.""" # Backoff is min(2^failures, 60) assert min(2**0, 60) == 1 assert min(2**1, 60) == 2 assert min(2**2, 60) == 4 assert min(2**3, 60) == 8 assert min(2**6, 60) == 60 # Capped at 60 assert min(2**10, 60) == 60 # Still capped