test_slicer_loaded_spools_3172.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. """GET /slicer/loaded-spools — what the Slice dialog may filter on (#3172).
  2. The dialog offers to narrow its lists to the printers that are online and the
  3. spools loaded in them. The endpoint behind it has to:
  4. * list only printers with a live connection, since an offline printer can't
  5. say what it has loaded;
  6. * report every AMS slot as the printer does, empty ones included, with the
  7. profile Bambuddy saved for it, and an external holder only while it holds
  8. a spool;
  9. * keep to the caller's printers (#1727) and to callers who may read printer
  10. status.
  11. """
  12. from __future__ import annotations
  13. from types import SimpleNamespace
  14. from unittest.mock import patch
  15. import pytest
  16. from httpx import AsyncClient
  17. from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _team_member, _user
  18. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  19. URL = "/api/v1/slicer/loaded-spools"
  20. def _status(raw_data: dict, connected: bool = True) -> SimpleNamespace:
  21. return SimpleNamespace(connected=connected, raw_data=raw_data)
  22. def _ams(ams_id: int, trays: list[dict]) -> dict:
  23. return {"id": ams_id, "tray": trays}
  24. def _tray(tray_id: int, tray_type: str = "", **kw) -> dict:
  25. return {"id": tray_id, "tray_type": tray_type, **kw}
  26. def _patched_status(statuses: dict[int, SimpleNamespace | None]):
  27. return patch(
  28. "backend.app.api.routes.slicer_presets.printer_manager.get_status",
  29. side_effect=lambda pid: statuses.get(pid),
  30. )
  31. class TestLoadedSpools:
  32. async def test_lists_only_connected_printers(self, async_client: AsyncClient, printer_factory):
  33. online = await printer_factory(name="A online", model="BL-P001")
  34. disconnected = await printer_factory(name="B disconnected")
  35. never_seen = await printer_factory(name="C never seen")
  36. inactive = await printer_factory(name="D inactive", is_active=False)
  37. statuses = {
  38. online.id: _status({"ams": []}),
  39. disconnected.id: _status({"ams": [_ams(0, [_tray(0, "PLA")])]}, connected=False),
  40. never_seen.id: None,
  41. inactive.id: _status({"ams": []}),
  42. }
  43. with _patched_status(statuses):
  44. response = await async_client.get(URL)
  45. assert response.status_code == 200, response.text
  46. printers = response.json()["printers"]
  47. assert [p["id"] for p in printers] == [online.id]
  48. # The SSDP model code is turned into the short name @BBL tags use.
  49. assert printers[0]["model"] == "X1C"
  50. async def test_no_connected_printer_is_an_empty_list(self, async_client: AsyncClient, printer_factory):
  51. printer = await printer_factory()
  52. with _patched_status({printer.id: None}):
  53. response = await async_client.get(URL)
  54. assert response.status_code == 200
  55. assert response.json() == {"printers": []}
  56. async def test_reports_slots_with_their_saved_profile(self, async_client: AsyncClient, printer_factory, db_session):
  57. from backend.app.models.slot_preset import SlotPresetMapping
  58. printer = await printer_factory(model="H2D")
  59. db_session.add_all(
  60. [
  61. SlotPresetMapping(
  62. printer_id=printer.id,
  63. ams_id=0,
  64. tray_id=1,
  65. preset_id="local_7",
  66. preset_name="Overture PLA Matte",
  67. preset_source="local",
  68. tray_info_idx="GFL99",
  69. ),
  70. SlotPresetMapping(
  71. printer_id=printer.id,
  72. ams_id=255,
  73. tray_id=1,
  74. preset_id="GFSG99",
  75. preset_name="Generic PETG",
  76. preset_source="cloud",
  77. ),
  78. ]
  79. )
  80. await db_session.commit()
  81. raw = {
  82. "ams": [
  83. _ams(
  84. 0,
  85. [
  86. _tray(0, "PLA", tray_color="FF0000FF", tray_info_idx="GFA00", tray_sub_brands="PLA Basic"),
  87. _tray(1, "PLA", tray_color="00FF00FF", tray_info_idx="GFL99"),
  88. _tray(2, exists=True),
  89. _tray(3, state=9),
  90. ],
  91. ),
  92. _ams(128, [_tray(0, "PETG", tray_color="0000FFFF")]),
  93. ],
  94. "vt_tray": [_tray(254, ""), _tray(255, "PETG", tray_color="FFFFFFFF")],
  95. }
  96. with _patched_status({printer.id: _status(raw)}):
  97. response = await async_client.get(URL)
  98. assert response.status_code == 200, response.text
  99. [entry] = response.json()["printers"]
  100. regular, ht = entry["ams"]
  101. assert regular["is_ams_ht"] is False
  102. assert [t["tray_id"] for t in regular["trays"]] == [0, 1, 2, 3]
  103. first, second, unknown, empty = regular["trays"]
  104. assert first["tray_sub_brands"] == "PLA Basic"
  105. assert first["saved_preset"] is None
  106. assert second["saved_preset"] == {
  107. "preset_id": "local_7",
  108. "preset_name": "Overture PLA Matte",
  109. "preset_source": "local",
  110. "tray_info_idx": "GFL99",
  111. }
  112. # Empty slots stay in the list, so the grid matches the unit.
  113. assert unknown["tray_type"] is None and unknown["exists"] is True
  114. assert empty["tray_type"] is None and empty["state"] == 9
  115. assert ht["is_ams_ht"] is True and ht["id"] == 128
  116. # The empty left holder is left out; the right one is AMS 255, tray 1.
  117. [ext] = entry["external"]
  118. assert (ext["ams_id"], ext["tray_id"]) == (255, 1)
  119. assert ext["saved_preset"]["preset_name"] == "Generic PETG"
  120. assert entry["external_holders"] == 2
  121. async def test_tolerates_malformed_status(self, async_client: AsyncClient, printer_factory):
  122. printer = await printer_factory()
  123. raw = {"ams": ["garbage", {"id": 1, "tray": [None, _tray(0, "ABS")]}], "vt_tray": None}
  124. with _patched_status({printer.id: _status(raw)}):
  125. response = await async_client.get(URL)
  126. assert response.status_code == 200, response.text
  127. [entry] = response.json()["printers"]
  128. assert [u["id"] for u in entry["ams"]] == [1]
  129. assert entry["external"] == []
  130. class TestLoadedSpoolsAccess:
  131. async def test_limited_user_sees_only_their_printers(self, async_client: AsyncClient, printer_factory):
  132. mine = await printer_factory(name="Mine")
  133. other = await printer_factory(name="Other")
  134. admin = await _admin_token(async_client)
  135. jwt, _ = await _team_member(async_client, admin, "slicer3172", [mine.id])
  136. # The team permissions don't include slicing; add it in a second group.
  137. slicing = await _group(async_client, admin, "slicing3172", permissions=["library:upload"])
  138. me = await async_client.get("/api/v1/auth/me", headers=_auth(jwt))
  139. groups = [g["id"] for g in me.json()["groups"]] + [slicing]
  140. updated = await async_client.patch(
  141. f"/api/v1/users/{me.json()['id']}", headers=_auth(admin), json={"group_ids": groups}
  142. )
  143. assert updated.status_code == 200, updated.text
  144. statuses = {mine.id: _status({"ams": []}), other.id: _status({"ams": []})}
  145. with _patched_status(statuses):
  146. response = await async_client.get(URL, headers=_auth(jwt))
  147. assert response.status_code == 200, response.text
  148. assert [p["id"] for p in response.json()["printers"]] == [mine.id]
  149. async def test_needs_printer_read(self, async_client: AsyncClient, printer_factory):
  150. """The slots are printer status; slicing alone doesn't grant it."""
  151. await printer_factory()
  152. admin = await _admin_token(async_client)
  153. group = await _group(async_client, admin, "uploadonly3172", permissions=["library:upload"])
  154. jwt, _ = await _user(async_client, admin, "uploadonly3172", [group])
  155. response = await async_client.get(URL, headers=_auth(jwt))
  156. assert response.status_code == 403