test_spoolman_extra_field_registration_2903.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """Extra-field registration travels with the write that needs it (issue #2903).
  2. Spoolman rejects any spool payload carrying an ``extra`` key it has not been
  3. told about, answering HTTP 400 ``Unknown extra field <name>.``. Bambuddy used
  4. to register those keys from three hand-maintained lists that ran when the
  5. integration was *set up* -- the connect route, application startup, and two
  6. inline blocks in the inventory routes. Enabling Spoolman from Settings runs
  7. none of them, so the first "Sync AMS Data" against a fresh Spoolman failed on
  8. every slot.
  9. The fake below is the point of these tests: it enforces Spoolman's rule rather
  10. than mocking it away, so every test here fails against the old code for the
  11. same reason the reporter's install did.
  12. """
  13. import asyncio
  14. import json
  15. import httpx
  16. import pytest
  17. from backend.app.services.spoolman import AMSTray, SpoolmanClient
  18. class FakeSpoolman:
  19. """A Spoolman that rejects unregistered extra keys, the way the real one does."""
  20. def __init__(self, *, registered: set[str] | None = None, field_status: int = 200):
  21. self.registered: set[str] = set(registered or ())
  22. # Lets a test make registration fail without breaking anything else.
  23. self.field_status = field_status
  24. self.spools: dict[int, dict] = {}
  25. self.log: list[str] = []
  26. self._next_id = 1
  27. def _reject_unknown_extra(self, body: dict) -> httpx.Response | None:
  28. for name in body.get("extra") or {}:
  29. if name not in self.registered:
  30. return httpx.Response(400, json={"message": f"Unknown extra field {name}."})
  31. return None
  32. async def handler(self, request: httpx.Request) -> httpx.Response:
  33. # Yield to the event loop on every call, so two coroutines driving this
  34. # fake genuinely interleave. Without it MockTransport answers without
  35. # ever suspending, and a "concurrent" test runs each request to
  36. # completion in turn -- proving nothing about the locking below.
  37. await asyncio.sleep(0)
  38. path = request.url.path.removeprefix("/api/v1")
  39. self.log.append(f"{request.method} {path}")
  40. body = json.loads(request.content) if request.content else {}
  41. if path == "/field/spool" and request.method == "GET":
  42. if self.field_status != 200:
  43. return httpx.Response(self.field_status)
  44. return httpx.Response(
  45. 200,
  46. json=[
  47. {"key": name, "name": name, "field_type": "text", "entity_type": "spool"}
  48. for name in sorted(self.registered)
  49. ],
  50. )
  51. if path.startswith("/field/spool/"):
  52. name = path.rsplit("/", 1)[-1]
  53. # Spoolman declares only POST and DELETE here -- its own OpenAPI
  54. # document says so, and the live server answers 405. Modelling this
  55. # as a working existence probe is what let the bug in issue #2983
  56. # sit unnoticed: the check could never succeed, and the POST that
  57. # followed silently overwrote whatever the user had customised.
  58. if request.method != "POST":
  59. return httpx.Response(405, json={"detail": "Method Not Allowed"})
  60. if self.field_status != 200:
  61. return httpx.Response(self.field_status)
  62. self.registered.add(name)
  63. return httpx.Response(200, json={"name": name})
  64. if path == "/spool" and request.method == "POST":
  65. if (rejection := self._reject_unknown_extra(body)) is not None:
  66. return rejection
  67. spool = {"id": self._next_id, **body}
  68. self.spools[self._next_id] = spool
  69. self._next_id += 1
  70. return httpx.Response(200, json=spool)
  71. if path.startswith("/spool/"):
  72. spool_id = int(path.rsplit("/", 1)[-1])
  73. if request.method == "GET":
  74. return httpx.Response(200, json=self.spools[spool_id])
  75. if (rejection := self._reject_unknown_extra(body)) is not None:
  76. return rejection
  77. self.spools[spool_id].update(body)
  78. return httpx.Response(200, json=self.spools[spool_id])
  79. if path == "/vendor":
  80. if request.method == "GET":
  81. return httpx.Response(200, json=[{"id": 1, "name": "Bambu Lab"}])
  82. return httpx.Response(200, json={"id": 1, "name": body.get("name", "")})
  83. if path == "/filament":
  84. if request.method == "GET":
  85. return httpx.Response(200, json=[])
  86. return httpx.Response(200, json={"id": 7, **body})
  87. if path == "/external/filament":
  88. return httpx.Response(200, json=[])
  89. return httpx.Response(200, json=[])
  90. def field_calls(self, name: str) -> list[str]:
  91. """Every request this client made about ``name``: the listing read that
  92. answers "does it exist", plus any creation of that specific field."""
  93. return [entry for entry in self.log if entry == "GET /field/spool" or entry.endswith(f"/field/spool/{name}")]
  94. def _client(fake: FakeSpoolman) -> SpoolmanClient:
  95. client = SpoolmanClient("https://spoolman.test")
  96. client._client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler))
  97. return client
  98. def _tray(tray_uuid: str) -> AMSTray:
  99. return AMSTray(
  100. ams_id=0,
  101. tray_id=0,
  102. tray_type="PLA",
  103. tray_sub_brands="PLA Basic",
  104. tray_color="000000FF",
  105. remain=100,
  106. tag_uid="",
  107. tray_uuid=tray_uuid,
  108. tray_info_idx="GFA00",
  109. tray_weight=1000,
  110. )
  111. class TestTheReportedCase:
  112. """A fresh Spoolman, a fresh Bambuddy, and the first AMS sync."""
  113. @pytest.mark.asyncio
  114. async def test_syncing_a_slot_no_longer_fails_on_a_fresh_spoolman(self):
  115. fake = FakeSpoolman() # GET /field/spool returns nothing: no custom fields at all
  116. client = _client(fake)
  117. result = await client.sync_ams_tray(_tray("D144798DEF394926ACAE9D69ABA910CC"), "OJIMPO-X2D-01")
  118. assert result is not None, "spool creation was rejected -- this is the reported 400"
  119. assert result["extra"]["tag"] == json.dumps("D144798DEF394926ACAE9D69ABA910CC")
  120. assert "tag" in fake.registered
  121. @pytest.mark.asyncio
  122. async def test_all_three_slots_sync_rather_than_erroring(self):
  123. """The report's exact shape: "Synced 0 spools with 3 errors"."""
  124. fake = FakeSpoolman()
  125. client = _client(fake)
  126. tags = [
  127. "D144798DEF394926ACAE9D69ABA910CC",
  128. "1880BE1371014F4CA951BE6A30C99E44",
  129. "1D1F3C49046246DBBADBC3631B7F1B61",
  130. ]
  131. synced = [await client.sync_ams_tray(_tray(tag), "OJIMPO-X2D-01") for tag in tags]
  132. assert all(s is not None for s in synced)
  133. assert [s["extra"]["tag"] for s in synced] == [json.dumps(t) for t in tags]
  134. @pytest.mark.asyncio
  135. async def test_the_field_is_registered_before_the_spool_is_posted(self):
  136. """Ordering is the whole fix -- registering afterwards rescues nothing."""
  137. fake = FakeSpoolman()
  138. client = _client(fake)
  139. await client.create_spool(filament_id=7, extra={"tag": json.dumps("ABC")})
  140. assert fake.log.index("POST /field/spool/tag") < fake.log.index("POST /spool")
  141. class TestItAsksSpoolmanOnlyOnce:
  142. @pytest.mark.asyncio
  143. async def test_a_second_write_does_not_ask_again(self):
  144. fake = FakeSpoolman()
  145. client = _client(fake)
  146. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  147. await client.create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  148. assert fake.field_calls("tag") == ["GET /field/spool", "POST /field/spool/tag"]
  149. @pytest.mark.asyncio
  150. async def test_an_already_registered_field_is_never_created(self):
  151. fake = FakeSpoolman(registered={"tag"})
  152. client = _client(fake)
  153. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  154. assert fake.field_calls("tag") == ["GET /field/spool"]
  155. @pytest.mark.asyncio
  156. async def test_a_write_carrying_no_extra_asks_nothing(self):
  157. fake = FakeSpoolman()
  158. client = _client(fake)
  159. await client.create_spool(filament_id=7, remaining_weight=500.0)
  160. assert fake.field_calls("tag") == []
  161. @pytest.mark.asyncio
  162. async def test_concurrent_syncs_ask_exactly_once_between_them(self):
  163. """Two slots syncing at once must not race into a duplicate POST.
  164. The exact call list, rather than just the POST count: the loser of the
  165. race should re-read the memo once it holds the lock and find the answer
  166. already there, rather than repeating the round-trip the winner just
  167. made.
  168. """
  169. fake = FakeSpoolman()
  170. client = _client(fake)
  171. await asyncio.gather(
  172. client.create_spool(filament_id=7, extra={"tag": json.dumps("A")}),
  173. client.create_spool(filament_id=7, extra={"tag": json.dumps("B")}),
  174. )
  175. assert fake.field_calls("tag") == ["GET /field/spool", "POST /field/spool/tag"]
  176. @pytest.mark.asyncio
  177. async def test_another_client_does_not_inherit_the_answer(self):
  178. """The memo describes one Spoolman, so a re-pointed client starts over."""
  179. fake = FakeSpoolman()
  180. await _client(fake).create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  181. second_fake = FakeSpoolman()
  182. await _client(second_fake).create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  183. assert "GET /field/spool" in second_fake.log
  184. class TestEveryWritePathThatCarriesExtra:
  185. @pytest.mark.asyncio
  186. async def test_update_spool(self):
  187. fake = FakeSpoolman()
  188. client = _client(fake)
  189. spool = await client.create_spool(filament_id=7)
  190. updated = await client.update_spool(spool_id=spool["id"], extra={"tag": json.dumps("A")})
  191. assert updated["extra"]["tag"] == json.dumps("A")
  192. @pytest.mark.asyncio
  193. async def test_update_spool_full(self):
  194. fake = FakeSpoolman()
  195. client = _client(fake)
  196. spool = await client.create_spool(filament_id=7)
  197. updated = await client.update_spool_full(spool_id=spool["id"], extra={"tag": json.dumps("A")})
  198. assert updated["extra"]["tag"] == json.dumps("A")
  199. @pytest.mark.asyncio
  200. async def test_merge_spool_extra(self):
  201. """The funnel behind linking and unlinking a tag from the inventory."""
  202. fake = FakeSpoolman()
  203. client = _client(fake)
  204. spool = await client.create_spool(filament_id=7)
  205. updated = await client.merge_spool_extra(spool["id"], {"tag": json.dumps("A")})
  206. assert updated["extra"]["tag"] == json.dumps("A")
  207. @pytest.mark.asyncio
  208. async def test_a_key_no_registration_list_ever_named(self):
  209. """``bambu_color_name`` is absent from the connect and startup lists.
  210. It survives today only because two call sites remember to register it
  211. by hand. Keying off the payload is what stops that being load-bearing.
  212. """
  213. fake = FakeSpoolman()
  214. client = _client(fake)
  215. spool = await client.create_spool(filament_id=7)
  216. updated = await client.merge_spool_extra(spool["id"], {"bambu_color_name": json.dumps("Jade White")})
  217. assert updated["extra"]["bambu_color_name"] == json.dumps("Jade White")
  218. assert "bambu_color_name" in fake.registered
  219. @pytest.mark.asyncio
  220. async def test_every_key_of_a_multi_key_write(self):
  221. fake = FakeSpoolman()
  222. client = _client(fake)
  223. spool = await client.create_spool(filament_id=7)
  224. await client.merge_spool_extra(
  225. spool["id"],
  226. {"bambu_slicer_filament": json.dumps("GFA00"), "bambu_color_name": json.dumps("Black")},
  227. )
  228. assert {"bambu_slicer_filament", "bambu_color_name"} <= fake.registered
  229. class TestWhenRegistrationItselfFails:
  230. @pytest.mark.asyncio
  231. async def test_the_write_is_still_attempted(self):
  232. """Best-effort: a failed registration must not swallow the write.
  233. Spoolman still rejects the payload, exactly as it did before this
  234. change -- the caller's error handling is what reports that, and it is
  235. deliberately left untouched.
  236. """
  237. from backend.app.services.spoolman import SpoolmanClientError
  238. fake = FakeSpoolman(field_status=500)
  239. client = _client(fake)
  240. with pytest.raises(SpoolmanClientError):
  241. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  242. assert "POST /spool" in fake.log
  243. @pytest.mark.asyncio
  244. async def test_a_later_write_tries_registering_again(self):
  245. """A failure is not cached -- Spoolman may simply have been restarting."""
  246. from backend.app.services.spoolman import SpoolmanClientError
  247. fake = FakeSpoolman(field_status=500)
  248. client = _client(fake)
  249. with pytest.raises(SpoolmanClientError):
  250. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  251. fake.field_status = 200
  252. result = await client.create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  253. assert result["extra"]["tag"] == json.dumps("B")