| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- """Integration tests for the spool-label routes (#809).
- Covers both ``POST /inventory/labels`` (local DB) and ``POST /spoolman/labels``
- (Spoolman-backed). The renderer itself has its own unit tests; these tests
- focus on auth, request validation, mode gating, and the wiring between route
- and renderer.
- """
- from __future__ import annotations
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.spool import Spool
- @pytest.fixture
- async def spool_factory(db_session: AsyncSession):
- """Factory to create test spools."""
- _counter = [0]
- async def _create_spool(**kwargs):
- _counter[0] += 1
- defaults = {
- "material": "PLA",
- "subtype": "Basic",
- "brand": "Polymaker",
- "color_name": f"Test {_counter[0]}",
- "rgba": "FF8800FF",
- "label_weight": 1000,
- "weight_used": 0,
- }
- defaults.update(kwargs)
- spool = Spool(**defaults)
- db_session.add(spool)
- await db_session.commit()
- await db_session.refresh(spool)
- return spool
- return _create_spool
- # ── /inventory/labels (local DB) ─────────────────────────────────────────────
- class TestLocalInventoryLabels:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_renders_pdf_for_local_spools(self, async_client: AsyncClient, spool_factory):
- s1 = await spool_factory()
- s2 = await spool_factory(material="PETG", brand="Sunlu")
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [s1.id, s2.id], "template": "box_62x29"},
- )
- assert resp.status_code == 200
- assert resp.headers["content-type"] == "application/pdf"
- assert resp.content.startswith(b"%PDF")
- assert int(resp.headers["content-length"]) == len(resp.content)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
- s = await spool_factory()
- for template in ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160"):
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [s.id], "template": template},
- )
- assert resp.status_code == 200, f"{template} returned {resp.status_code}: {resp.text}"
- assert resp.content.startswith(b"%PDF")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_template_rejected(self, async_client: AsyncClient, spool_factory):
- s = await spool_factory()
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [s.id], "template": "totally_made_up"},
- )
- # Pydantic Literal validation → 422
- assert resp.status_code in (400, 422)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_empty_spool_ids_rejected(self, async_client: AsyncClient):
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [], "template": "box_62x29"},
- )
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_spool_id_returns_404(self, async_client: AsyncClient, spool_factory):
- s = await spool_factory()
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
- )
- assert resp.status_code == 404
- assert "99999" in resp.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_preserves_request_order(self, async_client: AsyncClient, spool_factory):
- """Caller's `spool_ids` order should match the on-screen list — important
- for Avery sheet layouts where users curate the layout via filtering."""
- s1 = await spool_factory()
- s2 = await spool_factory()
- s3 = await spool_factory()
- # Reverse order; assert the route doesn't sort them. We can't peek
- # inside the PDF for assertion, but we can call render_labels directly
- # under the same patches and compare bytes deterministically.
- from backend.app.api.routes import labels as labels_module
- captured = {}
- original = labels_module.render_labels
- def _capture(template, data_list):
- captured["ids"] = [d.spool_id for d in data_list]
- return original(template, data_list)
- with patch.object(labels_module, "render_labels", side_effect=_capture):
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={"spool_ids": [s3.id, s1.id, s2.id], "template": "avery_l7160"},
- )
- assert resp.status_code == 200
- assert captured["ids"] == [s3.id, s1.id, s2.id]
- # ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
- class TestSpoolmanLabels:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
- # Default state in tests: spoolman_enabled is unset / "false"
- resp = await async_client.post(
- "/api/v1/spoolman/labels",
- json={"spool_ids": [1], "template": "box_62x29"},
- )
- assert resp.status_code == 400
- assert "Spoolman" in resp.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_503_when_spoolman_unreachable(self, async_client: AsyncClient, db_session: AsyncSession):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=None)):
- resp = await async_client.post(
- "/api/v1/spoolman/labels",
- json={"spool_ids": [1], "template": "box_62x29"},
- )
- assert resp.status_code == 503
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_renders_pdf_from_spoolman_data(self, async_client: AsyncClient, db_session: AsyncSession):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- spoolman_spool = {
- "id": 42,
- "filament": {
- "name": "PolyTerra Sapphire Blue",
- "material": "PLA",
- "color_hex": "0033AA",
- "vendor": {"name": "Polymaker"},
- },
- "location": "Shelf 5, slot C",
- }
- mock_client = MagicMock()
- mock_client.is_connected = True
- mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
- with patch(
- "backend.app.api.routes.labels.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.post(
- "/api/v1/spoolman/labels",
- json={"spool_ids": [42], "template": "avery_l7160"},
- )
- assert resp.status_code == 200
- assert resp.headers["content-type"] == "application/pdf"
- assert resp.content.startswith(b"%PDF")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_404_when_spool_missing_from_spoolman(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- mock_client = MagicMock()
- mock_client.is_connected = True
- mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
- with patch(
- "backend.app.api.routes.labels.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.post(
- "/api/v1/spoolman/labels",
- json={"spool_ids": [99], "template": "box_62x29"},
- )
- assert resp.status_code == 404
- assert "99" in resp.text
- # ── Validation cross-cutting ─────────────────────────────────────────────────
- class TestValidation:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_request_body_size_capped(self, async_client: AsyncClient):
- """spool_ids is bounded to MAX_LABELS_PER_REQUEST so a runaway client
- can't flood the renderer."""
- from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST
- resp = await async_client.post(
- "/api/v1/inventory/labels",
- json={
- "spool_ids": list(range(1, MAX_LABELS_PER_REQUEST + 2)),
- "template": "box_62x29",
- },
- )
- assert resp.status_code == 422
|