test_spoolman_service.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. """Unit tests for Spoolman service.
  2. These tests specifically target the sync_ams_tray method's disable_weight_sync
  3. functionality that controls whether remaining_weight is updated.
  4. Also includes tests for is_bambu_lab_spool RFID detection.
  5. """
  6. from unittest.mock import AsyncMock, Mock, patch
  7. import pytest
  8. from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
  9. class TestIsBambuLabSpool:
  10. """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
  11. @pytest.fixture
  12. def client(self):
  13. return SpoolmanClient("http://localhost:7912")
  14. def test_valid_tray_uuid_returns_true(self, client):
  15. """A non-zero 32-char hex tray_uuid identifies a BL spool."""
  16. assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True
  17. def test_valid_tag_uid_returns_true(self, client):
  18. """A non-zero 16-char hex tag_uid identifies a BL spool (fallback)."""
  19. assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True
  20. def test_zero_tray_uuid_returns_false(self, client):
  21. """All-zero tray_uuid means no RFID tag read."""
  22. assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False
  23. def test_zero_tag_uid_returns_false(self, client):
  24. """All-zero tag_uid means no RFID tag read."""
  25. assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False
  26. def test_empty_identifiers_returns_false(self, client):
  27. """No identifiers means no BL spool."""
  28. assert client.is_bambu_lab_spool("") is False
  29. assert client.is_bambu_lab_spool("", tag_uid="") is False
  30. def test_tray_info_idx_ignored(self, client):
  31. """tray_info_idx is NOT a reliable BL indicator — third-party spools
  32. using Bambu generic presets also have GF-prefixed tray_info_idx values."""
  33. # Third-party spool with Bambu preset but no RFID identifiers
  34. assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False
  35. assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False
  36. assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False
  37. def test_tray_info_idx_with_valid_uuid_returns_true(self, client):
  38. """BL spool with both RFID UUID and preset ID — detected by UUID."""
  39. assert (
  40. client.is_bambu_lab_spool(
  41. "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  42. tray_info_idx="GFA00",
  43. )
  44. is True
  45. )
  46. def test_tray_uuid_preferred_over_tag_uid(self, client):
  47. """tray_uuid is checked before tag_uid (both valid)."""
  48. assert (
  49. client.is_bambu_lab_spool(
  50. "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  51. tag_uid="A1B2C3D4E5F6A1B2",
  52. )
  53. is True
  54. )
  55. def test_short_tray_uuid_returns_false(self, client):
  56. """UUID must be exactly 32 hex chars."""
  57. assert client.is_bambu_lab_spool("A1B2C3D4") is False
  58. def test_non_hex_tray_uuid_returns_false(self, client):
  59. """UUID must be valid hex."""
  60. assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False
  61. class TestSpoolmanClient:
  62. """Tests for SpoolmanClient class."""
  63. @pytest.fixture
  64. def client(self):
  65. """Create a SpoolmanClient instance."""
  66. return SpoolmanClient("http://localhost:7912")
  67. @pytest.fixture
  68. def sample_tray(self):
  69. """Create a sample AMSTray for testing."""
  70. return AMSTray(
  71. ams_id=0,
  72. tray_id=0,
  73. tray_type="PLA",
  74. tray_sub_brands="PLA Basic",
  75. tray_color="FF0000FF",
  76. remain=50,
  77. tag_uid="",
  78. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  79. tray_info_idx="GFA00",
  80. tray_weight=1000,
  81. )
  82. @pytest.fixture
  83. def existing_spool(self):
  84. """Create a mock existing spool response."""
  85. return {
  86. "id": 42,
  87. "remaining_weight": 800,
  88. "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
  89. "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
  90. }
  91. @pytest.fixture
  92. def mock_filament(self):
  93. """Create a mock filament response."""
  94. return {"id": 1, "name": "PLA Basic", "material": "PLA"}
  95. # ========================================================================
  96. # Tests for sync_ams_tray with disable_weight_sync
  97. # ========================================================================
  98. @pytest.mark.asyncio
  99. async def test_sync_ams_tray_updates_weight_by_default(self, client, sample_tray, existing_spool):
  100. """Verify sync_ams_tray updates remaining_weight by default."""
  101. with (
  102. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  103. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  104. ):
  105. await client.sync_ams_tray(sample_tray, "TestPrinter")
  106. mock_update.assert_called_once()
  107. call_kwargs = mock_update.call_args.kwargs
  108. assert "remaining_weight" in call_kwargs
  109. assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
  110. assert "location" not in call_kwargs
  111. @pytest.mark.asyncio
  112. async def test_sync_ams_tray_skips_weight_when_disabled(self, client, sample_tray, existing_spool):
  113. """Verify sync_ams_tray skips remaining_weight when disable_weight_sync=True."""
  114. with (
  115. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  116. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  117. ):
  118. await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
  119. mock_update.assert_called_once()
  120. call_kwargs = mock_update.call_args.kwargs
  121. # remaining_weight should be None (not updated)
  122. assert call_kwargs.get("remaining_weight") is None
  123. # location must never be written by Bambuddy — user-managed in Spoolman
  124. assert "location" not in call_kwargs
  125. @pytest.mark.asyncio
  126. async def test_sync_ams_tray_new_spool_always_includes_weight(self, client, sample_tray, mock_filament):
  127. """Verify new spool creation always includes remaining_weight even when disabled."""
  128. with (
  129. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  130. patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
  131. patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
  132. ):
  133. await client.sync_ams_tray(sample_tray, "TestPrinter", disable_weight_sync=True)
  134. mock_create.assert_called_once()
  135. call_kwargs = mock_create.call_args.kwargs
  136. # New spools should ALWAYS include remaining_weight
  137. assert "remaining_weight" in call_kwargs
  138. assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
  139. @pytest.mark.asyncio
  140. async def test_sync_ams_tray_does_not_write_location(self, client, sample_tray, existing_spool):
  141. """Verify sync_ams_tray never writes location= to Spoolman (user-managed field)."""
  142. with (
  143. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  144. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  145. ):
  146. await client.sync_ams_tray(sample_tray, "My Printer", disable_weight_sync=True)
  147. call_kwargs = mock_update.call_args.kwargs
  148. # Bambuddy must never auto-set spool.location — it is user-managed in Spoolman
  149. assert "location" not in call_kwargs
  150. # ========================================================================
  151. # T6: non-BL spool with custom RFID (H5 guard)
  152. # ========================================================================
  153. @pytest.mark.asyncio
  154. async def test_sync_ams_tray_non_bl_rfid_find_or_create_error_returns_none(self, client):
  155. """Non-BL spool with custom RFID: find_or_create_filament failure returns None, not raises.
  156. A third-party spool whose tag_uid is not exactly 16 hex chars is not
  157. identified as BL. sync_ams_tray must catch find_or_create_filament
  158. errors and return None instead of propagating the exception.
  159. """
  160. from backend.app.services.spoolman import SpoolmanUnavailableError
  161. # 8-char tag → spool_tag is set, but is_bambu_lab_spool returns False
  162. tray = AMSTray(
  163. ams_id=0,
  164. tray_id=2,
  165. tray_type="PLA",
  166. tray_sub_brands="eSun PLA+",
  167. tray_color="00FF00FF",
  168. remain=50,
  169. tag_uid="AABB1234",
  170. tray_uuid="",
  171. tray_info_idx="",
  172. tray_weight=1000,
  173. )
  174. with (
  175. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  176. patch.object(
  177. client,
  178. "find_or_create_filament",
  179. AsyncMock(side_effect=SpoolmanUnavailableError("timeout")),
  180. ),
  181. ):
  182. result = await client.sync_ams_tray(tray, "TestPrinter")
  183. assert result is None
  184. # ========================================================================
  185. # T7: hint path uncached — get_spool(hint) called when not in cached_spools
  186. # ========================================================================
  187. @pytest.mark.asyncio
  188. async def test_sync_ams_tray_hint_uncached_calls_get_spool(self, client):
  189. """No-RFID path: when hint spool is absent from cached_spools, get_spool is called."""
  190. tray = AMSTray(
  191. ams_id=0,
  192. tray_id=3,
  193. tray_type="PETG",
  194. tray_sub_brands="Generic PETG",
  195. tray_color="0000FFFF",
  196. remain=75,
  197. tag_uid="",
  198. tray_uuid="",
  199. tray_info_idx="",
  200. tray_weight=1000,
  201. )
  202. # cached_spools exists but does NOT contain spool 99
  203. cached_spools = [{"id": 1, "extra": {}}]
  204. fetched_spool = {"id": 99, "extra": {}}
  205. with (
  206. patch.object(client, "get_spool", AsyncMock(return_value=fetched_spool)) as mock_get,
  207. patch.object(client, "update_spool", AsyncMock(return_value=fetched_spool)),
  208. ):
  209. result = await client.sync_ams_tray(
  210. tray,
  211. "TestPrinter",
  212. cached_spools=cached_spools,
  213. spoolman_spool_id_hint=99,
  214. )
  215. assert result is not None
  216. mock_get.assert_awaited_once_with(99)
  217. # ========================================================================
  218. # T8: hint ignored when RFID tag is present
  219. # ========================================================================
  220. @pytest.mark.asyncio
  221. async def test_sync_ams_tray_rfid_takes_precedence_over_hint(self, client, existing_spool):
  222. """When tray_uuid is set, the RFID path is used and the hint is never consulted."""
  223. tray = AMSTray(
  224. ams_id=0,
  225. tray_id=4,
  226. tray_type="PLA",
  227. tray_sub_brands="PLA Basic",
  228. tray_color="FF0000FF",
  229. remain=50,
  230. tag_uid="",
  231. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  232. tray_info_idx="GFA00",
  233. tray_weight=1000,
  234. )
  235. with (
  236. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  237. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
  238. patch.object(client, "get_spool", AsyncMock()) as mock_get_spool,
  239. ):
  240. result = await client.sync_ams_tray(
  241. tray,
  242. "TestPrinter",
  243. spoolman_spool_id_hint=99,
  244. )
  245. assert result is not None
  246. # hint path (get_spool) must NOT be called when RFID is present
  247. mock_get_spool.assert_not_called()
  248. @pytest.mark.asyncio
  249. async def test_sync_ams_tray_non_bambu_no_rfid_returns_none(self, client):
  250. """Third-party spool without any RFID and no hint returns None."""
  251. # Non-BL spool: no tray_uuid, no tag_uid, no spoolman_spool_id_hint → nothing to match
  252. tray = AMSTray(
  253. ams_id=0,
  254. tray_id=0,
  255. tray_type="PLA",
  256. tray_sub_brands="Third Party PLA",
  257. tray_color="FF0000FF",
  258. remain=50,
  259. tag_uid="",
  260. tray_uuid="",
  261. tray_info_idx="",
  262. tray_weight=1000,
  263. )
  264. result = await client.sync_ams_tray(tray, "TestPrinter")
  265. assert result is None
  266. @pytest.mark.asyncio
  267. async def test_sync_ams_tray_hint_updates_spool_without_rfid(self, client):
  268. """No-RFID fallback: spool_id_hint from local slot-assignment table updates the spool."""
  269. tray = AMSTray(
  270. ams_id=0,
  271. tray_id=0,
  272. tray_type="PLA",
  273. tray_sub_brands="Generic PLA",
  274. tray_color="00FF00FF",
  275. remain=80,
  276. tag_uid="",
  277. tray_uuid="",
  278. tray_info_idx="",
  279. tray_weight=1000,
  280. )
  281. cached_spools = [{"id": 99, "extra": {}}]
  282. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  283. mock_update.return_value = {"id": 99}
  284. result = await client.sync_ams_tray(
  285. tray, "TestPrinter", cached_spools=cached_spools, spoolman_spool_id_hint=99
  286. )
  287. assert result is not None
  288. assert result["id"] == 99
  289. mock_update.assert_called_once()
  290. call_kwargs = mock_update.call_args.kwargs
  291. assert "location" not in call_kwargs
  292. @pytest.mark.asyncio
  293. async def test_sync_ams_tray_weight_calculation(self, client, existing_spool):
  294. """Verify remaining weight is calculated correctly for various percentages."""
  295. test_cases = [
  296. (100, 1000, 1000.0), # Full spool
  297. (50, 1000, 500.0), # Half spool
  298. (25, 1000, 250.0), # Quarter spool
  299. (0, 1000, 0.0), # Empty spool
  300. (75, 500, 375.0), # Different spool weight
  301. ]
  302. for remain, weight, expected in test_cases:
  303. tray = AMSTray(
  304. ams_id=0,
  305. tray_id=0,
  306. tray_type="PLA",
  307. tray_sub_brands="PLA Basic",
  308. tray_color="FF0000FF",
  309. remain=remain,
  310. tag_uid="",
  311. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  312. tray_info_idx="GFA00",
  313. tray_weight=weight,
  314. )
  315. with (
  316. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  317. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  318. ):
  319. await client.sync_ams_tray(tray, "TestPrinter", disable_weight_sync=False)
  320. call_kwargs = mock_update.call_args.kwargs
  321. assert call_kwargs["remaining_weight"] == expected, (
  322. f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
  323. )
  324. # ========================================================================
  325. # Tests for caching functionality
  326. # ========================================================================
  327. @pytest.mark.asyncio
  328. async def test_find_spool_by_tag_with_cached_spools(self, client):
  329. """Verify find_spool_by_tag uses cached spools when provided (no API call)."""
  330. cached = [
  331. {"id": 1, "extra": {"tag": '"ABC123"'}},
  332. {"id": 2, "extra": {"tag": '"XYZ789"'}},
  333. ]
  334. with patch.object(client, "get_spools", AsyncMock()) as mock_get:
  335. result = await client.find_spool_by_tag("ABC123", cached_spools=cached)
  336. assert result["id"] == 1
  337. mock_get.assert_not_called() # Should NOT call get_spools
  338. @pytest.mark.asyncio
  339. async def test_find_spool_by_tag_without_cached_spools(self, client):
  340. """Verify find_spool_by_tag fetches spools when cache not provided."""
  341. mock_spools = [{"id": 1, "extra": {"tag": '"ABC123"'}}]
  342. with patch.object(client, "get_spools", AsyncMock(return_value=mock_spools)) as mock_get:
  343. result = await client.find_spool_by_tag("ABC123")
  344. assert result["id"] == 1
  345. mock_get.assert_called_once() # Should call get_spools
  346. @pytest.mark.asyncio
  347. async def test_find_spools_by_location_prefix_with_cached_spools(self, client):
  348. """Verify find_spools_by_location_prefix uses cached spools when provided."""
  349. cached = [
  350. {"id": 1, "location": "Printer1 - AMS A1"},
  351. {"id": 2, "location": "Printer2 - AMS A1"},
  352. {"id": 3, "location": "Printer1 - AMS A2"},
  353. ]
  354. with patch.object(client, "get_spools", AsyncMock()) as mock_get:
  355. result = await client.find_spools_by_location_prefix("Printer1 - ", cached_spools=cached)
  356. assert len(result) == 2
  357. assert result[0]["id"] == 1
  358. assert result[1]["id"] == 3
  359. mock_get.assert_not_called() # Should NOT call get_spools
  360. @pytest.mark.asyncio
  361. async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):
  362. """Verify sync_ams_tray passes cached_spools to find_spool_by_tag."""
  363. cached = [existing_spool]
  364. with (
  365. patch.object(client, "get_spools", AsyncMock()) as mock_get,
  366. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
  367. ):
  368. await client.sync_ams_tray(sample_tray, "TestPrinter", cached_spools=cached)
  369. mock_get.assert_not_called() # Should NOT call get_spools
  370. @pytest.mark.asyncio
  371. async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
  372. """Verify clear_location_for_removed_spools uses cached spools."""
  373. cached = [
  374. {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"A1B2C3D4E5F60718293A4B5C6D7E8F90"'}},
  375. {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"B1C2D3E4F5061728394A5B6C7D8E9F01"'}},
  376. {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"C1D2E3F40516273849A5B6C7D8E9F012"'}},
  377. ]
  378. # Tag 3 was cleared, so only tags 1 and 2 are current
  379. current_tags = {
  380. "A1B2C3D4E5F60718293A4B5C6D7E8F90",
  381. "B1C2D3E4F5061728394A5B6C7D8E9F01",
  382. }
  383. with (
  384. patch.object(client, "get_spools", AsyncMock()) as mock_get,
  385. patch.object(client, "update_spool", AsyncMock(return_value={"id": 3})) as mock_update,
  386. ):
  387. cleared = await client.clear_location_for_removed_spools("Printer1", current_tags, cached_spools=cached)
  388. assert cleared == 1
  389. mock_get.assert_not_called() # Should NOT call get_spools
  390. mock_update.assert_called_once()
  391. # Verify it cleared TAG3 (not in current_tags)
  392. call_kwargs = mock_update.call_args.kwargs
  393. assert call_kwargs["spool_id"] == 3
  394. assert call_kwargs.get("clear_location") is True
  395. # ========================================================================
  396. # Tests for retry logic in get_spools
  397. # ========================================================================
  398. @pytest.mark.asyncio
  399. async def test_get_spools_succeeds_on_first_attempt(self, client):
  400. """Verify get_spools succeeds immediately when no errors occur."""
  401. mock_spools = [{"id": 1}, {"id": 2}]
  402. with patch.object(client, "_get_client") as mock_get_client:
  403. mock_http_client = AsyncMock()
  404. mock_response = Mock()
  405. mock_response.raise_for_status = Mock()
  406. mock_response.json = Mock(return_value=mock_spools)
  407. mock_http_client.get = AsyncMock(return_value=mock_response)
  408. mock_get_client.return_value = mock_http_client
  409. result = await client.get_spools()
  410. assert result == mock_spools
  411. mock_get_client.assert_called_once()
  412. mock_http_client.get.assert_called_once()
  413. @pytest.mark.asyncio
  414. async def test_get_spools_retries_on_connection_error(self, client):
  415. """Verify get_spools retries up to 3 times on connection errors."""
  416. import httpx
  417. mock_spools = [{"id": 1}]
  418. with (
  419. patch.object(client, "_get_client") as mock_get_client,
  420. patch.object(client, "close", AsyncMock()) as mock_close,
  421. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  422. ):
  423. mock_http_client = AsyncMock()
  424. mock_get_client.return_value = mock_http_client
  425. # First 2 attempts fail with ReadError, 3rd succeeds
  426. mock_response = Mock()
  427. mock_response.raise_for_status = Mock()
  428. mock_response.json = Mock(return_value=mock_spools)
  429. mock_http_client.get = AsyncMock(
  430. side_effect=[
  431. httpx.ReadError("Connection closed"),
  432. httpx.ReadError("Connection closed"),
  433. mock_response,
  434. ]
  435. )
  436. result = await client.get_spools()
  437. assert result == mock_spools
  438. assert mock_get_client.call_count == 3
  439. assert mock_http_client.get.call_count == 3
  440. # Should close client twice (after each failed attempt)
  441. assert mock_close.call_count == 2
  442. # Should sleep twice (after first 2 attempts)
  443. assert mock_sleep.call_count == 2
  444. mock_sleep.assert_called_with(0.5)
  445. @pytest.mark.asyncio
  446. async def test_get_spools_raises_after_3_failed_attempts(self, client):
  447. """Verify get_spools raises SpoolmanUnavailableError after 3 failed connection attempts."""
  448. import httpx
  449. from backend.app.services.spoolman import SpoolmanUnavailableError
  450. with (
  451. patch.object(client, "_get_client", AsyncMock()) as mock_get_client,
  452. patch.object(client, "close", AsyncMock()) as mock_close,
  453. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  454. ):
  455. mock_http_client = AsyncMock()
  456. mock_get_client.return_value = mock_http_client
  457. # All 3 attempts fail
  458. mock_http_client.get.side_effect = httpx.ReadError("Connection closed")
  459. with pytest.raises(SpoolmanUnavailableError):
  460. await client.get_spools()
  461. assert mock_get_client.call_count == 3
  462. assert mock_http_client.get.call_count == 3
  463. # Should close client twice (after first 2 failed attempts, not after 3rd)
  464. assert mock_close.call_count == 2
  465. # Should sleep twice (after first 2 attempts, not after 3rd)
  466. assert mock_sleep.call_count == 2
  467. @pytest.mark.asyncio
  468. async def test_get_spools_handles_non_connection_errors(self, client):
  469. """Verify get_spools retries on non-connection errors without recreating client."""
  470. import httpx
  471. mock_spools = [{"id": 1}]
  472. with (
  473. patch.object(client, "_get_client") as mock_get_client,
  474. patch.object(client, "close", AsyncMock()) as mock_close,
  475. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  476. ):
  477. mock_http_client = AsyncMock()
  478. mock_get_client.return_value = mock_http_client
  479. # First attempt fails with HTTP error, 2nd succeeds
  480. mock_response_error = Mock()
  481. mock_response_error.raise_for_status = Mock(
  482. side_effect=httpx.HTTPStatusError("500 Server Error", request=Mock(), response=Mock())
  483. )
  484. mock_response_success = Mock()
  485. mock_response_success.raise_for_status = Mock()
  486. mock_response_success.json = Mock(return_value=mock_spools)
  487. mock_http_client.get = AsyncMock(side_effect=[mock_response_error, mock_response_success])
  488. result = await client.get_spools()
  489. assert result == mock_spools
  490. assert mock_get_client.call_count == 2
  491. # Should NOT close client for HTTP errors (only connection errors)
  492. mock_close.assert_not_called()
  493. # Should sleep once (after first failed attempt)
  494. assert mock_sleep.call_count == 1
  495. # ---------------------------------------------------------------------------
  496. # init_spoolman_client — SSRF guard (B4 / T3)
  497. # ---------------------------------------------------------------------------
  498. class TestInitSpoolmanClientSSRFGuard:
  499. """init_spoolman_client must reject genuinely unsafe URLs before creating a client.
  500. Scope: cloud metadata endpoints, multicast, unspecified, non-http(s) schemes,
  501. and numeric-encoded IP bypasses. Loopback and RFC-1918 private ranges are
  502. explicitly allowed — Bambuddy's primary deployment is LAN-local Spoolman.
  503. """
  504. @pytest.mark.asyncio
  505. async def test_cloud_metadata_raises_value_error(self):
  506. with pytest.raises(ValueError, match="cloud metadata"):
  507. await init_spoolman_client("http://169.254.169.254/latest/meta-data/")
  508. @pytest.mark.asyncio
  509. async def test_multicast_raises_value_error(self):
  510. with pytest.raises(ValueError, match="multicast|unspecified"):
  511. await init_spoolman_client("http://224.0.0.1/")
  512. @pytest.mark.asyncio
  513. async def test_unspecified_raises_value_error(self):
  514. with pytest.raises(ValueError, match="multicast|unspecified"):
  515. await init_spoolman_client("http://0.0.0.0/")
  516. @pytest.mark.asyncio
  517. async def test_numeric_encoded_ip_raises_value_error(self):
  518. # decimal-encoded 127.0.0.1 — libc resolves these but ipaddress doesn't
  519. with pytest.raises(ValueError, match="numeric-encoded"):
  520. await init_spoolman_client("http://2130706433/")
  521. @pytest.mark.asyncio
  522. async def test_non_http_scheme_raises_value_error(self):
  523. with pytest.raises(ValueError, match="http or https"):
  524. await init_spoolman_client("file:///etc/passwd")
  525. @pytest.mark.asyncio
  526. async def test_private_ip_is_allowed(self):
  527. """Regression: RFC-1918 private addresses are the normal LAN topology."""
  528. mock_instance = AsyncMock()
  529. with (
  530. patch("backend.app.services.spoolman._spoolman_client", None),
  531. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  532. ):
  533. client = await init_spoolman_client("http://192.168.1.50:7912/")
  534. mock_cls.assert_called_once_with("http://192.168.1.50:7912/")
  535. assert client is mock_instance
  536. @pytest.mark.asyncio
  537. async def test_loopback_ip_is_allowed(self):
  538. """Regression: same-host Spoolman via loopback is a supported topology."""
  539. mock_instance = AsyncMock()
  540. with (
  541. patch("backend.app.services.spoolman._spoolman_client", None),
  542. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  543. ):
  544. client = await init_spoolman_client("http://127.0.0.1:7912/")
  545. mock_cls.assert_called_once_with("http://127.0.0.1:7912/")
  546. assert client is mock_instance
  547. @pytest.mark.asyncio
  548. async def test_localhost_hostname_is_allowed(self):
  549. # localhost (hostname, not bare IP) is a supported topology for same-host Spoolman
  550. mock_instance = AsyncMock()
  551. with (
  552. patch("backend.app.services.spoolman._spoolman_client", None),
  553. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  554. ):
  555. client = await init_spoolman_client("http://localhost:7912/")
  556. mock_cls.assert_called_once_with("http://localhost:7912/")
  557. assert client is mock_instance
  558. @pytest.mark.asyncio
  559. async def test_public_url_is_allowed(self):
  560. mock_instance = AsyncMock()
  561. with (
  562. patch("backend.app.services.spoolman._spoolman_client", None),
  563. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  564. ):
  565. client = await init_spoolman_client("http://spoolman.example.com:7912/")
  566. mock_cls.assert_called_once_with("http://spoolman.example.com:7912/")
  567. assert client is mock_instance
  568. class TestFindOrCreateFilament:
  569. """Tests for SpoolmanClient._find_or_create_filament — the auto-create path
  570. that runs when AMS sync sees an RFID spool that isn't already in Spoolman.
  571. Regression tests for #1309 (Bambu Lab RFID spools getting competitor names
  572. like "3DXTECH™ Black" from the unfiltered SpoolmanDB lookup).
  573. """
  574. @pytest.fixture
  575. def client(self):
  576. return SpoolmanClient("http://localhost:7912")
  577. @pytest.fixture
  578. def tray_pla_black(self):
  579. """A typical Bambu PLA Basic Black RFID read."""
  580. return AMSTray(
  581. ams_id=0,
  582. tray_id=0,
  583. tray_type="PLA",
  584. tray_sub_brands="PLA Basic",
  585. tray_color="000000FF",
  586. remain=100,
  587. tag_uid="",
  588. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  589. tray_info_idx="GFA00",
  590. tray_weight=1000,
  591. )
  592. @pytest.mark.asyncio
  593. async def test_returns_existing_internal_bambu_lab_filament(self, client, tray_pla_black):
  594. """When a Bambu Lab filament matching material+color already exists internally,
  595. return it as-is — never touch the external library or create a new entry.
  596. This is the short-circuit that makes the workaround on #1309 necessary: once
  597. a wrong name is on disk, subsequent AMS reads keep reusing it and the user has
  598. to delete the mis-named entry manually for the corrected name to take effect.
  599. """
  600. existing = {
  601. "id": 6,
  602. "name": "Black",
  603. "material": "PLA",
  604. "color_hex": "000000", # alpha stripped by create_filament at insert time
  605. "vendor_id": 2,
  606. }
  607. with (
  608. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  609. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  610. patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
  611. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  612. ):
  613. result = await client._find_or_create_filament(tray_pla_black)
  614. assert result is existing
  615. mock_external.assert_not_called()
  616. mock_create.assert_not_called()
  617. @pytest.mark.asyncio
  618. async def test_skips_non_bambu_lab_external_entries(self, client, tray_pla_black):
  619. """Regression for #1309: the external-library loop must filter out non-Bambu-Lab
  620. manufacturers. PLA black 000000 is offered by 3DJAKE, 3DXTECH (and 60+ others)
  621. in SpoolmanDB before Bambu Lab's entry; without the filter the first hit wins
  622. and Bambu Lab spools get labeled with competitor names.
  623. """
  624. external = [
  625. {
  626. "id": "3djake_pla_black_1000_175_n",
  627. "manufacturer": "3DJAKE",
  628. "name": "Black",
  629. "material": "PLA",
  630. "color_hex": "000000",
  631. "density": 1.24,
  632. },
  633. {
  634. "id": "3dxtech_pla_carbonxcarbonfiberblack_500_175_p",
  635. "manufacturer": "3DXTECH",
  636. "name": "CarbonX™ Carbon Fiber Black",
  637. "material": "PLA",
  638. "color_hex": "000000",
  639. "density": 1.29,
  640. },
  641. {
  642. "id": "bambulab_pla_black_1000_175_n",
  643. "manufacturer": "Bambu Lab",
  644. "name": "Black",
  645. "material": "PLA",
  646. "color_hex": "000000",
  647. "density": 1.26,
  648. },
  649. ]
  650. with (
  651. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  652. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  653. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  654. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  655. ):
  656. await client._find_or_create_filament(tray_pla_black)
  657. mock_create.assert_called_once()
  658. kwargs = mock_create.call_args.kwargs
  659. # The Bambu Lab entry must win — not 3DJAKE / 3DXTECH which sort earlier.
  660. assert kwargs["name"] == "Black"
  661. assert kwargs["density"] == 1.26
  662. @pytest.mark.asyncio
  663. async def test_prefers_external_entry_matching_tray_sub_brands(self, client, tray_pla_black):
  664. """When SpoolmanDB has multiple Bambu Lab entries for the same material+color
  665. (e.g. a "PLA Basic" variant alongside a generic "Black"), prefer the entry
  666. whose `name` equals the AMS `tray_sub_brands` so the more specific variant wins.
  667. Per maintainer's request on #1309.
  668. """
  669. external = [
  670. {
  671. "id": "bambulab_pla_black_1000_175_n",
  672. "manufacturer": "Bambu Lab",
  673. "name": "Black",
  674. "material": "PLA",
  675. "color_hex": "000000",
  676. "density": 1.24,
  677. },
  678. {
  679. "id": "bambulab_plabasic_black_1000_175_n",
  680. "manufacturer": "Bambu Lab",
  681. "name": "PLA Basic",
  682. "material": "PLA",
  683. "color_hex": "000000",
  684. "density": 1.26,
  685. },
  686. ]
  687. with (
  688. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  689. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  690. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  691. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  692. ):
  693. await client._find_or_create_filament(tray_pla_black)
  694. mock_create.assert_called_once()
  695. kwargs = mock_create.call_args.kwargs
  696. # "PLA Basic" wins over generic "Black" because it matches tray_sub_brands.
  697. assert kwargs["name"] == "PLA Basic"
  698. assert kwargs["density"] == 1.26
  699. @pytest.mark.asyncio
  700. async def test_falls_back_to_create_when_no_bambu_match_anywhere(self, client, tray_pla_black):
  701. """If no internal Bambu Lab filament exists AND SpoolmanDB has no Bambu Lab
  702. entry for this material+color (e.g. the catalog hasn't been updated yet for a
  703. brand-new BL product), fall back to creating a fresh filament from the tray's
  704. own RFID data — without leaking a competitor's name in.
  705. """
  706. external = [
  707. {
  708. "id": "3djake_pla_black_1000_175_n",
  709. "manufacturer": "3DJAKE",
  710. "name": "Black",
  711. "material": "PLA",
  712. "color_hex": "000000",
  713. },
  714. ]
  715. with (
  716. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  717. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  718. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  719. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  720. ):
  721. await client._find_or_create_filament(tray_pla_black)
  722. mock_create.assert_called_once()
  723. kwargs = mock_create.call_args.kwargs
  724. # The 3DJAKE entry was rejected by the manufacturer filter; tray_sub_brands wins.
  725. assert kwargs["name"] == "PLA Basic"
  726. assert kwargs["material"] == "PLA"
  727. assert kwargs["color_hex"] == "000000" # alpha channel stripped from tray_color
  728. assert kwargs["vendor_id"] == 2
  729. @pytest.mark.asyncio
  730. async def test_accepts_external_entry_via_id_prefix_when_manufacturer_missing(self, client, tray_pla_black):
  731. """Defensive fallback: if `manufacturer` is absent or empty but the entry's `id`
  732. starts with `bambulab_`, treat it as a Bambu Lab entry. Keeps the filter robust
  733. against SpoolmanDB schema drift or stale catalog snapshots that omit the field.
  734. """
  735. external = [
  736. {
  737. "id": "bambulab_pla_black_1000_175_n",
  738. "name": "Black",
  739. "material": "PLA",
  740. "color_hex": "000000",
  741. "density": 1.24,
  742. }, # no `manufacturer` key at all
  743. ]
  744. with (
  745. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  746. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  747. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  748. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  749. ):
  750. await client._find_or_create_filament(tray_pla_black)
  751. mock_create.assert_called_once()
  752. assert mock_create.call_args.kwargs["name"] == "Black"
  753. @pytest.mark.asyncio
  754. async def test_external_density_propagates_to_create_filament(self, client, tray_pla_black):
  755. """The chosen external entry's `density` must be forwarded to `create_filament`
  756. instead of being silently replaced by the PLA-default 1.24 fallback inside
  757. `create_filament` itself. Verified end-to-end via the public
  758. `_find_or_create_filament` entry point.
  759. """
  760. external = [
  761. {
  762. "id": "bambulab_pla_black_1000_175_n",
  763. "manufacturer": "Bambu Lab",
  764. "name": "Black",
  765. "material": "PLA",
  766. "color_hex": "000000",
  767. "density": 1.31,
  768. },
  769. ]
  770. with (
  771. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  772. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  773. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  774. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  775. ):
  776. await client._find_or_create_filament(tray_pla_black)
  777. mock_create.assert_called_once()
  778. assert mock_create.call_args.kwargs["density"] == 1.31
  779. class TestColorHexAlphaHandling:
  780. """#2912 — a clear spool must not be stored as opaque black, and widening the
  781. stored value must not mint duplicates against inventories that hold six
  782. characters everywhere.
  783. """
  784. @pytest.fixture
  785. def client(self):
  786. return SpoolmanClient("http://localhost:7912")
  787. def _tray(self, tray_color: str) -> AMSTray:
  788. return AMSTray(
  789. ams_id=0,
  790. tray_id=0,
  791. tray_type="PLA",
  792. tray_sub_brands="PLA Basic",
  793. tray_color=tray_color,
  794. remain=100,
  795. tag_uid="",
  796. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  797. tray_info_idx="GFA00",
  798. tray_weight=1000,
  799. )
  800. async def _posted_payload(self, client, color_hex: str) -> dict:
  801. """Run create_filament and return the JSON body it actually sent."""
  802. with patch.object(client, "_get_client") as mock_get_client:
  803. mock_http_client = AsyncMock()
  804. mock_response = Mock()
  805. mock_response.status_code = 200
  806. mock_response.raise_for_status = Mock()
  807. mock_response.json = Mock(return_value={"id": 99})
  808. mock_http_client.post = AsyncMock(return_value=mock_response)
  809. mock_get_client.return_value = mock_http_client
  810. await client.create_filament(name="PLA Basic", material="PLA", color_hex=color_hex)
  811. return mock_http_client.post.call_args.kwargs["json"]
  812. @pytest.mark.asyncio
  813. async def test_create_filament_stores_alpha_for_a_translucent_spool(self, client):
  814. """create_filament is the chokepoint every create funnels through — it
  815. truncated to six characters unconditionally, which is what turned a clear
  816. spool into opaque black."""
  817. payload = await self._posted_payload(client, "00000000")
  818. assert payload["color_hex"] == "00000000"
  819. @pytest.mark.asyncio
  820. async def test_create_filament_keeps_an_opaque_spool_at_six(self, client):
  821. """Passing everything through would rewrite the color_hex of every opaque
  822. spool on its next touch. Existing data has to stay byte-identical."""
  823. payload = await self._posted_payload(client, "FF0000FF")
  824. assert payload["color_hex"] == "FF0000"
  825. @pytest.mark.asyncio
  826. async def test_clear_tray_creates_a_translucent_filament(self, client):
  827. """End-to-end through the AMS auto-create path with nothing to match."""
  828. with (
  829. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  830. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  831. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  832. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  833. ):
  834. await client._find_or_create_filament(self._tray("00000000"))
  835. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  836. @pytest.mark.asyncio
  837. async def test_opaque_tray_still_matches_an_existing_six_char_filament(self, client):
  838. """The upgrade hazard neither the report nor the original patch mentioned.
  839. Every filament already in a user's Spoolman is stored six characters. If
  840. the match compared full strings, an 8-char tray colour would stop matching
  841. them and the next AMS sync would mint a duplicate filament for every spool
  842. on the instance. An opaque tray keys to six characters and still matches.
  843. """
  844. existing = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  845. with (
  846. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  847. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  848. patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
  849. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  850. ):
  851. result = await client._find_or_create_filament(self._tray("000000FF"))
  852. assert result is existing
  853. mock_external.assert_not_called()
  854. mock_create.assert_not_called()
  855. @pytest.mark.asyncio
  856. async def test_clear_tray_does_not_attach_to_the_black_filament(self, client):
  857. """A translucent tray keys to eight characters, so it does not match the
  858. opaque filament of the same RGB and gets its own record instead."""
  859. black = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  860. with (
  861. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  862. patch.object(client, "get_filaments", AsyncMock(return_value=[black])),
  863. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  864. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  865. ):
  866. await client._find_or_create_filament(self._tray("00000000"))
  867. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  868. @pytest.mark.asyncio
  869. async def test_black_tray_does_not_attach_to_a_clear_filament(self, client):
  870. """The inverse direction, which only became possible once 8-char values
  871. were storable at all: without the alpha in the key, an opaque black roll
  872. would match the clear filament, then render as the transparency
  873. checkerboard and be named Clear. Whichever roll synced first would decide
  874. and the other would be mislabelled.
  875. """
  876. clear = {"id": 6, "name": "Clear", "material": "PLA", "color_hex": "00000000", "vendor_id": 2}
  877. with (
  878. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  879. patch.object(client, "get_filaments", AsyncMock(return_value=[clear])),
  880. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  881. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  882. ):
  883. await client._find_or_create_filament(self._tray("000000FF"))
  884. assert mock_create.call_args.kwargs["color_hex"] == "000000"
  885. @pytest.mark.asyncio
  886. async def test_clear_tray_does_not_take_a_same_rgb_external_entry(self, client):
  887. """The reported path on a fresh Spoolman with the external library
  888. reachable. Candidates are built with the same key, so SpoolmanDB's opaque
  889. "PLA Basic Black" is no longer a candidate for a clear tray and the
  890. filament is created from the tray data with its alpha intact.
  891. """
  892. external = [
  893. {
  894. "id": "bambulab_pla_black_1000_175_n",
  895. "manufacturer": "Bambu Lab",
  896. "name": "PLA Basic Black",
  897. "material": "PLA",
  898. "color_hex": "000000",
  899. },
  900. ]
  901. with (
  902. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  903. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  904. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  905. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  906. ):
  907. await client._find_or_create_filament(self._tray("00000000"))
  908. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  909. @pytest.mark.asyncio
  910. async def test_find_or_create_filament_creates_a_clear_filament_with_its_alpha(self, client):
  911. """The user-driven path, with nothing to match. There is no split to pin
  912. here: the key and the created value are the same string, and for a clear
  913. spool that string is eight characters."""
  914. with (
  915. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  916. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  917. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  918. ):
  919. await client.find_or_create_filament(
  920. material="PLA",
  921. subtype="Basic",
  922. brand="Bambu Lab",
  923. color_hex="00000000",
  924. label_weight=1000,
  925. )
  926. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  927. @pytest.mark.asyncio
  928. async def test_find_or_create_filament_matches_an_existing_six_char_filament(self, client):
  929. """The upgrade guard on the *other* match loop.
  930. `test_opaque_tray_still_matches_an_existing_six_char_filament` pins it for
  931. the AMS path. The public `find_or_create_filament` has its own loop, and
  932. the non-BL RFID auto-create (spoolman.py, `_sync_tray_to_spoolman`) now
  933. hands it `tray.tray_color` whole where it used to hand over
  934. `tray.tray_color[:6]`. If the opaque fold ever came off this key, every
  935. non-Bambu RFID spool on an instance would mint a duplicate filament on the
  936. next sync and nothing would go red.
  937. """
  938. existing = {
  939. "id": 7,
  940. "name": "PLA Basic",
  941. "material": "PLA",
  942. "color_hex": "FF0000",
  943. "vendor": {"id": 3, "name": "Bambu Lab"},
  944. }
  945. with (
  946. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  947. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  948. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  949. ):
  950. result = await client.find_or_create_filament(
  951. material="PLA",
  952. subtype="Basic",
  953. brand="Bambu Lab",
  954. color_hex="FF0000FF",
  955. label_weight=1000,
  956. )
  957. assert result == 7
  958. mock_create.assert_not_called()