test_inventory_csv.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. """Integration tests for inventory CSV import/export (#1576).
  2. Covers the export → import round-trip, dry-run preview (no writes), real
  3. import (only valid rows persisted, atomically), and Color Catalog resolution
  4. of brand + color_name → rgba.
  5. """
  6. import pytest
  7. from httpx import AsyncClient
  8. from sqlalchemy import select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.color_catalog import ColorCatalogEntry
  11. from backend.app.models.spool import Spool
  12. def _csv_upload(text: str):
  13. """Build the multipart `files=` payload for the import endpoint."""
  14. return {"file": ("inventory.csv", text.encode("utf-8"), "text/csv")}
  15. @pytest.mark.asyncio
  16. @pytest.mark.integration
  17. class TestInventoryCsvExport:
  18. async def test_export_returns_csv_with_header_and_rows(self, async_client: AsyncClient, db_session: AsyncSession):
  19. db_session.add(
  20. Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff", label_weight=1000)
  21. )
  22. await db_session.commit()
  23. response = await async_client.get("/api/v1/inventory/spools/export")
  24. assert response.status_code == 200, response.text
  25. assert response.headers["content-type"].startswith("text/csv")
  26. body = response.text
  27. lines = body.strip().splitlines()
  28. # Header row uses the fixed schema.
  29. assert lines[0].split(",")[0] == "material"
  30. assert "rgba" in lines[0]
  31. # Data row present, rgba written without leading '#'.
  32. assert "Polymaker" in body
  33. assert "e8e8e8ff" in body
  34. assert "#e8e8e8ff" not in body
  35. async def test_export_excludes_archived(self, async_client: AsyncClient, db_session: AsyncSession):
  36. from datetime import datetime, timezone
  37. db_session.add(Spool(material="PLA", brand="Active", color_name="A", rgba="ffffffff"))
  38. db_session.add(
  39. Spool(
  40. material="PETG",
  41. brand="Archived",
  42. color_name="B",
  43. rgba="000000ff",
  44. archived_at=datetime.now(timezone.utc),
  45. )
  46. )
  47. await db_session.commit()
  48. response = await async_client.get("/api/v1/inventory/spools/export")
  49. assert response.status_code == 200, response.text
  50. assert "Active" in response.text
  51. assert "Archived" not in response.text
  52. @pytest.mark.asyncio
  53. @pytest.mark.integration
  54. class TestInventoryCsvImportDryRun:
  55. async def test_dry_run_classifies_rows_and_writes_nothing(
  56. self, async_client: AsyncClient, db_session: AsyncSession
  57. ):
  58. csv_text = (
  59. "material,brand,color_name,rgba,label_weight\n"
  60. "PLA,Polymaker,Jade White,e8e8e8ff,1000\n" # valid
  61. ",Polymaker,No Material,ffffffff,1000\n" # error: material missing
  62. "PETG,Brand,Bad Hex,zzzz,1000\n" # error: invalid rgba
  63. "\n" # skipped: blank
  64. "ABS,Brand,Color,#00ff00,500\n" # valid: 6-char + '#' tolerated
  65. )
  66. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  67. assert response.status_code == 200, response.text
  68. data = response.json()
  69. assert data["valid_count"] == 2
  70. assert data["error_count"] == 2
  71. assert data["skipped_count"] == 1
  72. # 6-char hex got normalised to 8-char.
  73. valid_rows = [r for r in data["rows"] if r["status"] == "valid"]
  74. green = next(r for r in valid_rows if r["color_name"] == "Color")
  75. assert green["rgba"] == "00ff00ff"
  76. # Nothing was written.
  77. result = await db_session.execute(select(Spool))
  78. assert result.scalars().first() is None
  79. async def test_missing_material_column_fails_whole_file(self, async_client: AsyncClient):
  80. csv_text = "brand,color_name\nPolymaker,Jade White\n"
  81. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  82. assert response.status_code == 200, response.text
  83. data = response.json()
  84. assert data["valid_count"] == 0
  85. assert any("material" in w for w in data["warnings"])
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. class TestInventoryCsvImportReal:
  89. async def test_import_persists_only_valid_rows(self, async_client: AsyncClient, db_session: AsyncSession):
  90. csv_text = (
  91. "material,brand,color_name,rgba\n"
  92. "PLA,Polymaker,White,ffffffff\n" # valid
  93. ",Polymaker,No Material,ffffffff\n" # error
  94. "PETG,Brand,Color,ff0000ff\n" # valid
  95. )
  96. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  97. assert response.status_code == 200, response.text
  98. data = response.json()
  99. assert data["created"] == 2
  100. assert data["errors"] == 1
  101. assert len(data["error_rows"]) == 1
  102. result = await db_session.execute(select(Spool).order_by(Spool.material))
  103. spools = result.scalars().all()
  104. assert len(spools) == 2
  105. assert {s.material for s in spools} == {"PLA", "PETG"}
  106. async def test_case_and_space_tolerant_headers(self, async_client: AsyncClient, db_session: AsyncSession):
  107. csv_text = "Material, Color Name ,RGBA\nPLA,Snow,ffffffff\n"
  108. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  109. assert response.status_code == 200, response.text
  110. assert response.json()["created"] == 1
  111. result = await db_session.execute(select(Spool))
  112. spool = result.scalars().one()
  113. assert spool.color_name == "Snow"
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. class TestInventoryCsvColorResolution:
  117. async def test_brand_and_color_resolve_rgba_from_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
  118. db_session.add(
  119. ColorCatalogEntry(
  120. manufacturer="Polymaker",
  121. color_name="Jade White",
  122. hex_color="#E8E8E8",
  123. material="PLA",
  124. is_default=False,
  125. )
  126. )
  127. await db_session.commit()
  128. # No rgba in CSV — resolved from catalog (case-insensitive match).
  129. csv_text = "material,brand,color_name\nPLA,polymaker,jade white\n"
  130. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  131. assert response.status_code == 200, response.text
  132. data = response.json()
  133. assert data["valid_count"] == 1
  134. row = data["rows"][0]
  135. assert row["resolved_color"] is True
  136. assert row["rgba"] == "e8e8e8ff"
  137. async def test_explicit_rgba_wins_over_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
  138. db_session.add(
  139. ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
  140. )
  141. await db_session.commit()
  142. csv_text = "material,brand,color_name,rgba\nPLA,Polymaker,Jade White,123456ff\n"
  143. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  144. assert response.status_code == 200, response.text
  145. row = response.json()["rows"][0]
  146. assert row["rgba"] == "123456ff"
  147. assert row["resolved_color"] is False
  148. async def test_cross_material_fallback_is_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
  149. # Catalog only has a PLA variant of this colour; a PETG row resolves it
  150. # via cross-material fallback and must be flagged.
  151. db_session.add(
  152. ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
  153. )
  154. await db_session.commit()
  155. csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
  156. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  157. assert response.status_code == 200, response.text
  158. row = response.json()["rows"][0]
  159. assert row["resolved_color"] is True
  160. assert row["cross_material_color"] is True
  161. assert row["rgba"] == "e8e8e8ff"
  162. async def test_exact_material_match_not_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
  163. db_session.add(
  164. ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PETG")
  165. )
  166. await db_session.commit()
  167. csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
  168. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  169. assert response.status_code == 200, response.text
  170. row = response.json()["rows"][0]
  171. assert row["resolved_color"] is True
  172. assert row["cross_material_color"] is False
  173. async def test_generic_material_catalog_entry_not_flagged(
  174. self, async_client: AsyncClient, db_session: AsyncSession
  175. ):
  176. # A NULL-material catalog entry is the project's "matches any material"
  177. # convention — resolving a PLA row from it is an exact match, not a
  178. # cross-material fallback, so it must not raise the yellow warning.
  179. db_session.add(
  180. ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material=None)
  181. )
  182. await db_session.commit()
  183. csv_text = "material,brand,color_name\nPLA,Polymaker,Jade White\n"
  184. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  185. assert response.status_code == 200, response.text
  186. row = response.json()["rows"][0]
  187. assert row["resolved_color"] is True
  188. assert row["cross_material_color"] is False
  189. assert row["rgba"] == "e8e8e8ff"
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. class TestInventoryCsvReviewFollowups:
  193. """Covers the maintainer-requested hardening (PR #1659 review)."""
  194. async def test_oversized_upload_rejected_413(self, async_client: AsyncClient):
  195. # Build a body just over the 5 MB cap.
  196. from backend.app.services.spool_csv import MAX_CSV_IMPORT_BYTES
  197. header = "material\n"
  198. filler = "PLA\n" * ((MAX_CSV_IMPORT_BYTES // 4) + 10)
  199. big = header + filler
  200. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(big))
  201. assert response.status_code == 413, response.text
  202. detail = response.json()["detail"]
  203. assert detail["code"] == "csv_import_too_large"
  204. async def test_weight_used_negative_is_error(self, async_client: AsyncClient):
  205. csv_text = "material,color_name,rgba,weight_used\nPLA,X,ffffffff,-5\n"
  206. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  207. assert response.status_code == 200, response.text
  208. data = response.json()
  209. assert data["error_count"] == 1
  210. assert "weight_used" in data["rows"][0]["reason"]
  211. async def test_weight_used_exceeds_label_is_error(self, async_client: AsyncClient):
  212. csv_text = "material,color_name,rgba,label_weight,weight_used\nPLA,X,ffffffff,1000,1500\n"
  213. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  214. assert response.status_code == 200, response.text
  215. data = response.json()
  216. assert data["error_count"] == 1
  217. assert "exceeds" in data["rows"][0]["reason"]
  218. async def test_export_neutralises_formula_injection(self, async_client: AsyncClient, db_session: AsyncSession):
  219. # A note starting with '=' must be prefixed with a quote on export so
  220. # spreadsheets don't evaluate it as a formula.
  221. db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1:A9)"))
  222. await db_session.commit()
  223. response = await async_client.get("/api/v1/inventory/spools/export")
  224. assert response.status_code == 200, response.text
  225. assert "'=SUM(A1:A9)" in response.text
  226. async def test_formula_injection_round_trips_without_quote_accumulation(
  227. self, async_client: AsyncClient, db_session: AsyncSession
  228. ):
  229. # The export quote-guard must be undone on import so a formula-looking
  230. # note survives export → import unchanged (no accumulating leading ').
  231. db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1)"))
  232. await db_session.commit()
  233. export = await async_client.get("/api/v1/inventory/spools/export")
  234. assert export.status_code == 200, export.text
  235. assert "'=SUM(A1)" in export.text # guarded on export
  236. # Wipe, re-import the exact export, and confirm the note is restored
  237. # to its original value (not "'=SUM(A1)").
  238. existing = await db_session.execute(select(Spool))
  239. for spool in existing.scalars().all():
  240. await db_session.delete(spool)
  241. await db_session.commit()
  242. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(export.text))
  243. assert response.status_code == 200, response.text
  244. assert response.json()["created"] == 1
  245. result = await db_session.execute(select(Spool))
  246. spool = result.scalars().one()
  247. assert spool.note == "=SUM(A1)" # original value, no leading quote
  248. async def test_export_filename_is_date_stamped(self, async_client: AsyncClient):
  249. response = await async_client.get("/api/v1/inventory/spools/export")
  250. assert response.status_code == 200, response.text
  251. disposition = response.headers.get("content-disposition", "")
  252. assert "bambuddy_inventory_" in disposition
  253. assert disposition.rstrip('"').endswith(".csv")
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. class TestInventoryCsvRoundTrip:
  257. async def test_export_then_import_recreates_spools(self, async_client: AsyncClient, db_session: AsyncSession):
  258. db_session.add(
  259. Spool(
  260. material="PLA",
  261. brand="Polymaker",
  262. subtype="Matte",
  263. color_name="Jade White",
  264. rgba="e8e8e8ff",
  265. label_weight=1000,
  266. weight_used=250,
  267. cost_per_kg=24.99,
  268. note="batch order",
  269. )
  270. )
  271. await db_session.commit()
  272. export = await async_client.get("/api/v1/inventory/spools/export")
  273. assert export.status_code == 200, export.text
  274. csv_text = export.text
  275. # Wipe and re-import the exact export.
  276. existing = await db_session.execute(select(Spool))
  277. for spool in existing.scalars().all():
  278. await db_session.delete(spool)
  279. await db_session.commit()
  280. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  281. assert response.status_code == 200, response.text
  282. assert response.json()["created"] == 1
  283. result = await db_session.execute(select(Spool))
  284. spool = result.scalars().one()
  285. assert spool.material == "PLA"
  286. assert spool.brand == "Polymaker"
  287. assert spool.subtype == "Matte"
  288. assert spool.color_name == "Jade White"
  289. assert spool.rgba == "e8e8e8ff"
  290. assert spool.label_weight == 1000
  291. assert spool.weight_used == 250 # usage round-trips
  292. assert spool.cost_per_kg == 24.99
  293. assert spool.note == "batch order"
  294. @pytest.mark.asyncio
  295. @pytest.mark.integration
  296. class TestInventoryCsvUsageColumns:
  297. async def test_export_writes_weight_used_and_derived_remaining(
  298. self, async_client: AsyncClient, db_session: AsyncSession
  299. ):
  300. from datetime import datetime, timezone
  301. db_session.add(
  302. Spool(
  303. material="PLA",
  304. brand="Polymaker",
  305. color_name="White",
  306. rgba="ffffffff",
  307. label_weight=1000,
  308. weight_used=300,
  309. last_used=datetime(2026, 6, 1, 12, 30, tzinfo=timezone.utc),
  310. )
  311. )
  312. await db_session.commit()
  313. response = await async_client.get("/api/v1/inventory/spools/export")
  314. assert response.status_code == 200, response.text
  315. header, row = response.text.strip().splitlines()[:2]
  316. cols = header.split(",")
  317. cells = row.split(",")
  318. record = dict(zip(cols, cells, strict=False))
  319. assert record["weight_used"] == "300"
  320. assert record["remaining"] == "700" # 1000 - 300, derived
  321. assert record["last_used"].startswith("2026-06-01T12:30")
  322. async def test_import_reads_weight_used_ignores_remaining(
  323. self, async_client: AsyncClient, db_session: AsyncSession
  324. ):
  325. # remaining is intentionally contradictory — it must be ignored; only
  326. # weight_used is read back.
  327. csv_text = (
  328. "material,brand,color_name,rgba,label_weight,weight_used,remaining\nPLA,Brand,White,ffffffff,1000,400,999\n"
  329. )
  330. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  331. assert response.status_code == 200, response.text
  332. assert response.json()["created"] == 1
  333. result = await db_session.execute(select(Spool))
  334. spool = result.scalars().one()
  335. assert spool.weight_used == 400 # from CSV
  336. assert spool.label_weight == 1000 # remaining=999 ignored, not used to back-compute
  337. async def test_import_parses_last_used_iso(self, async_client: AsyncClient, db_session: AsyncSession):
  338. csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,2026-06-01T12:30:00+00:00\n"
  339. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  340. assert response.status_code == 200, response.text
  341. assert response.json()["created"] == 1
  342. result = await db_session.execute(select(Spool))
  343. spool = result.scalars().one()
  344. assert spool.last_used is not None
  345. assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
  346. async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
  347. csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
  348. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  349. assert response.status_code == 200, response.text
  350. data = response.json()
  351. assert data["error_count"] == 1
  352. assert "last_used" in data["rows"][0]["reason"]
  353. @pytest.mark.asyncio
  354. @pytest.mark.integration
  355. class TestInventoryCsvExtraColumns:
  356. async def test_storage_category_threshold_round_trip(self, async_client: AsyncClient, db_session: AsyncSession):
  357. # storage_location / category / low_stock_threshold_pct must survive an
  358. # export → import cycle (would otherwise be silently lost).
  359. db_session.add(
  360. Spool(
  361. material="PLA",
  362. brand="Polymaker",
  363. color_name="White",
  364. rgba="ffffffff",
  365. storage_location="Shelf B3",
  366. category="Production",
  367. low_stock_threshold_pct=20,
  368. )
  369. )
  370. await db_session.commit()
  371. csv_text = (await async_client.get("/api/v1/inventory/spools/export")).text
  372. for spool in (await db_session.execute(select(Spool))).scalars().all():
  373. await db_session.delete(spool)
  374. await db_session.commit()
  375. response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  376. assert response.status_code == 200, response.text
  377. assert response.json()["created"] == 1
  378. spool = (await db_session.execute(select(Spool))).scalars().one()
  379. assert spool.storage_location == "Shelf B3"
  380. assert spool.category == "Production"
  381. assert spool.low_stock_threshold_pct == 20
  382. async def test_low_stock_threshold_out_of_range_is_error(self, async_client: AsyncClient):
  383. # SpoolCreate bounds low_stock_threshold_pct to 1..99; the CSV path must
  384. # reject an out-of-range value rather than persist it.
  385. csv_text = "material,color_name,rgba,low_stock_threshold_pct\nPLA,White,ffffffff,150\n"
  386. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  387. assert response.status_code == 200, response.text
  388. data = response.json()
  389. assert data["error_count"] == 1
  390. assert "low_stock_threshold_pct" in data["rows"][0]["reason"]
  391. @pytest.mark.asyncio
  392. @pytest.mark.integration
  393. class TestInventoryCsvDuplicateWarning:
  394. async def test_existing_spool_flags_duplicate_but_still_imports(
  395. self, async_client: AsyncClient, db_session: AsyncSession
  396. ):
  397. db_session.add(Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff"))
  398. await db_session.commit()
  399. # Row 1 matches the existing spool (case-insensitively); row 2 is new.
  400. csv_text = (
  401. "material,brand,color_name,rgba\n"
  402. "pla,polymaker,jade white,e8e8e8ff\n" # duplicate of existing
  403. "PETG,OtherBrand,Black,000000ff\n" # new
  404. )
  405. response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
  406. assert response.status_code == 200, response.text
  407. rows = response.json()["rows"]
  408. assert rows[0]["duplicate_of_existing"] is True
  409. assert rows[1]["duplicate_of_existing"] is False
  410. # Soft-warn only: a real import still creates the duplicate row.
  411. real = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
  412. assert real.json()["created"] == 2
  413. all_spools = (await db_session.execute(select(Spool))).scalars().all()
  414. assert len(all_spools) == 3 # 1 pre-existing + 2 imported