test_material_number_api.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """API coverage for the spool material number (#2870).
  2. The material number is the internal purchasing identifier shared by all
  3. spools of a product. Pinned here: CRUD round-trip, server-side normalisation,
  4. inheritance on the create paths, the per-number statistics aggregate and its
  5. dashboard timeframe, and the CSV round-trip.
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.spool import Spool
  11. from backend.app.models.spool_usage_history import SpoolUsageHistory
  12. @pytest.fixture
  13. async def spool_factory(db_session: AsyncSession):
  14. async def _create(**kwargs):
  15. defaults = {
  16. "material": "PLA",
  17. "subtype": "Basic",
  18. "brand": "Bambu Lab",
  19. "color_name": "Jade White",
  20. "rgba": "FFFFFFFF",
  21. "label_weight": 1000,
  22. "core_weight": 250,
  23. "weight_used": 0,
  24. "weight_used_baseline": 0,
  25. "weight_locked": False,
  26. }
  27. defaults.update(kwargs)
  28. spool = Spool(**defaults)
  29. db_session.add(spool)
  30. await db_session.commit()
  31. await db_session.refresh(spool)
  32. return spool
  33. return _create
  34. class TestMaterialNumberCrud:
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_create_persists_and_lists_material_number(self, async_client: AsyncClient):
  38. resp = await async_client.post(
  39. "/api/v1/inventory/spools",
  40. json={"material": "PLA", "material_number": "15"},
  41. )
  42. assert resp.status_code == 200
  43. assert resp.json()["material_number"] == "15"
  44. listing = await async_client.get("/api/v1/inventory/spools")
  45. assert listing.status_code == 200
  46. assert [s["material_number"] for s in listing.json()] == ["15"]
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. async def test_patch_updates_material_number(self, async_client: AsyncClient, spool_factory):
  50. spool = await spool_factory(material_number="15")
  51. resp = await async_client.patch(
  52. f"/api/v1/inventory/spools/{spool.id}",
  53. json={"material_number": "16"},
  54. )
  55. assert resp.status_code == 200
  56. assert resp.json()["material_number"] == "16"
  57. @pytest.mark.asyncio
  58. @pytest.mark.integration
  59. async def test_material_number_longer_than_64_chars_is_rejected(self, async_client: AsyncClient):
  60. resp = await async_client.post(
  61. "/api/v1/inventory/spools",
  62. json={"material": "PLA", "material_number": "x" * 65},
  63. )
  64. assert resp.status_code == 422
  65. class TestMaterialNumberNormalisation:
  66. """One validator on the schema, so every write path normalises (#2870).
  67. Without it "15" and "15 " are two groups in the statistics aggregate and
  68. two entries in the inventory filter chip, and the chip's exact match
  69. never finds the padded one.
  70. """
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_create_strips_surrounding_whitespace(self, async_client: AsyncClient):
  74. resp = await async_client.post(
  75. "/api/v1/inventory/spools",
  76. json={"material": "PLA", "material_number": " 15 "},
  77. )
  78. assert resp.status_code == 200
  79. assert resp.json()["material_number"] == "15"
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_create_maps_blank_to_none(self, async_client: AsyncClient):
  83. resp = await async_client.post(
  84. "/api/v1/inventory/spools",
  85. json={"material": "PLA", "material_number": " "},
  86. )
  87. assert resp.status_code == 200
  88. # NULL, not "" — "has no number" stays a single state to query for.
  89. assert resp.json()["material_number"] is None
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_patch_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
  93. spool = await spool_factory(material_number="15")
  94. resp = await async_client.patch(
  95. f"/api/v1/inventory/spools/{spool.id}",
  96. json={"material_number": " 16 "},
  97. )
  98. assert resp.status_code == 200
  99. assert resp.json()["material_number"] == "16"
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_bulk_edit_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
  103. spool = await spool_factory()
  104. resp = await async_client.post(
  105. "/api/v1/inventory/spools/bulk-update",
  106. json={"ids": [spool.id], "update": {"material_number": " 15 "}},
  107. )
  108. assert resp.status_code == 200
  109. listing = await async_client.get("/api/v1/inventory/spools")
  110. assert [s["material_number"] for s in listing.json()] == ["15"]
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_csv_import_strips_surrounding_whitespace(self, async_client: AsyncClient):
  114. csv = "material,brand,material_number\nPLA,Bambu Lab, 15 \n"
  115. resp = await async_client.post(
  116. "/api/v1/inventory/spools/import",
  117. files={"file": ("spools.csv", csv.encode("utf-8"), "text/csv")},
  118. )
  119. assert resp.status_code == 200, resp.text
  120. listing = await async_client.get("/api/v1/inventory/spools")
  121. assert [s["material_number"] for s in listing.json()] == ["15"]
  122. @pytest.mark.asyncio
  123. @pytest.mark.integration
  124. async def test_padding_does_not_count_against_the_length_cap(self, async_client: AsyncClient, spool_factory):
  125. """The cap applies to what is stored: 63 characters with padding
  126. around them fit, on every path that goes through the schema."""
  127. padded = " " + "x" * 63
  128. created = await async_client.post(
  129. "/api/v1/inventory/spools", json={"material": "PLA", "material_number": padded}
  130. )
  131. assert created.status_code == 200, created.text
  132. assert created.json()["material_number"] == "x" * 63
  133. spool = await spool_factory()
  134. patched = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": padded})
  135. assert patched.status_code == 200, patched.text
  136. assert patched.json()["material_number"] == "x" * 63
  137. bulk = await async_client.post(
  138. "/api/v1/inventory/spools/bulk-update",
  139. json={"ids": [spool.id], "update": {"material_number": " " + "y" * 64 + " "}},
  140. )
  141. assert bulk.status_code == 200, bulk.text
  142. too_long = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": "x" * 65})
  143. assert too_long.status_code == 422
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_a_padded_duplicate_does_not_become_a_second_group(self, async_client: AsyncClient, spool_factory):
  147. await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15"})
  148. await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15 "})
  149. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  150. assert [r["material_number"] for r in resp.json()] == ["15"]
  151. assert resp.json()[0]["spool_count"] == 2
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_a_blank_number_is_not_offered_as_a_group(self, async_client: AsyncClient):
  155. await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": " "})
  156. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  157. assert resp.json() == []
  158. class TestMaterialNumberInheritance:
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_new_spool_of_same_product_inherits_number(self, async_client: AsyncClient, spool_factory):
  162. await spool_factory(material_number="15")
  163. resp = await async_client.post(
  164. "/api/v1/inventory/spools",
  165. json={
  166. "material": "PLA",
  167. "subtype": "Basic",
  168. "brand": "Bambu Lab",
  169. "color_name": "Jade White",
  170. },
  171. )
  172. assert resp.status_code == 200
  173. assert resp.json()["material_number"] == "15"
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_different_product_does_not_inherit(self, async_client: AsyncClient, spool_factory):
  177. await spool_factory(material_number="15")
  178. resp = await async_client.post(
  179. "/api/v1/inventory/spools",
  180. json={
  181. "material": "PLA",
  182. "subtype": "Basic",
  183. "brand": "Bambu Lab",
  184. "color_name": "Black",
  185. },
  186. )
  187. assert resp.status_code == 200
  188. assert resp.json()["material_number"] is None
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_explicit_number_wins_over_inheritance(self, async_client: AsyncClient, spool_factory):
  192. await spool_factory(material_number="15")
  193. resp = await async_client.post(
  194. "/api/v1/inventory/spools",
  195. json={
  196. "material": "PLA",
  197. "subtype": "Basic",
  198. "brand": "Bambu Lab",
  199. "color_name": "Jade White",
  200. "material_number": "99",
  201. },
  202. )
  203. assert resp.status_code == 200
  204. assert resp.json()["material_number"] == "99"
  205. @pytest.mark.asyncio
  206. @pytest.mark.integration
  207. async def test_bulk_create_inherits_number(self, async_client: AsyncClient, spool_factory):
  208. await spool_factory(material_number="15")
  209. resp = await async_client.post(
  210. "/api/v1/inventory/spools/bulk",
  211. json={
  212. "spool": {
  213. "material": "PLA",
  214. "subtype": "Basic",
  215. "brand": "Bambu Lab",
  216. "color_name": "Jade White",
  217. },
  218. "quantity": 3,
  219. },
  220. )
  221. assert resp.status_code == 200
  222. assert [s["material_number"] for s in resp.json()] == ["15", "15", "15"]
  223. @pytest.mark.asyncio
  224. @pytest.mark.integration
  225. async def test_archived_spool_still_provides_the_number(self, async_client: AsyncClient, spool_factory):
  226. from datetime import datetime, timezone
  227. await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
  228. resp = await async_client.post(
  229. "/api/v1/inventory/spools",
  230. json={
  231. "material": "PLA",
  232. "subtype": "Basic",
  233. "brand": "Bambu Lab",
  234. "color_name": "Jade White",
  235. },
  236. )
  237. assert resp.status_code == 200
  238. assert resp.json()["material_number"] == "15"
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_a_timestamp_tie_goes_to_the_newest_spool(
  242. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  243. ):
  244. """Two donors touched in the same second must not pick one at random."""
  245. from datetime import datetime
  246. same_moment = datetime(2026, 1, 1, 12, 0, 0)
  247. older = await spool_factory(material_number="15")
  248. newer = await spool_factory(material_number="16")
  249. for spool in (older, newer):
  250. spool.updated_at = same_moment
  251. await db_session.commit()
  252. resp = await async_client.post(
  253. "/api/v1/inventory/spools",
  254. json={
  255. "material": "PLA",
  256. "subtype": "Basic",
  257. "brand": "Bambu Lab",
  258. "color_name": "Jade White",
  259. },
  260. )
  261. assert resp.status_code == 200
  262. assert resp.json()["material_number"] == "16"
  263. class TestMaterialNumberStats:
  264. @pytest.mark.asyncio
  265. @pytest.mark.integration
  266. async def test_stats_group_by_number(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
  267. a = await spool_factory(material_number="15", label_weight=1000, weight_used=200)
  268. b = await spool_factory(material_number="15", label_weight=1000, weight_used=0)
  269. c = await spool_factory(material_number="16", color_name="Black", label_weight=1000, weight_used=500)
  270. await spool_factory(material_number=None, color_name="Gray")
  271. db_session.add_all(
  272. [
  273. SpoolUsageHistory(spool_id=a.id, weight_used=120, percent_used=12, status="completed", cost=2.4),
  274. SpoolUsageHistory(spool_id=b.id, weight_used=80, percent_used=8, status="completed", cost=1.6),
  275. SpoolUsageHistory(spool_id=c.id, weight_used=500, percent_used=50, status="failed", cost=15.0),
  276. ]
  277. )
  278. await db_session.commit()
  279. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  280. assert resp.status_code == 200
  281. rows = {r["material_number"]: r for r in resp.json()}
  282. assert set(rows) == {"15", "16"}
  283. assert rows["15"]["spool_count"] == 2
  284. assert rows["15"]["remaining_g"] == pytest.approx(1800)
  285. assert rows["15"]["consumed_g"] == pytest.approx(200)
  286. assert rows["15"]["cost"] == pytest.approx(4.0)
  287. assert rows["16"]["consumed_g"] == pytest.approx(500)
  288. assert rows["16"]["cost"] == pytest.approx(15.0)
  289. # Heaviest consumption first.
  290. assert [r["material_number"] for r in resp.json()] == ["16", "15"]
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_an_over_consumed_spool_does_not_eat_its_siblings_stock(
  294. self, async_client: AsyncClient, spool_factory
  295. ):
  296. """Remaining stock is clamped per spool, not once over the group.
  297. weight_used above label_weight is reachable (a scale reading, an AMS
  298. sync, or a plain PATCH), and every other remaining-weight computation
  299. in the codebase clamps each spool at 0. Summing the raw difference
  300. first would subtract the overshoot from the other spools of the same
  301. number and report less stock than the inventory list does.
  302. """
  303. await spool_factory(material_number="15", label_weight=1000, weight_used=0)
  304. await spool_factory(material_number="15", color_name="Black", label_weight=1000, weight_used=1200)
  305. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  306. assert resp.status_code == 200
  307. row = resp.json()[0]
  308. assert row["spool_count"] == 2
  309. assert row["remaining_g"] == pytest.approx(1000)
  310. @pytest.mark.asyncio
  311. @pytest.mark.integration
  312. async def test_archived_spools_keep_their_recorded_consumption(
  313. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  314. ):
  315. from datetime import datetime, timezone
  316. archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
  317. db_session.add(
  318. SpoolUsageHistory(spool_id=archived.id, weight_used=300, percent_used=30, status="completed", cost=6.0)
  319. )
  320. await db_session.commit()
  321. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  322. assert resp.status_code == 200
  323. rows = {r["material_number"]: r for r in resp.json()}
  324. # No active spools carry the number, but the consumption is still there.
  325. assert rows["15"]["spool_count"] == 0
  326. assert rows["15"]["remaining_g"] == 0
  327. assert rows["15"]["consumed_g"] == pytest.approx(300)
  328. class TestMaterialNumberStatsTimeframe:
  329. """The widget sits in the stats dashboard, so it follows its timeframe.
  330. Usage history is the per-period half; stock is point-in-time and stays
  331. whole — "how much do I hold" has no date range.
  332. """
  333. @staticmethod
  334. async def _usage(db_session, spool_id, *, days_ago, grams, cost):
  335. from datetime import datetime, timedelta, timezone
  336. row = SpoolUsageHistory(
  337. spool_id=spool_id, weight_used=grams, percent_used=grams / 10, status="completed", cost=cost
  338. )
  339. # created_at is a server default, so set it explicitly to age the row.
  340. row.created_at = (datetime.now(timezone.utc) - timedelta(days=days_ago)).replace(tzinfo=None)
  341. db_session.add(row)
  342. await db_session.commit()
  343. @pytest.mark.asyncio
  344. @pytest.mark.integration
  345. async def test_date_from_excludes_older_consumption(
  346. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  347. ):
  348. from datetime import datetime, timedelta, timezone
  349. spool = await spool_factory(material_number="15", label_weight=1000, weight_used=400)
  350. await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
  351. await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
  352. since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
  353. resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
  354. assert resp.status_code == 200
  355. row = resp.json()[0]
  356. assert row["consumed_g"] == pytest.approx(10)
  357. assert row["cost"] == pytest.approx(0.2)
  358. # Stock is point-in-time: unaffected by the range.
  359. assert row["spool_count"] == 1
  360. assert row["remaining_g"] == pytest.approx(600)
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_date_to_excludes_newer_consumption(
  364. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  365. ):
  366. from datetime import datetime, timedelta, timezone
  367. spool = await spool_factory(material_number="15")
  368. await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
  369. await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
  370. until = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
  371. resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_to={until}")
  372. assert resp.json()[0]["consumed_g"] == pytest.approx(1000)
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_no_range_still_reports_lifetime_totals(
  376. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  377. ):
  378. spool = await spool_factory(material_number="15")
  379. await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
  380. await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
  381. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  382. assert resp.json()[0]["consumed_g"] == pytest.approx(1010)
  383. @pytest.mark.asyncio
  384. @pytest.mark.integration
  385. async def test_a_number_with_no_usage_in_range_still_lists_its_stock(
  386. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  387. ):
  388. from datetime import datetime, timedelta, timezone
  389. spool = await spool_factory(material_number="15", label_weight=1000, weight_used=250)
  390. await self._usage(db_session, spool.id, days_ago=200, grams=250, cost=5.0)
  391. since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
  392. resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
  393. row = resp.json()[0]
  394. assert row["consumed_g"] == 0
  395. assert row["remaining_g"] == pytest.approx(750)
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_ties_sort_by_number_so_the_order_is_stable(
  399. self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
  400. ):
  401. """Equal consumption has to fall back to the number, not to row order.
  402. The response is assembled in two passes — active spools first, then
  403. the numbers that only appear in usage history — so "16" (which has a
  404. live spool) is seeded before "15" (archived, usage only). Without the
  405. number tie-break the endpoint hands that seeding order straight back.
  406. """
  407. from datetime import datetime, timezone
  408. live = await spool_factory(material_number="16", color_name="Black")
  409. archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
  410. db_session.add_all(
  411. [
  412. SpoolUsageHistory(spool_id=live.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
  413. SpoolUsageHistory(spool_id=archived.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
  414. ]
  415. )
  416. await db_session.commit()
  417. resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
  418. rows = resp.json()
  419. assert [r["consumed_g"] for r in rows] == [pytest.approx(100), pytest.approx(100)]
  420. assert [r["material_number"] for r in rows] == ["15", "16"]
  421. class TestMaterialNumberCsv:
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_export_import_round_trip(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
  425. await spool_factory(material_number="15")
  426. export = await async_client.get("/api/v1/inventory/spools/export")
  427. assert export.status_code == 200
  428. text = export.text
  429. header = text.splitlines()[0]
  430. assert "material_number" in header.split(",")
  431. assert ",15" in text.splitlines()[1] or text.splitlines()[1].endswith("15")
  432. # Wipe and re-import: the number must survive the round trip.
  433. from sqlalchemy import delete
  434. await db_session.execute(delete(Spool))
  435. await db_session.commit()
  436. imported = await async_client.post(
  437. "/api/v1/inventory/spools/import",
  438. files={"file": ("spools.csv", text.encode("utf-8"), "text/csv")},
  439. )
  440. assert imported.status_code == 200, imported.text
  441. assert imported.json()["created"] == 1
  442. listing = await async_client.get("/api/v1/inventory/spools")
  443. assert [s["material_number"] for s in listing.json()] == ["15"]