test_spoolman_extra_field_registration_2903.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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.startswith("/field/spool/"):
  42. name = path.rsplit("/", 1)[-1]
  43. if request.method == "GET":
  44. if self.field_status != 200:
  45. return httpx.Response(self.field_status)
  46. return httpx.Response(200) if name in self.registered else httpx.Response(404)
  47. if self.field_status != 200:
  48. return httpx.Response(self.field_status)
  49. self.registered.add(name)
  50. return httpx.Response(200, json={"name": name})
  51. if path == "/spool" and request.method == "POST":
  52. if (rejection := self._reject_unknown_extra(body)) is not None:
  53. return rejection
  54. spool = {"id": self._next_id, **body}
  55. self.spools[self._next_id] = spool
  56. self._next_id += 1
  57. return httpx.Response(200, json=spool)
  58. if path.startswith("/spool/"):
  59. spool_id = int(path.rsplit("/", 1)[-1])
  60. if request.method == "GET":
  61. return httpx.Response(200, json=self.spools[spool_id])
  62. if (rejection := self._reject_unknown_extra(body)) is not None:
  63. return rejection
  64. self.spools[spool_id].update(body)
  65. return httpx.Response(200, json=self.spools[spool_id])
  66. if path == "/vendor":
  67. if request.method == "GET":
  68. return httpx.Response(200, json=[{"id": 1, "name": "Bambu Lab"}])
  69. return httpx.Response(200, json={"id": 1, "name": body.get("name", "")})
  70. if path == "/filament":
  71. if request.method == "GET":
  72. return httpx.Response(200, json=[])
  73. return httpx.Response(200, json={"id": 7, **body})
  74. if path == "/external/filament":
  75. return httpx.Response(200, json=[])
  76. return httpx.Response(200, json=[])
  77. def field_calls(self, name: str) -> list[str]:
  78. return [entry for entry in self.log if entry.endswith(f"/field/spool/{name}")]
  79. def _client(fake: FakeSpoolman) -> SpoolmanClient:
  80. client = SpoolmanClient("https://spoolman.test")
  81. client._client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler))
  82. return client
  83. def _tray(tray_uuid: str) -> AMSTray:
  84. return AMSTray(
  85. ams_id=0,
  86. tray_id=0,
  87. tray_type="PLA",
  88. tray_sub_brands="PLA Basic",
  89. tray_color="000000FF",
  90. remain=100,
  91. tag_uid="",
  92. tray_uuid=tray_uuid,
  93. tray_info_idx="GFA00",
  94. tray_weight=1000,
  95. )
  96. class TestTheReportedCase:
  97. """A fresh Spoolman, a fresh Bambuddy, and the first AMS sync."""
  98. @pytest.mark.asyncio
  99. async def test_syncing_a_slot_no_longer_fails_on_a_fresh_spoolman(self):
  100. fake = FakeSpoolman() # GET /field/spool returns nothing: no custom fields at all
  101. client = _client(fake)
  102. result = await client.sync_ams_tray(_tray("D144798DEF394926ACAE9D69ABA910CC"), "OJIMPO-X2D-01")
  103. assert result is not None, "spool creation was rejected -- this is the reported 400"
  104. assert result["extra"]["tag"] == json.dumps("D144798DEF394926ACAE9D69ABA910CC")
  105. assert "tag" in fake.registered
  106. @pytest.mark.asyncio
  107. async def test_all_three_slots_sync_rather_than_erroring(self):
  108. """The report's exact shape: "Synced 0 spools with 3 errors"."""
  109. fake = FakeSpoolman()
  110. client = _client(fake)
  111. tags = [
  112. "D144798DEF394926ACAE9D69ABA910CC",
  113. "1880BE1371014F4CA951BE6A30C99E44",
  114. "1D1F3C49046246DBBADBC3631B7F1B61",
  115. ]
  116. synced = [await client.sync_ams_tray(_tray(tag), "OJIMPO-X2D-01") for tag in tags]
  117. assert all(s is not None for s in synced)
  118. assert [s["extra"]["tag"] for s in synced] == [json.dumps(t) for t in tags]
  119. @pytest.mark.asyncio
  120. async def test_the_field_is_registered_before_the_spool_is_posted(self):
  121. """Ordering is the whole fix -- registering afterwards rescues nothing."""
  122. fake = FakeSpoolman()
  123. client = _client(fake)
  124. await client.create_spool(filament_id=7, extra={"tag": json.dumps("ABC")})
  125. assert fake.log.index("POST /field/spool/tag") < fake.log.index("POST /spool")
  126. class TestItAsksSpoolmanOnlyOnce:
  127. @pytest.mark.asyncio
  128. async def test_a_second_write_does_not_ask_again(self):
  129. fake = FakeSpoolman()
  130. client = _client(fake)
  131. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  132. await client.create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  133. assert fake.field_calls("tag") == ["GET /field/spool/tag", "POST /field/spool/tag"]
  134. @pytest.mark.asyncio
  135. async def test_an_already_registered_field_is_never_created(self):
  136. fake = FakeSpoolman(registered={"tag"})
  137. client = _client(fake)
  138. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  139. assert fake.field_calls("tag") == ["GET /field/spool/tag"]
  140. @pytest.mark.asyncio
  141. async def test_a_write_carrying_no_extra_asks_nothing(self):
  142. fake = FakeSpoolman()
  143. client = _client(fake)
  144. await client.create_spool(filament_id=7, remaining_weight=500.0)
  145. assert fake.field_calls("tag") == []
  146. @pytest.mark.asyncio
  147. async def test_concurrent_syncs_ask_exactly_once_between_them(self):
  148. """Two slots syncing at once must not race into a duplicate POST.
  149. The exact call list, rather than just the POST count: the loser of the
  150. race should re-read the memo once it holds the lock and find the answer
  151. already there, rather than repeating the round-trip the winner just
  152. made.
  153. """
  154. fake = FakeSpoolman()
  155. client = _client(fake)
  156. await asyncio.gather(
  157. client.create_spool(filament_id=7, extra={"tag": json.dumps("A")}),
  158. client.create_spool(filament_id=7, extra={"tag": json.dumps("B")}),
  159. )
  160. assert fake.field_calls("tag") == ["GET /field/spool/tag", "POST /field/spool/tag"]
  161. @pytest.mark.asyncio
  162. async def test_another_client_does_not_inherit_the_answer(self):
  163. """The memo describes one Spoolman, so a re-pointed client starts over."""
  164. fake = FakeSpoolman()
  165. await _client(fake).create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  166. second_fake = FakeSpoolman()
  167. await _client(second_fake).create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  168. assert "GET /field/spool/tag" in second_fake.log
  169. class TestEveryWritePathThatCarriesExtra:
  170. @pytest.mark.asyncio
  171. async def test_update_spool(self):
  172. fake = FakeSpoolman()
  173. client = _client(fake)
  174. spool = await client.create_spool(filament_id=7)
  175. updated = await client.update_spool(spool_id=spool["id"], extra={"tag": json.dumps("A")})
  176. assert updated["extra"]["tag"] == json.dumps("A")
  177. @pytest.mark.asyncio
  178. async def test_update_spool_full(self):
  179. fake = FakeSpoolman()
  180. client = _client(fake)
  181. spool = await client.create_spool(filament_id=7)
  182. updated = await client.update_spool_full(spool_id=spool["id"], extra={"tag": json.dumps("A")})
  183. assert updated["extra"]["tag"] == json.dumps("A")
  184. @pytest.mark.asyncio
  185. async def test_merge_spool_extra(self):
  186. """The funnel behind linking and unlinking a tag from the inventory."""
  187. fake = FakeSpoolman()
  188. client = _client(fake)
  189. spool = await client.create_spool(filament_id=7)
  190. updated = await client.merge_spool_extra(spool["id"], {"tag": json.dumps("A")})
  191. assert updated["extra"]["tag"] == json.dumps("A")
  192. @pytest.mark.asyncio
  193. async def test_a_key_no_registration_list_ever_named(self):
  194. """``bambu_color_name`` is absent from the connect and startup lists.
  195. It survives today only because two call sites remember to register it
  196. by hand. Keying off the payload is what stops that being load-bearing.
  197. """
  198. fake = FakeSpoolman()
  199. client = _client(fake)
  200. spool = await client.create_spool(filament_id=7)
  201. updated = await client.merge_spool_extra(spool["id"], {"bambu_color_name": json.dumps("Jade White")})
  202. assert updated["extra"]["bambu_color_name"] == json.dumps("Jade White")
  203. assert "bambu_color_name" in fake.registered
  204. @pytest.mark.asyncio
  205. async def test_every_key_of_a_multi_key_write(self):
  206. fake = FakeSpoolman()
  207. client = _client(fake)
  208. spool = await client.create_spool(filament_id=7)
  209. await client.merge_spool_extra(
  210. spool["id"],
  211. {"bambu_slicer_filament": json.dumps("GFA00"), "bambu_color_name": json.dumps("Black")},
  212. )
  213. assert {"bambu_slicer_filament", "bambu_color_name"} <= fake.registered
  214. class TestWhenRegistrationItselfFails:
  215. @pytest.mark.asyncio
  216. async def test_the_write_is_still_attempted(self):
  217. """Best-effort: a failed registration must not swallow the write.
  218. Spoolman still rejects the payload, exactly as it did before this
  219. change -- the caller's error handling is what reports that, and it is
  220. deliberately left untouched.
  221. """
  222. from backend.app.services.spoolman import SpoolmanClientError
  223. fake = FakeSpoolman(field_status=500)
  224. client = _client(fake)
  225. with pytest.raises(SpoolmanClientError):
  226. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  227. assert "POST /spool" in fake.log
  228. @pytest.mark.asyncio
  229. async def test_a_later_write_tries_registering_again(self):
  230. """A failure is not cached -- Spoolman may simply have been restarting."""
  231. from backend.app.services.spoolman import SpoolmanClientError
  232. fake = FakeSpoolman(field_status=500)
  233. client = _client(fake)
  234. with pytest.raises(SpoolmanClientError):
  235. await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
  236. fake.field_status = 200
  237. result = await client.create_spool(filament_id=7, extra={"tag": json.dumps("B")})
  238. assert result["extra"]["tag"] == json.dumps("B")