| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- """Integration tests for SpoolBuddy + Spoolman NFC fixes.
- Group 1 – tag-scanned broadcasts include tray_uuid in all WebSocket messages.
- Group 2 – PATCH /api/v1/spoolman/inventory/spools/{id}/tag endpoint.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.settings import Settings
- from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
- SPOOLBUDDY_API = "/api/v1/spoolbuddy"
- INVENTORY_API = "/api/v1/spoolman/inventory"
- # ---------------------------------------------------------------------------
- # Shared helpers
- # ---------------------------------------------------------------------------
- @pytest.fixture
- async def spoolman_settings_local(db_session: AsyncSession):
- """Spoolman enabled, URL = spoolman.local (matches SpoolBuddy service patches)."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
- await db_session.commit()
- @pytest.fixture
- async def spoolman_settings_disabled(db_session: AsyncSession):
- """Spoolman disabled — used by tests that exercise the local-DB-only
- lookup path on /nfc/tag-scanned. With the mode-routing in the #1228
- follow-up, Spoolman is now consulted exclusively when enabled, so the
- local-DB broadcast tests have to disable Spoolman to reach the local
- code path.
- """
- db_session.add(Settings(key="spoolman_enabled", value="false"))
- await db_session.commit()
- @pytest.fixture
- async def spoolman_settings_inventory(db_session: AsyncSession):
- """Spoolman enabled, URL = localhost (matches inventory proxy patches)."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
- await db_session.commit()
- def _spoolman_spool(spool_id: int) -> dict:
- """Minimal Spoolman raw spool dict suitable for _map_spoolman_spool()."""
- return {
- "id": spool_id,
- "filament": {
- "material": "PLA",
- "name": "PLA Basic",
- "color_hex": "FF0000",
- "weight": 1000.0,
- "spool_weight": 196.0,
- "vendor": {"name": "Bambu Lab"},
- },
- "used_weight": 0.0,
- "archived": False,
- "registered": "2024-01-01T00:00:00Z",
- }
- def _mock_spoolman_client_local() -> MagicMock:
- client = MagicMock()
- client.base_url = "http://spoolman.local:7912"
- client.get_spools = AsyncMock(return_value=[])
- client.find_spool_by_tag = AsyncMock(return_value=None)
- client.merge_spool_extra = AsyncMock(return_value={})
- return client
- # ---------------------------------------------------------------------------
- # Group 1: broadcast tests — tray_uuid forwarded in all WS broadcasts
- # ---------------------------------------------------------------------------
- class TestTagScannedBroadcastsTrayUuid:
- """nfc/tag-scanned broadcasts include tray_uuid from the request payload."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_local_match_broadcast_includes_tray_uuid(
- self, async_client: AsyncClient, spoolman_settings_disabled
- ):
- """Local DB match broadcasts tray_uuid alongside tag_uid."""
- mock_local_spool = MagicMock()
- mock_local_spool.id = 1
- mock_local_spool.material = "PLA"
- mock_local_spool.subtype = None
- mock_local_spool.color_name = "Red"
- mock_local_spool.rgba = "FF0000FF"
- mock_local_spool.brand = "Bambu Lab"
- mock_local_spool.label_weight = 1000
- mock_local_spool.core_weight = 250
- mock_local_spool.weight_used = 0
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=mock_local_spool,
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{SPOOLBUDDY_API}/nfc/tag-scanned",
- json={
- "device_id": "sb-test",
- "tag_uid": "AABB1122334455FF",
- "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
- },
- )
- assert resp.status_code == 200
- assert resp.json()["matched"] is True
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_matched"
- assert msg["tag_uid"] == "AABB1122334455FF"
- assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_match_broadcast_includes_tray_uuid(
- self, async_client: AsyncClient, spoolman_settings_local
- ):
- """Spoolman fallback match broadcasts tray_uuid alongside tag_uid."""
- sm_spool = _spoolman_spool(5)
- sm_spool["extra"] = {"tag": '"DEADBEEFDEADBEEFDEADBEEFDEADBEEF"'}
- mock_client = _mock_spoolman_client_local()
- mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{SPOOLBUDDY_API}/nfc/tag-scanned",
- json={
- "device_id": "sb-test",
- "tag_uid": "AABB1122334455FF",
- "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
- },
- )
- assert resp.status_code == 200
- assert resp.json()["matched"] is True
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_tag_matched"
- assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_tag_broadcast_includes_tray_uuid(self, async_client: AsyncClient, spoolman_settings_local):
- """Unknown tag broadcast includes tray_uuid when Bambu spool is not yet linked."""
- mock_client = _mock_spoolman_client_local()
- mock_client.find_spool_by_tag = AsyncMock(return_value=None)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{SPOOLBUDDY_API}/nfc/tag-scanned",
- json={
- "device_id": "sb-test",
- "tag_uid": "AABB1122334455FF",
- "tray_uuid": "CAFEBABECAFEBABECAFEBABECAFEBABE",
- },
- )
- assert resp.status_code == 200
- assert resp.json()["matched"] is False
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- assert msg["tray_uuid"] == "CAFEBABECAFEBABECAFEBABECAFEBABE"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_tag_broadcast_tray_uuid_null_when_absent(
- self, async_client: AsyncClient, spoolman_settings_local
- ):
- """tray_uuid is None in the broadcast when the request omits it."""
- mock_client = _mock_spoolman_client_local()
- mock_client.find_spool_by_tag = AsyncMock(return_value=None)
- with (
- patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
- patch(
- "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.spoolman.get_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- patch(
- "backend.app.services.spoolman.init_spoolman_client",
- AsyncMock(return_value=mock_client),
- ),
- ):
- mock_ws.broadcast = AsyncMock()
- resp = await async_client.post(
- f"{SPOOLBUDDY_API}/nfc/tag-scanned",
- json={"device_id": "sb-test", "tag_uid": "AABB1122334455FF"},
- )
- assert resp.status_code == 200
- assert resp.json()["matched"] is False
- mock_ws.broadcast.assert_called_once()
- msg = mock_ws.broadcast.call_args[0][0]
- assert msg["type"] == "spoolbuddy_unknown_tag"
- assert msg["tray_uuid"] is None
- # ---------------------------------------------------------------------------
- # Group 2: PATCH /spoolman/inventory/spools/{id}/tag endpoint
- # ---------------------------------------------------------------------------
- class TestLinkTagToSpoolmanSpool:
- """PATCH /spoolman/inventory/spools/{id}/tag writes an NFC tag into Spoolman extra.tag."""
- def _mock_client(self, spool_id: int) -> MagicMock:
- client = MagicMock()
- client.base_url = "http://localhost:7912"
- # get_all_spools returns empty list — no duplicate tags in Spoolman.
- client.get_all_spools = AsyncMock(return_value=[])
- client.get_spool = AsyncMock(return_value=_spoolman_spool(spool_id))
- client.update_spool_full = AsyncMock(return_value=_spoolman_spool(spool_id))
- return client
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_link_tag_uid_writes_to_extra_tag(self, async_client: AsyncClient):
- """PATCH with tag_uid writes uppercased tag_uid to Spoolman extra.tag."""
- import json as _json
- mock_client = self._mock_client(42)
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/42/tag",
- json={"tag_uid": "aabb1122334455ff"},
- )
- assert resp.status_code == 200
- mock_client.update_spool_full.assert_called_once()
- _, kwargs = mock_client.update_spool_full.call_args
- assert kwargs.get("extra", {}).get("tag") == _json.dumps("AABB1122334455FF")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_tray_uuid_takes_precedence_over_tag_uid(self, async_client: AsyncClient):
- """tray_uuid takes precedence when both tag_uid and tray_uuid are provided."""
- import json as _json
- mock_client = self._mock_client(7)
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/7/tag",
- json={
- "tag_uid": "AABB1122334455FF",
- "tray_uuid": "deadbeefdeadbeefdeadbeefdeadbeef",
- },
- )
- assert resp.status_code == 200
- mock_client.update_spool_full.assert_called_once()
- _, kwargs = mock_client.update_spool_full.call_args
- assert kwargs.get("extra", {}).get("tag") == _json.dumps("DEADBEEFDEADBEEFDEADBEEFDEADBEEF")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_neither_tag_uid_nor_tray_uuid_returns_422(self, async_client: AsyncClient):
- """422 Unprocessable Entity when neither tag_uid nor tray_uuid is provided."""
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=MagicMock()),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/1/tag",
- json={},
- )
- assert resp.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_not_found_returns_404(self, async_client: AsyncClient):
- """404 when Spoolman reports the spool does not exist."""
- mock_client = MagicMock()
- mock_client.get_all_spools = AsyncMock(return_value=[])
- mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 999 not found"))
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/999/tag",
- json={"tag_uid": "AABB1122334455FF"},
- )
- assert resp.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spoolman_unavailable_returns_503(self, async_client: AsyncClient):
- """503 when Spoolman is unreachable during the tag link (duplicate check fails first)."""
- mock_client = MagicMock()
- mock_client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/42/tag",
- json={"tag_uid": "AABB1122334455FF"},
- )
- assert resp.status_code == 503
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
- """400 when Spoolman integration is not enabled (no settings in DB)."""
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/42/tag",
- json={"tag_uid": "AABB1122334455FF"},
- )
- assert resp.status_code == 400
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_4_byte_uid_writes_to_extra_tag(self, async_client: AsyncClient):
- """PATCH with 8-char (4-byte Bambu Lab) tag_uid writes correctly to Spoolman extra.tag."""
- import json as _json
- mock_client = self._mock_client(42)
- with patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=mock_client),
- ):
- resp = await async_client.patch(
- f"{INVENTORY_API}/spools/42/tag",
- json={"tag_uid": "2728C17B"}, # 4-byte / 8-char Bambu Lab hardware UID
- )
- assert resp.status_code == 200
- mock_client.update_spool_full.assert_called_once()
- _, kwargs = mock_client.update_spool_full.call_args
- assert kwargs.get("extra", {}).get("tag") == _json.dumps("2728C17B")
|