mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
402 lines
15 KiB
Python
402 lines
15 KiB
Python
"""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")
|