test_bug_report.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """Unit tests for bug report service and route."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. class TestBugReportService:
  5. """Tests for bug_report.submit_report()."""
  6. @pytest.mark.asyncio
  7. @pytest.mark.unit
  8. async def test_submit_success(self):
  9. """Successful relay call saves report and returns issue details."""
  10. from backend.app.services.bug_report import submit_report
  11. mock_response = MagicMock()
  12. mock_response.status_code = 200
  13. mock_response.json.return_value = {
  14. "success": True,
  15. "message": "Created",
  16. "issue_url": "https://github.com/maziggy/bambuddy/issues/99",
  17. "issue_number": 99,
  18. }
  19. mock_db = AsyncMock()
  20. mock_db.add = MagicMock()
  21. mock_db.commit = AsyncMock()
  22. with (
  23. patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
  24. patch("backend.app.services.bug_report.async_session") as mock_session,
  25. patch("backend.app.services.bug_report._rate_limit_timestamps", []),
  26. patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
  27. ):
  28. mock_client = AsyncMock()
  29. mock_client.post = AsyncMock(return_value=mock_response)
  30. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  31. mock_client.__aexit__ = AsyncMock(return_value=False)
  32. mock_client_cls.return_value = mock_client
  33. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  34. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  35. result = await submit_report(
  36. description="Test bug",
  37. reporter_email="user@test.com",
  38. screenshot_base64=None,
  39. support_info=None,
  40. )
  41. assert result["success"] is True
  42. assert result["issue_number"] == 99
  43. assert result["issue_url"] == "https://github.com/maziggy/bambuddy/issues/99"
  44. mock_db.add.assert_called_once()
  45. @pytest.mark.asyncio
  46. @pytest.mark.unit
  47. async def test_submit_rate_limited(self):
  48. """Returns failure when rate limit exceeded."""
  49. import time
  50. from backend.app.services.bug_report import submit_report
  51. timestamps = [time.time()] * 5 # Already at limit
  52. with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
  53. result = await submit_report(
  54. description="Test",
  55. reporter_email=None,
  56. screenshot_base64=None,
  57. support_info=None,
  58. )
  59. assert result["success"] is False
  60. assert "Rate limit" in result["message"]
  61. @pytest.mark.asyncio
  62. @pytest.mark.unit
  63. async def test_submit_no_relay_url(self):
  64. """Returns failure when relay URL is not configured."""
  65. from backend.app.services.bug_report import submit_report
  66. with (
  67. patch("backend.app.services.bug_report._rate_limit_timestamps", []),
  68. patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", ""),
  69. ):
  70. result = await submit_report(
  71. description="Test",
  72. reporter_email=None,
  73. screenshot_base64=None,
  74. support_info=None,
  75. )
  76. assert result["success"] is False
  77. assert "not configured" in result["message"]
  78. @pytest.mark.asyncio
  79. @pytest.mark.unit
  80. async def test_submit_relay_http_error(self):
  81. """Non-200 relay response saves failed report."""
  82. from backend.app.services.bug_report import submit_report
  83. mock_response = MagicMock()
  84. mock_response.status_code = 500
  85. mock_db = AsyncMock()
  86. mock_db.add = MagicMock()
  87. mock_db.commit = AsyncMock()
  88. with (
  89. patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
  90. patch("backend.app.services.bug_report.async_session") as mock_session,
  91. patch("backend.app.services.bug_report._rate_limit_timestamps", []),
  92. patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
  93. ):
  94. mock_client = AsyncMock()
  95. mock_client.post = AsyncMock(return_value=mock_response)
  96. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  97. mock_client.__aexit__ = AsyncMock(return_value=False)
  98. mock_client_cls.return_value = mock_client
  99. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  100. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  101. result = await submit_report(
  102. description="Test",
  103. reporter_email=None,
  104. screenshot_base64=None,
  105. support_info=None,
  106. )
  107. assert result["success"] is False
  108. assert "not available" in result["message"]
  109. mock_db.add.assert_called_once()
  110. @pytest.mark.asyncio
  111. @pytest.mark.unit
  112. async def test_submit_relay_connection_error(self):
  113. """Connection failure saves failed report."""
  114. from backend.app.services.bug_report import submit_report
  115. mock_db = AsyncMock()
  116. mock_db.add = MagicMock()
  117. mock_db.commit = AsyncMock()
  118. with (
  119. patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
  120. patch("backend.app.services.bug_report.async_session") as mock_session,
  121. patch("backend.app.services.bug_report._rate_limit_timestamps", []),
  122. patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
  123. ):
  124. mock_client = AsyncMock()
  125. mock_client.post = AsyncMock(side_effect=ConnectionError("Connection refused"))
  126. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  127. mock_client.__aexit__ = AsyncMock(return_value=False)
  128. mock_client_cls.return_value = mock_client
  129. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  130. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  131. result = await submit_report(
  132. description="Test",
  133. reporter_email=None,
  134. screenshot_base64=None,
  135. support_info=None,
  136. )
  137. assert result["success"] is False
  138. assert "Failed to submit" in result["message"]
  139. @pytest.mark.asyncio
  140. @pytest.mark.unit
  141. async def test_submit_relay_failure_response(self):
  142. """Relay returns success=false in JSON body."""
  143. from backend.app.services.bug_report import submit_report
  144. mock_response = MagicMock()
  145. mock_response.status_code = 200
  146. mock_response.json.return_value = {
  147. "success": False,
  148. "message": "GitHub API error",
  149. }
  150. mock_db = AsyncMock()
  151. mock_db.add = MagicMock()
  152. mock_db.commit = AsyncMock()
  153. with (
  154. patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
  155. patch("backend.app.services.bug_report.async_session") as mock_session,
  156. patch("backend.app.services.bug_report._rate_limit_timestamps", []),
  157. patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
  158. ):
  159. mock_client = AsyncMock()
  160. mock_client.post = AsyncMock(return_value=mock_response)
  161. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  162. mock_client.__aexit__ = AsyncMock(return_value=False)
  163. mock_client_cls.return_value = mock_client
  164. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  165. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  166. result = await submit_report(
  167. description="Test",
  168. reporter_email=None,
  169. screenshot_base64=None,
  170. support_info=None,
  171. )
  172. assert result["success"] is False
  173. assert "GitHub API error" in result["message"]
  174. class TestStartLogging:
  175. """Tests for the start-logging endpoint handler."""
  176. @pytest.mark.asyncio
  177. @pytest.mark.unit
  178. async def test_enables_debug_when_not_already_enabled(self):
  179. """Debug logging is enabled and printers are pushed."""
  180. from backend.app.api.routes.bug_report import start_logging
  181. apply_calls = []
  182. mock_db = AsyncMock()
  183. with (
  184. patch("backend.app.api.routes.bug_report.async_session") as mock_session,
  185. patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(False, None)),
  186. patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
  187. patch(
  188. "backend.app.api.routes.bug_report._apply_log_level",
  189. side_effect=lambda v: apply_calls.append(v),
  190. ),
  191. patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
  192. ):
  193. mock_pm._clients = {"printer1": MagicMock()}
  194. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  195. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  196. result = await start_logging()
  197. assert result.started is True
  198. assert result.was_debug is False
  199. assert apply_calls == [True]
  200. mock_set.assert_called_once()
  201. mock_pm.request_status_update.assert_called_once_with("printer1")
  202. @pytest.mark.asyncio
  203. @pytest.mark.unit
  204. async def test_skips_enable_when_already_debug(self):
  205. """Debug logging not toggled when already enabled."""
  206. mock_db = AsyncMock()
  207. from backend.app.api.routes.bug_report import start_logging
  208. with (
  209. patch("backend.app.api.routes.bug_report.async_session") as mock_session,
  210. patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
  211. patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
  212. patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
  213. patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
  214. ):
  215. mock_pm._clients = {}
  216. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  217. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  218. result = await start_logging()
  219. assert result.started is True
  220. assert result.was_debug is True
  221. mock_apply.assert_not_called()
  222. mock_set.assert_not_called()
  223. @pytest.mark.asyncio
  224. @pytest.mark.unit
  225. async def test_pushes_all_connected_printers(self):
  226. """Sends status update request to all connected printers."""
  227. mock_db = AsyncMock()
  228. from backend.app.api.routes.bug_report import start_logging
  229. with (
  230. patch("backend.app.api.routes.bug_report.async_session") as mock_session,
  231. patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
  232. patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock),
  233. patch("backend.app.api.routes.bug_report._apply_log_level"),
  234. patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
  235. ):
  236. mock_pm._clients = {"printer1": MagicMock(), "printer2": MagicMock()}
  237. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  238. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  239. await start_logging()
  240. assert mock_pm.request_status_update.call_count == 2
  241. class TestStopLogging:
  242. """Tests for the stop-logging endpoint handler."""
  243. @pytest.mark.asyncio
  244. @pytest.mark.unit
  245. async def test_collects_logs_and_restores_level(self):
  246. """Collects logs and restores log level when was_debug=False."""
  247. from backend.app.api.routes.bug_report import stop_logging
  248. apply_calls = []
  249. mock_db = AsyncMock()
  250. with (
  251. patch("backend.app.api.routes.bug_report.async_session") as mock_session,
  252. patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
  253. patch(
  254. "backend.app.api.routes.bug_report._apply_log_level",
  255. side_effect=lambda v: apply_calls.append(v),
  256. ),
  257. patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
  258. ):
  259. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  260. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  261. result = await stop_logging(was_debug=False)
  262. assert result.logs == "DEBUG log line"
  263. assert apply_calls == [False]
  264. mock_set.assert_called_once()
  265. @pytest.mark.asyncio
  266. @pytest.mark.unit
  267. async def test_skips_restore_when_was_debug(self):
  268. """Does not restore log level when was_debug=True."""
  269. from backend.app.api.routes.bug_report import stop_logging
  270. with (
  271. patch("backend.app.api.routes.bug_report.async_session") as mock_session,
  272. patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
  273. patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
  274. patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
  275. ):
  276. mock_db = AsyncMock()
  277. mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  278. mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
  279. result = await stop_logging(was_debug=True)
  280. assert result.logs == "logs"
  281. mock_apply.assert_not_called()
  282. mock_set.assert_not_called()
  283. class TestSubmitBugReportRoute:
  284. """Tests for the submit_bug_report route handler."""
  285. @pytest.mark.asyncio
  286. @pytest.mark.unit
  287. async def test_uses_provided_debug_logs(self):
  288. """When debug_logs is provided, it is used as recent_logs."""
  289. from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
  290. report = BugReportRequest(
  291. description="Test bug",
  292. debug_logs="pre-collected debug logs",
  293. )
  294. with (
  295. patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
  296. patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
  297. ):
  298. mock_submit.return_value = {
  299. "success": True,
  300. "message": "Created",
  301. "issue_url": "https://github.com/maziggy/bambuddy/issues/1",
  302. "issue_number": 1,
  303. }
  304. result = await submit_bug_report(report)
  305. assert result.success is True
  306. call_kwargs = mock_submit.call_args[1]
  307. assert call_kwargs["support_info"]["recent_logs"] == "pre-collected debug logs"
  308. @pytest.mark.asyncio
  309. @pytest.mark.unit
  310. async def test_no_logs_when_debug_logs_not_provided(self):
  311. """When debug_logs is None, recent_logs is not added."""
  312. from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
  313. report = BugReportRequest(description="Test bug")
  314. with (
  315. patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
  316. patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
  317. ):
  318. mock_submit.return_value = {
  319. "success": True,
  320. "message": "Created",
  321. "issue_url": None,
  322. "issue_number": None,
  323. }
  324. await submit_bug_report(report)
  325. call_kwargs = mock_submit.call_args[1]
  326. assert "recent_logs" not in call_kwargs["support_info"]
  327. class TestRateLimit:
  328. """Tests for rate limiting in bug report service."""
  329. def test_check_rate_limit_allows_first(self):
  330. """First request within window is allowed."""
  331. from backend.app.services.bug_report import _check_rate_limit
  332. with patch("backend.app.services.bug_report._rate_limit_timestamps", []):
  333. assert _check_rate_limit() is True
  334. def test_check_rate_limit_blocks_at_max(self):
  335. """Requests at max limit are blocked."""
  336. import time
  337. from backend.app.services.bug_report import _check_rate_limit
  338. now = time.time()
  339. timestamps = [now] * 5
  340. with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
  341. assert _check_rate_limit() is False
  342. def test_check_rate_limit_clears_old(self):
  343. """Old timestamps outside window are cleared."""
  344. import time
  345. from backend.app.services.bug_report import _check_rate_limit
  346. old_time = time.time() - 7200 # 2 hours ago
  347. timestamps = [old_time] * 5
  348. with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
  349. assert _check_rate_limit() is True