| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- """Integration tests for inventory CSV import/export (#1576).
- Covers the export → import round-trip, dry-run preview (no writes), real
- import (only valid rows persisted, atomically), and Color Catalog resolution
- of brand + color_name → rgba.
- """
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.color_catalog import ColorCatalogEntry
- from backend.app.models.spool import Spool
- def _csv_upload(text: str):
- """Build the multipart `files=` payload for the import endpoint."""
- return {"file": ("inventory.csv", text.encode("utf-8"), "text/csv")}
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvExport:
- async def test_export_returns_csv_with_header_and_rows(self, async_client: AsyncClient, db_session: AsyncSession):
- db_session.add(
- Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff", label_weight=1000)
- )
- await db_session.commit()
- response = await async_client.get("/api/v1/inventory/spools/export")
- assert response.status_code == 200, response.text
- assert response.headers["content-type"].startswith("text/csv")
- body = response.text
- lines = body.strip().splitlines()
- # Header row uses the fixed schema.
- assert lines[0].split(",")[0] == "material"
- assert "rgba" in lines[0]
- # Data row present, rgba written without leading '#'.
- assert "Polymaker" in body
- assert "e8e8e8ff" in body
- assert "#e8e8e8ff" not in body
- async def test_export_excludes_archived(self, async_client: AsyncClient, db_session: AsyncSession):
- from datetime import datetime, timezone
- db_session.add(Spool(material="PLA", brand="Active", color_name="A", rgba="ffffffff"))
- db_session.add(
- Spool(
- material="PETG",
- brand="Archived",
- color_name="B",
- rgba="000000ff",
- archived_at=datetime.now(timezone.utc),
- )
- )
- await db_session.commit()
- response = await async_client.get("/api/v1/inventory/spools/export")
- assert response.status_code == 200, response.text
- assert "Active" in response.text
- assert "Archived" not in response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvImportDryRun:
- async def test_dry_run_classifies_rows_and_writes_nothing(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- csv_text = (
- "material,brand,color_name,rgba,label_weight\n"
- "PLA,Polymaker,Jade White,e8e8e8ff,1000\n" # valid
- ",Polymaker,No Material,ffffffff,1000\n" # error: material missing
- "PETG,Brand,Bad Hex,zzzz,1000\n" # error: invalid rgba
- "\n" # skipped: blank
- "ABS,Brand,Color,#00ff00,500\n" # valid: 6-char + '#' tolerated
- )
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["valid_count"] == 2
- assert data["error_count"] == 2
- assert data["skipped_count"] == 1
- # 6-char hex got normalised to 8-char.
- valid_rows = [r for r in data["rows"] if r["status"] == "valid"]
- green = next(r for r in valid_rows if r["color_name"] == "Color")
- assert green["rgba"] == "00ff00ff"
- # Nothing was written.
- result = await db_session.execute(select(Spool))
- assert result.scalars().first() is None
- async def test_missing_material_column_fails_whole_file(self, async_client: AsyncClient):
- csv_text = "brand,color_name\nPolymaker,Jade White\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["valid_count"] == 0
- assert any("material" in w for w in data["warnings"])
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvImportReal:
- async def test_import_persists_only_valid_rows(self, async_client: AsyncClient, db_session: AsyncSession):
- csv_text = (
- "material,brand,color_name,rgba\n"
- "PLA,Polymaker,White,ffffffff\n" # valid
- ",Polymaker,No Material,ffffffff\n" # error
- "PETG,Brand,Color,ff0000ff\n" # valid
- )
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["created"] == 2
- assert data["errors"] == 1
- assert len(data["error_rows"]) == 1
- result = await db_session.execute(select(Spool).order_by(Spool.material))
- spools = result.scalars().all()
- assert len(spools) == 2
- assert {s.material for s in spools} == {"PLA", "PETG"}
- async def test_case_and_space_tolerant_headers(self, async_client: AsyncClient, db_session: AsyncSession):
- csv_text = "Material, Color Name ,RGBA\nPLA,Snow,ffffffff\n"
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- result = await db_session.execute(select(Spool))
- spool = result.scalars().one()
- assert spool.color_name == "Snow"
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvColorResolution:
- async def test_brand_and_color_resolve_rgba_from_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
- db_session.add(
- ColorCatalogEntry(
- manufacturer="Polymaker",
- color_name="Jade White",
- hex_color="#E8E8E8",
- material="PLA",
- is_default=False,
- )
- )
- await db_session.commit()
- # No rgba in CSV — resolved from catalog (case-insensitive match).
- csv_text = "material,brand,color_name\nPLA,polymaker,jade white\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["valid_count"] == 1
- row = data["rows"][0]
- assert row["resolved_color"] is True
- assert row["rgba"] == "e8e8e8ff"
- async def test_explicit_rgba_wins_over_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
- db_session.add(
- ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
- )
- await db_session.commit()
- csv_text = "material,brand,color_name,rgba\nPLA,Polymaker,Jade White,123456ff\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- row = response.json()["rows"][0]
- assert row["rgba"] == "123456ff"
- assert row["resolved_color"] is False
- async def test_cross_material_fallback_is_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
- # Catalog only has a PLA variant of this colour; a PETG row resolves it
- # via cross-material fallback and must be flagged.
- db_session.add(
- ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
- )
- await db_session.commit()
- csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- row = response.json()["rows"][0]
- assert row["resolved_color"] is True
- assert row["cross_material_color"] is True
- assert row["rgba"] == "e8e8e8ff"
- async def test_exact_material_match_not_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
- db_session.add(
- ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PETG")
- )
- await db_session.commit()
- csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- row = response.json()["rows"][0]
- assert row["resolved_color"] is True
- assert row["cross_material_color"] is False
- async def test_generic_material_catalog_entry_not_flagged(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- # A NULL-material catalog entry is the project's "matches any material"
- # convention — resolving a PLA row from it is an exact match, not a
- # cross-material fallback, so it must not raise the yellow warning.
- db_session.add(
- ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material=None)
- )
- await db_session.commit()
- csv_text = "material,brand,color_name\nPLA,Polymaker,Jade White\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- row = response.json()["rows"][0]
- assert row["resolved_color"] is True
- assert row["cross_material_color"] is False
- assert row["rgba"] == "e8e8e8ff"
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvReviewFollowups:
- """Covers the maintainer-requested hardening (PR #1659 review)."""
- async def test_oversized_upload_rejected_413(self, async_client: AsyncClient):
- # Build a body just over the 5 MB cap.
- from backend.app.services.spool_csv import MAX_CSV_IMPORT_BYTES
- header = "material\n"
- filler = "PLA\n" * ((MAX_CSV_IMPORT_BYTES // 4) + 10)
- big = header + filler
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(big))
- assert response.status_code == 413, response.text
- detail = response.json()["detail"]
- assert detail["code"] == "csv_import_too_large"
- async def test_weight_used_negative_is_error(self, async_client: AsyncClient):
- csv_text = "material,color_name,rgba,weight_used\nPLA,X,ffffffff,-5\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["error_count"] == 1
- assert "weight_used" in data["rows"][0]["reason"]
- async def test_weight_used_exceeds_label_is_error(self, async_client: AsyncClient):
- csv_text = "material,color_name,rgba,label_weight,weight_used\nPLA,X,ffffffff,1000,1500\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["error_count"] == 1
- assert "exceeds" in data["rows"][0]["reason"]
- async def test_export_neutralises_formula_injection(self, async_client: AsyncClient, db_session: AsyncSession):
- # A note starting with '=' must be prefixed with a quote on export so
- # spreadsheets don't evaluate it as a formula.
- db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1:A9)"))
- await db_session.commit()
- response = await async_client.get("/api/v1/inventory/spools/export")
- assert response.status_code == 200, response.text
- assert "'=SUM(A1:A9)" in response.text
- async def test_formula_injection_round_trips_without_quote_accumulation(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- # The export quote-guard must be undone on import so a formula-looking
- # note survives export → import unchanged (no accumulating leading ').
- db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1)"))
- await db_session.commit()
- export = await async_client.get("/api/v1/inventory/spools/export")
- assert export.status_code == 200, export.text
- assert "'=SUM(A1)" in export.text # guarded on export
- # Wipe, re-import the exact export, and confirm the note is restored
- # to its original value (not "'=SUM(A1)").
- existing = await db_session.execute(select(Spool))
- for spool in existing.scalars().all():
- await db_session.delete(spool)
- await db_session.commit()
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(export.text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- result = await db_session.execute(select(Spool))
- spool = result.scalars().one()
- assert spool.note == "=SUM(A1)" # original value, no leading quote
- async def test_export_filename_is_date_stamped(self, async_client: AsyncClient):
- response = await async_client.get("/api/v1/inventory/spools/export")
- assert response.status_code == 200, response.text
- disposition = response.headers.get("content-disposition", "")
- assert "bambuddy_inventory_" in disposition
- assert disposition.rstrip('"').endswith(".csv")
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvRoundTrip:
- async def test_export_then_import_recreates_spools(self, async_client: AsyncClient, db_session: AsyncSession):
- db_session.add(
- Spool(
- material="PLA",
- brand="Polymaker",
- subtype="Matte",
- color_name="Jade White",
- rgba="e8e8e8ff",
- label_weight=1000,
- weight_used=250,
- cost_per_kg=24.99,
- note="batch order",
- )
- )
- await db_session.commit()
- export = await async_client.get("/api/v1/inventory/spools/export")
- assert export.status_code == 200, export.text
- csv_text = export.text
- # Wipe and re-import the exact export.
- existing = await db_session.execute(select(Spool))
- for spool in existing.scalars().all():
- await db_session.delete(spool)
- await db_session.commit()
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- result = await db_session.execute(select(Spool))
- spool = result.scalars().one()
- assert spool.material == "PLA"
- assert spool.brand == "Polymaker"
- assert spool.subtype == "Matte"
- assert spool.color_name == "Jade White"
- assert spool.rgba == "e8e8e8ff"
- assert spool.label_weight == 1000
- assert spool.weight_used == 250 # usage round-trips
- assert spool.cost_per_kg == 24.99
- assert spool.note == "batch order"
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvUsageColumns:
- async def test_export_writes_weight_used_and_derived_remaining(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- from datetime import datetime, timezone
- db_session.add(
- Spool(
- material="PLA",
- brand="Polymaker",
- color_name="White",
- rgba="ffffffff",
- label_weight=1000,
- weight_used=300,
- last_used=datetime(2026, 6, 1, 12, 30, tzinfo=timezone.utc),
- )
- )
- await db_session.commit()
- response = await async_client.get("/api/v1/inventory/spools/export")
- assert response.status_code == 200, response.text
- header, row = response.text.strip().splitlines()[:2]
- cols = header.split(",")
- cells = row.split(",")
- record = dict(zip(cols, cells, strict=False))
- assert record["weight_used"] == "300"
- assert record["remaining"] == "700" # 1000 - 300, derived
- assert record["last_used"].startswith("2026-06-01T12:30")
- async def test_import_reads_weight_used_ignores_remaining(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- # remaining is intentionally contradictory — it must be ignored; only
- # weight_used is read back.
- csv_text = (
- "material,brand,color_name,rgba,label_weight,weight_used,remaining\nPLA,Brand,White,ffffffff,1000,400,999\n"
- )
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- result = await db_session.execute(select(Spool))
- spool = result.scalars().one()
- assert spool.weight_used == 400 # from CSV
- assert spool.label_weight == 1000 # remaining=999 ignored, not used to back-compute
- async def test_import_parses_last_used_iso(self, async_client: AsyncClient, db_session: AsyncSession):
- csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,2026-06-01T12:30:00+00:00\n"
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- result = await db_session.execute(select(Spool))
- spool = result.scalars().one()
- assert spool.last_used is not None
- assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
- async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
- csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["error_count"] == 1
- assert "last_used" in data["rows"][0]["reason"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvExtraColumns:
- async def test_storage_category_threshold_round_trip(self, async_client: AsyncClient, db_session: AsyncSession):
- # storage_location / category / low_stock_threshold_pct must survive an
- # export → import cycle (would otherwise be silently lost).
- db_session.add(
- Spool(
- material="PLA",
- brand="Polymaker",
- color_name="White",
- rgba="ffffffff",
- storage_location="Shelf B3",
- category="Production",
- low_stock_threshold_pct=20,
- )
- )
- await db_session.commit()
- csv_text = (await async_client.get("/api/v1/inventory/spools/export")).text
- for spool in (await db_session.execute(select(Spool))).scalars().all():
- await db_session.delete(spool)
- await db_session.commit()
- response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- assert response.json()["created"] == 1
- spool = (await db_session.execute(select(Spool))).scalars().one()
- assert spool.storage_location == "Shelf B3"
- assert spool.category == "Production"
- assert spool.low_stock_threshold_pct == 20
- async def test_low_stock_threshold_out_of_range_is_error(self, async_client: AsyncClient):
- # SpoolCreate bounds low_stock_threshold_pct to 1..99; the CSV path must
- # reject an out-of-range value rather than persist it.
- csv_text = "material,color_name,rgba,low_stock_threshold_pct\nPLA,White,ffffffff,150\n"
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- data = response.json()
- assert data["error_count"] == 1
- assert "low_stock_threshold_pct" in data["rows"][0]["reason"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestInventoryCsvDuplicateWarning:
- async def test_existing_spool_flags_duplicate_but_still_imports(
- self, async_client: AsyncClient, db_session: AsyncSession
- ):
- db_session.add(Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff"))
- await db_session.commit()
- # Row 1 matches the existing spool (case-insensitively); row 2 is new.
- csv_text = (
- "material,brand,color_name,rgba\n"
- "pla,polymaker,jade white,e8e8e8ff\n" # duplicate of existing
- "PETG,OtherBrand,Black,000000ff\n" # new
- )
- response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
- assert response.status_code == 200, response.text
- rows = response.json()["rows"]
- assert rows[0]["duplicate_of_existing"] is True
- assert rows[1]["duplicate_of_existing"] is False
- # Soft-warn only: a real import still creates the duplicate row.
- real = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
- assert real.json()["created"] == 2
- all_spools = (await db_session.execute(select(Spool))).scalars().all()
- assert len(all_spools) == 3 # 1 pre-existing + 2 imported
|