test_telemetry.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Unit tests for Telemetry service.
  2. Tests the anonymous telemetry/stats collection functionality.
  3. """
  4. from datetime import datetime, timedelta
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from backend.app.models.settings import Settings
  8. from backend.app.services.telemetry import (
  9. DEFAULT_TELEMETRY_URL,
  10. HEARTBEAT_INTERVAL,
  11. _last_heartbeat,
  12. get_or_create_installation_id,
  13. get_telemetry_url,
  14. is_telemetry_enabled,
  15. send_heartbeat,
  16. )
  17. class TestTelemetryService:
  18. """Tests for telemetry service functions."""
  19. # ========================================================================
  20. # Installation ID Tests
  21. # ========================================================================
  22. @pytest.mark.asyncio
  23. async def test_get_or_create_installation_id_creates_new(self, db_session):
  24. """Verify new installation ID is created when none exists."""
  25. installation_id = await get_or_create_installation_id(db_session)
  26. assert installation_id is not None
  27. assert len(installation_id) == 36 # UUID format
  28. assert "-" in installation_id
  29. @pytest.mark.asyncio
  30. async def test_get_or_create_installation_id_returns_existing(self, db_session):
  31. """Verify existing installation ID is returned."""
  32. # Create an existing installation ID
  33. existing_id = "test-uuid-1234-5678-abcd"
  34. setting = Settings(key="installation_id", value=existing_id)
  35. db_session.add(setting)
  36. await db_session.commit()
  37. result = await get_or_create_installation_id(db_session)
  38. assert result == existing_id
  39. @pytest.mark.asyncio
  40. async def test_get_or_create_installation_id_persists(self, db_session):
  41. """Verify created installation ID persists in database."""
  42. first_id = await get_or_create_installation_id(db_session)
  43. second_id = await get_or_create_installation_id(db_session)
  44. assert first_id == second_id
  45. # ========================================================================
  46. # Telemetry Enabled Tests
  47. # ========================================================================
  48. @pytest.mark.asyncio
  49. async def test_is_telemetry_enabled_default_true(self, db_session):
  50. """Verify telemetry is enabled by default (opt-out model)."""
  51. result = await is_telemetry_enabled(db_session)
  52. assert result is True
  53. @pytest.mark.asyncio
  54. async def test_is_telemetry_enabled_explicit_true(self, db_session):
  55. """Verify telemetry enabled when explicitly set to true."""
  56. setting = Settings(key="telemetry_enabled", value="true")
  57. db_session.add(setting)
  58. await db_session.commit()
  59. result = await is_telemetry_enabled(db_session)
  60. assert result is True
  61. @pytest.mark.asyncio
  62. async def test_is_telemetry_enabled_explicit_false(self, db_session):
  63. """Verify telemetry disabled when set to false."""
  64. setting = Settings(key="telemetry_enabled", value="false")
  65. db_session.add(setting)
  66. await db_session.commit()
  67. result = await is_telemetry_enabled(db_session)
  68. assert result is False
  69. @pytest.mark.asyncio
  70. async def test_is_telemetry_enabled_case_insensitive(self, db_session):
  71. """Verify telemetry enabled check is case insensitive."""
  72. setting = Settings(key="telemetry_enabled", value="TRUE")
  73. db_session.add(setting)
  74. await db_session.commit()
  75. result = await is_telemetry_enabled(db_session)
  76. assert result is True
  77. # ========================================================================
  78. # Telemetry URL Tests
  79. # ========================================================================
  80. @pytest.mark.asyncio
  81. async def test_get_telemetry_url_default(self, db_session):
  82. """Verify default telemetry URL is returned when not configured."""
  83. result = await get_telemetry_url(db_session)
  84. assert result == DEFAULT_TELEMETRY_URL
  85. @pytest.mark.asyncio
  86. async def test_get_telemetry_url_custom(self, db_session):
  87. """Verify custom telemetry URL is returned when configured."""
  88. custom_url = "https://custom.telemetry.example.com"
  89. setting = Settings(key="telemetry_url", value=custom_url)
  90. db_session.add(setting)
  91. await db_session.commit()
  92. result = await get_telemetry_url(db_session)
  93. assert result == custom_url
  94. # ========================================================================
  95. # Send Heartbeat Tests
  96. # ========================================================================
  97. @pytest.mark.asyncio
  98. async def test_send_heartbeat_when_disabled(self, db_session):
  99. """Verify heartbeat is not sent when telemetry is disabled."""
  100. setting = Settings(key="telemetry_enabled", value="false")
  101. db_session.add(setting)
  102. await db_session.commit()
  103. with patch("httpx.AsyncClient") as mock_client:
  104. result = await send_heartbeat(db_session)
  105. assert result is False
  106. mock_client.assert_not_called()
  107. @pytest.mark.asyncio
  108. async def test_send_heartbeat_success(self, db_session, mock_httpx_client):
  109. """Verify heartbeat is sent successfully when enabled."""
  110. # Reset the last heartbeat to allow sending
  111. import backend.app.services.telemetry as telemetry_module
  112. telemetry_module._last_heartbeat = None
  113. result = await send_heartbeat(db_session)
  114. assert result is True
  115. @pytest.mark.asyncio
  116. async def test_send_heartbeat_rate_limited(self, db_session):
  117. """Verify heartbeat is rate limited to once per day."""
  118. import backend.app.services.telemetry as telemetry_module
  119. # Set last heartbeat to recent time
  120. telemetry_module._last_heartbeat = datetime.now()
  121. with patch("httpx.AsyncClient") as mock_client:
  122. result = await send_heartbeat(db_session)
  123. # Should return True (already sent) without making HTTP request
  124. assert result is True
  125. mock_client.assert_not_called()
  126. @pytest.mark.asyncio
  127. async def test_send_heartbeat_handles_exceptions(self, db_session):
  128. """Verify heartbeat returns False on general exceptions."""
  129. import backend.app.services.telemetry as telemetry_module
  130. telemetry_module._last_heartbeat = None
  131. # Test that the function handles exceptions gracefully by checking
  132. # the code path - the actual telemetry URL may or may not be reachable
  133. # The function should not raise exceptions to the caller
  134. try:
  135. result = await send_heartbeat(db_session)
  136. # Result can be True (success) or False (failure) but should not raise
  137. assert isinstance(result, bool)
  138. except Exception as e:
  139. pytest.fail(f"send_heartbeat should not raise exceptions: {e}")
  140. @pytest.mark.asyncio
  141. async def test_send_heartbeat_sends_correct_data(self, db_session):
  142. """Verify heartbeat sends correct payload."""
  143. import backend.app.services.telemetry as telemetry_module
  144. from backend.app.core.config import APP_VERSION
  145. telemetry_module._last_heartbeat = None
  146. captured_data = {}
  147. with patch("httpx.AsyncClient") as mock_class:
  148. mock_instance = AsyncMock()
  149. mock_response = MagicMock()
  150. mock_response.raise_for_status = MagicMock()
  151. async def capture_post(url, json=None):
  152. captured_data["url"] = url
  153. captured_data["json"] = json
  154. return mock_response
  155. mock_instance.post = capture_post
  156. mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
  157. mock_instance.__aexit__ = AsyncMock()
  158. mock_class.return_value = mock_instance
  159. await send_heartbeat(db_session)
  160. assert "heartbeat" in captured_data["url"]
  161. assert "installation_id" in captured_data["json"]
  162. assert captured_data["json"]["version"] == APP_VERSION
  163. class TestHeartbeatInterval:
  164. """Tests for heartbeat interval configuration."""
  165. def test_heartbeat_interval_is_24_hours(self):
  166. """Verify heartbeat interval is set to 24 hours."""
  167. assert timedelta(hours=24) == HEARTBEAT_INTERVAL
  168. def test_default_telemetry_url(self):
  169. """Verify default telemetry URL is correct."""
  170. assert DEFAULT_TELEMETRY_URL == "https://telemetry.bambuddy.cool"