test_printer_diagnostic.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """Unit tests for the connection diagnostic.
  2. Pins the pass / fail / warn / skip contract of each check. Those statuses
  3. drive the localized fix text the user sees when a printer won't connect,
  4. so a status flip is a user-facing regression — each one is asserted here.
  5. """
  6. import types
  7. from contextlib import ExitStack
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. from backend.app.services.printer_diagnostic import _same_subnet, run_connection_diagnostic
  10. MOD = "backend.app.services.printer_diagnostic"
  11. def _statuses(result):
  12. """Map of check id -> status for concise assertions."""
  13. return {c.id: c.status for c in result.checks}
  14. def _port_probe(overrides=None):
  15. """Sync side_effect for _check_port. Defaults: every port reachable."""
  16. reachable = {8883: True, 990: True, 322: True}
  17. reachable.update(overrides or {})
  18. def _probe(ip, port, timeout=3.0):
  19. return reachable[port]
  20. return _probe
  21. def _state(*, connected=True, developer_mode=True):
  22. return types.SimpleNamespace(connected=connected, developer_mode=developer_mode)
  23. class _Env:
  24. """Patches the diagnostic's network/printer helpers for one run."""
  25. def __init__(
  26. self,
  27. *,
  28. ports=None,
  29. in_docker=True,
  30. network_mode="host",
  31. host_ip="192.168.1.5",
  32. state=None,
  33. test_connection_success=True,
  34. report_messages_since_connect: int | None = 5,
  35. ):
  36. self.ports = ports or _port_probe()
  37. self.in_docker = in_docker
  38. self.network_mode = network_mode
  39. self.host_ip = host_ip
  40. self.state = state
  41. self.test_connection_success = test_connection_success
  42. # ``None`` means get_client returns None (e.g. pre-add flow); an int
  43. # means there's a client with that counter value.
  44. self.report_messages_since_connect = report_messages_since_connect
  45. self._stack = ExitStack()
  46. def __enter__(self):
  47. manager = MagicMock()
  48. manager.get_status.return_value = self.state
  49. manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
  50. if self.report_messages_since_connect is None:
  51. manager.get_client.return_value = None
  52. else:
  53. client = MagicMock()
  54. client.report_messages_since_connect = self.report_messages_since_connect
  55. manager.get_client.return_value = client
  56. self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
  57. self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
  58. self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
  59. self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
  60. self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
  61. return self
  62. def __exit__(self, *exc):
  63. self._stack.close()
  64. return False
  65. def _printer(ip="192.168.1.50"):
  66. return types.SimpleNamespace(id=1, ip_address=ip)
  67. class TestSameSubnet:
  68. def test_same_24(self):
  69. assert _same_subnet("192.168.1.10", "192.168.1.200") is True
  70. def test_different_24(self):
  71. assert _same_subnet("192.168.1.10", "192.168.2.10") is False
  72. def test_hostname_undeterminable(self):
  73. assert _same_subnet("printer.local", "192.168.1.10") is None
  74. def test_ipv6_undeterminable(self):
  75. assert _same_subnet("fe80::1", "192.168.1.10") is None
  76. class TestExistingPrinter:
  77. async def test_all_healthy(self):
  78. with _Env(state=_state(connected=True, developer_mode=True), report_messages_since_connect=42):
  79. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  80. s = _statuses(result)
  81. assert result.overall == "ok"
  82. assert s == {
  83. "port_mqtt": "pass",
  84. "port_ftps": "pass",
  85. "port_rtsps": "pass",
  86. "network_mode": "pass",
  87. "subnet": "pass",
  88. "mqtt_auth": "pass",
  89. "developer_mode": "pass",
  90. "printer_publishing": "pass",
  91. }
  92. async def test_mqtt_port_unreachable_is_a_problem(self):
  93. with _Env(ports=_port_probe({8883: False}), state=_state()):
  94. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  95. s = _statuses(result)
  96. assert result.overall == "problems"
  97. assert s["port_mqtt"] == "fail"
  98. # Auth can't be judged when the broker port itself is closed.
  99. assert s["mqtt_auth"] == "skip"
  100. async def test_ftps_and_rtsps_only_warn(self):
  101. with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
  102. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  103. s = _statuses(result)
  104. # No critical failure -> warnings, not problems.
  105. assert result.overall == "warnings"
  106. assert s["port_ftps"] == "warn"
  107. assert s["port_rtsps"] == "warn"
  108. async def test_developer_mode_off_is_a_problem(self):
  109. with _Env(state=_state(connected=True, developer_mode=False)):
  110. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  111. s = _statuses(result)
  112. assert s["developer_mode"] == "fail"
  113. assert result.overall == "problems"
  114. async def test_developer_mode_skipped_when_disconnected(self):
  115. # No live MQTT connection -> developer_mode can't be read.
  116. with _Env(state=_state(connected=False)):
  117. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  118. s = _statuses(result)
  119. assert s["developer_mode"] == "skip"
  120. # Reachable port but no connection -> credential failure class.
  121. assert s["mqtt_auth"] == "fail"
  122. # Can't observe report messages without a connection.
  123. assert s["printer_publishing"] == "skip"
  124. async def test_bridge_mode_warns_and_skips_subnet(self):
  125. with _Env(network_mode="bridge", state=_state()):
  126. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  127. s = _statuses(result)
  128. assert s["network_mode"] == "warn"
  129. # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
  130. assert s["subnet"] == "skip"
  131. async def test_network_mode_skipped_outside_docker(self):
  132. with _Env(in_docker=False, state=_state()):
  133. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  134. assert _statuses(result)["network_mode"] == "skip"
  135. async def test_different_subnet_warns(self):
  136. with _Env(host_ip="10.0.0.5", state=_state()):
  137. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  138. assert _statuses(result)["subnet"] == "warn"
  139. async def test_printer_publishing_passes_when_reports_seen(self):
  140. # Counter > 0 means the printer is publishing on the report topic.
  141. with _Env(state=_state(), report_messages_since_connect=1):
  142. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  143. assert _statuses(result)["printer_publishing"] == "pass"
  144. async def test_printer_publishing_fails_when_zero_reports_after_wait(self):
  145. # Counter stays at 0 across the wait window — printer never published.
  146. # Tiny wait_for_publish_seconds keeps the test sub-second.
  147. with _Env(state=_state(), report_messages_since_connect=0):
  148. result = await run_connection_diagnostic(
  149. "192.168.1.50",
  150. printer=_printer(),
  151. wait_for_publish_seconds=0.05,
  152. )
  153. s = _statuses(result)
  154. assert s["printer_publishing"] == "fail"
  155. # Overall escalates because fail is present.
  156. assert result.overall == "problems"
  157. # The check exposes the wait budget so the UI can render a countdown.
  158. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  159. assert params == {"max_wait_seconds": 0.05}
  160. async def test_printer_publishing_skips_when_disconnected(self):
  161. # No live MQTT connection -> can't observe report messages.
  162. with _Env(state=_state(connected=False), report_messages_since_connect=0):
  163. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  164. assert _statuses(result)["printer_publishing"] == "skip"
  165. async def test_printer_publishing_skips_when_no_client(self):
  166. # State says connected but printer_manager has no client object
  167. # (race between client teardown and a fresh diagnostic request).
  168. with _Env(state=_state(), report_messages_since_connect=None):
  169. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  170. assert _statuses(result)["printer_publishing"] == "skip"
  171. async def test_printer_publishing_no_wait_returns_instantly_on_zero(self):
  172. # Default wait is 0 — instant pass/fail without polling. Used by the
  173. # support-package code path so bundling stays fast.
  174. with _Env(state=_state(), report_messages_since_connect=0):
  175. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  176. s = _statuses(result)
  177. assert s["printer_publishing"] == "fail"
  178. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  179. # No wait -> no max_wait_seconds param surfaced to the UI.
  180. assert params == {}
  181. class TestPreAddFlow:
  182. async def test_bad_credentials_fail_mqtt_auth(self):
  183. with _Env(test_connection_success=False):
  184. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  185. s = _statuses(result)
  186. assert s["mqtt_auth"] == "fail"
  187. # No saved printer -> developer mode can't be read.
  188. assert s["developer_mode"] == "skip"
  189. async def test_good_credentials_pass_mqtt_auth(self):
  190. with _Env(test_connection_success=True):
  191. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
  192. assert _statuses(result)["mqtt_auth"] == "pass"
  193. async def test_no_credentials_skips_mqtt_auth(self):
  194. with _Env():
  195. result = await run_connection_diagnostic("192.168.1.50")
  196. assert _statuses(result)["mqtt_auth"] == "skip"