Files
bambuddy/backend/tests/unit/test_spoolman_inventory_methods.py
T
maziggy b8e350c3bf fix(spoolman): persist color_name edits and stop form round-tripping the subtype synth fallback (#1319)
Editing a spool's color name on Spoolman-backed inventory appeared to
  accept the new value but the inventory list column and the next edit
  showed it back to the subtype. Three layers stacked to produce this:

  1. find_or_create_filament matches by material/name/color_hex/vendor —
     color_name is intentionally not part of the match key, but on a
     match it returned the existing filament's id unchanged, silently
     dropping the new value.
  2. The read helper falls back to subtype when filament.color_name is
     empty (kept on purpose: without it Spoolman installs that don't
     fill the field render every spool as "Unknown color").
  3. The edit form prefilled color_name from spool.color_name — which
     on those installs was the synth value. Changing subtype but not
     color_name silently round-tripped the OLD subtype back to Spoolman
     as if it were a real user-set color_name.

  Fixes:
  - find_or_create_filament now patches the matched filament's
    color_name via the existing patch_filament wrapper when the request
    differs. Parameter convention: None = don't touch, "" = explicit
    clear, any other string = set/update. A patch failure is logged but
    does not block the match.
  - The PATCH route uses model_fields_set to distinguish "field omitted"
    from "field explicitly set to null" (mirrors the existing
    storage_location pattern at the same site).
  - The map helper returns color_name_is_synthesized: bool. The edit
    form leaves the input blank when true, so the user sees the real
    stored state and can't accidentally round-trip the synth value back.
2026-05-14 08:27:06 +02:00

500 lines
21 KiB
Python

"""Unit tests for new SpoolmanClient inventory methods.
Covers: get_spool, get_all_spools, delete_spool, set_spool_archived,
update_spool_full, find_or_create_vendor, find_or_create_filament.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from backend.app.services.spoolman import SpoolmanClient, SpoolmanUnavailableError
@pytest.fixture
def client():
return SpoolmanClient("http://localhost:7912")
def _make_response(json_data, status_code=200):
"""Build a mock httpx response."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.raise_for_status = MagicMock()
return resp
SAMPLE_SPOOL = {
"id": 42,
"remaining_weight": 750.0,
"used_weight": 250.0,
"archived": False,
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA"},
}
SAMPLE_FILAMENT = {
"id": 7,
"name": "PLA Basic",
"material": "PLA",
"color_hex": "FF0000",
"weight": 1000.0,
"vendor": {"id": 3, "name": "Bambu Lab"},
}
SAMPLE_VENDOR = {"id": 3, "name": "Bambu Lab"}
# ---------------------------------------------------------------------------
# get_spool
# ---------------------------------------------------------------------------
class TestGetSpool:
@pytest.mark.asyncio
async def test_returns_spool_dict_on_success(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.get_spool(42)
assert result == SAMPLE_SPOOL
mock_http.request.assert_called_once_with("GET", "http://localhost:7912/api/v1/spool/42", json=None)
@pytest.mark.asyncio
async def test_raises_unavailable_on_http_error(self, client):
from backend.app.services.spoolman import SpoolmanUnavailableError
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("not found"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_spool(99)
@pytest.mark.asyncio
async def test_raises_not_found_on_404_response(self, client):
"""get_spool raises SpoolmanNotFoundError when Spoolman returns HTTP 404 (PT-I3)."""
from backend.app.services.spoolman import SpoolmanNotFoundError
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(None, status_code=404))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanNotFoundError),
):
await client.get_spool(99)
@pytest.mark.asyncio
async def test_raises_client_error_on_4xx_response(self, client):
"""get_spool raises SpoolmanClientError (not SpoolmanUnavailableError) on non-404 4xx (H2)."""
from backend.app.services.spoolman import SpoolmanClientError
mock_request = MagicMock()
mock_request.url = "http://localhost:7912/api/v1/spool/42"
mock_resp_obj = MagicMock()
mock_resp_obj.status_code = 422
mock_http = AsyncMock()
resp = _make_response(None, status_code=422)
resp.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("Unprocessable", request=mock_request, response=mock_resp_obj)
)
mock_http.request = AsyncMock(return_value=resp)
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanClientError) as exc_info,
):
await client.get_spool(42)
assert exc_info.value.status_code == 422
# ---------------------------------------------------------------------------
# get_all_spools
# ---------------------------------------------------------------------------
class TestGetAllSpools:
@pytest.mark.asyncio
async def test_returns_list_without_archived_by_default(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.get_all_spools()
assert result == [SAMPLE_SPOOL]
mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params=None)
@pytest.mark.asyncio
async def test_passes_allow_archived_param(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.get_all_spools(allow_archived=True)
mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params={"allow_archived": "true"})
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("connection error"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_all_spools()
# ---------------------------------------------------------------------------
# delete_spool
# ---------------------------------------------------------------------------
class TestDeleteSpool:
@pytest.mark.asyncio
async def test_returns_none_on_success(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(None))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.delete_spool(42)
assert result is None
mock_http.request.assert_called_once_with("DELETE", "http://localhost:7912/api/v1/spool/42", json=None)
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("server error"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.delete_spool(42)
# ---------------------------------------------------------------------------
# set_spool_archived
# ---------------------------------------------------------------------------
class TestSetSpoolArchived:
@pytest.mark.asyncio
async def test_archives_spool(self, client):
archived_spool = {**SAMPLE_SPOOL, "archived": True}
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(archived_spool))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.set_spool_archived(42, archived=True)
assert result == archived_spool
mock_http.request.assert_called_once_with(
"PATCH",
"http://localhost:7912/api/v1/spool/42",
json={"archived": True},
)
@pytest.mark.asyncio
async def test_restores_spool(self, client):
restored_spool = {**SAMPLE_SPOOL, "archived": False}
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(restored_spool))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.set_spool_archived(42, archived=False)
assert result == restored_spool
mock_http.request.assert_called_once_with(
"PATCH",
"http://localhost:7912/api/v1/spool/42",
json={"archived": False},
)
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.set_spool_archived(42, archived=True)
# ---------------------------------------------------------------------------
# update_spool_full
# ---------------------------------------------------------------------------
class TestUpdateSpoolFull:
@pytest.mark.asyncio
async def test_sends_only_provided_fields(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, remaining_weight=600.0, comment="note")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"remaining_weight": 600.0, "comment": "note"}
@pytest.mark.asyncio
async def test_clear_location_sets_none(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, clear_location=True)
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"location": None}
@pytest.mark.asyncio
async def test_location_set_when_not_clearing(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, location="Shelf A")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"location": "Shelf A"}
@pytest.mark.asyncio
async def test_empty_comment_sent_as_none(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, comment="")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"comment": None}
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("network"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.update_spool_full(42, remaining_weight=500.0)
# ---------------------------------------------------------------------------
# find_or_create_vendor
# ---------------------------------------------------------------------------
class TestFindOrCreateVendor:
@pytest.mark.asyncio
async def test_returns_existing_vendor_id(self, client):
with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
result = await client.find_or_create_vendor("Bambu Lab")
assert result == 3
@pytest.mark.asyncio
async def test_case_insensitive_match(self, client):
with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
result = await client.find_or_create_vendor("bambu lab")
assert result == 3
@pytest.mark.asyncio
async def test_creates_vendor_when_not_found(self, client):
new_vendor = {"id": 10, "name": "New Brand"}
with (
patch.object(client, "get_vendors", AsyncMock(return_value=[])),
patch.object(client, "create_vendor", AsyncMock(return_value=new_vendor)) as mock_create,
):
result = await client.find_or_create_vendor("New Brand")
assert result == 10
mock_create.assert_called_once_with("New Brand")
@pytest.mark.asyncio
async def test_raises_when_create_fails(self, client):
with (
patch.object(client, "get_vendors", AsyncMock(return_value=[])),
patch.object(client, "create_vendor", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
pytest.raises(SpoolmanUnavailableError),
):
await client.find_or_create_vendor("Ghost Brand")
# ---------------------------------------------------------------------------
# find_or_create_filament
# ---------------------------------------------------------------------------
class TestFindOrCreateFilament:
@pytest.mark.asyncio
async def test_returns_existing_filament_id(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[SAMPLE_FILAMENT])),
):
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000)
assert result == 7
@pytest.mark.asyncio
async def test_patches_color_name_on_existing_filament_when_changed(self, client):
"""#1319: color_name is not part of the match key, so when a caller
updates a spool with a new color_name and the material/name/color/vendor
still match an existing filament, the existing filament's color_name
must be patched — otherwise the user's edit is silently dropped."""
existing = {**SAMPLE_FILAMENT, "color_name": None}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
):
result = await client.find_or_create_filament(
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
)
assert result == 7
mock_patch.assert_called_once_with(7, {"color_name": "Sunny Yellow"})
@pytest.mark.asyncio
async def test_does_not_patch_when_color_name_unchanged(self, client):
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
):
result = await client.find_or_create_filament(
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
)
assert result == 7
mock_patch.assert_not_called()
@pytest.mark.asyncio
async def test_does_not_patch_when_color_name_empty(self, client):
"""An empty/None color_name should not clobber an existing value."""
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
):
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name=None)
assert result == 7
mock_patch.assert_not_called()
@pytest.mark.asyncio
async def test_clears_color_name_when_empty_string_passed(self, client):
"""#1319 follow-up: empty string means "explicit clear" — the route
layer translates a wire-level null into "" so the user can blank the
field on a previously-set spool."""
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
):
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="")
assert result == 7
mock_patch.assert_called_once_with(7, {"color_name": None})
@pytest.mark.asyncio
async def test_patch_failure_does_not_block_match(self, client):
"""A patch_filament failure must not prevent returning the matched id —
save should still link the spool to the correct filament."""
existing = {**SAMPLE_FILAMENT, "color_name": None}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
patch.object(client, "patch_filament", AsyncMock(side_effect=SpoolmanUnavailableError("boom"))),
):
result = await client.find_or_create_filament(
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
)
assert result == 7
@pytest.mark.asyncio
async def test_creates_filament_when_no_match(self, client):
new_filament = {"id": 99, "name": "PETG Pro"}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
):
result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
assert result == 99
mock_create.assert_called_once_with(
name="PETG Pro",
vendor_id=3,
material="PETG",
color_hex="00FF00",
color_name=None,
weight=1000.0,
)
@pytest.mark.asyncio
async def test_no_brand_skips_vendor_lookup(self, client):
filament_no_vendor = {
**SAMPLE_FILAMENT,
"vendor": None,
"name": "PLA Basic",
"color_hex": "FF0000",
}
with (
patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
):
result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
assert result == 7
@pytest.mark.asyncio
async def test_color_hex_normalised_to_uppercase(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
):
await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
mock_create.assert_called_once_with(
name="ABS",
vendor_id=None,
material="ABS",
color_hex="FF0000",
color_name=None,
weight=750.0,
)
@pytest.mark.asyncio
async def test_raises_when_create_fails(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=1)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
pytest.raises(SpoolmanUnavailableError),
):
await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
# ---------------------------------------------------------------------------
# get_filaments / get_vendors / get_external_filaments error propagation (H11)
# ---------------------------------------------------------------------------
class TestGetFilamentsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_filaments()
class TestGetVendorsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_vendors()
class TestGetExternalFilamentsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_external_filaments()