maziggy před 3 měsíci
rodič
revize
6ab48fa338

+ 2 - 2
.github/workflows/security.yml

@@ -85,7 +85,7 @@ jobs:
 
 
       - name: Upload Trivy results to GitHub Security
       - name: Upload Trivy results to GitHub Security
         uses: github/codeql-action/upload-sarif@v4
         uses: github/codeql-action/upload-sarif@v4
-        if: always()
+        if: always() && hashFiles('trivy-results.sarif') != ''
         with:
         with:
           sarif_file: trivy-results.sarif
           sarif_file: trivy-results.sarif
           category: trivy
           category: trivy
@@ -102,7 +102,7 @@ jobs:
 
 
       - name: Upload Trivy config results
       - name: Upload Trivy config results
         uses: github/codeql-action/upload-sarif@v4
         uses: github/codeql-action/upload-sarif@v4
-        if: always()
+        if: always() && hashFiles('trivy-config-results.sarif') != ''
         with:
         with:
           sarif_file: trivy-config-results.sarif
           sarif_file: trivy-config-results.sarif
           category: trivy-config
           category: trivy-config

+ 2 - 0
CHANGELOG.md

@@ -14,10 +14,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 
 
 ### New Features
 ### New Features
+- **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
 - **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 
 
 ### Improved
 ### Improved
+- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
 - **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
 - **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
 - **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
 - **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
 - **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.
 - **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.

+ 1 - 0
README.md

@@ -155,6 +155,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
+- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 
 
 ### 🔧 Integrations
 ### 🔧 Integrations

+ 12 - 26
backend/tests/integration/test_cost_statistics.py

@@ -324,7 +324,7 @@ class TestCostCalculationScenarios:
     async def test_archive_cost_with_archive_id_and_print_name(
     async def test_archive_cost_with_archive_id_and_print_name(
         self, async_client, archive_factory, printer_factory, db_session
         self, async_client, archive_factory, printer_factory, db_session
     ):
     ):
-        """Test archive cost calculation using both archive_id and print_name fallback."""
+        """Test archive cost recalculation using both archive_id and print_name fallback."""
         from backend.app.models.spool import Spool
         from backend.app.models.spool import Spool
         from backend.app.models.spool_usage_history import SpoolUsageHistory
         from backend.app.models.spool_usage_history import SpoolUsageHistory
 
 
@@ -357,13 +357,6 @@ class TestCostCalculationScenarios:
             status="completed",
             status="completed",
             cost=None,
             cost=None,
         )
         )
-        # Create dummy file for archive_new
-        import os
-
-        if hasattr(archive_new, "file_path") and archive_new.file_path:
-            os.makedirs(os.path.dirname(archive_new.file_path), exist_ok=True)
-            with open(archive_new.file_path, "w") as f:
-                f.write("dummy content")
 
 
         history_new = SpoolUsageHistory(
         history_new = SpoolUsageHistory(
             spool_id=spool_new.id,
             spool_id=spool_new.id,
@@ -377,19 +370,13 @@ class TestCostCalculationScenarios:
         )
         )
         db_session.add(history_new)
         db_session.add(history_new)
 
 
-        # Create archive with old SpoolUsageHistory (archive_id NULL)
+        # Create archive with old SpoolUsageHistory (archive_id NULL — legacy record)
         archive_old = await archive_factory(
         archive_old = await archive_factory(
             printer.id,
             printer.id,
             print_name="LegacyPrint",
             print_name="LegacyPrint",
             status="completed",
             status="completed",
             cost=None,
             cost=None,
         )
         )
-        # Create dummy file for archive_old
-        if hasattr(archive_old, "file_path") and archive_old.file_path:
-            os.makedirs(os.path.dirname(archive_old.file_path), exist_ok=True)
-            with open(archive_old.file_path, "w") as f:
-                f.write("dummy content")
-        # Explicitly set filament_used_grams for archive_old
         archive_old.filament_used_grams = 30.0
         archive_old.filament_used_grams = 30.0
         await db_session.commit()
         await db_session.commit()
 
 
@@ -407,20 +394,19 @@ class TestCostCalculationScenarios:
 
 
         await db_session.commit()
         await db_session.commit()
 
 
-        # Rescan both archives
-        response_new = await async_client.post(f"/api/v1/archives/{archive_new.id}/rescan")
-        response_old = await async_client.post(f"/api/v1/archives/{archive_old.id}/rescan")
+        # Recalculate costs for all archives
+        recalc_response = await async_client.post("/api/v1/archives/recalculate-costs")
+        assert recalc_response.status_code == 200
+        assert recalc_response.json()["updated"] >= 1
 
 
+        # Verify archive_new cost from archive_id-linked SpoolUsageHistory
+        response_new = await async_client.get(f"/api/v1/archives/{archive_new.id}")
         assert response_new.status_code == 200
         assert response_new.status_code == 200
         assert response_new.json()["cost"] == 0.50
         assert response_new.json()["cost"] == 0.50
-        assert response_old.status_code == 200
-        # Legacy fallback: sum all SpoolUsageHistory costs for print_name/printer_id (0.45 + 0.30 = 0.75)
-        assert response_old.json()["cost"] == 0.75
 
 
-        # Check recalculate_all_costs endpoint
-        recalc_response = await async_client.post("/api/v1/archives/recalculate-costs")
-        assert recalc_response.status_code == 200
-        # Accept 0 or more updated archives for practical robustness
-        assert recalc_response.json()["updated"] >= 0
+        # Verify archive_old cost from legacy print_name fallback
+        response_old = await async_client.get(f"/api/v1/archives/{archive_old.id}")
+        assert response_old.status_code == 200
+        assert response_old.json()["cost"] == 0.45
 
 
         await db_session.rollback()
         await db_session.rollback()

+ 24 - 1
backend/tests/unit/services/test_usage_tracker.py

@@ -27,6 +27,8 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
     spool.tag_uid = tag_uid
     spool.tag_uid = tag_uid
     spool.tray_uuid = tray_uuid
     spool.tray_uuid = tray_uuid
     spool.last_used = None
     spool.last_used = None
+    spool.cost_per_kg = None
+    spool.material = "PLA"
     return spool
     return spool
 
 
 
 
@@ -112,6 +114,15 @@ class TestOnPrintCompleteAMSDelta:
         yield
         yield
         _active_sessions.clear()
         _active_sessions.clear()
 
 
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_computes_delta_and_updates_spool(self):
     async def test_computes_delta_and_updates_spool(self):
         """Spool weight_used updated by remain% delta * label_weight."""
         """Spool weight_used updated by remain% delta * label_weight."""
@@ -414,6 +425,15 @@ class TestSpoolAssignmentSnapshot:
         yield
         yield
         _active_sessions.clear()
         _active_sessions.clear()
 
 
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_on_print_start_snapshots_assignments_with_db(self):
     async def test_on_print_start_snapshots_assignments_with_db(self):
         """on_print_start captures spool assignments when db is provided."""
         """on_print_start captures spool assignments when db is provided."""
@@ -637,7 +657,7 @@ class TestSpoolAssignmentSnapshot:
 
 
         filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
         filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
 
 
-        # db: archive, queue_item(None), spool
+        # db: archive, queue_item(None), spool, then cost aggregation queries
         # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
         # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
         db = AsyncMock()
         db = AsyncMock()
         db.execute = AsyncMock(
         db.execute = AsyncMock(
@@ -645,6 +665,9 @@ class TestSpoolAssignmentSnapshot:
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+                # Cost aggregation: sum query (uses .scalar()), archive lookup
+                MagicMock(scalar=MagicMock(return_value=0)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
             ]
             ]
         )
         )
 
 

+ 177 - 6
backend/tests/unit/test_cost_tracking.py

@@ -497,16 +497,13 @@ class TestCostAggregation:
     """Tests for cost aggregation to PrintArchive."""
     """Tests for cost aggregation to PrintArchive."""
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_costs_summed_in_archive(self):
-        """Multiple spool costs are summed when aggregated to archive."""
-        # This test would need to mock the full main.py flow
-        # For now, we verify the results dict structure includes cost
+    async def test_costs_summed_in_results(self):
+        """Multiple spool costs are correctly summed from result dicts."""
         results = [
         results = [
             {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
             {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
             {"spool_id": 2, "weight_used": 30.0, "cost": 0.75},
             {"spool_id": 2, "weight_used": 30.0, "cost": 0.75},
         ]
         ]
 
 
-        # Simulate aggregation logic from main.py
         total_cost = sum(r.get("cost", 0) or 0 for r in results)
         total_cost = sum(r.get("cost", 0) or 0 for r in results)
         assert total_cost == 1.25
         assert total_cost == 1.25
 
 
@@ -519,10 +516,184 @@ class TestCostAggregation:
             {"spool_id": 3, "weight_used": 10.0, "cost": 0.25},
             {"spool_id": 3, "weight_used": 10.0, "cost": 0.25},
         ]
         ]
 
 
-        # Aggregation should handle None gracefully
         total_cost = sum(r.get("cost", 0) or 0 for r in results)
         total_cost = sum(r.get("cost", 0) or 0 for r in results)
         assert total_cost == 0.75  # Only spools 1 and 3
         assert total_cost == 0.75  # Only spools 1 and 3
 
 
+    @pytest.mark.asyncio
+    async def test_archive_cost_not_overwritten_with_zero(self):
+        """archive.cost is preserved when no spool usage has cost data."""
+        # Spool without cost_per_kg, default_filament_cost also 0 → cost=None per usage
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = 5.00  # Pre-existing cost from catalog
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # Build mock db that returns proper scalars for the aggregation queries
+        responses = []
+        # 1. select(PrintArchive) → archive
+        responses.append(("scalar_one_or_none", archive))
+        # 2. select(PrintQueueItem) → None
+        responses.append(("scalar_one_or_none", None))
+        # 3. select(SpoolAssignment) → assignment
+        responses.append(("scalar_one_or_none", assignment))
+        # 4. select(Spool) → spool
+        responses.append(("scalar_one_or_none", spool))
+        # 5. cost aggregation: coalesce(sum(cost)) → 0 (no costs)
+        responses.append(("scalar", 0))
+        # 6. select(PrintArchive) → archive (for the guard check)
+        responses.append(("scalar_one_or_none", archive))
+        # 7. legacy fallback: coalesce(sum(cost)) → 0
+        responses.append(("scalar", 0))
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                method, value = responses[idx]
+                if method == "scalar":
+                    result.scalar.return_value = value
+                    result.scalar_one_or_none.return_value = value
+                else:
+                    result.scalar_one_or_none.return_value = value
+                    result.scalar.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="0.0"),  # no default cost
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # Usage tracked but cost is None (no cost_per_kg, no default)
+        assert len(results) == 1
+        assert results[0]["cost"] is None
+
+        # Archive cost should NOT have been overwritten — still 5.00
+        assert archive.cost == 5.00
+
+    @pytest.mark.asyncio
+    async def test_archive_cost_set_when_spool_has_cost(self):
+        """archive.cost is set from spool usage when cost data exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = None  # No pre-existing cost
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # 20g at 25/kg = 0.50
+        expected_cost = 0.50
+
+        responses = []
+        responses.append(("scalar_one_or_none", archive))
+        responses.append(("scalar_one_or_none", None))  # queue item
+        responses.append(("scalar_one_or_none", assignment))
+        responses.append(("scalar_one_or_none", spool))
+        # cost aggregation: sum returns 0.50
+        responses.append(("scalar", expected_cost))
+        # select archive for guard
+        responses.append(("scalar_one_or_none", archive))
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                method, value = responses[idx]
+                result.scalar.return_value = value
+                result.scalar_one_or_none.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["cost"] == expected_cost
+        # Archive cost should have been updated
+        assert archive.cost == expected_cost
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_cost_with_archive_id(self):
     async def test_cost_with_archive_id(self):
         """Test cost aggregation using archive_id (3MF path)."""
         """Test cost aggregation using archive_id (3MF path)."""

+ 13 - 0
backend/tests/unit/test_usage_tracker.py

@@ -31,6 +31,8 @@ def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray
     spool.tag_uid = tag_uid
     spool.tag_uid = tag_uid
     spool.tray_uuid = tray_uuid
     spool.tray_uuid = tray_uuid
     spool.last_used = None
     spool.last_used = None
+    spool.cost_per_kg = None
+    spool.material = "PLA"
     return spool
     return spool
 
 
 
 
@@ -86,6 +88,8 @@ def _mock_db_sequential(responses):
             result.scalar_one_or_none.return_value = responses[idx]
             result.scalar_one_or_none.return_value = responses[idx]
         else:
         else:
             result.scalar_one_or_none.return_value = None
             result.scalar_one_or_none.return_value = None
+        # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()
+        result.scalar.return_value = None
         return result
         return result
 
 
     db.execute = mock_execute
     db.execute = mock_execute
@@ -167,6 +171,15 @@ class TestOnPrintComplete:
         yield
         yield
         _active_sessions.clear()
         _active_sessions.clear()
 
 
+    @pytest.fixture(autouse=True)
+    def _mock_get_setting(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            yield
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_bl_spool_uses_3mf(self):
     async def test_bl_spool_uses_3mf(self):
         """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
         """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""

+ 70 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -93,6 +93,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
         spool={existingSpool}
         spool={existingSpool}
+        currencySymbol="$"
       />
       />
     );
     );
 
 
@@ -124,6 +125,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
         spool={existingSpool}
         spool={existingSpool}
+        currencySymbol="$"
       />
       />
     );
     );
 
 
@@ -158,6 +160,7 @@ describe('SpoolFormModal weightTouched', () => {
       <SpoolFormModal
       <SpoolFormModal
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
+        currencySymbol="$"
       />
       />
     );
     );
 
 
@@ -196,6 +199,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
         spool={spoolWithCatalogId}
         spool={spoolWithCatalogId}
+        currencySymbol="$"
       />
       />
     );
     );
 
 
@@ -237,6 +241,7 @@ describe('SpoolFormModal weightTouched', () => {
       <SpoolFormModal
       <SpoolFormModal
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
+        currencySymbol="$"
       />
       />
     );
     );
 
 
@@ -277,6 +282,70 @@ describe('SpoolFormModal weightTouched', () => {
     expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
     expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
   });
   });
 
 
+  it('preserves cost_per_kg when editing spool', async () => {
+    const spoolWithCost: InventorySpool = {
+      ...existingSpool,
+      cost_per_kg: 25.50,
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCost}
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Click Save without changing cost
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // cost_per_kg should be preserved in the update payload
+    expect(payload).toHaveProperty('cost_per_kg', 25.50);
+  });
+
+  it('sends null cost_per_kg when spool has no cost', async () => {
+    const spoolWithoutCost: InventorySpool = {
+      ...existingSpool,
+      cost_per_kg: null,
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithoutCost}
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    // cost_per_kg should be null when not set
+    expect(payload).toHaveProperty('cost_per_kg', null);
+  });
+
   it('displays correct catalog name when duplicates exist', async () => {
   it('displays correct catalog name when duplicates exist', async () => {
     const spoolWithCatalogId: InventorySpool = {
     const spoolWithCatalogId: InventorySpool = {
       ...existingSpool,
       ...existingSpool,
@@ -297,6 +366,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         isOpen={true}
         onClose={vi.fn()}
         onClose={vi.fn()}
         spool={spoolWithCatalogId}
         spool={spoolWithCatalogId}
+        currencySymbol="$"
       />
       />
     );
     );