test_spoolman_extra_field_registration_2903.py 14 KB

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