test_printer_diagnostic.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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, 6000: 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, store_to_sdcard=True):
  22. return types.SimpleNamespace(
  23. connected=connected,
  24. developer_mode=developer_mode,
  25. store_to_sdcard=store_to_sdcard,
  26. )
  27. class _Env:
  28. """Patches the diagnostic's network/printer helpers for one run."""
  29. def __init__(
  30. self,
  31. *,
  32. ports=None,
  33. in_docker=True,
  34. network_mode="host",
  35. host_ip="192.168.1.5",
  36. state=None,
  37. test_connection_success=True,
  38. report_messages_since_connect: int | None = 5,
  39. connect_error: str | None = None,
  40. ):
  41. self.ports = ports or _port_probe()
  42. self.in_docker = in_docker
  43. self.network_mode = network_mode
  44. self.host_ip = host_ip
  45. self.state = state
  46. self.test_connection_success = test_connection_success
  47. # ``None`` means get_client returns None (e.g. pre-add flow); an int
  48. # means there's a client with that counter value.
  49. self.report_messages_since_connect = report_messages_since_connect
  50. # CONNACK-refusal slug the live client reports, or None when the last
  51. # connection attempt was never refused (#2698).
  52. self.connect_error = connect_error
  53. self._stack = ExitStack()
  54. def __enter__(self):
  55. manager = MagicMock()
  56. manager.get_status.return_value = self.state
  57. manager.test_connection = AsyncMock(
  58. return_value={
  59. "success": self.test_connection_success,
  60. "reason": None if self.test_connection_success else self.connect_error,
  61. }
  62. )
  63. if self.report_messages_since_connect is None:
  64. manager.get_client.return_value = None
  65. else:
  66. client = MagicMock()
  67. client.report_messages_since_connect = self.report_messages_since_connect
  68. client.last_connect_error = self.connect_error
  69. manager.get_client.return_value = client
  70. self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
  71. self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
  72. self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
  73. self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
  74. self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
  75. return self
  76. def __exit__(self, *exc):
  77. self._stack.close()
  78. return False
  79. def _printer(ip="192.168.1.50", model=None):
  80. return types.SimpleNamespace(id=1, ip_address=ip, model=model)
  81. class TestSameSubnet:
  82. def test_same_24(self):
  83. assert _same_subnet("192.168.1.10", "192.168.1.200") is True
  84. def test_different_24(self):
  85. assert _same_subnet("192.168.1.10", "192.168.2.10") is False
  86. def test_hostname_undeterminable(self):
  87. assert _same_subnet("printer.local", "192.168.1.10") is None
  88. def test_ipv6_undeterminable(self):
  89. assert _same_subnet("fe80::1", "192.168.1.10") is None
  90. class TestExistingPrinter:
  91. async def test_all_healthy(self):
  92. with _Env(
  93. state=_state(connected=True, developer_mode=True, store_to_sdcard=True),
  94. report_messages_since_connect=42,
  95. ):
  96. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  97. s = _statuses(result)
  98. assert result.overall == "ok"
  99. assert s == {
  100. "port_mqtt": "pass",
  101. "port_ftps": "pass",
  102. "port_rtsps": "pass",
  103. "network_mode": "pass",
  104. "subnet": "pass",
  105. "external_storage": "pass",
  106. "mqtt_auth": "pass",
  107. "developer_mode": "pass",
  108. "printer_publishing": "pass",
  109. }
  110. async def test_mqtt_port_unreachable_is_a_problem(self):
  111. with _Env(ports=_port_probe({8883: False}), state=_state()):
  112. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  113. s = _statuses(result)
  114. assert result.overall == "problems"
  115. assert s["port_mqtt"] == "fail"
  116. # Auth can't be judged when the broker port itself is closed.
  117. assert s["mqtt_auth"] == "skip"
  118. async def test_ftps_and_rtsps_only_warn(self):
  119. with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
  120. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  121. s = _statuses(result)
  122. # No critical failure -> warnings, not problems.
  123. assert result.overall == "warnings"
  124. assert s["port_ftps"] == "warn"
  125. assert s["port_rtsps"] == "warn"
  126. async def test_a1_mini_uses_chamber_image_camera_port(self):
  127. # A1/P1-family printers use the chamber-image camera protocol on 6000,
  128. # not RTSPS on 322. A closed 322 must not create a false camera warning.
  129. with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
  130. result = await run_connection_diagnostic(
  131. "192.168.1.50",
  132. printer=_printer(model="A1 Mini"),
  133. )
  134. assert _statuses(result)["port_rtsps"] == "pass"
  135. camera_check = next(c for c in result.checks if c.id == "port_rtsps")
  136. assert camera_check.params == {"port": 6000, "protocol": "Chamber Image"}
  137. async def test_rtsp_models_still_probe_rtsps_port(self):
  138. with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
  139. result = await run_connection_diagnostic(
  140. "192.168.1.50",
  141. printer=_printer(model="X1C"),
  142. )
  143. assert _statuses(result)["port_rtsps"] == "warn"
  144. camera_check = next(c for c in result.checks if c.id == "port_rtsps")
  145. assert camera_check.params == {"port": 322, "protocol": "RTSPS"}
  146. async def test_developer_mode_off_is_a_problem(self):
  147. with _Env(state=_state(connected=True, developer_mode=False)):
  148. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  149. s = _statuses(result)
  150. assert s["developer_mode"] == "fail"
  151. assert result.overall == "problems"
  152. async def test_developer_mode_skipped_when_disconnected(self):
  153. # No live MQTT connection -> developer_mode can't be read.
  154. with _Env(state=_state(connected=False)):
  155. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  156. s = _statuses(result)
  157. assert s["developer_mode"] == "skip"
  158. # Reachable port but no connection -> credential failure class.
  159. assert s["mqtt_auth"] == "fail"
  160. # Can't observe report messages without a connection.
  161. assert s["printer_publishing"] == "skip"
  162. async def test_bridge_mode_warns_and_skips_subnet(self):
  163. with _Env(network_mode="bridge", state=_state()):
  164. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  165. s = _statuses(result)
  166. assert s["network_mode"] == "warn"
  167. # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
  168. assert s["subnet"] == "skip"
  169. async def test_network_mode_skipped_outside_docker(self):
  170. with _Env(in_docker=False, state=_state()):
  171. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  172. assert _statuses(result)["network_mode"] == "skip"
  173. async def test_different_subnet_warns(self):
  174. with _Env(host_ip="10.0.0.5", state=_state()):
  175. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  176. assert _statuses(result)["subnet"] == "warn"
  177. async def test_printer_publishing_passes_when_reports_seen(self):
  178. # Counter > 0 means the printer is publishing on the report topic.
  179. with _Env(state=_state(), report_messages_since_connect=1):
  180. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  181. assert _statuses(result)["printer_publishing"] == "pass"
  182. async def test_printer_publishing_fails_when_zero_reports_after_wait(self):
  183. # Counter stays at 0 across the wait window — printer never published.
  184. # Tiny wait_for_publish_seconds keeps the test sub-second.
  185. with _Env(state=_state(), report_messages_since_connect=0):
  186. result = await run_connection_diagnostic(
  187. "192.168.1.50",
  188. printer=_printer(),
  189. wait_for_publish_seconds=0.05,
  190. )
  191. s = _statuses(result)
  192. assert s["printer_publishing"] == "fail"
  193. # Overall escalates because fail is present.
  194. assert result.overall == "problems"
  195. # The check exposes the wait budget so the UI can render a countdown.
  196. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  197. assert params == {"max_wait_seconds": 0.05}
  198. async def test_printer_publishing_skips_when_disconnected(self):
  199. # No live MQTT connection -> can't observe report messages.
  200. with _Env(state=_state(connected=False), report_messages_since_connect=0):
  201. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  202. assert _statuses(result)["printer_publishing"] == "skip"
  203. async def test_printer_publishing_skips_when_no_client(self):
  204. # State says connected but printer_manager has no client object
  205. # (race between client teardown and a fresh diagnostic request).
  206. with _Env(state=_state(), report_messages_since_connect=None):
  207. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  208. assert _statuses(result)["printer_publishing"] == "skip"
  209. async def test_printer_publishing_no_wait_returns_instantly_on_zero(self):
  210. # Default wait is 0 — instant pass/fail without polling. Used by the
  211. # support-package code path so bundling stays fast.
  212. with _Env(state=_state(), report_messages_since_connect=0):
  213. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  214. s = _statuses(result)
  215. assert s["printer_publishing"] == "fail"
  216. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  217. # No wait -> no max_wait_seconds param surfaced to the UI.
  218. assert params == {}
  219. class TestAuthRejectedReason:
  220. """#2698: "not connected" and "credentials refused" are different answers.
  221. `state.connected == False` only says we have no session — the printer may
  222. be rebooting, at its connection limit, or refusing the access code. When
  223. the printer actually sent a CONNACK refusal the client records it, and the
  224. check surfaces it as a `params.reason` variant so the UI can name the cause
  225. instead of making the user guess. Without a recorded refusal the params
  226. stay empty and the generic text is used.
  227. """
  228. def _params(self, result):
  229. return next(c.params for c in result.checks if c.id == "mqtt_auth")
  230. async def test_recorded_refusal_surfaces_reason(self):
  231. with _Env(state=_state(connected=False), connect_error="auth_rejected"):
  232. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  233. assert _statuses(result)["mqtt_auth"] == "fail"
  234. assert self._params(result) == {"reason": "auth_rejected"}
  235. async def test_disconnected_without_refusal_stays_generic(self):
  236. with _Env(state=_state(connected=False)):
  237. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  238. assert _statuses(result)["mqtt_auth"] == "fail"
  239. assert self._params(result) == {}
  240. async def test_unknown_slug_falls_back_to_generic(self):
  241. # `refused` has no dedicated message — degrade to the plain fail text
  242. # rather than asking the frontend for a key that doesn't exist.
  243. with _Env(state=_state(connected=False), connect_error="refused"):
  244. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  245. assert self._params(result) == {}
  246. async def test_connected_printer_carries_no_reason(self):
  247. with _Env(state=_state(connected=True), connect_error="auth_rejected"):
  248. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  249. assert _statuses(result)["mqtt_auth"] == "pass"
  250. assert self._params(result) == {}
  251. async def test_pre_add_probe_surfaces_reason(self):
  252. with _Env(test_connection_success=False, connect_error="auth_rejected"):
  253. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  254. assert _statuses(result)["mqtt_auth"] == "fail"
  255. assert self._params(result) == {"reason": "auth_rejected"}
  256. class TestPreAddFlow:
  257. async def test_bad_credentials_fail_mqtt_auth(self):
  258. with _Env(test_connection_success=False):
  259. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  260. s = _statuses(result)
  261. assert s["mqtt_auth"] == "fail"
  262. # No saved printer -> developer mode can't be read.
  263. assert s["developer_mode"] == "skip"
  264. async def test_good_credentials_pass_mqtt_auth(self):
  265. with _Env(test_connection_success=True):
  266. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
  267. assert _statuses(result)["mqtt_auth"] == "pass"
  268. async def test_no_credentials_skips_mqtt_auth(self):
  269. with _Env():
  270. result = await run_connection_diagnostic("192.168.1.50")
  271. assert _statuses(result)["mqtt_auth"] == "skip"
  272. class TestExternalStorageCheck:
  273. """Install step 4 — "Store sent files on external storage".
  274. Detected via ``state.store_to_sdcard`` (parsed from MQTT push_status
  275. ``home_flag`` bit 11). Only catches the printer-side variant of the
  276. setting on newer firmware (P2S 01.02 / Studio 2.6+) — the older
  277. slicer-side variant is undetectable from outside the slicer and is
  278. covered separately by the no-3MF archive-fallback banner.
  279. """
  280. async def test_passes_when_store_to_sdcard_true(self):
  281. with _Env(state=_state(store_to_sdcard=True)):
  282. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  283. assert _statuses(result)["external_storage"] == "pass"
  284. async def test_fails_when_store_to_sdcard_false(self):
  285. # Bit 11 reported as 0 -> printer-side toggle is off. Overall
  286. # escalates to "problems" because a fail is present.
  287. with _Env(state=_state(store_to_sdcard=False)):
  288. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  289. assert _statuses(result)["external_storage"] == "fail"
  290. assert result.overall == "problems"
  291. async def test_skips_when_disconnected(self):
  292. # State exists (we have a saved printer) but the MQTT connection
  293. # dropped, so the latest store_to_sdcard value can't be trusted.
  294. with _Env(state=_state(connected=False, store_to_sdcard=True)):
  295. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  296. assert _statuses(result)["external_storage"] == "skip"
  297. async def test_skips_pre_add_flow(self):
  298. # No saved printer -> no state -> nothing to read. The check has
  299. # to skip; pre-add can't probe this without a live MQTT session.
  300. with _Env():
  301. result = await run_connection_diagnostic(
  302. "192.168.1.50",
  303. serial_number="01P",
  304. access_code="probe-code",
  305. )
  306. assert _statuses(result)["external_storage"] == "skip"
  307. async def test_skips_when_field_missing(self):
  308. # State exists and is connected but store_to_sdcard was never
  309. # populated (firmware that doesn't push home_flag). Skip rather
  310. # than fabricate a False from a missing field.
  311. bare = types.SimpleNamespace(connected=True, developer_mode=True)
  312. with _Env(state=bare):
  313. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  314. assert _statuses(result)["external_storage"] == "skip"
  315. async def test_skips_on_a1_no_external_storage_slot(self):
  316. # Regression for #1703: A1 and A1 Mini ship without a MicroSD slot
  317. # at all, so home_flag bit 11 is never set and a naive read would
  318. # report `fail` for every A1-series user. The model-aware skip
  319. # branch suppresses that — and the overall result must NOT escalate
  320. # to "problems" purely because of this check.
  321. with _Env(state=_state(store_to_sdcard=False)):
  322. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1"))
  323. assert _statuses(result)["external_storage"] == "skip"
  324. assert result.overall == "ok"
  325. async def test_skips_on_a1_mini_no_external_storage_slot(self):
  326. with _Env(state=_state(store_to_sdcard=False)):
  327. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1 Mini"))
  328. assert _statuses(result)["external_storage"] == "skip"
  329. async def test_still_fails_on_x1c_when_toggle_off(self):
  330. # Sanity: the model-aware skip MUST NOT silently let X1C-class
  331. # printers off the hook. The store_to_sdcard=False path is the
  332. # one real bit of value this check provides for those models.
  333. with _Env(state=_state(store_to_sdcard=False)):
  334. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
  335. assert _statuses(result)["external_storage"] == "fail"
  336. async def test_skips_on_p1s_no_reachable_toggle(self):
  337. # #2524: P1S HAS a MicroSD slot (so has_external_storage is True and
  338. # the check proceeds), but current P1 firmware never publishes the
  339. # capability that renders the toggle in Bambu Studio and the P1S has
  340. # no screen — store_to_sdcard is stuck False with no way to fix it.
  341. # Report an informational skip (with a reason the UI explains), not a
  342. # permanently-unresolvable fail; overall must not escalate.
  343. with _Env(state=_state(store_to_sdcard=False)):
  344. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
  345. check = next(c for c in result.checks if c.id == "external_storage")
  346. assert check.status == "skip"
  347. assert check.params == {"reason": "unsupported_model"}
  348. assert result.overall == "ok"
  349. async def test_skips_on_p1p_no_reachable_toggle(self):
  350. with _Env(state=_state(store_to_sdcard=False)):
  351. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1P"))
  352. check = next(c for c in result.checks if c.id == "external_storage")
  353. assert check.status == "skip"
  354. assert check.params == {"reason": "unsupported_model"}
  355. async def test_p1s_still_passes_when_store_to_sdcard_true(self):
  356. # If a P1S somehow reports the option ON, respect it — pass, don't
  357. # mask it as an unsupported-model skip.
  358. with _Env(state=_state(store_to_sdcard=True)):
  359. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
  360. assert _statuses(result)["external_storage"] == "pass"