"""Extra-field registration reads Spoolman's listing (#2983, reported by @ngreatorex). ``ensure_extra_field`` used to probe ``GET /field/spool/{name}`` per field. Spoolman declares only POST and DELETE at that path -- verified against its own OpenAPI document -- so the probe answered 405 and the existence check could never succeed. Every call fell through to ``POST /field/spool/{name}``, which is an *upsert*: it answered 200 whether or not the field existed, so a field a user had renamed or retyped in Spoolman's UI was reset to Bambuddy's version of it on every client init, and an untrue "Created Spoolman extra field" was logged each time. Existence now comes from ``GET /field/spool``, matched on ``key``. These drive ``ensure_extra_field``'s own branches directly, with the HTTP layer stubbed, so the awkward answers can be posed one at a time -- a listing that isn't a list, a row with no key, a transport error mid-read. ``test_spoolman_extra_field_registration_2903`` covers the other half from the outside, through a fake Spoolman that enforces the real API's rules; its fake answers the per-field path 405, as the real server does. """ import json from unittest.mock import AsyncMock, MagicMock import pytest from backend.app.services.spoolman import SpoolmanClient BAMBU_FIELDS = ("tag", "bambu_slicer_filament", "bambu_slicer_filament_name", "bambu_color_name") def _response(status: int, payload=None, text: str = "") -> MagicMock: r = MagicMock() r.status_code = status r.text = text if payload is None else json.dumps(payload) r.json = MagicMock(return_value=payload) return r def _field(key: str, name: str | None = None) -> dict: """A Spoolman field row. `name` is the user-editable label, `key` the identifier the spool's extra dict is written under.""" return { "key": key, "name": name if name is not None else key, "field_type": "text", "entity_type": "spool", "order": 0, } LISTING_URL = "http://spoolman.test/api/v1/field/spool" def _client_with(listing, post_status: int = 200) -> tuple[SpoolmanClient, MagicMock]: """A SpoolmanClient whose HTTP calls are recorded. ``listing`` is either the parsed body of ``GET /field/spool`` or a ready-made response for the failure cases. GET is routed by URL rather than answering everything with the listing. That matters: a mock that returns the listing for any GET also answers the old per-field probe with it, which would let the pre-fix code pass these tests. The real server answers ``GET /field/spool/{key}`` with **405**, because it declares only POST and DELETE there -- so that is what any URL other than the listing gets here. """ listing_response = listing if isinstance(listing, MagicMock) else _response(200, listing) async def get(url, *args, **kwargs): if url == LISTING_URL: return listing_response return _response(405, None, '{"detail":"Method Not Allowed"}') http = MagicMock() http.get = AsyncMock(side_effect=get) http.post = AsyncMock(return_value=_response(post_status, {})) client = SpoolmanClient("http://spoolman.test") client._get_client = AsyncMock(return_value=http) return client, http class TestTheExistenceCheck: @pytest.mark.asyncio async def test_reads_the_listing_endpoint_not_the_per_field_one(self): client, http = _client_with([_field("tag")]) await client.ensure_extra_field("tag") (url,), _ = http.get.call_args assert url == "http://spoolman.test/api/v1/field/spool" @pytest.mark.asyncio async def test_an_existing_field_is_never_posted(self): """The whole point: POST is an upsert, so posting an existing field is what reset the user's customisation of it.""" client, http = _client_with([_field("tag", "Bambu RFID Tag")]) assert await client.ensure_extra_field("tag") is True http.post.assert_not_awaited() @pytest.mark.asyncio async def test_matches_on_key_not_on_the_user_facing_name(self): """A renamed field is still the same field. Matching on `name` would make a rename look like a deletion and re-create it -- clobbering the rename, which is exactly the reported behaviour.""" client, http = _client_with([_field("bambu_color_name", "Bambu Colour")]) assert await client.ensure_extra_field("bambu_color_name") is True http.post.assert_not_awaited() @pytest.mark.asyncio async def test_a_missing_field_is_created(self): client, http = _client_with([_field("tag")]) assert await client.ensure_extra_field("bambu_color_name") is True http.post.assert_awaited_once() (url,), kwargs = http.post.call_args assert url == "http://spoolman.test/api/v1/field/spool/bambu_color_name" assert kwargs["json"] == { "name": "bambu_color_name", "field_type": "text", "default_value": None, } @pytest.mark.asyncio async def test_creates_every_field_when_spoolman_has_none(self): """An empty listing is a real answer -- "no extra fields yet" -- and must be acted on, unlike an unreadable one.""" client, http = _client_with([]) for name in BAMBU_FIELDS: assert await client.ensure_extra_field(name) is True assert http.post.await_count == len(BAMBU_FIELDS) @pytest.mark.asyncio async def test_honours_a_non_default_field_type_when_creating(self): client, http = _client_with([]) await client.ensure_extra_field("some_number", field_type="integer") assert http.post.call_args.kwargs["json"]["field_type"] == "integer" class TestRequestCount: @pytest.mark.asyncio async def test_registering_every_field_costs_one_listing_read(self): client, http = _client_with([_field(k) for k in BAMBU_FIELDS]) await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value")) assert http.get.await_count == 1 http.post.assert_not_awaited() @pytest.mark.asyncio async def test_creating_several_new_fields_still_reads_the_listing_once(self): client, http = _client_with([]) await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value")) assert http.get.await_count == 1 assert http.post.await_count == len(BAMBU_FIELDS) @pytest.mark.asyncio async def test_a_second_pass_on_the_same_client_makes_no_requests(self): client, http = _client_with([_field(k) for k in BAMBU_FIELDS]) await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value")) http.get.reset_mock() await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value")) http.get.assert_not_awaited() http.post.assert_not_awaited() class TestDegradingWhenTheListingCannotBeRead: """An unreadable listing must not stop a write that might still succeed -- registration is best-effort, and falling back to the blind POST is exactly what shipped before.""" @pytest.mark.asyncio async def test_a_non_200_listing_falls_back_to_posting(self): client, http = _client_with(_response(404, None, "Not Found")) assert await client.ensure_extra_field("tag") is True http.post.assert_awaited_once() @pytest.mark.asyncio async def test_a_transport_error_on_the_listing_falls_back_to_posting(self): client, http = _client_with([]) http.get = AsyncMock(side_effect=OSError("connection reset")) assert await client.ensure_extra_field("tag") is True http.post.assert_awaited_once() @pytest.mark.asyncio async def test_a_listing_that_is_not_a_list_falls_back_to_posting(self): client, http = _client_with(_response(200, {"detail": "Method Not Allowed"})) assert await client.ensure_extra_field("tag") is True http.post.assert_awaited_once() @pytest.mark.asyncio async def test_rows_without_a_usable_key_are_skipped_not_fatal(self): client, http = _client_with([{"name": "orphan"}, "junk", _field("tag")]) assert await client.ensure_extra_field("tag") is True http.post.assert_not_awaited() @pytest.mark.asyncio async def test_a_field_created_blind_is_not_looked_up_again(self): """The blind POST succeeded, so that field demonstrably exists now. Re-reading the listing to confirm it would be a wasted request.""" client, http = _client_with([]) http.get = AsyncMock(side_effect=[_response(503), _response(200, [_field("tag")])]) assert await client.ensure_extra_field("tag") is True assert await client.ensure_extra_field("tag") is True assert http.get.await_count == 1 @pytest.mark.asyncio async def test_a_later_field_still_gets_a_listing_read(self): """Only a *successful* read is banked. A client that could not reach the listing once must not spend the rest of its life posting blind -- the next field it has never seen has to ask again, or a transient blip would clobber every remaining field's customisation.""" client, http = _client_with([]) http.get = AsyncMock( side_effect=[_response(503), _response(200, [_field("bambu_color_name")])], ) await client.ensure_extra_field("tag") http.post.reset_mock() assert await client.ensure_extra_field("bambu_color_name") is True assert http.get.await_count == 2 http.post.assert_not_awaited() class TestFailureReporting: @pytest.mark.asyncio async def test_a_rejected_creation_returns_false(self): client, _ = _client_with([], post_status=400) assert await client.ensure_extra_field("tag") is False @pytest.mark.asyncio async def test_a_rejected_creation_is_not_remembered_as_ensured(self): client, _ = _client_with([], post_status=400) await client.ensure_extra_field("tag") assert "tag" not in client._ensured_extra_fields @pytest.mark.asyncio async def test_ensure_tag_extra_field_goes_through_the_same_path(self): client, http = _client_with([_field("tag", "Bambu RFID Tag")]) assert await client.ensure_tag_extra_field() is True http.post.assert_not_awaited()