"""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