test_firmware_check_offline_logging.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. """Without internet, the firmware check logs the outage once, not every failure.
  2. Each check of each printer model fetches the Bambu Lab wiki and download page.
  3. Offline, every one of those fetches logged a full ERROR (and every model a
  4. WARNING): measured, 3 polls of 3 models gave 12 ERROR and 9 WARNING lines
  5. saying the same thing. The outage is now logged once as a WARNING, failures
  6. during it at debug, and the recovery once at INFO.
  7. """
  8. from __future__ import annotations
  9. import logging
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import httpx
  12. import pytest
  13. from backend.app.services.firmware_check import FirmwareCheckService
  14. LOGGER = "backend.app.services.firmware_check"
  15. class _Offline:
  16. """Both HTTP clients failing at the transport, as without internet."""
  17. def __init__(self, service: FirmwareCheckService):
  18. self.service = service
  19. self.online = False
  20. self.curl = MagicMock()
  21. self.curl.get = AsyncMock(side_effect=self._curl_get)
  22. async def _curl_get(self, _url):
  23. if not self.online:
  24. raise RuntimeError("Failed to perform, curl: (7) Couldn't connect to server")
  25. return MagicMock(status_code=503, text="")
  26. async def httpx_get(self, url, **_kwargs):
  27. if not self.online:
  28. raise httpx.ConnectError("All connection attempts failed")
  29. return httpx.Response(200, text='<h2 id="h-01090000-20260101">x</h2>', request=httpx.Request("GET", url))
  30. def _records(caplog, level: int) -> list[logging.LogRecord]:
  31. return [r for r in caplog.records if r.name == LOGGER and r.levelno == level]
  32. @pytest.fixture
  33. def offline(tmp_path):
  34. service = FirmwareCheckService()
  35. net = _Offline(service)
  36. with (
  37. patch("backend.app.services.firmware_check._data_dir", tmp_path),
  38. patch.object(service, "_get_bambulab_client", return_value=net.curl),
  39. patch.object(service._client, "get", new=net.httpx_get),
  40. ):
  41. yield service, net
  42. @pytest.mark.asyncio
  43. async def test_an_outage_is_logged_once(offline, caplog):
  44. service, _net = offline
  45. caplog.set_level(logging.DEBUG, logger=LOGGER)
  46. for _poll in range(3):
  47. for model in ("X1C", "P1S", "H2D"):
  48. await service.check_for_update(model, "01.00.00.00")
  49. assert _records(caplog, logging.ERROR) == []
  50. # Once per host: the wiki and bambulab.com fail independently.
  51. warnings = [r.getMessage() for r in _records(caplog, logging.WARNING)]
  52. assert len(warnings) == 2, warnings
  53. assert {w.split()[0] for w in warnings} == {"wiki.bambulab.com", "bambulab.com"}
  54. assert all("unreachable" in w for w in warnings)
  55. @pytest.mark.asyncio
  56. async def test_the_recovery_is_logged_and_a_new_outage_warns_again(offline, caplog):
  57. service, net = offline
  58. caplog.set_level(logging.DEBUG, logger=LOGGER)
  59. await service.check_for_update("X1C", "01.00.00.00")
  60. net.online = True
  61. await service.check_for_update("P1S", "01.00.00.00")
  62. recovered = [r.getMessage() for r in _records(caplog, logging.INFO) if "reachable again" in r.getMessage()]
  63. assert {m.split()[0] for m in recovered} == {"wiki.bambulab.com", "bambulab.com"}
  64. net.online = False
  65. caplog.clear()
  66. await service.check_for_update("H2D", "01.00.00.00")
  67. assert len(_records(caplog, logging.WARNING)) == 2
  68. @pytest.mark.asyncio
  69. async def test_one_host_down_is_logged_once_without_false_recoveries(offline, caplog):
  70. """The wiki answers while bambulab.com does not: one warning for that host,
  71. no 'reachable again' from the other."""
  72. service, _net = offline
  73. caplog.set_level(logging.DEBUG, logger=LOGGER)
  74. async def wiki_up_only(url, **_kwargs):
  75. return httpx.Response(200, text='<h2 id="h-01090000-20260101">x</h2>', request=httpx.Request("GET", url))
  76. with patch.object(service._client, "get", new=wiki_up_only):
  77. for _poll in range(3):
  78. for model in ("X1C", "P1S", "H2D"):
  79. await service.check_for_update(model, "01.00.00.00")
  80. assert len(_records(caplog, logging.WARNING)) == 1
  81. assert [r for r in _records(caplog, logging.INFO) if "reachable again" in r.getMessage()] == []
  82. @pytest.mark.asyncio
  83. @pytest.mark.parametrize("error", [httpx.TooManyRedirects("loop"), httpx.DecodingError("bad encoding")])
  84. async def test_a_wiki_error_that_is_not_an_outage_still_degrades_quietly(offline, error):
  85. service, _net = offline
  86. async def failing_wiki(_url, **_kwargs):
  87. raise error
  88. with patch.object(service._client, "get", new=failing_wiki):
  89. result = await service.check_for_update("X1C", "01.00.00.00")
  90. assert isinstance(result, dict)