"""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()