test_bulk_reads_api.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Bulk reads (/api/v1/bulk/...) answer exactly what their single-id routes do.
  2. The Printers page asked for each printer's status, slot presets, AMS labels,
  3. plugs, sensor readings, firmware and queue once per printer, and the Archives
  4. page for each card's folders once per archive; the frontend now batches those
  5. calls into these routes. Each test compares a bulk answer with the single
  6. route's answer for the same ids, and pins what a bulk answer leaves out: ids
  7. the caller may not see, which the client then asks the single route for.
  8. """
  9. from __future__ import annotations
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  14. BULK = "/api/v1/bulk"
  15. def _auth(jwt: str) -> dict[str, str]:
  16. return {"Authorization": f"Bearer {jwt}"}
  17. async def _admin_token(async_client: AsyncClient) -> str:
  18. await async_client.post(
  19. "/api/v1/auth/setup",
  20. json={"auth_enabled": True, "admin_username": "bulkadmin", "admin_password": "AdminPass1!"},
  21. )
  22. login = await async_client.post("/api/v1/auth/login", json={"username": "bulkadmin", "password": "AdminPass1!"})
  23. assert login.status_code == 200, login.text
  24. return login.json()["access_token"]
  25. async def _user(async_client, admin_jwt, username, permissions, printer_ids=None) -> tuple[str, int]:
  26. groups = []
  27. perms = await async_client.post(
  28. "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"perms_{username}", "permissions": permissions}
  29. )
  30. assert perms.status_code == 201, perms.text
  31. groups.append(perms.json()["id"])
  32. if printer_ids is not None:
  33. team = await async_client.post(
  34. "/api/v1/groups/",
  35. headers=_auth(admin_jwt),
  36. json={"name": f"team_{username}", "permissions": [], "restrict_printers": True, "printer_ids": printer_ids},
  37. )
  38. assert team.status_code == 201, team.text
  39. groups.append(team.json()["id"])
  40. created = await async_client.post(
  41. "/api/v1/users/",
  42. headers=_auth(admin_jwt),
  43. json={"username": username, "password": "UserPass1!", "group_ids": groups},
  44. )
  45. assert created.status_code in (200, 201), created.text
  46. login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  47. assert login.status_code == 200, login.text
  48. return login.json()["access_token"], created.json()["id"]
  49. async def _get(async_client: AsyncClient, url: str, **kwargs):
  50. response = await async_client.get(url, **kwargs)
  51. assert response.status_code == 200, f"{url}: {response.text}"
  52. return response.json()
  53. def _live_state(**overrides) -> MagicMock:
  54. """A connected printer's state, enough for the status route to build its answer."""
  55. state = MagicMock(
  56. connected=True,
  57. state="IDLE",
  58. current_print=None,
  59. subtask_name=None,
  60. gcode_file=None,
  61. progress=0,
  62. remaining_time=None,
  63. layer_num=0,
  64. total_layers=0,
  65. temperatures={"nozzle": 25.0, "bed": 25.0},
  66. raw_data={"ams": [{"id": 0, "sn": "AMS-SN-1", "tray": []}]},
  67. hms_errors=[],
  68. firmware_version="01.08.00.00",
  69. developer_mode=True,
  70. ams_filament_backup=None,
  71. fila_switch=None,
  72. subtask_id=None,
  73. kprofiles=[],
  74. nozzles=[],
  75. ams_switch_inlet={},
  76. )
  77. for key, value in overrides.items():
  78. setattr(state, key, value)
  79. return state
  80. class TestPrinterStatuses:
  81. async def test_matches_the_single_route(self, async_client, printer_factory):
  82. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  83. bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{b.id},{a.id}"})
  84. # In the order asked for, each the single route's answer
  85. assert [row["id"] for row in bulk] == [b.id, a.id]
  86. for row in bulk:
  87. assert row == await _get(async_client, f"/api/v1/printers/{row['id']}/status")
  88. async def test_matches_the_single_route_for_a_printing_printer(
  89. self, async_client, printer_factory, archive_factory
  90. ):
  91. # The full status path: live state, the cover, and the active print's archive
  92. from backend.app.services.bambu_mqtt import PrinterState
  93. printing, idle = await printer_factory(name="Busy"), await printer_factory(name="Idle")
  94. archive = await archive_factory(printing.id, subtask_id="sub-42")
  95. states = {}
  96. for printer, run_state in ((printing, "RUNNING"), (idle, "IDLE")):
  97. state = PrinterState()
  98. state.connected = True
  99. state.state = run_state
  100. states[printer.id] = state
  101. states[printing.id].gcode_file = "/data/Metadata/plate_1.gcode"
  102. states[printing.id].subtask_id = "sub-42"
  103. states[printing.id].progress = 40
  104. with patch("backend.app.api.routes.printers.printer_manager") as manager:
  105. manager.get_status = MagicMock(side_effect=lambda pid: states.get(pid))
  106. manager.is_awaiting_plate_clear = MagicMock(return_value=False)
  107. bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{printing.id},{idle.id}"})
  108. singles = [await _get(async_client, f"/api/v1/printers/{pid}/status") for pid in (printing.id, idle.id)]
  109. assert bulk == singles
  110. assert bulk[0]["current_archive_id"] == archive.id
  111. assert bulk[0]["cover_url"] == f"/api/v1/printers/{printing.id}/cover"
  112. assert bulk[1]["connected"] is True
  113. async def test_unknown_printer_is_left_out(self, async_client, printer_factory):
  114. printer = await printer_factory()
  115. bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{printer.id},99999"})
  116. assert [row["id"] for row in bulk] == [printer.id]
  117. async def test_printer_outside_the_scope_is_left_out(self, async_client, printer_factory):
  118. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  119. admin = await _admin_token(async_client)
  120. jwt, _ = await _user(async_client, admin, "member", ["printers:read"], printer_ids=[a.id])
  121. bulk = await _get(
  122. async_client, f"{BULK}/printer-statuses", params={"ids": f"{a.id},{b.id}"}, headers=_auth(jwt)
  123. )
  124. assert [row["id"] for row in bulk] == [a.id]
  125. async def test_needs_printers_read(self, async_client, printer_factory):
  126. printer = await printer_factory()
  127. admin = await _admin_token(async_client)
  128. jwt, _ = await _user(async_client, admin, "nobody", ["queue:read_all"])
  129. response = await async_client.get(
  130. f"{BULK}/printer-statuses", params={"ids": str(printer.id)}, headers=_auth(jwt)
  131. )
  132. assert response.status_code == 403
  133. @pytest.mark.parametrize("ids", ["abc", "1,x", "-1", "0", ",".join(str(i) for i in range(1, 502))])
  134. async def test_rejects_bad_id_lists(self, async_client, ids):
  135. response = await async_client.get(f"{BULK}/printer-statuses", params={"ids": ids})
  136. assert response.status_code == 422
  137. class TestSlotPresetsAndAmsLabels:
  138. async def test_slot_presets_match_the_single_route(self, async_client, db_session, printer_factory):
  139. from backend.app.models.slot_preset import SlotPresetMapping
  140. a, b = await printer_factory(), await printer_factory()
  141. db_session.add_all(
  142. [
  143. SlotPresetMapping(printer_id=a.id, ams_id=0, tray_id=1, preset_id="GFA00", preset_name="PLA"),
  144. SlotPresetMapping(printer_id=a.id, ams_id=255, tray_id=0, preset_id="GFB00", preset_name="ABS"),
  145. ]
  146. )
  147. await db_session.commit()
  148. bulk = await _get(async_client, f"{BULK}/slot-presets", params={"ids": f"{a.id},{b.id}"})
  149. assert set(bulk) == {str(a.id), str(b.id)}
  150. for printer_id in (a.id, b.id):
  151. assert bulk[str(printer_id)] == await _get(async_client, f"/api/v1/printers/{printer_id}/slot-presets")
  152. assert bulk[str(b.id)] == {}
  153. async def test_ams_labels_match_the_single_route(self, async_client, db_session, printer_factory):
  154. from backend.app.models.ams_label import AmsLabel
  155. a, b = await printer_factory(), await printer_factory()
  156. db_session.add(AmsLabel(ams_serial_number="AMS-SN-1", label="Left rack"))
  157. await db_session.commit()
  158. with patch("backend.app.api.routes.printers.printer_manager") as manager:
  159. manager.get_status = MagicMock(
  160. side_effect=lambda pid: _live_state() if pid == a.id else None,
  161. )
  162. bulk = await _get(async_client, f"{BULK}/ams-labels", params={"ids": f"{a.id},{b.id}"})
  163. singles = {pid: await _get(async_client, f"/api/v1/printers/{pid}/ams-labels") for pid in (a.id, b.id)}
  164. assert bulk == {str(pid): answer for pid, answer in singles.items()}
  165. assert bulk[str(a.id)] == {"0": "Left rack"}
  166. class TestCardPlugs:
  167. async def test_matches_the_two_single_routes(self, async_client, printer_factory, smart_plug_factory):
  168. a, b = await printer_factory(), await printer_factory()
  169. await smart_plug_factory(name="Outlet", printer_id=a.id, ip_address="10.0.0.5")
  170. await smart_plug_factory(
  171. name="Lights",
  172. plug_type="homeassistant",
  173. ha_entity_id="script.lights",
  174. printer_id=a.id,
  175. show_on_printer_card=True,
  176. )
  177. bulk = await _get(async_client, f"{BULK}/card-plugs", params={"ids": f"{a.id},{b.id}"})
  178. for printer_id in (a.id, b.id):
  179. assert bulk[str(printer_id)]["plug"] == await _get(
  180. async_client, f"/api/v1/smart-plugs/by-printer/{printer_id}"
  181. )
  182. assert bulk[str(printer_id)]["scripts"] == await _get(
  183. async_client, f"/api/v1/smart-plugs/by-printer/{printer_id}/scripts"
  184. )
  185. assert bulk[str(a.id)]["plug"]["name"] == "Outlet"
  186. assert [p["name"] for p in bulk[str(a.id)]["scripts"]] == ["Lights"]
  187. assert bulk[str(b.id)] == {"plug": None, "scripts": []}
  188. class TestHaSensorReadings:
  189. async def test_matches_the_single_route(self, async_client, db_session, printer_factory):
  190. from backend.app.models.printer_ha_sensor import PrinterHASensor
  191. a, b = await printer_factory(), await printer_factory()
  192. db_session.add_all(
  193. [
  194. PrinterHASensor(printer_id=a.id, name="Door", entity_id="binary_sensor.door", sort_order=2),
  195. PrinterHASensor(printer_id=a.id, name="Smoke", entity_id="binary_sensor.smoke", sort_order=1),
  196. PrinterHASensor(
  197. printer_id=a.id, name="Hidden", entity_id="binary_sensor.hidden", show_on_printer_card=False
  198. ),
  199. ]
  200. )
  201. await db_session.commit()
  202. bulk = await _get(async_client, f"{BULK}/ha-sensor-readings", params={"ids": f"{a.id},{b.id}"})
  203. for printer_id in (a.id, b.id):
  204. assert bulk[str(printer_id)] == await _get(
  205. async_client, f"/api/v1/ha-sensors/by-printer/{printer_id}/readings"
  206. )
  207. assert [r["name"] for r in bulk[str(a.id)]] == ["Smoke", "Door"]
  208. assert bulk[str(b.id)] == []
  209. class TestFirmwareUpdates:
  210. async def test_matches_the_single_route(self, async_client, printer_factory):
  211. a, b = await printer_factory(model="X1C"), await printer_factory(model="P1S")
  212. service = MagicMock()
  213. service.check_for_update = AsyncMock(
  214. return_value={
  215. "update_available": True,
  216. "latest_version": "01.09.00.00",
  217. "download_url": None,
  218. "release_notes": None,
  219. "available_versions": [],
  220. }
  221. )
  222. with (
  223. patch("backend.app.api.routes.bulk.get_firmware_service", return_value=service),
  224. patch("backend.app.api.routes.firmware.get_firmware_service", return_value=service),
  225. ):
  226. bulk = await _get(async_client, f"{BULK}/firmware-updates", params={"ids": f"{a.id},{b.id},99999"})
  227. singles = [await _get(async_client, f"/api/v1/firmware/updates/{pid}") for pid in (a.id, b.id)]
  228. assert bulk == singles
  229. async def test_switched_off_asks_nobody(self, async_client, db_session, printer_factory):
  230. from backend.app.models.settings import Settings
  231. a, b = await printer_factory(), await printer_factory()
  232. db_session.add(Settings(key="check_printer_firmware", value="false"))
  233. await db_session.commit()
  234. service = MagicMock()
  235. service.check_for_update = AsyncMock()
  236. with patch("backend.app.api.routes.bulk.get_firmware_service", return_value=service):
  237. bulk = await _get(async_client, f"{BULK}/firmware-updates", params={"ids": f"{a.id},{b.id}"})
  238. assert [row["update_available"] for row in bulk] == [False, False]
  239. service.check_for_update.assert_not_called()
  240. class TestPrinterQueues:
  241. async def test_matches_the_single_route_including_model_jobs(
  242. self, async_client, db_session, printer_factory, archive_factory
  243. ):
  244. from backend.app.models.print_queue import PrintQueueItem
  245. x1_a = await printer_factory(model="X1C")
  246. x1_b = await printer_factory(model="X1C")
  247. p1 = await printer_factory(model="P1S")
  248. archive = await archive_factory(x1_a.id)
  249. db_session.add_all(
  250. [
  251. PrintQueueItem(archive_id=archive.id, printer_id=x1_a.id, status="pending", position=3),
  252. PrintQueueItem(
  253. archive_id=archive.id, printer_id=None, target_model="x1c", status="pending", position=1
  254. ),
  255. PrintQueueItem(archive_id=archive.id, printer_id=p1.id, status="pending", position=2),
  256. PrintQueueItem(archive_id=archive.id, printer_id=x1_a.id, status="printing", position=4),
  257. PrintQueueItem(archive_id=archive.id, printer_id=x1_b.id, status="completed", position=5),
  258. ]
  259. )
  260. await db_session.commit()
  261. ids = [x1_a.id, x1_b.id, p1.id]
  262. for status in ("pending", "printing"):
  263. bulk = await _get(
  264. async_client,
  265. f"{BULK}/printer-queues",
  266. params={"ids": ",".join(map(str, ids)), "status": status},
  267. )
  268. for printer_id in ids:
  269. single = await _get(async_client, "/api/v1/queue/", params={"printer_id": printer_id, "status": status})
  270. assert bulk[str(printer_id)] == single, (status, printer_id)
  271. pending = await _get(
  272. async_client, f"{BULK}/printer-queues", params={"ids": ",".join(map(str, ids)), "status": "pending"}
  273. )
  274. # The "Any X1C" job sits under both X1Cs, ahead of the pinned one
  275. assert [i["printer_id"] for i in pending[str(x1_a.id)]] == [None, x1_a.id]
  276. assert [i["printer_id"] for i in pending[str(x1_b.id)]] == [None]
  277. assert [i["printer_id"] for i in pending[str(p1.id)]] == [p1.id]
  278. async def test_read_own_sees_only_own_jobs(self, async_client, db_session, printer_factory, archive_factory):
  279. from backend.app.models.print_queue import PrintQueueItem
  280. printer = await printer_factory()
  281. archive = await archive_factory(printer.id)
  282. admin = await _admin_token(async_client)
  283. jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_own"])
  284. db_session.add_all(
  285. [
  286. PrintQueueItem(archive_id=archive.id, printer_id=printer.id, status="pending", created_by_id=user_id),
  287. PrintQueueItem(archive_id=archive.id, printer_id=printer.id, status="pending"),
  288. ]
  289. )
  290. await db_session.commit()
  291. bulk = await _get(
  292. async_client,
  293. f"{BULK}/printer-queues",
  294. params={"ids": str(printer.id), "status": "pending"},
  295. headers=_auth(jwt),
  296. )
  297. assert [i["created_by_id"] for i in bulk[str(printer.id)]] == [user_id]
  298. class TestArchiveFolders:
  299. async def test_matches_the_single_route(self, async_client, db_session, printer_factory, archive_factory):
  300. from backend.app.models.library import LibraryFolder
  301. printer = await printer_factory()
  302. linked, unlinked = await archive_factory(printer.id), await archive_factory(printer.id)
  303. db_session.add_all(
  304. [
  305. LibraryFolder(name="Zeta", archive_id=linked.id),
  306. LibraryFolder(name="Alpha", archive_id=linked.id),
  307. LibraryFolder(name="Elsewhere"),
  308. ]
  309. )
  310. await db_session.commit()
  311. bulk = await _get(async_client, f"{BULK}/archive-folders", params={"ids": f"{linked.id},{unlinked.id}"})
  312. for archive_id in (linked.id, unlinked.id):
  313. assert bulk[str(archive_id)] == await _get(async_client, f"/api/v1/library/folders/by-archive/{archive_id}")
  314. assert [f["name"] for f in bulk[str(linked.id)]] == ["Alpha", "Zeta"]
  315. assert bulk[str(unlinked.id)] == []