| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- """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_returns_none_when_create_fails(self, client):
- with (
- patch.object(client, "get_vendors", AsyncMock(return_value=[])),
- patch.object(client, "create_vendor", AsyncMock(return_value=None)),
- ):
- result = await client.find_or_create_vendor("Ghost Brand")
- assert result is None
- # ---------------------------------------------------------------------------
- # 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_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_returns_none_when_create_fails(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=None)),
- ):
- result = await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
- assert result is None
- # ---------------------------------------------------------------------------
- # 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()
|