test_spoolman_service.py 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356
  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. import json
  7. from unittest.mock import AsyncMock, Mock, patch
  8. import pytest
  9. from backend.app.services.color_catalog_lookup import CatalogColor
  10. from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
  11. class _NoCatalog:
  12. """A DB session whose colour catalogue has no row for anything.
  13. ``sync_ams_tray`` resolves the roll's colour from the catalogue now (#2907),
  14. so it needs a session. These tests predate the catalogue and were written
  15. against the state where it cannot name the colour, which is this.
  16. """
  17. async def execute(self, *_args, **_kwargs):
  18. class _Result:
  19. @staticmethod
  20. def scalar_one_or_none():
  21. return None
  22. return _Result()
  23. class _BlackCatalog:
  24. """A DB session whose colour catalogue answers "Black" for anything.
  25. The fake ignores the query, so it stands for the trap rather than the lookup:
  26. a catalogue that would name a clear roll's ``000000`` if it were asked.
  27. """
  28. async def execute(self, *_args, **_kwargs):
  29. entry = type("_Entry", (), {"color_name": "Black", "extra_colors": None, "effect_type": None})()
  30. class _Result:
  31. @staticmethod
  32. def scalar_one_or_none():
  33. return entry
  34. return _Result()
  35. class TestIsBambuLabSpool:
  36. """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
  37. @pytest.fixture
  38. def client(self):
  39. return SpoolmanClient("http://localhost:7912")
  40. def test_valid_tray_uuid_returns_true(self, client):
  41. """A non-zero 32-char hex tray_uuid identifies a BL spool."""
  42. assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True
  43. def test_valid_tag_uid_returns_true(self, client):
  44. """A non-zero 16-char hex tag_uid identifies a BL spool (fallback)."""
  45. assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True
  46. def test_zero_tray_uuid_returns_false(self, client):
  47. """All-zero tray_uuid means no RFID tag read."""
  48. assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False
  49. def test_zero_tag_uid_returns_false(self, client):
  50. """All-zero tag_uid means no RFID tag read."""
  51. assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False
  52. def test_empty_identifiers_returns_false(self, client):
  53. """No identifiers means no BL spool."""
  54. assert client.is_bambu_lab_spool("") is False
  55. assert client.is_bambu_lab_spool("", tag_uid="") is False
  56. def test_tray_info_idx_ignored(self, client):
  57. """tray_info_idx is NOT a reliable BL indicator — third-party spools
  58. using Bambu generic presets also have GF-prefixed tray_info_idx values."""
  59. # Third-party spool with Bambu preset but no RFID identifiers
  60. assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False
  61. assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False
  62. assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False
  63. def test_tray_info_idx_with_valid_uuid_returns_true(self, client):
  64. """BL spool with both RFID UUID and preset ID — detected by UUID."""
  65. assert (
  66. client.is_bambu_lab_spool(
  67. "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  68. tray_info_idx="GFA00",
  69. )
  70. is True
  71. )
  72. def test_tray_uuid_preferred_over_tag_uid(self, client):
  73. """tray_uuid is checked before tag_uid (both valid)."""
  74. assert (
  75. client.is_bambu_lab_spool(
  76. "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  77. tag_uid="A1B2C3D4E5F6A1B2",
  78. )
  79. is True
  80. )
  81. def test_short_tray_uuid_returns_false(self, client):
  82. """UUID must be exactly 32 hex chars."""
  83. assert client.is_bambu_lab_spool("A1B2C3D4") is False
  84. def test_non_hex_tray_uuid_returns_false(self, client):
  85. """UUID must be valid hex."""
  86. assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False
  87. class TestSpoolmanClient:
  88. """Tests for SpoolmanClient class."""
  89. @pytest.fixture
  90. def client(self):
  91. """Create a SpoolmanClient instance."""
  92. return SpoolmanClient("http://localhost:7912")
  93. @pytest.fixture
  94. def sample_tray(self):
  95. """Create a sample AMSTray for testing."""
  96. return AMSTray(
  97. ams_id=0,
  98. tray_id=0,
  99. tray_type="PLA",
  100. tray_sub_brands="PLA Basic",
  101. tray_color="FF0000FF",
  102. remain=50,
  103. tag_uid="",
  104. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  105. tray_info_idx="GFA00",
  106. tray_weight=1000,
  107. )
  108. @pytest.fixture
  109. def existing_spool(self):
  110. """Create a mock existing spool response."""
  111. return {
  112. "id": 42,
  113. "remaining_weight": 800,
  114. "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
  115. "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
  116. }
  117. @pytest.fixture
  118. def mock_filament(self):
  119. """Create a mock filament response."""
  120. return {"id": 1, "name": "PLA Basic", "material": "PLA"}
  121. # ========================================================================
  122. # Tests for sync_ams_tray with disable_weight_sync
  123. # ========================================================================
  124. @pytest.mark.asyncio
  125. async def test_sync_ams_tray_updates_weight_by_default(self, client, sample_tray, existing_spool):
  126. """Verify sync_ams_tray updates remaining_weight by default."""
  127. with (
  128. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  129. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  130. ):
  131. await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog())
  132. mock_update.assert_called_once()
  133. call_kwargs = mock_update.call_args.kwargs
  134. assert "remaining_weight" in call_kwargs
  135. assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
  136. assert "location" not in call_kwargs
  137. @pytest.mark.asyncio
  138. async def test_sync_ams_tray_skips_weight_when_disabled(self, client, sample_tray, existing_spool):
  139. """Verify sync_ams_tray skips remaining_weight when disable_weight_sync=True."""
  140. with (
  141. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  142. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  143. ):
  144. await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), disable_weight_sync=True)
  145. mock_update.assert_called_once()
  146. call_kwargs = mock_update.call_args.kwargs
  147. # remaining_weight should be None (not updated)
  148. assert call_kwargs.get("remaining_weight") is None
  149. # location must never be written by Bambuddy — user-managed in Spoolman
  150. assert "location" not in call_kwargs
  151. @pytest.mark.asyncio
  152. async def test_sync_ams_tray_new_spool_always_includes_weight(self, client, sample_tray, mock_filament):
  153. """Verify new spool creation always includes remaining_weight even when disabled."""
  154. with (
  155. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  156. patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
  157. patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
  158. ):
  159. await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), disable_weight_sync=True)
  160. mock_create.assert_called_once()
  161. call_kwargs = mock_create.call_args.kwargs
  162. # New spools should ALWAYS include remaining_weight
  163. assert "remaining_weight" in call_kwargs
  164. assert call_kwargs["remaining_weight"] == 500.0 # 50% of 1000g
  165. @pytest.mark.asyncio
  166. async def test_a_new_spool_carries_the_catalogue_colour_name(self, client, sample_tray, mock_filament):
  167. """Where #1357 put the colour name on this side: Spoolman has no color_name
  168. on Filament, so it lives in spool.extra.bambu_color_name and
  169. _map_spoolman_spool reads that first. The AMS path never wrote it, which
  170. is why the colour ended up in the filament name and the product line fell
  171. out of the subtype (#2907, round three)."""
  172. with (
  173. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  174. patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)) as mock_find,
  175. patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
  176. ):
  177. await client.sync_ams_tray(sample_tray, "TestPrinter", _BlackCatalog())
  178. # The same answer goes to the filament lookup and onto the spool.
  179. assert mock_find.call_args.args[1] == CatalogColor("Black")
  180. extra = mock_create.call_args.kwargs["extra"]
  181. assert extra["bambu_color_name"] == json.dumps("Black")
  182. assert "tag" in extra
  183. @pytest.mark.asyncio
  184. async def test_no_catalogue_row_means_no_colour_name_rather_than_an_empty_one(
  185. self, client, sample_tray, mock_filament
  186. ):
  187. """An empty string would read back as a name. Leaving the key out lets
  188. _map_spoolman_spool fall through to the subtype, as it did before."""
  189. with (
  190. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  191. patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
  192. patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
  193. ):
  194. await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog())
  195. assert "bambu_color_name" not in mock_create.call_args.kwargs["extra"]
  196. @pytest.mark.asyncio
  197. async def test_sync_ams_tray_does_not_write_location(self, client, sample_tray, existing_spool):
  198. """Verify sync_ams_tray never writes location= to Spoolman (user-managed field)."""
  199. with (
  200. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  201. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  202. ):
  203. await client.sync_ams_tray(sample_tray, "My Printer", _NoCatalog(), disable_weight_sync=True)
  204. call_kwargs = mock_update.call_args.kwargs
  205. # Bambuddy must never auto-set spool.location — it is user-managed in Spoolman
  206. assert "location" not in call_kwargs
  207. # ========================================================================
  208. # T6: non-BL spool with custom RFID (H5 guard)
  209. # ========================================================================
  210. @pytest.mark.asyncio
  211. async def test_sync_ams_tray_non_bl_rfid_find_or_create_error_returns_none(self, client):
  212. """Non-BL spool with custom RFID: find_or_create_filament failure returns None, not raises.
  213. A third-party spool whose tag_uid is not exactly 16 hex chars is not
  214. identified as BL. sync_ams_tray must catch find_or_create_filament
  215. errors and return None instead of propagating the exception.
  216. """
  217. from backend.app.services.spoolman import SpoolmanUnavailableError
  218. # 8-char tag → spool_tag is set, but is_bambu_lab_spool returns False
  219. tray = AMSTray(
  220. ams_id=0,
  221. tray_id=2,
  222. tray_type="PLA",
  223. tray_sub_brands="eSun PLA+",
  224. tray_color="00FF00FF",
  225. remain=50,
  226. tag_uid="AABB1234",
  227. tray_uuid="",
  228. tray_info_idx="",
  229. tray_weight=1000,
  230. )
  231. with (
  232. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  233. patch.object(
  234. client,
  235. "find_or_create_filament",
  236. AsyncMock(side_effect=SpoolmanUnavailableError("timeout")),
  237. ),
  238. ):
  239. result = await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog())
  240. assert result is None
  241. # ========================================================================
  242. # T7: hint path uncached — get_spool(hint) called when not in cached_spools
  243. # ========================================================================
  244. @pytest.mark.asyncio
  245. async def test_sync_ams_tray_hint_uncached_calls_get_spool(self, client):
  246. """No-RFID path: when hint spool is absent from cached_spools, get_spool is called."""
  247. tray = AMSTray(
  248. ams_id=0,
  249. tray_id=3,
  250. tray_type="PETG",
  251. tray_sub_brands="Generic PETG",
  252. tray_color="0000FFFF",
  253. remain=75,
  254. tag_uid="",
  255. tray_uuid="",
  256. tray_info_idx="",
  257. tray_weight=1000,
  258. )
  259. # cached_spools exists but does NOT contain spool 99
  260. cached_spools = [{"id": 1, "extra": {}}]
  261. fetched_spool = {"id": 99, "extra": {}}
  262. with (
  263. patch.object(client, "get_spool", AsyncMock(return_value=fetched_spool)) as mock_get,
  264. patch.object(client, "update_spool", AsyncMock(return_value=fetched_spool)),
  265. ):
  266. result = await client.sync_ams_tray(
  267. tray,
  268. "TestPrinter",
  269. _NoCatalog(),
  270. cached_spools=cached_spools,
  271. spoolman_spool_id_hint=99,
  272. )
  273. assert result is not None
  274. mock_get.assert_awaited_once_with(99)
  275. # ========================================================================
  276. # T8: hint ignored when RFID tag is present
  277. # ========================================================================
  278. @pytest.mark.asyncio
  279. async def test_sync_ams_tray_rfid_takes_precedence_over_hint(self, client, existing_spool):
  280. """When tray_uuid is set, the RFID path is used and the hint is never consulted."""
  281. tray = AMSTray(
  282. ams_id=0,
  283. tray_id=4,
  284. tray_type="PLA",
  285. tray_sub_brands="PLA Basic",
  286. tray_color="FF0000FF",
  287. remain=50,
  288. tag_uid="",
  289. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  290. tray_info_idx="GFA00",
  291. tray_weight=1000,
  292. )
  293. with (
  294. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  295. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
  296. patch.object(client, "get_spool", AsyncMock()) as mock_get_spool,
  297. ):
  298. result = await client.sync_ams_tray(
  299. tray,
  300. "TestPrinter",
  301. _NoCatalog(),
  302. spoolman_spool_id_hint=99,
  303. )
  304. assert result is not None
  305. # hint path (get_spool) must NOT be called when RFID is present
  306. mock_get_spool.assert_not_called()
  307. @pytest.mark.asyncio
  308. async def test_sync_ams_tray_non_bambu_no_rfid_returns_none(self, client):
  309. """Third-party spool without any RFID and no hint returns None."""
  310. # Non-BL spool: no tray_uuid, no tag_uid, no spoolman_spool_id_hint → nothing to match
  311. tray = AMSTray(
  312. ams_id=0,
  313. tray_id=0,
  314. tray_type="PLA",
  315. tray_sub_brands="Third Party PLA",
  316. tray_color="FF0000FF",
  317. remain=50,
  318. tag_uid="",
  319. tray_uuid="",
  320. tray_info_idx="",
  321. tray_weight=1000,
  322. )
  323. result = await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog())
  324. assert result is None
  325. @pytest.mark.asyncio
  326. async def test_sync_ams_tray_hint_updates_spool_without_rfid(self, client):
  327. """No-RFID fallback: spool_id_hint from local slot-assignment table updates the spool."""
  328. tray = AMSTray(
  329. ams_id=0,
  330. tray_id=0,
  331. tray_type="PLA",
  332. tray_sub_brands="Generic PLA",
  333. tray_color="00FF00FF",
  334. remain=80,
  335. tag_uid="",
  336. tray_uuid="",
  337. tray_info_idx="",
  338. tray_weight=1000,
  339. )
  340. cached_spools = [{"id": 99, "extra": {}}]
  341. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  342. mock_update.return_value = {"id": 99}
  343. result = await client.sync_ams_tray(
  344. tray, "TestPrinter", _NoCatalog(), cached_spools=cached_spools, spoolman_spool_id_hint=99
  345. )
  346. assert result is not None
  347. assert result["id"] == 99
  348. mock_update.assert_called_once()
  349. call_kwargs = mock_update.call_args.kwargs
  350. assert "location" not in call_kwargs
  351. @pytest.mark.asyncio
  352. async def test_sync_ams_tray_weight_calculation(self, client, existing_spool):
  353. """Verify remaining weight is calculated correctly for various percentages."""
  354. test_cases = [
  355. (100, 1000, 1000.0), # Full spool
  356. (50, 1000, 500.0), # Half spool
  357. (25, 1000, 250.0), # Quarter spool
  358. (0, 1000, 0.0), # Empty spool
  359. (75, 500, 375.0), # Different spool weight
  360. ]
  361. for remain, weight, expected in test_cases:
  362. tray = AMSTray(
  363. ams_id=0,
  364. tray_id=0,
  365. tray_type="PLA",
  366. tray_sub_brands="PLA Basic",
  367. tray_color="FF0000FF",
  368. remain=remain,
  369. tag_uid="",
  370. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  371. tray_info_idx="GFA00",
  372. tray_weight=weight,
  373. )
  374. with (
  375. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
  376. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
  377. ):
  378. await client.sync_ams_tray(tray, "TestPrinter", _NoCatalog(), disable_weight_sync=False)
  379. call_kwargs = mock_update.call_args.kwargs
  380. assert call_kwargs["remaining_weight"] == expected, (
  381. f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
  382. )
  383. # ========================================================================
  384. # Tests for caching functionality
  385. # ========================================================================
  386. @pytest.mark.asyncio
  387. async def test_find_spool_by_tag_with_cached_spools(self, client):
  388. """Verify find_spool_by_tag uses cached spools when provided (no API call)."""
  389. cached = [
  390. {"id": 1, "extra": {"tag": '"ABC123"'}},
  391. {"id": 2, "extra": {"tag": '"XYZ789"'}},
  392. ]
  393. with patch.object(client, "get_spools", AsyncMock()) as mock_get:
  394. result = await client.find_spool_by_tag("ABC123", cached_spools=cached)
  395. assert result["id"] == 1
  396. mock_get.assert_not_called() # Should NOT call get_spools
  397. @pytest.mark.asyncio
  398. async def test_find_spool_by_tag_without_cached_spools(self, client):
  399. """Verify find_spool_by_tag fetches spools when cache not provided."""
  400. mock_spools = [{"id": 1, "extra": {"tag": '"ABC123"'}}]
  401. with patch.object(client, "get_spools", AsyncMock(return_value=mock_spools)) as mock_get:
  402. result = await client.find_spool_by_tag("ABC123")
  403. assert result["id"] == 1
  404. mock_get.assert_called_once() # Should call get_spools
  405. @pytest.mark.asyncio
  406. async def test_find_spools_by_location_prefix_with_cached_spools(self, client):
  407. """Verify find_spools_by_location_prefix uses cached spools when provided."""
  408. cached = [
  409. {"id": 1, "location": "Printer1 - AMS A1"},
  410. {"id": 2, "location": "Printer2 - AMS A1"},
  411. {"id": 3, "location": "Printer1 - AMS A2"},
  412. ]
  413. with patch.object(client, "get_spools", AsyncMock()) as mock_get:
  414. result = await client.find_spools_by_location_prefix("Printer1 - ", cached_spools=cached)
  415. assert len(result) == 2
  416. assert result[0]["id"] == 1
  417. assert result[1]["id"] == 3
  418. mock_get.assert_not_called() # Should NOT call get_spools
  419. @pytest.mark.asyncio
  420. async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):
  421. """Verify sync_ams_tray passes cached_spools to find_spool_by_tag."""
  422. cached = [existing_spool]
  423. with (
  424. patch.object(client, "get_spools", AsyncMock()) as mock_get,
  425. patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
  426. ):
  427. await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog(), cached_spools=cached)
  428. mock_get.assert_not_called() # Should NOT call get_spools
  429. @pytest.mark.asyncio
  430. async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
  431. """Verify clear_location_for_removed_spools uses cached spools."""
  432. cached = [
  433. {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"A1B2C3D4E5F60718293A4B5C6D7E8F90"'}},
  434. {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"B1C2D3E4F5061728394A5B6C7D8E9F01"'}},
  435. {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"C1D2E3F40516273849A5B6C7D8E9F012"'}},
  436. ]
  437. # Tag 3 was cleared, so only tags 1 and 2 are current
  438. current_tags = {
  439. "A1B2C3D4E5F60718293A4B5C6D7E8F90",
  440. "B1C2D3E4F5061728394A5B6C7D8E9F01",
  441. }
  442. with (
  443. patch.object(client, "get_spools", AsyncMock()) as mock_get,
  444. patch.object(client, "update_spool", AsyncMock(return_value={"id": 3})) as mock_update,
  445. ):
  446. cleared = await client.clear_location_for_removed_spools("Printer1", current_tags, cached_spools=cached)
  447. assert cleared == 1
  448. mock_get.assert_not_called() # Should NOT call get_spools
  449. mock_update.assert_called_once()
  450. # Verify it cleared TAG3 (not in current_tags)
  451. call_kwargs = mock_update.call_args.kwargs
  452. assert call_kwargs["spool_id"] == 3
  453. assert call_kwargs.get("clear_location") is True
  454. # ========================================================================
  455. # Tests for retry logic in get_spools
  456. # ========================================================================
  457. @pytest.mark.asyncio
  458. async def test_get_spools_succeeds_on_first_attempt(self, client):
  459. """Verify get_spools succeeds immediately when no errors occur."""
  460. mock_spools = [{"id": 1}, {"id": 2}]
  461. with patch.object(client, "_get_client") as mock_get_client:
  462. mock_http_client = AsyncMock()
  463. mock_response = Mock()
  464. mock_response.raise_for_status = Mock()
  465. mock_response.json = Mock(return_value=mock_spools)
  466. mock_http_client.get = AsyncMock(return_value=mock_response)
  467. mock_get_client.return_value = mock_http_client
  468. result = await client.get_spools()
  469. assert result == mock_spools
  470. mock_get_client.assert_called_once()
  471. mock_http_client.get.assert_called_once()
  472. @pytest.mark.asyncio
  473. async def test_get_spools_retries_on_connection_error(self, client):
  474. """Verify get_spools retries up to 3 times on connection errors."""
  475. import httpx
  476. mock_spools = [{"id": 1}]
  477. with (
  478. patch.object(client, "_get_client") as mock_get_client,
  479. patch.object(client, "close", AsyncMock()) as mock_close,
  480. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  481. ):
  482. mock_http_client = AsyncMock()
  483. mock_get_client.return_value = mock_http_client
  484. # First 2 attempts fail with ReadError, 3rd succeeds
  485. mock_response = Mock()
  486. mock_response.raise_for_status = Mock()
  487. mock_response.json = Mock(return_value=mock_spools)
  488. mock_http_client.get = AsyncMock(
  489. side_effect=[
  490. httpx.ReadError("Connection closed"),
  491. httpx.ReadError("Connection closed"),
  492. mock_response,
  493. ]
  494. )
  495. result = await client.get_spools()
  496. assert result == mock_spools
  497. assert mock_get_client.call_count == 3
  498. assert mock_http_client.get.call_count == 3
  499. # Should close client twice (after each failed attempt)
  500. assert mock_close.call_count == 2
  501. # Should sleep twice (after first 2 attempts)
  502. assert mock_sleep.call_count == 2
  503. mock_sleep.assert_called_with(0.5)
  504. @pytest.mark.asyncio
  505. async def test_get_spools_raises_after_3_failed_attempts(self, client):
  506. """Verify get_spools raises SpoolmanUnavailableError after 3 failed connection attempts."""
  507. import httpx
  508. from backend.app.services.spoolman import SpoolmanUnavailableError
  509. with (
  510. patch.object(client, "_get_client", AsyncMock()) as mock_get_client,
  511. patch.object(client, "close", AsyncMock()) as mock_close,
  512. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  513. ):
  514. mock_http_client = AsyncMock()
  515. mock_get_client.return_value = mock_http_client
  516. # All 3 attempts fail
  517. mock_http_client.get.side_effect = httpx.ReadError("Connection closed")
  518. with pytest.raises(SpoolmanUnavailableError):
  519. await client.get_spools()
  520. assert mock_get_client.call_count == 3
  521. assert mock_http_client.get.call_count == 3
  522. # Should close client twice (after first 2 failed attempts, not after 3rd)
  523. assert mock_close.call_count == 2
  524. # Should sleep twice (after first 2 attempts, not after 3rd)
  525. assert mock_sleep.call_count == 2
  526. @pytest.mark.asyncio
  527. async def test_get_spools_handles_non_connection_errors(self, client):
  528. """Verify get_spools retries on non-connection errors without recreating client."""
  529. import httpx
  530. mock_spools = [{"id": 1}]
  531. with (
  532. patch.object(client, "_get_client") as mock_get_client,
  533. patch.object(client, "close", AsyncMock()) as mock_close,
  534. patch("asyncio.sleep", AsyncMock()) as mock_sleep,
  535. ):
  536. mock_http_client = AsyncMock()
  537. mock_get_client.return_value = mock_http_client
  538. # First attempt fails with HTTP error, 2nd succeeds
  539. mock_response_error = Mock()
  540. mock_response_error.raise_for_status = Mock(
  541. side_effect=httpx.HTTPStatusError("500 Server Error", request=Mock(), response=Mock())
  542. )
  543. mock_response_success = Mock()
  544. mock_response_success.raise_for_status = Mock()
  545. mock_response_success.json = Mock(return_value=mock_spools)
  546. mock_http_client.get = AsyncMock(side_effect=[mock_response_error, mock_response_success])
  547. result = await client.get_spools()
  548. assert result == mock_spools
  549. assert mock_get_client.call_count == 2
  550. # Should NOT close client for HTTP errors (only connection errors)
  551. mock_close.assert_not_called()
  552. # Should sleep once (after first failed attempt)
  553. assert mock_sleep.call_count == 1
  554. # ---------------------------------------------------------------------------
  555. # init_spoolman_client — SSRF guard (B4 / T3)
  556. # ---------------------------------------------------------------------------
  557. class TestInitSpoolmanClientSSRFGuard:
  558. """init_spoolman_client must reject genuinely unsafe URLs before creating a client.
  559. Scope: cloud metadata endpoints, multicast, unspecified, non-http(s) schemes,
  560. and numeric-encoded IP bypasses. Loopback and RFC-1918 private ranges are
  561. explicitly allowed — Bambuddy's primary deployment is LAN-local Spoolman.
  562. """
  563. @pytest.mark.asyncio
  564. async def test_cloud_metadata_raises_value_error(self):
  565. with pytest.raises(ValueError, match="cloud metadata"):
  566. await init_spoolman_client("http://169.254.169.254/latest/meta-data/")
  567. @pytest.mark.asyncio
  568. async def test_multicast_raises_value_error(self):
  569. with pytest.raises(ValueError, match="multicast|unspecified"):
  570. await init_spoolman_client("http://224.0.0.1/")
  571. @pytest.mark.asyncio
  572. async def test_unspecified_raises_value_error(self):
  573. with pytest.raises(ValueError, match="multicast|unspecified"):
  574. await init_spoolman_client("http://0.0.0.0/")
  575. @pytest.mark.asyncio
  576. async def test_numeric_encoded_ip_raises_value_error(self):
  577. # decimal-encoded 127.0.0.1 — libc resolves these but ipaddress doesn't
  578. with pytest.raises(ValueError, match="numeric-encoded"):
  579. await init_spoolman_client("http://2130706433/")
  580. @pytest.mark.asyncio
  581. async def test_non_http_scheme_raises_value_error(self):
  582. with pytest.raises(ValueError, match="http or https"):
  583. await init_spoolman_client("file:///etc/passwd")
  584. @pytest.mark.asyncio
  585. async def test_private_ip_is_allowed(self):
  586. """Regression: RFC-1918 private addresses are the normal LAN topology."""
  587. mock_instance = AsyncMock()
  588. with (
  589. patch("backend.app.services.spoolman._spoolman_client", None),
  590. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  591. ):
  592. client = await init_spoolman_client("http://192.168.1.50:7912/")
  593. mock_cls.assert_called_once_with("http://192.168.1.50:7912/")
  594. assert client is mock_instance
  595. @pytest.mark.asyncio
  596. async def test_loopback_ip_is_allowed(self):
  597. """Regression: same-host Spoolman via loopback is a supported topology."""
  598. mock_instance = AsyncMock()
  599. with (
  600. patch("backend.app.services.spoolman._spoolman_client", None),
  601. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  602. ):
  603. client = await init_spoolman_client("http://127.0.0.1:7912/")
  604. mock_cls.assert_called_once_with("http://127.0.0.1:7912/")
  605. assert client is mock_instance
  606. @pytest.mark.asyncio
  607. async def test_localhost_hostname_is_allowed(self):
  608. # localhost (hostname, not bare IP) is a supported topology for same-host Spoolman
  609. mock_instance = AsyncMock()
  610. with (
  611. patch("backend.app.services.spoolman._spoolman_client", None),
  612. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  613. ):
  614. client = await init_spoolman_client("http://localhost:7912/")
  615. mock_cls.assert_called_once_with("http://localhost:7912/")
  616. assert client is mock_instance
  617. @pytest.mark.asyncio
  618. async def test_public_url_is_allowed(self):
  619. mock_instance = AsyncMock()
  620. with (
  621. patch("backend.app.services.spoolman._spoolman_client", None),
  622. patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
  623. ):
  624. client = await init_spoolman_client("http://spoolman.example.com:7912/")
  625. mock_cls.assert_called_once_with("http://spoolman.example.com:7912/")
  626. assert client is mock_instance
  627. class TestFindOrCreateFilament:
  628. """Tests for SpoolmanClient._find_or_create_filament — the auto-create path
  629. that runs when AMS sync sees an RFID spool that isn't already in Spoolman.
  630. Regression tests for #1309 (Bambu Lab RFID spools getting competitor names
  631. like "3DXTECH™ Black" from the unfiltered SpoolmanDB lookup).
  632. """
  633. @pytest.fixture
  634. def client(self):
  635. return SpoolmanClient("http://localhost:7912")
  636. @pytest.fixture
  637. def tray_pla_black(self):
  638. """A typical Bambu PLA Basic Black RFID read."""
  639. return AMSTray(
  640. ams_id=0,
  641. tray_id=0,
  642. tray_type="PLA",
  643. tray_sub_brands="PLA Basic",
  644. tray_color="000000FF",
  645. remain=100,
  646. tag_uid="",
  647. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  648. tray_info_idx="GFA00",
  649. tray_weight=1000,
  650. )
  651. @pytest.fixture
  652. def tray_matte(self):
  653. """The reported roll: PLA Matte Charcoal, same material and hex as PLA Basic Black."""
  654. return AMSTray(
  655. ams_id=0,
  656. tray_id=0,
  657. tray_type="PLA",
  658. tray_sub_brands="PLA Matte",
  659. tray_color="000000FF",
  660. remain=100,
  661. tag_uid="",
  662. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  663. tray_info_idx="GFA01",
  664. tray_weight=1000,
  665. )
  666. async def _run(self, client, tray, catalog_color, *, filaments=None, external=None):
  667. with (
  668. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  669. patch.object(client, "get_filaments", AsyncMock(return_value=filaments or [])),
  670. patch.object(client, "get_external_filaments", AsyncMock(return_value=external or [])) as mock_external,
  671. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  672. ):
  673. result = await client._find_or_create_filament(tray, catalog_color)
  674. return result, mock_external, mock_create
  675. @pytest.mark.asyncio
  676. async def test_reuses_an_existing_filament_of_the_same_product_line(self, client, tray_pla_black):
  677. """The short-circuit still short-circuits — for a filament that is actually
  678. this roll's.
  679. This test used to pin the version that matched on material and colour alone,
  680. with a filament named "Black" standing in for a PLA Basic roll. That is the
  681. defect in #2907: PLA Basic Black and PLA Matte Charcoal are both PLA at
  682. #000000, so a Matte roll was linked to the Basic filament. The name is the
  683. only field on a Spoolman filament that carries the product line, so it is
  684. now part of the match.
  685. Named for the sub-brand here because that is what Bambuddy has been calling
  686. its own creations (``name=tray.tray_sub_brands``) — which is what stops this
  687. change minting a duplicate filament for every spool on an existing instance.
  688. """
  689. existing = {
  690. "id": 6,
  691. "name": "PLA Basic",
  692. "material": "PLA",
  693. "color_hex": "000000", # alpha stripped by create_filament at insert time
  694. "vendor_id": 2,
  695. }
  696. result, mock_external, mock_create = await self._run(client, tray_pla_black, None, filaments=[existing])
  697. assert result is existing
  698. mock_external.assert_not_called()
  699. mock_create.assert_not_called()
  700. @pytest.mark.asyncio
  701. async def test_reuses_a_filament_named_for_the_catalogue_colour(self, client, tray_pla_black):
  702. """The bare-colour spelling: what an entry taken from the external library
  703. used to be called, and what PLA Basic is called there. An existing instance
  704. is full of these, so refusing it would duplicate every one."""
  705. existing = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  706. result, _, mock_create = await self._run(client, tray_pla_black, CatalogColor("Black"), filaments=[existing])
  707. assert result is existing
  708. mock_create.assert_not_called()
  709. @pytest.mark.asyncio
  710. async def test_does_not_reuse_a_filament_from_a_different_product_line(self, client, tray_matte):
  711. """The reported case. A PLA Matte Charcoal roll must not attach to the
  712. PLA Basic Black filament that happens to share its material and hex."""
  713. pla_basic_black = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  714. _, _, mock_create = await self._run(client, tray_matte, CatalogColor("Charcoal"), filaments=[pla_basic_black])
  715. mock_create.assert_called_once()
  716. assert mock_create.call_args.kwargs["name"] == "PLA Matte"
  717. @pytest.mark.asyncio
  718. async def test_skips_non_bambu_lab_external_entries(self, client, tray_pla_black):
  719. """Regression for #1309: the external-library loop must filter out non-Bambu-Lab
  720. manufacturers. PLA black 000000 is offered by 3DJAKE, 3DXTECH (and 60+ others)
  721. in SpoolmanDB before Bambu Lab's entry; without the filter the first hit wins
  722. and Bambu Lab spools get labeled with competitor names.
  723. """
  724. external = [
  725. {
  726. "id": "3djake_pla_black_1000_175_n",
  727. "manufacturer": "3DJAKE",
  728. "name": "Black",
  729. "material": "PLA",
  730. "color_hex": "000000",
  731. "density": 1.24,
  732. },
  733. {
  734. "id": "3dxtech_pla_carbonxcarbonfiberblack_500_175_p",
  735. "manufacturer": "3DXTECH",
  736. "name": "CarbonX™ Carbon Fiber Black",
  737. "material": "PLA",
  738. "color_hex": "000000",
  739. "density": 1.29,
  740. },
  741. {
  742. "id": "bambulab_pla_black_1000_175_n",
  743. "manufacturer": "Bambu Lab",
  744. "name": "Black",
  745. "material": "PLA",
  746. "color_hex": "000000",
  747. "density": 1.26,
  748. },
  749. ]
  750. with (
  751. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  752. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  753. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  754. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  755. ):
  756. await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
  757. mock_create.assert_called_once()
  758. kwargs = mock_create.call_args.kwargs
  759. # The Bambu Lab entry must win — not 3DJAKE / 3DXTECH which sort earlier.
  760. # The name is the tray's either way, so the density says which entry it was.
  761. assert kwargs["name"] == "PLA Basic"
  762. assert kwargs["density"] == 1.26
  763. @pytest.mark.asyncio
  764. async def test_prefers_the_external_entry_that_carries_the_line(self, client, tray_matte):
  765. """The tie-break selects on the line and the catalogue's colour together.
  766. The fixture is the real pair. A current SpoolmanDB carries, for Bambu Lab
  767. PLA at #000000, ``bambulab_pla_black_1000_175_n`` named "Black" and
  768. ``bambulab_pla_mattecharcoal_1000_175_n`` named "Matte Charcoal" -- both
  769. material "PLA", because the line lives in the id and the name, never in
  770. the material column. The previous version of this test staged an entry
  771. named "PLA Basic" instead, which is a row SpoolmanDB does not contain:
  772. none of its 269 Bambu Lab entries is named for a sub-brand.
  773. That matters for what the old code did. Against this fixture the old
  774. equality (``name == tray_sub_brands``, i.e. "pla matte") matches nothing,
  775. so it falls through to candidates[0] -- "Black" -- and a Matte roll is
  776. created as PLA Basic Black. That is #2907. Against the invented fixture
  777. it matched, which is why the old test passed.
  778. The catalogue value is the real one too. ``catalog_defaults.py`` seeds
  779. #000000 under PLA Matte as "Charcoal", with the line in its material
  780. column; the round-two version of this test staged "Matte Charcoal", which
  781. is the library's spelling, and so exercised a branch the seed never
  782. reaches. Neither side's name equals the other's here, which is why the
  783. selector rebuilds the library's spelling from the line and the colour.
  784. """
  785. external = [
  786. {
  787. "id": "bambulab_pla_black_1000_175_n",
  788. "manufacturer": "Bambu Lab",
  789. "name": "Black",
  790. "material": "PLA",
  791. "color_hex": "000000",
  792. "density": 1.24,
  793. },
  794. {
  795. "id": "bambulab_pla_mattecharcoal_1000_175_n",
  796. "manufacturer": "Bambu Lab",
  797. "name": "Matte Charcoal",
  798. "material": "PLA",
  799. "color_hex": "000000",
  800. "density": 1.26,
  801. },
  802. ]
  803. with (
  804. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  805. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  806. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  807. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  808. ):
  809. await client._find_or_create_filament(tray_matte, CatalogColor("Charcoal"))
  810. mock_create.assert_called_once()
  811. kwargs = mock_create.call_args.kwargs
  812. # "Matte Charcoal" is the entry, and the density that comes with it says
  813. # so. candidates[0] is "Black", which the old tie-break would have taken.
  814. # The filament is named for the line either way, as internal mode's
  815. # subtype is.
  816. assert kwargs["density"] == 1.26
  817. assert kwargs["name"] == "PLA Matte"
  818. @pytest.mark.asyncio
  819. async def test_falls_back_to_create_when_no_bambu_match_anywhere(self, client, tray_pla_black):
  820. """If no internal Bambu Lab filament exists AND SpoolmanDB has no Bambu Lab
  821. entry for this material+color (e.g. the catalog hasn't been updated yet for a
  822. brand-new BL product), fall back to creating a fresh filament from the tray's
  823. own RFID data — without leaking a competitor's name in.
  824. """
  825. external = [
  826. {
  827. "id": "3djake_pla_black_1000_175_n",
  828. "manufacturer": "3DJAKE",
  829. "name": "Black",
  830. "material": "PLA",
  831. "color_hex": "000000",
  832. },
  833. ]
  834. with (
  835. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  836. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  837. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  838. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  839. ):
  840. await client._find_or_create_filament(tray_pla_black, None)
  841. mock_create.assert_called_once()
  842. kwargs = mock_create.call_args.kwargs
  843. # The 3DJAKE entry was rejected by the manufacturer filter; tray_sub_brands wins.
  844. assert kwargs["name"] == "PLA Basic"
  845. assert kwargs["material"] == "PLA"
  846. assert kwargs["color_hex"] == "000000" # alpha channel stripped from tray_color
  847. assert kwargs["vendor_id"] == 2
  848. @pytest.mark.asyncio
  849. async def test_a_colour_the_catalogue_lags_is_built_from_the_tray_not_a_bambu_entry(self, client, tray_pla_black):
  850. """A real Bambu candidate is present and deliberately not used.
  851. This is the first half of the decision the fall-through makes, and the
  852. half ``test_falls_back_to_create_when_no_bambu_match_anywhere`` does not
  853. reach: its only external entry is 3DJAKE, which the manufacturer filter
  854. drops before ``bambu_candidates`` is built, so that test arrives at this
  855. branch with an empty candidate list. An empty list exercises nothing
  856. about the choice.
  857. Here the library does carry this roll's material and colour under Bambu
  858. Lab, and the catalogue -- seeded from Bambu's published list, so it lags
  859. new releases -- has no row to name it. Without a name there is nothing to
  860. select on, and attaching to whichever candidate came first is the
  861. misattribution #2907 is about, so the roll is built from what the printer
  862. reported instead.
  863. """
  864. external = [
  865. {
  866. "id": "bambulab_pla_black_1000_175_n",
  867. "manufacturer": "Bambu Lab",
  868. "name": "Black",
  869. "material": "PLA",
  870. "color_hex": "000000",
  871. "density": 1.31,
  872. },
  873. ]
  874. _, _, mock_create = await self._run(client, tray_pla_black, None, external=external)
  875. mock_create.assert_called_once()
  876. kwargs = mock_create.call_args.kwargs
  877. # The tray's own sub-brand, not the candidate's colour name.
  878. assert kwargs["name"] == "PLA Basic"
  879. assert kwargs["material"] == "PLA"
  880. assert kwargs["color_hex"] == "000000"
  881. assert kwargs["weight"] == 1000
  882. # `density` reaches create_filament only via _create_filament_from_external,
  883. # so its absence is what separates the two paths -- the name alone would
  884. # not, since the candidate here is called "Black" for other reasons too.
  885. assert "density" not in kwargs
  886. @pytest.mark.asyncio
  887. async def test_a_name_the_library_does_not_carry_is_built_from_the_tray_too(self, client, tray_matte):
  888. """The other half: the catalogue answers and no candidate carries that name.
  889. The catalogue names this roll "Charcoal" under PLA Matte; the library
  890. offers only "Black" for Bambu Lab PLA at #000000. That is the state an instance sits
  891. in between a colour shipping and SpoolmanDB catching up. The name is the
  892. only field separating the two product lines at this hex, so a candidate
  893. that does not carry it is not this roll -- and "Black" is exactly the
  894. entry the old tie-break took.
  895. """
  896. external = [
  897. {
  898. "id": "bambulab_pla_black_1000_175_n",
  899. "manufacturer": "Bambu Lab",
  900. "name": "Black",
  901. "material": "PLA",
  902. "color_hex": "000000",
  903. "density": 1.24,
  904. },
  905. ]
  906. _, _, mock_create = await self._run(client, tray_matte, CatalogColor("Charcoal"), external=external)
  907. mock_create.assert_called_once()
  908. kwargs = mock_create.call_args.kwargs
  909. # Named for the line, as every filament this path creates is; what it
  910. # must not take is the "Black" entry's identity or the density with it.
  911. assert kwargs["name"] == "PLA Matte"
  912. assert kwargs["material"] == "PLA"
  913. assert kwargs["color_hex"] == "000000"
  914. assert "density" not in kwargs
  915. @pytest.mark.asyncio
  916. async def test_accepts_external_entry_via_id_prefix_when_manufacturer_missing(self, client, tray_pla_black):
  917. """Defensive fallback: if `manufacturer` is absent or empty but the entry's `id`
  918. starts with `bambulab_`, treat it as a Bambu Lab entry. Keeps the filter robust
  919. against SpoolmanDB schema drift or stale catalog snapshots that omit the field.
  920. """
  921. external = [
  922. {
  923. "id": "bambulab_pla_black_1000_175_n",
  924. "name": "Black",
  925. "material": "PLA",
  926. "color_hex": "000000",
  927. "density": 1.24,
  928. }, # no `manufacturer` key at all
  929. ]
  930. with (
  931. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  932. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  933. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  934. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  935. ):
  936. await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
  937. mock_create.assert_called_once()
  938. # Only the external path forwards a density, so this is what shows the
  939. # entry was accepted rather than the roll being built from scratch.
  940. assert mock_create.call_args.kwargs["density"] == 1.24
  941. @pytest.mark.asyncio
  942. async def test_external_density_propagates_to_create_filament(self, client, tray_pla_black):
  943. """The chosen external entry's `density` must be forwarded to `create_filament`
  944. instead of being silently replaced by the PLA-default 1.24 fallback inside
  945. `create_filament` itself. Verified end-to-end via the public
  946. `_find_or_create_filament` entry point.
  947. """
  948. external = [
  949. {
  950. "id": "bambulab_pla_black_1000_175_n",
  951. "manufacturer": "Bambu Lab",
  952. "name": "Black",
  953. "material": "PLA",
  954. "color_hex": "000000",
  955. "density": 1.31,
  956. },
  957. ]
  958. with (
  959. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  960. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  961. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  962. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  963. ):
  964. await client._find_or_create_filament(tray_pla_black, CatalogColor("Black"))
  965. mock_create.assert_called_once()
  966. assert mock_create.call_args.kwargs["density"] == 1.31
  967. class TestColorHexAlphaHandling:
  968. """#2912 — a clear spool must not be stored as opaque black, and widening the
  969. stored value must not mint duplicates against inventories that hold six
  970. characters everywhere.
  971. """
  972. @pytest.fixture
  973. def client(self):
  974. return SpoolmanClient("http://localhost:7912")
  975. def _tray(self, tray_color: str) -> AMSTray:
  976. return AMSTray(
  977. ams_id=0,
  978. tray_id=0,
  979. tray_type="PLA",
  980. tray_sub_brands="PLA Basic",
  981. tray_color=tray_color,
  982. remain=100,
  983. tag_uid="",
  984. tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
  985. tray_info_idx="GFA00",
  986. tray_weight=1000,
  987. )
  988. async def _posted_payload(self, client, color_hex: str) -> dict:
  989. """Run create_filament and return the JSON body it actually sent."""
  990. with patch.object(client, "_get_client") as mock_get_client:
  991. mock_http_client = AsyncMock()
  992. mock_response = Mock()
  993. mock_response.status_code = 200
  994. mock_response.raise_for_status = Mock()
  995. mock_response.json = Mock(return_value={"id": 99})
  996. mock_http_client.post = AsyncMock(return_value=mock_response)
  997. mock_get_client.return_value = mock_http_client
  998. await client.create_filament(name="PLA Basic", material="PLA", color_hex=color_hex)
  999. return mock_http_client.post.call_args.kwargs["json"]
  1000. @pytest.mark.asyncio
  1001. async def test_create_filament_stores_alpha_for_a_translucent_spool(self, client):
  1002. """create_filament is the chokepoint every create funnels through — it
  1003. truncated to six characters unconditionally, which is what turned a clear
  1004. spool into opaque black."""
  1005. payload = await self._posted_payload(client, "00000000")
  1006. assert payload["color_hex"] == "00000000"
  1007. @pytest.mark.asyncio
  1008. async def test_create_filament_keeps_an_opaque_spool_at_six(self, client):
  1009. """Passing everything through would rewrite the color_hex of every opaque
  1010. spool on its next touch. Existing data has to stay byte-identical."""
  1011. payload = await self._posted_payload(client, "FF0000FF")
  1012. assert payload["color_hex"] == "FF0000"
  1013. @pytest.mark.asyncio
  1014. async def test_clear_tray_creates_a_translucent_filament(self, client):
  1015. """End-to-end through the AMS auto-create path with nothing to match."""
  1016. with (
  1017. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  1018. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  1019. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  1020. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  1021. ):
  1022. await client._find_or_create_filament(self._tray("00000000"), None)
  1023. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  1024. @pytest.mark.asyncio
  1025. async def test_a_clear_tray_is_not_named_from_the_catalogue_even_when_it_can_answer(self, client):
  1026. """#1545 at this level: the catalogue must not get to name a clear roll.
  1027. The catalogue here does have a row -- the trap is that a clear roll
  1028. reports ``00000000`` and the catalogue stores RGB, so the lookup would hit
  1029. #000000 and come back "Black". Without the guard the spool is stored as
  1030. "Black" for a clear roll, while the built-in path calls the same roll
  1031. "Clear" on the same printer. The colour is resolved in ``sync_ams_tray``
  1032. and handed down, so that is where this has to be driven from.
  1033. """
  1034. with (
  1035. patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
  1036. patch.object(client, "_find_or_create_filament", AsyncMock(return_value={"id": 7})) as mock_find,
  1037. patch.object(client, "create_spool", AsyncMock(return_value={"id": 99})) as mock_create,
  1038. ):
  1039. await client.sync_ams_tray(self._tray("00000000"), "TestPrinter", _BlackCatalog())
  1040. assert mock_find.call_args.args[1] == CatalogColor("Clear")
  1041. assert mock_create.call_args.kwargs["extra"]["bambu_color_name"] == json.dumps("Clear")
  1042. @pytest.mark.asyncio
  1043. async def test_opaque_tray_still_matches_an_existing_six_char_filament(self, client):
  1044. """The upgrade hazard neither the report nor the original patch mentioned.
  1045. Every filament already in a user's Spoolman is stored six characters. If
  1046. the match compared full strings, an 8-char tray colour would stop matching
  1047. them and the next AMS sync would mint a duplicate filament for every spool
  1048. on the instance. An opaque tray keys to six characters and still matches.
  1049. """
  1050. existing = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  1051. with (
  1052. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  1053. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  1054. patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
  1055. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  1056. ):
  1057. # Named after the catalogue's colour name, so the product-line
  1058. # criterion (#2907) is satisfied and the six/eight-character key is
  1059. # the only thing this test can fail on.
  1060. result = await client._find_or_create_filament(self._tray("000000FF"), CatalogColor("Black"))
  1061. assert result is existing
  1062. mock_external.assert_not_called()
  1063. mock_create.assert_not_called()
  1064. @pytest.mark.asyncio
  1065. async def test_clear_tray_does_not_attach_to_the_black_filament(self, client):
  1066. """A translucent tray keys to eight characters, so it does not match the
  1067. opaque filament of the same RGB and gets its own record instead."""
  1068. black = {"id": 6, "name": "Black", "material": "PLA", "color_hex": "000000", "vendor_id": 2}
  1069. with (
  1070. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  1071. patch.object(client, "get_filaments", AsyncMock(return_value=[black])),
  1072. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  1073. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  1074. ):
  1075. await client._find_or_create_filament(self._tray("00000000"), CatalogColor("Black"))
  1076. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  1077. @pytest.mark.asyncio
  1078. async def test_black_tray_does_not_attach_to_a_clear_filament(self, client):
  1079. """The inverse direction, which only became possible once 8-char values
  1080. were storable at all: without the alpha in the key, an opaque black roll
  1081. would match the clear filament, then render as the transparency
  1082. checkerboard and be named Clear. Whichever roll synced first would decide
  1083. and the other would be mislabelled.
  1084. """
  1085. clear = {"id": 6, "name": "Clear", "material": "PLA", "color_hex": "00000000", "vendor_id": 2}
  1086. with (
  1087. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  1088. patch.object(client, "get_filaments", AsyncMock(return_value=[clear])),
  1089. patch.object(client, "get_external_filaments", AsyncMock(return_value=[])),
  1090. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  1091. ):
  1092. await client._find_or_create_filament(self._tray("000000FF"), CatalogColor("Clear"))
  1093. assert mock_create.call_args.kwargs["color_hex"] == "000000"
  1094. @pytest.mark.asyncio
  1095. async def test_clear_tray_does_not_take_a_same_rgb_external_entry(self, client):
  1096. """The reported path on a fresh Spoolman with the external library
  1097. reachable. Candidates are built with the same key, so SpoolmanDB's opaque
  1098. "PLA Basic Black" is no longer a candidate for a clear tray and the
  1099. filament is created from the tray data with its alpha intact.
  1100. """
  1101. external = [
  1102. {
  1103. "id": "bambulab_pla_black_1000_175_n",
  1104. "manufacturer": "Bambu Lab",
  1105. "name": "PLA Basic Black",
  1106. "material": "PLA",
  1107. "color_hex": "000000",
  1108. },
  1109. ]
  1110. with (
  1111. patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
  1112. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  1113. patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
  1114. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  1115. ):
  1116. # The catalogue is made to name this colour exactly what the external
  1117. # entry is called, so the selector (#2907) would take it. Only the
  1118. # colour key keeps it out of the candidate list at all.
  1119. await client._find_or_create_filament(self._tray("00000000"), CatalogColor("PLA Basic Black"))
  1120. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  1121. @pytest.mark.asyncio
  1122. async def test_find_or_create_filament_creates_a_clear_filament_with_its_alpha(self, client):
  1123. """The user-driven path, with nothing to match. There is no split to pin
  1124. here: the key and the created value are the same string, and for a clear
  1125. spool that string is eight characters."""
  1126. with (
  1127. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  1128. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  1129. patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
  1130. ):
  1131. await client.find_or_create_filament(
  1132. material="PLA",
  1133. subtype="Basic",
  1134. brand="Bambu Lab",
  1135. color_hex="00000000",
  1136. label_weight=1000,
  1137. )
  1138. assert mock_create.call_args.kwargs["color_hex"] == "00000000"
  1139. @pytest.mark.asyncio
  1140. async def test_find_or_create_filament_matches_an_existing_six_char_filament(self, client):
  1141. """The upgrade guard on the *other* match loop.
  1142. `test_opaque_tray_still_matches_an_existing_six_char_filament` pins it for
  1143. the AMS path. The public `find_or_create_filament` has its own loop, and
  1144. the non-BL RFID auto-create (spoolman.py, `_sync_tray_to_spoolman`) now
  1145. hands it `tray.tray_color` whole where it used to hand over
  1146. `tray.tray_color[:6]`. If the opaque fold ever came off this key, every
  1147. non-Bambu RFID spool on an instance would mint a duplicate filament on the
  1148. next sync and nothing would go red.
  1149. """
  1150. existing = {
  1151. "id": 7,
  1152. "name": "PLA Basic",
  1153. "material": "PLA",
  1154. "color_hex": "FF0000",
  1155. "vendor": {"id": 3, "name": "Bambu Lab"},
  1156. }
  1157. with (
  1158. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  1159. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  1160. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  1161. ):
  1162. result = await client.find_or_create_filament(
  1163. material="PLA",
  1164. subtype="Basic",
  1165. brand="Bambu Lab",
  1166. color_hex="FF0000FF",
  1167. label_weight=1000,
  1168. )
  1169. assert result == 7
  1170. mock_create.assert_not_called()