test_spoolman_extra_field_registration_2983.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. """Extra-field registration reads Spoolman's listing (#2983, reported by @ngreatorex).
  2. ``ensure_extra_field`` used to probe ``GET /field/spool/{name}`` per field.
  3. Spoolman declares only POST and DELETE at that path -- verified against its own
  4. OpenAPI document -- so the probe answered 405 and the existence check could
  5. never succeed. Every call fell through to ``POST /field/spool/{name}``, which is
  6. an *upsert*: it answered 200 whether or not the field existed, so a field a user
  7. had renamed or retyped in Spoolman's UI was reset to Bambuddy's version of it
  8. on every client init, and an untrue "Created Spoolman extra field" was logged
  9. each time.
  10. Existence now comes from ``GET /field/spool``, matched on ``key``.
  11. These drive ``ensure_extra_field``'s own branches directly, with the HTTP layer
  12. stubbed, so the awkward answers can be posed one at a time -- a listing that
  13. isn't a list, a row with no key, a transport error mid-read.
  14. ``test_spoolman_extra_field_registration_2903`` covers the other half from the
  15. outside, through a fake Spoolman that enforces the real API's rules; its fake
  16. answers the per-field path 405, as the real server does.
  17. """
  18. import json
  19. from unittest.mock import AsyncMock, MagicMock
  20. import pytest
  21. from backend.app.services.spoolman import SpoolmanClient
  22. BAMBU_FIELDS = ("tag", "bambu_slicer_filament", "bambu_slicer_filament_name", "bambu_color_name")
  23. def _response(status: int, payload=None, text: str = "") -> MagicMock:
  24. r = MagicMock()
  25. r.status_code = status
  26. r.text = text if payload is None else json.dumps(payload)
  27. r.json = MagicMock(return_value=payload)
  28. return r
  29. def _field(key: str, name: str | None = None) -> dict:
  30. """A Spoolman field row. `name` is the user-editable label, `key` the
  31. identifier the spool's extra dict is written under."""
  32. return {
  33. "key": key,
  34. "name": name if name is not None else key,
  35. "field_type": "text",
  36. "entity_type": "spool",
  37. "order": 0,
  38. }
  39. LISTING_URL = "http://spoolman.test/api/v1/field/spool"
  40. def _client_with(listing, post_status: int = 200) -> tuple[SpoolmanClient, MagicMock]:
  41. """A SpoolmanClient whose HTTP calls are recorded.
  42. ``listing`` is either the parsed body of ``GET /field/spool`` or a
  43. ready-made response for the failure cases.
  44. GET is routed by URL rather than answering everything with the listing.
  45. That matters: a mock that returns the listing for any GET also answers the
  46. old per-field probe with it, which would let the pre-fix code pass these
  47. tests. The real server answers ``GET /field/spool/{key}`` with **405**,
  48. because it declares only POST and DELETE there -- so that is what any URL
  49. other than the listing gets here.
  50. """
  51. listing_response = listing if isinstance(listing, MagicMock) else _response(200, listing)
  52. async def get(url, *args, **kwargs):
  53. if url == LISTING_URL:
  54. return listing_response
  55. return _response(405, None, '{"detail":"Method Not Allowed"}')
  56. http = MagicMock()
  57. http.get = AsyncMock(side_effect=get)
  58. http.post = AsyncMock(return_value=_response(post_status, {}))
  59. client = SpoolmanClient("http://spoolman.test")
  60. client._get_client = AsyncMock(return_value=http)
  61. return client, http
  62. class TestTheExistenceCheck:
  63. @pytest.mark.asyncio
  64. async def test_reads_the_listing_endpoint_not_the_per_field_one(self):
  65. client, http = _client_with([_field("tag")])
  66. await client.ensure_extra_field("tag")
  67. (url,), _ = http.get.call_args
  68. assert url == "http://spoolman.test/api/v1/field/spool"
  69. @pytest.mark.asyncio
  70. async def test_an_existing_field_is_never_posted(self):
  71. """The whole point: POST is an upsert, so posting an existing field is
  72. what reset the user's customisation of it."""
  73. client, http = _client_with([_field("tag", "Bambu RFID Tag")])
  74. assert await client.ensure_extra_field("tag") is True
  75. http.post.assert_not_awaited()
  76. @pytest.mark.asyncio
  77. async def test_matches_on_key_not_on_the_user_facing_name(self):
  78. """A renamed field is still the same field. Matching on `name` would
  79. make a rename look like a deletion and re-create it -- clobbering the
  80. rename, which is exactly the reported behaviour."""
  81. client, http = _client_with([_field("bambu_color_name", "Bambu Colour")])
  82. assert await client.ensure_extra_field("bambu_color_name") is True
  83. http.post.assert_not_awaited()
  84. @pytest.mark.asyncio
  85. async def test_a_missing_field_is_created(self):
  86. client, http = _client_with([_field("tag")])
  87. assert await client.ensure_extra_field("bambu_color_name") is True
  88. http.post.assert_awaited_once()
  89. (url,), kwargs = http.post.call_args
  90. assert url == "http://spoolman.test/api/v1/field/spool/bambu_color_name"
  91. assert kwargs["json"] == {
  92. "name": "bambu_color_name",
  93. "field_type": "text",
  94. "default_value": None,
  95. }
  96. @pytest.mark.asyncio
  97. async def test_creates_every_field_when_spoolman_has_none(self):
  98. """An empty listing is a real answer -- "no extra fields yet" -- and
  99. must be acted on, unlike an unreadable one."""
  100. client, http = _client_with([])
  101. for name in BAMBU_FIELDS:
  102. assert await client.ensure_extra_field(name) is True
  103. assert http.post.await_count == len(BAMBU_FIELDS)
  104. @pytest.mark.asyncio
  105. async def test_honours_a_non_default_field_type_when_creating(self):
  106. client, http = _client_with([])
  107. await client.ensure_extra_field("some_number", field_type="integer")
  108. assert http.post.call_args.kwargs["json"]["field_type"] == "integer"
  109. class TestRequestCount:
  110. @pytest.mark.asyncio
  111. async def test_registering_every_field_costs_one_listing_read(self):
  112. client, http = _client_with([_field(k) for k in BAMBU_FIELDS])
  113. await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
  114. assert http.get.await_count == 1
  115. http.post.assert_not_awaited()
  116. @pytest.mark.asyncio
  117. async def test_creating_several_new_fields_still_reads_the_listing_once(self):
  118. client, http = _client_with([])
  119. await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
  120. assert http.get.await_count == 1
  121. assert http.post.await_count == len(BAMBU_FIELDS)
  122. @pytest.mark.asyncio
  123. async def test_a_second_pass_on_the_same_client_makes_no_requests(self):
  124. client, http = _client_with([_field(k) for k in BAMBU_FIELDS])
  125. await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
  126. http.get.reset_mock()
  127. await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
  128. http.get.assert_not_awaited()
  129. http.post.assert_not_awaited()
  130. class TestDegradingWhenTheListingCannotBeRead:
  131. """An unreadable listing must not stop a write that might still succeed --
  132. registration is best-effort, and falling back to the blind POST is exactly
  133. what shipped before."""
  134. @pytest.mark.asyncio
  135. async def test_a_non_200_listing_falls_back_to_posting(self):
  136. client, http = _client_with(_response(404, None, "Not Found"))
  137. assert await client.ensure_extra_field("tag") is True
  138. http.post.assert_awaited_once()
  139. @pytest.mark.asyncio
  140. async def test_a_transport_error_on_the_listing_falls_back_to_posting(self):
  141. client, http = _client_with([])
  142. http.get = AsyncMock(side_effect=OSError("connection reset"))
  143. assert await client.ensure_extra_field("tag") is True
  144. http.post.assert_awaited_once()
  145. @pytest.mark.asyncio
  146. async def test_a_listing_that_is_not_a_list_falls_back_to_posting(self):
  147. client, http = _client_with(_response(200, {"detail": "Method Not Allowed"}))
  148. assert await client.ensure_extra_field("tag") is True
  149. http.post.assert_awaited_once()
  150. @pytest.mark.asyncio
  151. async def test_rows_without_a_usable_key_are_skipped_not_fatal(self):
  152. client, http = _client_with([{"name": "orphan"}, "junk", _field("tag")])
  153. assert await client.ensure_extra_field("tag") is True
  154. http.post.assert_not_awaited()
  155. @pytest.mark.asyncio
  156. async def test_a_field_created_blind_is_not_looked_up_again(self):
  157. """The blind POST succeeded, so that field demonstrably exists now.
  158. Re-reading the listing to confirm it would be a wasted request."""
  159. client, http = _client_with([])
  160. http.get = AsyncMock(side_effect=[_response(503), _response(200, [_field("tag")])])
  161. assert await client.ensure_extra_field("tag") is True
  162. assert await client.ensure_extra_field("tag") is True
  163. assert http.get.await_count == 1
  164. @pytest.mark.asyncio
  165. async def test_a_later_field_still_gets_a_listing_read(self):
  166. """Only a *successful* read is banked. A client that could not reach the
  167. listing once must not spend the rest of its life posting blind -- the
  168. next field it has never seen has to ask again, or a transient blip
  169. would clobber every remaining field's customisation."""
  170. client, http = _client_with([])
  171. http.get = AsyncMock(
  172. side_effect=[_response(503), _response(200, [_field("bambu_color_name")])],
  173. )
  174. await client.ensure_extra_field("tag")
  175. http.post.reset_mock()
  176. assert await client.ensure_extra_field("bambu_color_name") is True
  177. assert http.get.await_count == 2
  178. http.post.assert_not_awaited()
  179. class TestFailureReporting:
  180. @pytest.mark.asyncio
  181. async def test_a_rejected_creation_returns_false(self):
  182. client, _ = _client_with([], post_status=400)
  183. assert await client.ensure_extra_field("tag") is False
  184. @pytest.mark.asyncio
  185. async def test_a_rejected_creation_is_not_remembered_as_ensured(self):
  186. client, _ = _client_with([], post_status=400)
  187. await client.ensure_extra_field("tag")
  188. assert "tag" not in client._ensured_extra_fields
  189. @pytest.mark.asyncio
  190. async def test_ensure_tag_extra_field_goes_through_the_same_path(self):
  191. client, http = _client_with([_field("tag", "Bambu RFID Tag")])
  192. assert await client.ensure_tag_extra_field() is True
  193. http.post.assert_not_awaited()