test_printer_diagnostic.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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. ):
  35. self.ports = ports or _port_probe()
  36. self.in_docker = in_docker
  37. self.network_mode = network_mode
  38. self.host_ip = host_ip
  39. self.state = state
  40. self.test_connection_success = test_connection_success
  41. self._stack = ExitStack()
  42. def __enter__(self):
  43. manager = MagicMock()
  44. manager.get_status.return_value = self.state
  45. manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
  46. self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
  47. self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
  48. self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
  49. self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
  50. self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
  51. return self
  52. def __exit__(self, *exc):
  53. self._stack.close()
  54. return False
  55. def _printer(ip="192.168.1.50"):
  56. return types.SimpleNamespace(id=1, ip_address=ip)
  57. class TestSameSubnet:
  58. def test_same_24(self):
  59. assert _same_subnet("192.168.1.10", "192.168.1.200") is True
  60. def test_different_24(self):
  61. assert _same_subnet("192.168.1.10", "192.168.2.10") is False
  62. def test_hostname_undeterminable(self):
  63. assert _same_subnet("printer.local", "192.168.1.10") is None
  64. def test_ipv6_undeterminable(self):
  65. assert _same_subnet("fe80::1", "192.168.1.10") is None
  66. class TestExistingPrinter:
  67. async def test_all_healthy(self):
  68. with _Env(state=_state(connected=True, developer_mode=True)):
  69. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  70. s = _statuses(result)
  71. assert result.overall == "ok"
  72. assert s == {
  73. "port_mqtt": "pass",
  74. "port_ftps": "pass",
  75. "port_rtsps": "pass",
  76. "network_mode": "pass",
  77. "subnet": "pass",
  78. "mqtt_auth": "pass",
  79. "developer_mode": "pass",
  80. }
  81. async def test_mqtt_port_unreachable_is_a_problem(self):
  82. with _Env(ports=_port_probe({8883: False}), state=_state()):
  83. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  84. s = _statuses(result)
  85. assert result.overall == "problems"
  86. assert s["port_mqtt"] == "fail"
  87. # Auth can't be judged when the broker port itself is closed.
  88. assert s["mqtt_auth"] == "skip"
  89. async def test_ftps_and_rtsps_only_warn(self):
  90. with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
  91. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  92. s = _statuses(result)
  93. # No critical failure -> warnings, not problems.
  94. assert result.overall == "warnings"
  95. assert s["port_ftps"] == "warn"
  96. assert s["port_rtsps"] == "warn"
  97. async def test_developer_mode_off_is_a_problem(self):
  98. with _Env(state=_state(connected=True, developer_mode=False)):
  99. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  100. s = _statuses(result)
  101. assert s["developer_mode"] == "fail"
  102. assert result.overall == "problems"
  103. async def test_developer_mode_skipped_when_disconnected(self):
  104. # No live MQTT connection -> developer_mode can't be read.
  105. with _Env(state=_state(connected=False)):
  106. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  107. s = _statuses(result)
  108. assert s["developer_mode"] == "skip"
  109. # Reachable port but no connection -> credential failure class.
  110. assert s["mqtt_auth"] == "fail"
  111. async def test_bridge_mode_warns_and_skips_subnet(self):
  112. with _Env(network_mode="bridge", state=_state()):
  113. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  114. s = _statuses(result)
  115. assert s["network_mode"] == "warn"
  116. # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
  117. assert s["subnet"] == "skip"
  118. async def test_network_mode_skipped_outside_docker(self):
  119. with _Env(in_docker=False, state=_state()):
  120. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  121. assert _statuses(result)["network_mode"] == "skip"
  122. async def test_different_subnet_warns(self):
  123. with _Env(host_ip="10.0.0.5", state=_state()):
  124. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  125. assert _statuses(result)["subnet"] == "warn"
  126. class TestPreAddFlow:
  127. async def test_bad_credentials_fail_mqtt_auth(self):
  128. with _Env(test_connection_success=False):
  129. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  130. s = _statuses(result)
  131. assert s["mqtt_auth"] == "fail"
  132. # No saved printer -> developer mode can't be read.
  133. assert s["developer_mode"] == "skip"
  134. async def test_good_credentials_pass_mqtt_auth(self):
  135. with _Env(test_connection_success=True):
  136. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
  137. assert _statuses(result)["mqtt_auth"] == "pass"
  138. async def test_no_credentials_skips_mqtt_auth(self):
  139. with _Env():
  140. result = await run_connection_diagnostic("192.168.1.50")
  141. assert _statuses(result)["mqtt_auth"] == "skip"