test_suppliers_api.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926
  1. """API coverage for the supplier master list and spool assignments (#2988).
  2. The master list lives under /api/v1/inventory/suppliers (Locations pattern),
  3. gated by the plain inventory permissions. Assignments exist for both
  4. inventories: `spool_suppliers` for built-in spools and the
  5. `spoolman_spool_suppliers` twin keyed by the remote spool id.
  6. """
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.api.routes.inventory import DUPLICATE_SUPPLIER_NAME
  13. from backend.app.models.spool import Spool
  14. from backend.app.models.spool_usage_history import SpoolUsageHistory
  15. from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
  16. SAMPLE_SPOOLMAN_SPOOL = {
  17. "id": 7,
  18. "filament": {
  19. "id": 1,
  20. "name": "PETG CF",
  21. "material": "PETG",
  22. "weight": 1000,
  23. "color_hex": "000000",
  24. "vendor": {"id": 1, "name": "BrandX"},
  25. },
  26. "remaining_weight": 600.0,
  27. "used_weight": 400.0,
  28. "location": None,
  29. "comment": None,
  30. "first_used": None,
  31. "last_used": None,
  32. "registered": "2024-01-01T00:00:00+00:00",
  33. "archived": False,
  34. "price": None,
  35. "extra": {},
  36. }
  37. @pytest.fixture
  38. async def spool_factory(db_session: AsyncSession):
  39. async def _create(**kwargs):
  40. defaults = {
  41. "material": "PLA",
  42. "subtype": "Matte",
  43. "brand": "Bambu Lab",
  44. "color_name": "Charcoal",
  45. "rgba": "333333FF",
  46. "label_weight": 1000,
  47. "core_weight": 250,
  48. "weight_used": 0,
  49. "weight_used_baseline": 0,
  50. "weight_locked": False,
  51. }
  52. defaults.update(kwargs)
  53. spool = Spool(**defaults)
  54. db_session.add(spool)
  55. await db_session.commit()
  56. await db_session.refresh(spool)
  57. return spool
  58. return _create
  59. @pytest.fixture
  60. async def supplier_factory(db_session: AsyncSession):
  61. _counter = [0]
  62. async def _create(**kwargs):
  63. _counter[0] += 1
  64. defaults = {"name": f"Supplier {_counter[0]}"}
  65. defaults.update(kwargs)
  66. supplier = Supplier(**defaults)
  67. db_session.add(supplier)
  68. await db_session.commit()
  69. await db_session.refresh(supplier)
  70. return supplier
  71. return _create
  72. @pytest.fixture
  73. async def spoolman_settings(db_session: AsyncSession):
  74. from backend.app.models.settings import Settings
  75. db_session.add(Settings(key="spoolman_enabled", value="true"))
  76. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  77. await db_session.commit()
  78. @pytest.fixture
  79. def mock_spoolman_client():
  80. client = MagicMock()
  81. client.base_url = "http://localhost:7912"
  82. client.health_check = AsyncMock(return_value=True)
  83. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  84. client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
  85. client.get_distinct_locations = AsyncMock(return_value=[])
  86. client.delete_spool = AsyncMock(return_value=None)
  87. with (
  88. patch(
  89. "backend.app.api.routes.spoolman_inventory._get_client",
  90. AsyncMock(return_value=client),
  91. ),
  92. # The supplier delete reconciles twin rows against Spoolman before it
  93. # refuses (#2988); inventory.py resolves its own client.
  94. patch("backend.app.api.routes.inventory.get_spoolman_client", AsyncMock(return_value=client)),
  95. ):
  96. yield client
  97. @pytest.mark.unit
  98. def test_supplier_relationships_use_the_default_loader():
  99. """No relationship-level eager loader (#2988).
  100. ``Spool.supplier_links`` used to be ``lazy="selectin"``, which made every
  101. ``select(Spool)`` in the app — usage tracker, AMS sync, labels, backup —
  102. pay two extra round trips for assignments it never reads. The routes that
  103. embed them ask for ``selectinload()`` at the query site instead.
  104. """
  105. from sqlalchemy import inspect as sa_inspect
  106. assert sa_inspect(Spool).relationships["supplier_links"].lazy == "select"
  107. assert sa_inspect(SpoolSupplier).relationships["supplier"].lazy == "select"
  108. assert sa_inspect(SpoolmanSpoolSupplier).relationships["supplier"].lazy == "select"
  109. class TestSupplierCrud:
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_create_and_list(self, async_client: AsyncClient):
  113. resp = await async_client.post(
  114. "/api/v1/inventory/suppliers",
  115. json={"name": "Filament24", "website": "https://filament24.example", "customer_number": "C-1042"},
  116. )
  117. assert resp.status_code == 201
  118. body = resp.json()
  119. assert body["name"] == "Filament24"
  120. assert body["spool_count"] == 0
  121. listing = await async_client.get("/api/v1/inventory/suppliers")
  122. assert listing.status_code == 200
  123. assert [s["name"] for s in listing.json()] == ["Filament24"]
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_update(self, async_client: AsyncClient, supplier_factory):
  127. supplier = await supplier_factory(name="Old Name")
  128. resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": "New Name"})
  129. assert resp.status_code == 200
  130. assert resp.json()["name"] == "New Name"
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_delete_unreferenced(self, async_client: AsyncClient, supplier_factory):
  134. supplier = await supplier_factory()
  135. resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
  136. assert resp.status_code == 200
  137. assert (await async_client.get("/api/v1/inventory/suppliers")).json() == []
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_delete_referenced_is_refused(
  141. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  142. ):
  143. supplier = await supplier_factory()
  144. spool = await spool_factory()
  145. db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
  146. await db_session.commit()
  147. resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
  148. assert resp.status_code == 409
  149. assert "cannot be deleted" in resp.json()["detail"]
  150. # The listing surfaces the usage count behind the refusal.
  151. listing = await async_client.get("/api/v1/inventory/suppliers")
  152. assert listing.json()[0]["spool_count"] == 1
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_delete_refused_for_spoolman_only_reference(
  156. self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
  157. ):
  158. """A supplier used only by Spoolman-mode assignments is still protected."""
  159. supplier = await supplier_factory()
  160. db_session.add(SpoolmanSpoolSupplier(spoolman_spool_id=7, supplier_id=supplier.id))
  161. await db_session.commit()
  162. resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
  163. assert resp.status_code == 409
  164. assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 1
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_list_reads_case_insensitively(self, async_client: AsyncClient, supplier_factory):
  168. """Ordered on the folded name, so "extrudr" sits between "Bambu Store"
  169. and "Zultrat" rather than after every capitalised name (#2988)."""
  170. for name in ["Zultrat", "extrudr", "Bambu Store"]:
  171. await supplier_factory(name=name)
  172. listing = (await async_client.get("/api/v1/inventory/suppliers")).json()
  173. assert [s["name"] for s in listing] == ["Bambu Store", "extrudr", "Zultrat"]
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_a_padded_name_at_the_cap_is_measured_after_trimming(
  177. self, async_client: AsyncClient, supplier_factory
  178. ):
  179. """The length limit applies to the stored name, not to surrounding
  180. whitespace that is stripped anyway (#2988)."""
  181. name = "S" * 200
  182. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": f" {name} "})
  183. assert resp.status_code == 201
  184. assert resp.json()["name"] == name
  185. other = await supplier_factory(name="Other")
  186. resp = await async_client.patch(f"/api/v1/inventory/suppliers/{other.id}", json={"name": f" {'T' * 200} "})
  187. assert resp.status_code == 200
  188. assert resp.json()["name"] == "T" * 200
  189. # A name that really is too long is still refused.
  190. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "U" * 201})
  191. assert resp.status_code == 422
  192. class TestSupplierNameUniqueness:
  193. """Supplier names are the feature's key (#2988): CSV import resolves
  194. against them and a rename re-points every assignment, so two rows with
  195. the same name silently send an import to the wrong supplier."""
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_duplicate_name_is_refused(self, async_client: AsyncClient):
  199. assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})).status_code == 201
  200. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
  201. assert resp.status_code == 409
  202. assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
  203. assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_duplicate_is_case_and_whitespace_insensitive(self, async_client: AsyncClient):
  207. """The CSV map is keyed on the trimmed lower-cased name, so a case
  208. variant would be just as ambiguous as an exact duplicate."""
  209. await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
  210. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " eXtRuDr "})
  211. assert resp.status_code == 409
  212. @pytest.mark.asyncio
  213. @pytest.mark.integration
  214. async def test_duplicate_is_refused_for_non_ascii_names(self, async_client: AsyncClient):
  215. """The fold has to be the Python one to be worth anything here.
  216. SQLite's lower() folds ASCII only, so a unique index on lower(name)
  217. saw these as two different names and let both in — while the import
  218. map, which folds in Python, collapsed them onto a single entry and
  219. resolved to whichever row it built last. That is exactly the silent
  220. wrong-supplier assignment the rule exists to prevent.
  221. """
  222. assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Ökofilament"})).status_code == 201
  223. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "ökofilament"})
  224. assert resp.status_code == 409
  225. assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
  226. assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
  227. @pytest.mark.asyncio
  228. @pytest.mark.integration
  229. async def test_rename_onto_an_existing_name_is_refused(self, async_client: AsyncClient, supplier_factory):
  230. a = await supplier_factory(name="Extrudr")
  231. b = await supplier_factory(name="Filament24")
  232. resp = await async_client.patch(f"/api/v1/inventory/suppliers/{b.id}", json={"name": "extrudr"})
  233. assert resp.status_code == 409
  234. # Renaming a supplier to the name it already has is not a conflict.
  235. assert (
  236. await async_client.patch(f"/api/v1/inventory/suppliers/{a.id}", json={"name": "Extrudr"})
  237. ).status_code == 200
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_name_is_trimmed_on_write(self, async_client: AsyncClient):
  241. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " Extrudr "})
  242. assert resp.status_code == 201
  243. assert resp.json()["name"] == "Extrudr"
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_explicit_null_name_is_a_validation_error(self, async_client: AsyncClient, supplier_factory):
  247. """422, not the 500 a NOT NULL violation used to produce."""
  248. supplier = await supplier_factory()
  249. resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": None})
  250. assert resp.status_code == 422
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. async def test_csv_separator_in_name_is_refused(self, async_client: AsyncClient):
  254. """A ';' in the name would split into unknown names on CSV import and
  255. silently drop every assignment that used it."""
  256. resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr; GmbH"})
  257. assert resp.status_code == 422
  258. class TestSpoolSupplierAssignments:
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_replace_all_and_embed_in_spool_response(
  262. self, async_client: AsyncClient, supplier_factory, spool_factory
  263. ):
  264. a = await supplier_factory(name="Supplier A")
  265. b = await supplier_factory(name="Supplier B")
  266. spool = await spool_factory()
  267. resp = await async_client.put(
  268. f"/api/v1/inventory/spools/{spool.id}/suppliers",
  269. json=[
  270. {
  271. "supplier_id": a.id,
  272. "supplier_article_number": "A-100",
  273. "quoted_price_per_kg": 19.99,
  274. "is_purchase_source": True,
  275. },
  276. {"supplier_id": b.id, "quoted_price_per_kg": 22.5},
  277. ],
  278. )
  279. assert resp.status_code == 200
  280. body = resp.json()
  281. assert {row["supplier_name"] for row in body} == {"Supplier A", "Supplier B"}
  282. assert [row["is_purchase_source"] for row in sorted(body, key=lambda r: r["supplier_id"])] == [True, False]
  283. # Embedded in the inventory listing.
  284. listing = await async_client.get("/api/v1/inventory/spools")
  285. spool_row = next(s for s in listing.json() if s["id"] == spool.id)
  286. assert {row["supplier_name"] for row in spool_row["suppliers"]} == {"Supplier A", "Supplier B"}
  287. # Replace-all: shrinking the list removes the other assignment.
  288. resp = await async_client.put(
  289. f"/api/v1/inventory/spools/{spool.id}/suppliers",
  290. json=[{"supplier_id": b.id, "is_purchase_source": True}],
  291. )
  292. assert resp.status_code == 200
  293. assert [row["supplier_name"] for row in resp.json()] == ["Supplier B"]
  294. @pytest.mark.asyncio
  295. @pytest.mark.integration
  296. async def test_two_purchase_sources_are_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
  297. a = await supplier_factory()
  298. b = await supplier_factory()
  299. spool = await spool_factory()
  300. resp = await async_client.put(
  301. f"/api/v1/inventory/spools/{spool.id}/suppliers",
  302. json=[
  303. {"supplier_id": a.id, "is_purchase_source": True},
  304. {"supplier_id": b.id, "is_purchase_source": True},
  305. ],
  306. )
  307. assert resp.status_code == 400
  308. @pytest.mark.asyncio
  309. @pytest.mark.integration
  310. async def test_unknown_supplier_is_refused(self, async_client: AsyncClient, spool_factory):
  311. spool = await spool_factory()
  312. resp = await async_client.put(
  313. f"/api/v1/inventory/spools/{spool.id}/suppliers",
  314. json=[{"supplier_id": 999999}],
  315. )
  316. assert resp.status_code == 404
  317. @pytest.mark.asyncio
  318. @pytest.mark.integration
  319. async def test_duplicate_supplier_is_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
  320. a = await supplier_factory()
  321. spool = await spool_factory()
  322. resp = await async_client.put(
  323. f"/api/v1/inventory/spools/{spool.id}/suppliers",
  324. json=[{"supplier_id": a.id}, {"supplier_id": a.id}],
  325. )
  326. assert resp.status_code == 400
  327. class TestSpoolmanSupplierAssignments:
  328. """Spoolman parity (#2988): same endpoints, same shape, twin table."""
  329. @pytest.mark.asyncio
  330. @pytest.mark.integration
  331. async def test_replace_all_and_embed(
  332. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
  333. ):
  334. a = await supplier_factory(name="Supplier A")
  335. resp = await async_client.put(
  336. "/api/v1/spoolman/inventory/spools/7/suppliers",
  337. json=[
  338. {
  339. "supplier_id": a.id,
  340. "supplier_article_number": "A-100",
  341. "quoted_price_per_kg": 19.99,
  342. "is_purchase_source": True,
  343. }
  344. ],
  345. )
  346. assert resp.status_code == 200
  347. body = resp.json()
  348. assert body[0]["supplier_name"] == "Supplier A"
  349. assert body[0]["quoted_price_per_kg"] == 19.99
  350. # Same rows via GET, embedded in the single-spool and list responses.
  351. assert (await async_client.get("/api/v1/spoolman/inventory/spools/7/suppliers")).json() == body
  352. single = await async_client.get("/api/v1/spoolman/inventory/spools/7")
  353. assert [row["supplier_name"] for row in single.json()["suppliers"]] == ["Supplier A"]
  354. listing = await async_client.get("/api/v1/spoolman/inventory/spools")
  355. spool_row = next(s for s in listing.json() if s["id"] == 7)
  356. assert [row["supplier_name"] for row in spool_row["suppliers"]] == ["Supplier A"]
  357. @pytest.mark.asyncio
  358. @pytest.mark.integration
  359. async def test_validation_matches_builtin_inventory(
  360. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
  361. ):
  362. a = await supplier_factory()
  363. b = await supplier_factory()
  364. resp = await async_client.put(
  365. "/api/v1/spoolman/inventory/spools/7/suppliers",
  366. json=[{"supplier_id": a.id, "is_purchase_source": True}, {"supplier_id": b.id, "is_purchase_source": True}],
  367. )
  368. assert resp.status_code == 400
  369. resp = await async_client.put(
  370. "/api/v1/spoolman/inventory/spools/7/suppliers",
  371. json=[{"supplier_id": 999999}],
  372. )
  373. assert resp.status_code == 404
  374. class TestSupplierInheritance:
  375. @pytest.mark.asyncio
  376. @pytest.mark.integration
  377. async def test_new_spool_of_same_product_inherits_sources(
  378. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  379. ):
  380. supplier = await supplier_factory(name="Supplier A")
  381. donor = await spool_factory()
  382. db_session.add(
  383. SpoolSupplier(
  384. spool_id=donor.id,
  385. supplier_id=supplier.id,
  386. supplier_article_number="A-100",
  387. quoted_price_per_kg=19.99,
  388. is_purchase_source=True,
  389. )
  390. )
  391. await db_session.commit()
  392. resp = await async_client.post(
  393. "/api/v1/inventory/spools",
  394. json={"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
  395. )
  396. assert resp.status_code == 200
  397. suppliers = resp.json()["suppliers"]
  398. assert [row["supplier_name"] for row in suppliers] == ["Supplier A"]
  399. assert suppliers[0]["supplier_article_number"] == "A-100"
  400. assert suppliers[0]["quoted_price_per_kg"] == 19.99
  401. # Where THIS spool was bought is unknown — never inherited.
  402. assert suppliers[0]["is_purchase_source"] is False
  403. @pytest.mark.asyncio
  404. @pytest.mark.integration
  405. async def test_different_product_inherits_nothing(
  406. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  407. ):
  408. supplier = await supplier_factory()
  409. donor = await spool_factory()
  410. db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id))
  411. await db_session.commit()
  412. resp = await async_client.post(
  413. "/api/v1/inventory/spools",
  414. json={"material": "PETG", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
  415. )
  416. assert resp.status_code == 200
  417. assert resp.json()["suppliers"] == []
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_bulk_create_gives_every_copy_its_own_links(
  421. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  422. ):
  423. """One donor lookup for the batch, one set of rows per copy."""
  424. supplier = await supplier_factory(name="Supplier A")
  425. donor = await spool_factory()
  426. db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
  427. await db_session.commit()
  428. resp = await async_client.post(
  429. "/api/v1/inventory/spools/bulk",
  430. json={
  431. "spool": {"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
  432. "quantity": 3,
  433. },
  434. )
  435. assert resp.status_code == 200
  436. created = resp.json()
  437. assert len(created) == 3
  438. for row in created:
  439. assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
  440. assert row["suppliers"][0]["supplier_article_number"] == "A-100"
  441. assert row["suppliers"][0]["is_purchase_source"] is False
  442. # Own rows, not shared ones.
  443. assert len({row["suppliers"][0]["id"] for row in created}) == 3
  444. class TestSupplierLifecycle:
  445. """What happens to assignments when the spool they hang on goes away."""
  446. @pytest.mark.asyncio
  447. @pytest.mark.integration
  448. async def test_deleting_a_spool_frees_its_supplier(
  449. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  450. ):
  451. supplier = await supplier_factory()
  452. spool = await spool_factory()
  453. db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
  454. await db_session.commit()
  455. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  456. assert (await async_client.delete(f"/api/v1/inventory/spools/{spool.id}")).status_code == 200
  457. # delete-orphan on Spool.supplier_links takes the assignment with it,
  458. # so the supplier stops being referenced and becomes deletable.
  459. assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
  460. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
  461. @pytest.mark.asyncio
  462. @pytest.mark.integration
  463. async def test_deleting_a_spoolman_spool_drops_the_twin_rows(
  464. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
  465. ):
  466. """Spoolman owns the spool, Bambuddy owns the assignment, and nothing
  467. in the database can cascade it. A leaked row keeps the supplier's
  468. reference count non-zero, so the delete guard would answer 409 for a
  469. spool the user can no longer see (#2988)."""
  470. supplier = await supplier_factory()
  471. assert (
  472. await async_client.put(
  473. "/api/v1/spoolman/inventory/spools/7/suppliers",
  474. json=[{"supplier_id": supplier.id, "is_purchase_source": True}],
  475. )
  476. ).status_code == 200
  477. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  478. assert (await async_client.delete("/api/v1/spoolman/inventory/spools/7")).status_code == 200
  479. rows = await db_session.execute(select(SpoolmanSpoolSupplier))
  480. assert rows.scalars().all() == []
  481. assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
  482. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
  483. @pytest.mark.asyncio
  484. @pytest.mark.integration
  485. async def test_bulk_delete_drops_the_twin_rows_too(
  486. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
  487. ):
  488. supplier = await supplier_factory()
  489. assert (
  490. await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
  491. ).status_code == 200
  492. # The row has to be proven present before the delete, or the empty
  493. # assertion below holds whether or not the purge did anything.
  494. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  495. resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk-delete", json={"ids": [7]})
  496. assert resp.status_code == 200
  497. assert resp.json()["deleted"] == 1
  498. rows = await db_session.execute(select(SpoolmanSpoolSupplier))
  499. assert rows.scalars().all() == []
  500. assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
  501. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_delete_reconciles_a_spool_deleted_in_spoolman_itself(
  505. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
  506. ):
  507. """Spoolman is a separate application with its own UI, and Bambuddy
  508. only hears about the deletes it performs itself. A spool removed over
  509. there leaves its assignment behind, and that phantom reference used to
  510. make the supplier permanently undeletable with nothing on any screen
  511. that could show or clear it (#2988)."""
  512. supplier = await supplier_factory()
  513. assert (
  514. await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
  515. ).status_code == 200
  516. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  517. # Spool 7 disappears from Spoolman without Bambuddy doing anything.
  518. mock_spoolman_client.get_all_spools = AsyncMock(return_value=[])
  519. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
  520. rows = await db_session.execute(select(SpoolmanSpoolSupplier))
  521. assert rows.scalars().all() == []
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_reconcile_keeps_assignments_of_an_archived_spool(
  525. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
  526. ):
  527. """Archiving is a soft delete: the spool is still there and its
  528. assignment has to survive, so the reconcile asks for the archived ones
  529. too and the delete still answers 409."""
  530. supplier = await supplier_factory()
  531. assert (
  532. await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
  533. ).status_code == 200
  534. async def _all_spools(allow_archived: bool = False):
  535. return [dict(SAMPLE_SPOOLMAN_SPOOL, archived=True)] if allow_archived else []
  536. mock_spoolman_client.get_all_spools = AsyncMock(side_effect=_all_spools)
  537. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  538. @pytest.mark.asyncio
  539. @pytest.mark.integration
  540. async def test_reconcile_keeps_the_rows_when_spoolman_is_unreachable(
  541. self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
  542. ):
  543. """A failed lookup is not evidence that the spool is gone."""
  544. supplier = await supplier_factory()
  545. assert (
  546. await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
  547. ).status_code == 200
  548. mock_spoolman_client.get_all_spools = AsyncMock(side_effect=RuntimeError("Cannot reach Spoolman"))
  549. assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
  550. rows = await db_session.execute(select(SpoolmanSpoolSupplier))
  551. assert len(rows.scalars().all()) == 1
  552. class TestSupplierStats:
  553. @pytest.mark.asyncio
  554. @pytest.mark.integration
  555. async def test_groups_by_purchase_source(
  556. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  557. ):
  558. a = await supplier_factory(name="Supplier A")
  559. b = await supplier_factory(name="Supplier B")
  560. bought_at_a = await spool_factory(label_weight=1000, weight_used=200)
  561. alt_only = await spool_factory(color_name="Red")
  562. db_session.add_all(
  563. [
  564. SpoolSupplier(spool_id=bought_at_a.id, supplier_id=a.id, is_purchase_source=True),
  565. # Alternative source only — must NOT count toward supplier B.
  566. SpoolSupplier(spool_id=alt_only.id, supplier_id=b.id, is_purchase_source=False),
  567. SpoolUsageHistory(
  568. spool_id=bought_at_a.id, weight_used=150, percent_used=15, status="completed", cost=3.0
  569. ),
  570. ]
  571. )
  572. await db_session.commit()
  573. resp = await async_client.get("/api/v1/inventory/stats/suppliers")
  574. assert resp.status_code == 200
  575. rows = resp.json()
  576. assert len(rows) == 1
  577. assert rows[0]["supplier_name"] == "Supplier A"
  578. assert rows[0]["spool_count"] == 1
  579. assert rows[0]["remaining_g"] == pytest.approx(800)
  580. assert rows[0]["consumed_g"] == pytest.approx(150)
  581. # Cost comes from the recorded usage history (spool.cost_per_kg based),
  582. # never from quoted_price_per_kg.
  583. assert rows[0]["cost"] == pytest.approx(3.0)
  584. @pytest.mark.asyncio
  585. @pytest.mark.integration
  586. async def test_usage_half_honours_the_date_window(
  587. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  588. ):
  589. """The dashboard timeframe scopes consumption and cost; stock is
  590. point-in-time and stays out of the window (#2988)."""
  591. from datetime import datetime, timedelta, timezone
  592. supplier = await supplier_factory(name="Supplier A")
  593. spool = await spool_factory(label_weight=1000, weight_used=300)
  594. now = datetime.now(timezone.utc)
  595. db_session.add_all(
  596. [
  597. SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True),
  598. SpoolUsageHistory(
  599. spool_id=spool.id,
  600. weight_used=100,
  601. percent_used=10,
  602. status="completed",
  603. cost=2.0,
  604. created_at=now - timedelta(days=90),
  605. ),
  606. SpoolUsageHistory(
  607. spool_id=spool.id,
  608. weight_used=200,
  609. percent_used=20,
  610. status="completed",
  611. cost=4.0,
  612. created_at=now - timedelta(days=2),
  613. ),
  614. ]
  615. )
  616. await db_session.commit()
  617. lifetime = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
  618. assert lifetime[0]["consumed_g"] == pytest.approx(300)
  619. assert lifetime[0]["cost"] == pytest.approx(6.0)
  620. date_from = (now - timedelta(days=30)).date().isoformat()
  621. windowed = (await async_client.get(f"/api/v1/inventory/stats/suppliers?date_from={date_from}")).json()
  622. assert windowed[0]["consumed_g"] == pytest.approx(200)
  623. assert windowed[0]["cost"] == pytest.approx(4.0)
  624. # Stock is not windowed.
  625. assert windowed[0]["spool_count"] == 1
  626. assert windowed[0]["remaining_g"] == pytest.approx(700)
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_an_over_consumed_spool_counts_as_empty_not_negative(
  630. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  631. ):
  632. """Each spool is clamped before the sum, as the inventory page shows it:
  633. a full 1000 g spool next to one over-consumed by 200 g is 1000 g in
  634. stock, not 800 g (#2988)."""
  635. supplier = await supplier_factory(name="Supplier A")
  636. full = await spool_factory(label_weight=1000, weight_used=0)
  637. over = await spool_factory(label_weight=1000, weight_used=1200, color_name="Red")
  638. db_session.add_all(
  639. [
  640. SpoolSupplier(spool_id=full.id, supplier_id=supplier.id, is_purchase_source=True),
  641. SpoolSupplier(spool_id=over.id, supplier_id=supplier.id, is_purchase_source=True),
  642. ]
  643. )
  644. await db_session.commit()
  645. rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
  646. assert rows[0]["spool_count"] == 2
  647. assert rows[0]["remaining_g"] == pytest.approx(1000)
  648. @pytest.mark.asyncio
  649. @pytest.mark.integration
  650. async def test_equal_consumption_falls_back_to_the_name(
  651. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  652. ):
  653. """A narrow date range leaves most rows at 0 g; they read by name
  654. instead of in whatever order the GROUP BY produced (#2988)."""
  655. names = ["zultrat", "Extrudr", "Bambu Store"]
  656. for name in names:
  657. supplier = await supplier_factory(name=name)
  658. spool = await spool_factory(color_name=name)
  659. db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True))
  660. heavy = await supplier_factory(name="Yet Another")
  661. heavy_spool = await spool_factory(color_name="Heavy")
  662. db_session.add_all(
  663. [
  664. SpoolSupplier(spool_id=heavy_spool.id, supplier_id=heavy.id, is_purchase_source=True),
  665. SpoolUsageHistory(spool_id=heavy_spool.id, weight_used=50, percent_used=5, status="completed"),
  666. ]
  667. )
  668. await db_session.commit()
  669. rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
  670. assert [r["supplier_name"] for r in rows] == ["Yet Another", "Bambu Store", "Extrudr", "zultrat"]
  671. class TestSupplierCsv:
  672. @pytest.mark.asyncio
  673. @pytest.mark.integration
  674. async def test_export_carries_both_columns(
  675. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  676. ):
  677. a = await supplier_factory(name="Supplier A")
  678. b = await supplier_factory(name="Supplier B")
  679. spool = await spool_factory()
  680. db_session.add_all(
  681. [
  682. SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True),
  683. SpoolSupplier(spool_id=spool.id, supplier_id=b.id),
  684. ]
  685. )
  686. await db_session.commit()
  687. export = await async_client.get("/api/v1/inventory/spools/export")
  688. assert export.status_code == 200
  689. header, row = export.text.splitlines()[:2]
  690. columns = header.split(",")
  691. assert "suppliers" in columns
  692. assert "purchase_supplier" in columns
  693. assert "Supplier A; Supplier B" in row
  694. assert row.split(",")[columns.index("purchase_supplier")].strip('"') == "Supplier A"
  695. @pytest.mark.asyncio
  696. @pytest.mark.integration
  697. async def test_import_matches_by_name_and_creates_nothing(
  698. self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
  699. ):
  700. await supplier_factory(name="Supplier A")
  701. await supplier_factory(name="Supplier B")
  702. csv_text = (
  703. "material,brand,color_name,suppliers,purchase_supplier\n"
  704. 'PLA,Bambu Lab,Charcoal," supplier a ; Supplier B",Supplier B\n'
  705. "PETG,Bambu Lab,Red,Unknown Corp,\n"
  706. )
  707. # Dry run: the unknown name is a warning, not a row error.
  708. preview = await async_client.post(
  709. "/api/v1/inventory/spools/import?dry_run=true",
  710. files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
  711. )
  712. assert preview.status_code == 200
  713. body = preview.json()
  714. assert body["valid_count"] == 2
  715. assert body["error_count"] == 0
  716. assert any("Unknown Corp" in w for w in body["warnings"])
  717. # Real import: both rows land; assignments match by name, trimmed and
  718. # case-insensitive; the unknown name is dropped and NOT created.
  719. result = await async_client.post(
  720. "/api/v1/inventory/spools/import",
  721. files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
  722. )
  723. assert result.status_code == 200
  724. assert result.json()["created"] == 2
  725. listing = await async_client.get("/api/v1/inventory/spools")
  726. by_color = {s["color_name"]: s for s in listing.json()}
  727. charcoal = by_color["Charcoal"]["suppliers"]
  728. assert {row["supplier_name"] for row in charcoal} == {"Supplier A", "Supplier B"}
  729. assert [row["supplier_name"] for row in charcoal if row["is_purchase_source"]] == ["Supplier B"]
  730. assert by_color["Red"]["suppliers"] == []
  731. suppliers = (await async_client.get("/api/v1/inventory/suppliers")).json()
  732. assert {s["name"] for s in suppliers} == {"Supplier A", "Supplier B"}
  733. @pytest.mark.asyncio
  734. @pytest.mark.integration
  735. async def test_round_trip_preserves_assignments(
  736. self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
  737. ):
  738. a = await supplier_factory(name="Supplier A")
  739. spool = await spool_factory()
  740. db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True))
  741. await db_session.commit()
  742. export = await async_client.get("/api/v1/inventory/spools/export")
  743. result = await async_client.post(
  744. "/api/v1/inventory/spools/import",
  745. files={"file": ("spools.csv", export.content, "text/csv")},
  746. )
  747. assert result.status_code == 200
  748. assert result.json()["created"] == 1
  749. listing = (await async_client.get("/api/v1/inventory/spools")).json()
  750. assert len(listing) == 2
  751. for row in listing:
  752. assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
  753. assert row["suppliers"][0]["is_purchase_source"] is True
  754. class TestFromSlotInheritance:
  755. """The RFID "+ Add to inventory" path (#2988).
  756. POST /spools/from-slot builds the spool through create_spool_from_tray,
  757. which pre-initialises spool.supplier_links to []. The inheritance rows are
  758. added afterwards, so the closing query has to repopulate the collection —
  759. otherwise the identity-mapped instance answers with the stale empty list
  760. and the caller sees no suppliers until the next fetch.
  761. """
  762. @staticmethod
  763. def _status_for_tray(ams_id: int, tray_id: int, tray: dict):
  764. status = MagicMock()
  765. status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
  766. return status
  767. @pytest.mark.asyncio
  768. @pytest.mark.integration
  769. async def test_from_slot_response_carries_inherited_suppliers(
  770. self,
  771. async_client: AsyncClient,
  772. printer_factory,
  773. supplier_factory,
  774. spool_factory,
  775. db_session: AsyncSession,
  776. ):
  777. printer = await printer_factory(name="X1C-supplier-inherit")
  778. supplier = await supplier_factory(name="Supplier A")
  779. donor = await spool_factory(material="PLA", subtype=None, brand="Bambu Lab", color_name="Clear")
  780. db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
  781. await db_session.commit()
  782. # alpha=00 → create_spool_from_tray names the colour "Clear", matching
  783. # the donor product without needing a colour-catalogue row.
  784. tray = {
  785. "tray_type": "PLA",
  786. "tray_color": "11223300",
  787. "tag_uid": "1122334455667788",
  788. "tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
  789. }
  790. with patch(
  791. "backend.app.services.printer_manager.printer_manager.get_status",
  792. return_value=self._status_for_tray(0, 1, tray),
  793. ):
  794. resp = await async_client.post(
  795. "/api/v1/inventory/spools/from-slot",
  796. json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  797. )
  798. assert resp.status_code == 200, resp.text
  799. body = resp.json()
  800. assert [row["supplier_name"] for row in body["suppliers"]] == ["Supplier A"]
  801. assert body["suppliers"][0]["supplier_article_number"] == "A-100"
  802. assert body["suppliers"][0]["is_purchase_source"] is False
  803. # The row was always written — the defect was the response reading a
  804. # stale collection off the identity-mapped instance.
  805. rows = await db_session.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == body["id"]))
  806. assert [row.supplier_id for row in rows.scalars().all()] == [supplier.id]