Files
bambuddy/backend/tests/integration/test_spoolbuddy_spoolman_nfc.py
T
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00

402 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")