test_labels.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  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):
  110. captured["ids"] = [d.spool_id for d in data_list]
  111. return original(template, data_list)
  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. # ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
  120. class TestSpoolmanLabels:
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
  124. # Default state in tests: spoolman_enabled is unset / "false"
  125. resp = await async_client.post(
  126. "/api/v1/spoolman/labels",
  127. json={"spool_ids": [1], "template": "box_62x29"},
  128. )
  129. assert resp.status_code == 400
  130. assert "Spoolman" in resp.text
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_returns_503_when_spoolman_unreachable(self, async_client: AsyncClient, db_session: AsyncSession):
  134. from backend.app.models.settings import Settings
  135. db_session.add(Settings(key="spoolman_enabled", value="true"))
  136. await db_session.commit()
  137. with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=None)):
  138. resp = await async_client.post(
  139. "/api/v1/spoolman/labels",
  140. json={"spool_ids": [1], "template": "box_62x29"},
  141. )
  142. assert resp.status_code == 503
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_renders_pdf_from_spoolman_data(self, async_client: AsyncClient, db_session: AsyncSession):
  146. from backend.app.models.settings import Settings
  147. db_session.add(Settings(key="spoolman_enabled", value="true"))
  148. await db_session.commit()
  149. spoolman_spool = {
  150. "id": 42,
  151. "filament": {
  152. "name": "PolyTerra Sapphire Blue",
  153. "material": "PLA",
  154. "color_hex": "0033AA",
  155. "vendor": {"name": "Polymaker"},
  156. },
  157. "location": "Shelf 5, slot C",
  158. }
  159. mock_client = MagicMock()
  160. mock_client.is_connected = True
  161. mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
  162. with patch(
  163. "backend.app.api.routes.labels.get_spoolman_client",
  164. AsyncMock(return_value=mock_client),
  165. ):
  166. resp = await async_client.post(
  167. "/api/v1/spoolman/labels",
  168. json={"spool_ids": [42], "template": "avery_l7160"},
  169. )
  170. assert resp.status_code == 200
  171. assert resp.headers["content-type"] == "application/pdf"
  172. assert resp.content.startswith(b"%PDF")
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_returns_404_when_spool_missing_from_spoolman(
  176. self, async_client: AsyncClient, db_session: AsyncSession
  177. ):
  178. from backend.app.models.settings import Settings
  179. db_session.add(Settings(key="spoolman_enabled", value="true"))
  180. await db_session.commit()
  181. mock_client = MagicMock()
  182. mock_client.is_connected = True
  183. mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
  184. with patch(
  185. "backend.app.api.routes.labels.get_spoolman_client",
  186. AsyncMock(return_value=mock_client),
  187. ):
  188. resp = await async_client.post(
  189. "/api/v1/spoolman/labels",
  190. json={"spool_ids": [99], "template": "box_62x29"},
  191. )
  192. assert resp.status_code == 404
  193. assert "99" in resp.text
  194. # ── Validation cross-cutting ─────────────────────────────────────────────────
  195. class TestValidation:
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_request_body_size_capped(self, async_client: AsyncClient):
  199. """spool_ids is bounded to MAX_LABELS_PER_REQUEST so a runaway client
  200. can't flood the renderer."""
  201. from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST
  202. resp = await async_client.post(
  203. "/api/v1/inventory/labels",
  204. json={
  205. "spool_ids": list(range(1, MAX_LABELS_PER_REQUEST + 2)),
  206. "template": "box_62x29",
  207. },
  208. )
  209. assert resp.status_code == 422