test_labels.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. """Integration tests for the spool-label routes (#809).
  2. Covers both ``POST /inventory/labels`` (local DB) and ``POST /spoolman/labels``
  3. (Spoolman-backed). The renderer itself has its own unit tests; these tests
  4. focus on auth, request validation, mode gating, and the wiring between route
  5. and renderer.
  6. """
  7. from __future__ import annotations
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.models.spool import Spool
  13. @pytest.fixture
  14. async def spool_factory(db_session: AsyncSession):
  15. """Factory to create test spools."""
  16. _counter = [0]
  17. async def _create_spool(**kwargs):
  18. _counter[0] += 1
  19. defaults = {
  20. "material": "PLA",
  21. "subtype": "Basic",
  22. "brand": "Polymaker",
  23. "color_name": f"Test {_counter[0]}",
  24. "rgba": "FF8800FF",
  25. "label_weight": 1000,
  26. "weight_used": 0,
  27. }
  28. defaults.update(kwargs)
  29. spool = Spool(**defaults)
  30. db_session.add(spool)
  31. await db_session.commit()
  32. await db_session.refresh(spool)
  33. return spool
  34. return _create_spool
  35. # ── /inventory/labels (local DB) ─────────────────────────────────────────────
  36. class TestLocalInventoryLabels:
  37. @pytest.mark.asyncio
  38. @pytest.mark.integration
  39. async def test_renders_pdf_for_local_spools(self, async_client: AsyncClient, spool_factory):
  40. s1 = await spool_factory()
  41. s2 = await spool_factory(material="PETG", brand="Sunlu")
  42. resp = await async_client.post(
  43. "/api/v1/inventory/labels",
  44. json={"spool_ids": [s1.id, s2.id], "template": "box_62x29"},
  45. )
  46. assert resp.status_code == 200
  47. assert resp.headers["content-type"] == "application/pdf"
  48. assert resp.content.startswith(b"%PDF")
  49. assert int(resp.headers["content-length"]) == len(resp.content)
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
  53. s = await spool_factory()
  54. for template in (
  55. "ams_holder_74x33",
  56. "ams_holder_75x55",
  57. "box_62x29",
  58. "avery_5160",
  59. "avery_l7160",
  60. ):
  61. resp = await async_client.post(
  62. "/api/v1/inventory/labels",
  63. json={"spool_ids": [s.id], "template": template},
  64. )
  65. assert resp.status_code == 200, f"{template} returned {resp.status_code}: {resp.text}"
  66. assert resp.content.startswith(b"%PDF")
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_unknown_template_rejected(self, async_client: AsyncClient, spool_factory):
  70. s = await spool_factory()
  71. resp = await async_client.post(
  72. "/api/v1/inventory/labels",
  73. json={"spool_ids": [s.id], "template": "totally_made_up"},
  74. )
  75. # Pydantic Literal validation → 422
  76. assert resp.status_code in (400, 422)
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_empty_spool_ids_rejected(self, async_client: AsyncClient):
  80. resp = await async_client.post(
  81. "/api/v1/inventory/labels",
  82. json={"spool_ids": [], "template": "box_62x29"},
  83. )
  84. assert resp.status_code == 422
  85. @pytest.mark.asyncio
  86. @pytest.mark.integration
  87. async def test_unknown_spool_id_returns_404(self, async_client: AsyncClient, spool_factory):
  88. s = await spool_factory()
  89. resp = await async_client.post(
  90. "/api/v1/inventory/labels",
  91. json={"spool_ids": [s.id, 99999], "template": "ams_holder_74x33"},
  92. )
  93. assert resp.status_code == 404
  94. assert "99999" in resp.text
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_preserves_request_order(self, async_client: AsyncClient, spool_factory):
  98. """Caller's `spool_ids` order should match the on-screen list — important
  99. for Avery sheet layouts where users curate the layout via filtering."""
  100. s1 = await spool_factory()
  101. s2 = await spool_factory()
  102. s3 = await spool_factory()
  103. # Reverse order; assert the route doesn't sort them. We can't peek
  104. # inside the PDF for assertion, but we can call render_labels directly
  105. # under the same patches and compare bytes deterministically.
  106. from backend.app.api.routes import labels as labels_module
  107. captured = {}
  108. original = labels_module.render_labels
  109. def _capture(template, data_list, **kwargs):
  110. captured["ids"] = [d.spool_id for d in data_list]
  111. return original(template, data_list, **kwargs)
  112. with patch.object(labels_module, "render_labels", side_effect=_capture):
  113. resp = await async_client.post(
  114. "/api/v1/inventory/labels",
  115. json={"spool_ids": [s3.id, s1.id, s2.id], "template": "avery_l7160"},
  116. )
  117. assert resp.status_code == 200
  118. assert captured["ids"] == [s3.id, s1.id, s2.id]
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_forwards_sheet_starting_position(self, async_client: AsyncClient, spool_factory):
  122. spool = await spool_factory()
  123. from backend.app.api.routes import labels as labels_module
  124. captured = {}
  125. original = labels_module.render_labels
  126. def _capture(template, data_list, **kwargs):
  127. captured["starting_position"] = kwargs["starting_position"]
  128. return original(template, data_list, **kwargs)
  129. with patch.object(labels_module, "render_labels", side_effect=_capture):
  130. resp = await async_client.post(
  131. "/api/v1/inventory/labels",
  132. json={"spool_ids": [spool.id], "template": "avery_5160", "starting_position": 8},
  133. )
  134. assert resp.status_code == 200
  135. assert captured["starting_position"] == 8
  136. # ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
  137. class TestSpoolmanLabels:
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
  141. # Default state in tests: spoolman_enabled is unset / "false"
  142. resp = await async_client.post(
  143. "/api/v1/spoolman/labels",
  144. json={"spool_ids": [1], "template": "box_62x29"},
  145. )
  146. assert resp.status_code == 400
  147. assert "Spoolman" in resp.text
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_returns_503_when_spoolman_unreachable(self, async_client: AsyncClient, db_session: AsyncSession):
  151. from backend.app.models.settings import Settings
  152. db_session.add(Settings(key="spoolman_enabled", value="true"))
  153. await db_session.commit()
  154. with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=None)):
  155. resp = await async_client.post(
  156. "/api/v1/spoolman/labels",
  157. json={"spool_ids": [1], "template": "box_62x29"},
  158. )
  159. assert resp.status_code == 503
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_renders_pdf_from_spoolman_data(self, async_client: AsyncClient, db_session: AsyncSession):
  163. from backend.app.models.settings import Settings
  164. db_session.add(Settings(key="spoolman_enabled", value="true"))
  165. await db_session.commit()
  166. spoolman_spool = {
  167. "id": 42,
  168. "filament": {
  169. "name": "PolyTerra Sapphire Blue",
  170. "material": "PLA",
  171. "color_hex": "0033AA",
  172. "vendor": {"name": "Polymaker"},
  173. },
  174. "location": "Shelf 5, slot C",
  175. }
  176. mock_client = MagicMock()
  177. mock_client.is_connected = True
  178. mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
  179. mock_client.get_all_spools = AsyncMock(return_value=[spoolman_spool])
  180. with patch(
  181. "backend.app.api.routes.labels.get_spoolman_client",
  182. AsyncMock(return_value=mock_client),
  183. ):
  184. resp = await async_client.post(
  185. "/api/v1/spoolman/labels",
  186. json={"spool_ids": [42], "template": "avery_l7160"},
  187. )
  188. assert resp.status_code == 200
  189. assert resp.headers["content-type"] == "application/pdf"
  190. assert resp.content.startswith(b"%PDF")
  191. @pytest.mark.asyncio
  192. @pytest.mark.integration
  193. async def test_returns_404_when_spool_missing_from_spoolman(
  194. self, async_client: AsyncClient, db_session: AsyncSession
  195. ):
  196. from backend.app.models.settings import Settings
  197. db_session.add(Settings(key="spoolman_enabled", value="true"))
  198. await db_session.commit()
  199. mock_client = MagicMock()
  200. mock_client.is_connected = True
  201. mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
  202. mock_client.get_all_spools = mock_client.get_spools
  203. with patch(
  204. "backend.app.api.routes.labels.get_spoolman_client",
  205. AsyncMock(return_value=mock_client),
  206. ):
  207. resp = await async_client.post(
  208. "/api/v1/spoolman/labels",
  209. json={"spool_ids": [99], "template": "box_62x29"},
  210. )
  211. assert resp.status_code == 404
  212. assert "99" in resp.text
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_renders_an_archived_spool(self, async_client: AsyncClient, db_session: AsyncSession):
  216. """SM-C12: Spoolman leaves archived spools out of its plain list, so the
  217. whole batch failed with a 404 when one was selected."""
  218. from backend.app.models.settings import Settings
  219. db_session.add(Settings(key="spoolman_enabled", value="true"))
  220. await db_session.commit()
  221. active = {"id": 1, "filament": {"name": "X", "material": "PLA"}}
  222. archived = {"id": 2, "archived": True, "filament": {"name": "Y", "material": "PETG"}}
  223. mock_client = MagicMock()
  224. mock_client.is_connected = True
  225. mock_client.get_spools = AsyncMock(return_value=[active])
  226. mock_client.get_all_spools = AsyncMock(
  227. side_effect=lambda allow_archived=False: [active, archived] if allow_archived else [active]
  228. )
  229. with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=mock_client)):
  230. resp = await async_client.post(
  231. "/api/v1/spoolman/labels", json={"spool_ids": [1, 2], "template": "box_62x29"}
  232. )
  233. assert resp.status_code == 200, resp.text
  234. # ── Validation cross-cutting ─────────────────────────────────────────────────
  235. class TestValidation:
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. @pytest.mark.parametrize(
  239. ("template", "starting_position"),
  240. (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
  241. )
  242. async def test_invalid_starting_position_rejected(
  243. self,
  244. async_client: AsyncClient,
  245. template: str,
  246. starting_position: int,
  247. ):
  248. resp = await async_client.post(
  249. "/api/v1/inventory/labels",
  250. json={"spool_ids": [1], "template": template, "starting_position": starting_position},
  251. )
  252. assert resp.status_code == 422
  253. @pytest.mark.asyncio
  254. @pytest.mark.integration
  255. async def test_request_body_size_capped(self, async_client: AsyncClient):
  256. """spool_ids is bounded to MAX_LABELS_PER_REQUEST so a runaway client
  257. can't flood the renderer."""
  258. from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST
  259. resp = await async_client.post(
  260. "/api/v1/inventory/labels",
  261. json={
  262. "spool_ids": list(range(1, MAX_LABELS_PER_REQUEST + 2)),
  263. "template": "box_62x29",
  264. },
  265. )
  266. assert resp.status_code == 422
  267. # ── Fields, PNG and preview (#2981) ──────────────────────────────────────────
  268. def _spoolman_on(db_session: AsyncSession):
  269. from backend.app.models.settings import Settings
  270. db_session.add(Settings(key="spoolman_enabled", value="true"))
  271. def _spoolman_client(spools: list[dict]) -> MagicMock:
  272. client = MagicMock()
  273. client.is_connected = True
  274. client.get_spools = AsyncMock(return_value=spools)
  275. client.get_all_spools = AsyncMock(return_value=spools)
  276. return client
  277. class TestLabelFieldsAndFormats:
  278. @pytest.mark.asyncio
  279. @pytest.mark.integration
  280. async def test_omitted_fields_print_the_default_set(self, async_client: AsyncClient, spool_factory):
  281. from backend.app.api.routes import labels as labels_module
  282. from backend.app.services.label_renderer import DEFAULT_LABEL_FIELDS
  283. spool = await spool_factory()
  284. captured = {}
  285. original = labels_module.render_labels
  286. def _capture(template, data_list, **kwargs):
  287. captured["fields"] = kwargs["fields"]
  288. return original(template, data_list, **kwargs)
  289. with patch.object(labels_module, "render_labels", side_effect=_capture):
  290. resp = await async_client.post(
  291. "/api/v1/inventory/labels", json={"spool_ids": [spool.id], "template": "box_40x30"}
  292. )
  293. assert resp.status_code == 200
  294. assert captured["fields"] == DEFAULT_LABEL_FIELDS
  295. @pytest.mark.asyncio
  296. @pytest.mark.integration
  297. async def test_chosen_fields_and_new_spool_data_reach_the_renderer(self, async_client: AsyncClient, spool_factory):
  298. from backend.app.api.routes import labels as labels_module
  299. spool = await spool_factory(material_number="MN-15", nozzle_temp_min=190, nozzle_temp_max=230, note="Dry first")
  300. captured = {}
  301. original = labels_module.render_labels
  302. def _capture(template, data_list, **kwargs):
  303. captured["fields"] = kwargs["fields"]
  304. captured["data"] = data_list[0]
  305. return original(template, data_list, **kwargs)
  306. with patch.object(labels_module, "render_labels", side_effect=_capture):
  307. resp = await async_client.post(
  308. "/api/v1/inventory/labels",
  309. json={
  310. "spool_ids": [spool.id],
  311. "template": "box_40x30",
  312. "fields": ["temps", "material_number", "temps"],
  313. },
  314. )
  315. assert resp.status_code == 200
  316. assert captured["fields"] == {"temps", "material_number"}
  317. data = captured["data"]
  318. assert (data.material_number, data.nozzle_temp_min, data.nozzle_temp_max) == ("MN-15", 190, 230)
  319. assert (data.label_weight, data.note) == (1000, "Dry first")
  320. assert data.added is not None
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_unknown_field_rejected(self, async_client: AsyncClient, spool_factory):
  324. spool = await spool_factory()
  325. resp = await async_client.post(
  326. "/api/v1/inventory/labels",
  327. json={"spool_ids": [spool.id], "template": "box_40x30", "fields": ["brand", "password"]},
  328. )
  329. assert resp.status_code == 422
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. @pytest.mark.parametrize("body", [{"format": "gif"}, {"format": "png", "dpi": 72}, {"spool_ids": [0]}])
  333. async def test_invalid_output_options_rejected(self, async_client: AsyncClient, body: dict):
  334. resp = await async_client.post(
  335. "/api/v1/inventory/labels", json={"spool_ids": [1], "template": "box_40x30", **body}
  336. )
  337. assert resp.status_code == 422
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_one_label_as_png(self, async_client: AsyncClient, spool_factory):
  341. spool = await spool_factory()
  342. resp = await async_client.post(
  343. "/api/v1/inventory/labels",
  344. json={"spool_ids": [spool.id], "template": "box_40x30", "format": "png", "dpi": 203},
  345. )
  346. assert resp.status_code == 200
  347. assert resp.headers["content-type"] == "image/png"
  348. assert "bambuddy-labels-box_40x30.png" in resp.headers["content-disposition"]
  349. assert resp.content.startswith(b"\x89PNG")
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_several_roll_labels_come_as_a_zip_named_by_spool(self, async_client: AsyncClient, spool_factory):
  353. import io
  354. import zipfile
  355. s1 = await spool_factory()
  356. s2 = await spool_factory()
  357. resp = await async_client.post(
  358. "/api/v1/inventory/labels",
  359. json={"spool_ids": [s2.id, s1.id], "template": "box_62x29", "format": "png"},
  360. )
  361. assert resp.status_code == 200
  362. assert resp.headers["content-type"] == "application/zip"
  363. with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
  364. assert zf.namelist() == [f"label-{s2.id}.png", f"label-{s1.id}.png"]
  365. assert all(zf.read(n).startswith(b"\x89PNG") for n in zf.namelist())
  366. @pytest.mark.asyncio
  367. @pytest.mark.integration
  368. async def test_sheet_pngs_are_numbered_by_page(self, async_client: AsyncClient, spool_factory):
  369. import io
  370. import zipfile
  371. s1 = await spool_factory()
  372. s2 = await spool_factory()
  373. resp = await async_client.post(
  374. "/api/v1/inventory/labels",
  375. # 21 per L7160 page; starting at 21 puts the second label on page two.
  376. json={
  377. "spool_ids": [s1.id, s2.id],
  378. "template": "avery_l7160",
  379. "format": "png",
  380. "starting_position": 21,
  381. },
  382. )
  383. assert resp.status_code == 200
  384. with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
  385. assert zf.namelist() == ["sheet-1.png", "sheet-2.png"]
  386. @pytest.mark.asyncio
  387. @pytest.mark.integration
  388. async def test_preview_is_one_png(self, async_client: AsyncClient, spool_factory):
  389. spool = await spool_factory()
  390. resp = await async_client.post(
  391. "/api/v1/inventory/labels/preview",
  392. json={"spool_id": spool.id, "template": "avery_5160", "fields": ["brand", "qr"]},
  393. )
  394. assert resp.status_code == 200
  395. assert resp.headers["content-type"] == "image/png"
  396. assert resp.headers["cache-control"] == "no-store"
  397. assert resp.content.startswith(b"\x89PNG")
  398. @pytest.mark.asyncio
  399. @pytest.mark.integration
  400. async def test_preview_of_unknown_spool_is_404(self, async_client: AsyncClient):
  401. resp = await async_client.post(
  402. "/api/v1/inventory/labels/preview", json={"spool_id": 99999, "template": "box_40x30"}
  403. )
  404. assert resp.status_code == 404
  405. class TestSpoolmanLabelParity:
  406. """A Spoolman spool's label is built from the same mapping the inventory
  407. page shows, so it carries what a built-in spool's label does."""
  408. SPOOL = {
  409. "id": 7,
  410. "filament": {
  411. "name": "PLA Matte",
  412. "material": "PLA",
  413. "color_hex": "000000",
  414. "vendor": {"name": "Bambu Lab"},
  415. "article_number": "MN-15",
  416. "settings_extruder_temp": 220,
  417. "weight": 1000,
  418. },
  419. "extra": {"bambu_color_name": '"Charcoal"'},
  420. "comment": "Dry first",
  421. "registered": "2026-09-01T10:00:00Z",
  422. "location": "Shelf 2",
  423. }
  424. async def _captured(self, async_client: AsyncClient, db_session: AsyncSession, spool: dict):
  425. from backend.app.api.routes import labels as labels_module
  426. _spoolman_on(db_session)
  427. await db_session.commit()
  428. captured = {}
  429. original = labels_module.render_labels
  430. def _capture(template, data_list, **kwargs):
  431. captured["data"] = data_list[0]
  432. return original(template, data_list, **kwargs)
  433. with (
  434. patch.object(labels_module, "render_labels", side_effect=_capture),
  435. patch(
  436. "backend.app.api.routes.labels.get_spoolman_client",
  437. AsyncMock(return_value=_spoolman_client([spool])),
  438. ),
  439. ):
  440. resp = await async_client.post(
  441. "/api/v1/spoolman/labels", json={"spool_ids": [spool["id"]], "template": "box_40x30"}
  442. )
  443. assert resp.status_code == 200
  444. return captured["data"]
  445. @pytest.mark.asyncio
  446. @pytest.mark.integration
  447. async def test_carries_subtype_colour_name_and_the_new_fields(
  448. self, async_client: AsyncClient, db_session: AsyncSession
  449. ):
  450. from datetime import date
  451. data = await self._captured(async_client, db_session, self.SPOOL)
  452. assert (data.material, data.subtype, data.name, data.brand) == ("PLA", "Matte", "Charcoal", "Bambu Lab")
  453. assert (data.material_number, data.nozzle_temp_min, data.label_weight) == ("MN-15", 220, 1000)
  454. assert (data.note, data.added, data.storage_location) == ("Dry first", date(2026, 9, 1), "Shelf 2")
  455. @pytest.mark.asyncio
  456. @pytest.mark.integration
  457. async def test_without_a_colour_name_the_filament_name_is_used(
  458. self, async_client: AsyncClient, db_session: AsyncSession
  459. ):
  460. """As before: the name line falls back to the filament name, not to the
  461. subtype the mapping synthesises as a colour name."""
  462. spool = {**self.SPOOL, "extra": {}}
  463. data = await self._captured(async_client, db_session, spool)
  464. assert data.name == "PLA Matte"
  465. @pytest.mark.asyncio
  466. @pytest.mark.integration
  467. async def test_preview_uses_spoolman(self, async_client: AsyncClient, db_session: AsyncSession):
  468. _spoolman_on(db_session)
  469. await db_session.commit()
  470. with patch(
  471. "backend.app.api.routes.labels.get_spoolman_client",
  472. AsyncMock(return_value=_spoolman_client([self.SPOOL])),
  473. ):
  474. resp = await async_client.post(
  475. "/api/v1/spoolman/labels/preview", json={"spool_id": 7, "template": "box_40x30"}
  476. )
  477. assert resp.status_code == 200
  478. assert resp.content.startswith(b"\x89PNG")
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_preview_refused_when_spoolman_is_off(self, async_client: AsyncClient):
  482. resp = await async_client.post("/api/v1/spoolman/labels/preview", json={"spool_id": 7, "template": "box_40x30"})
  483. assert resp.status_code == 400