test_spoolman_extra_field_registration_2903.py 14 KB

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