| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- """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_color_name_does_not_trigger_filament_patch(self, client):
- """#1357: Spoolman 0.23.1 has no `color_name` field on Filament
- (verified against FilamentUpdateParameters schema). find_or_create_filament
- must NOT attempt to PATCH it — the route now persists the user's
- color_name to spool.extra.bambu_color_name instead. Any patch call
- from this layer would be a silent no-op (Spoolman ignores unknown
- keys) and was the original symptom of "edits never save".
- """
- existing = {**SAMPLE_FILAMENT}
- 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_matches_filament_named_with_just_subtype(self, client):
- """#1357: AMS-sync auto-create saves the filament with name set to just
- ``tray.tray_sub_brands`` (e.g. ``"Glow"`` without the material prefix),
- but the user-driven edit path composes ``"<material> <subtype>"``
- (``"PLA Glow"``). Before this fix the literal `f_name == name` check
- failed to bridge the two shapes, so every edit fell through to
- ``create_filament`` and left a trail of duplicate filaments. Now the
- name match strips the material prefix on both sides, so the two
- shapes resolve to the same subtype key."""
- existing = {
- **SAMPLE_FILAMENT,
- "id": 11,
- "name": "Glow", # AMS-sync shape: just subtype
- "material": "PLA",
- "color_hex": "AAF3C6",
- "color_name": None,
- "vendor": {"id": 3, "name": "Amazon Basics"},
- }
- 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,
- patch.object(client, "create_filament", AsyncMock()) as mock_create,
- ):
- result = await client.find_or_create_filament(
- "PLA", "Glow", "Amazon Basics", "AAF3C6", 1000, color_name="Bright Glow"
- )
- assert result == 11
- # color_name is no longer written via the filament — see #1357 — and
- # the function must not create a duplicate filament.
- mock_patch.assert_not_called()
- mock_create.assert_not_called()
- @pytest.mark.asyncio
- async def test_still_matches_filament_named_material_plus_subtype(self, client):
- """The composed-name shape (``"PLA Basic"`` matching a Spoolman filament
- also named ``"PLA Basic"``) must keep working — the normalisation strips
- the prefix on both sides, so the comparison is on the subtype part."""
- existing = {
- **SAMPLE_FILAMENT,
- "id": 7,
- "name": "PLA Basic",
- "material": "PLA",
- "color_hex": "FF0000",
- "color_name": "Sunset",
- }
- 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})),
- patch.object(client, "create_filament", AsyncMock()) as mock_create,
- ):
- result = await client.find_or_create_filament(
- "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
- )
- assert result == 7
- mock_create.assert_not_called()
- @pytest.mark.asyncio
- async def test_name_match_does_not_cross_materials(self, client):
- """Sanity check: a filament with name=subtype must NOT match a request
- with a different material that happens to share the subtype string.
- material_match runs first and fails, so the iteration moves on and
- ``create_filament`` is called."""
- existing = {
- **SAMPLE_FILAMENT,
- "id": 7,
- "name": "Basic",
- "material": "PETG", # different material
- "color_hex": "FF0000",
- }
- new_filament = {"id": 99, "name": "PLA Basic"}
- with (
- patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
- patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
- patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
- ):
- result = await client.find_or_create_filament(
- "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
- )
- assert result == 99
- mock_create.assert_called_once()
- @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
- # color_name is intentionally not forwarded to create_filament (#1357):
- # Spoolman has no such field on Filament, so passing it would be a
- # no-op. The route persists color_name to spool.extra.bambu_color_name
- # after this returns.
- mock_create.assert_called_once_with(
- name="PETG Pro",
- vendor_id=3,
- material="PETG",
- color_hex="00FF00",
- 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",
- 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()
|