diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0496148a4..c5eea9f86 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -85,7 +85,7 @@ jobs: - name: Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@v4 - if: always() + if: always() && hashFiles('trivy-results.sarif') != '' with: sarif_file: trivy-results.sarif category: trivy @@ -102,7 +102,7 @@ jobs: - name: Upload Trivy config results uses: github/codeql-action/upload-sarif@v4 - if: always() + if: always() && hashFiles('trivy-config-results.sarif') != '' with: sarif_file: trivy-config-results.sarif category: trivy-config diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7c5e63f..2803ef7d8 100644 --- a/CHANGELOG.md +++ b/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. ### 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). - **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 +- **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. - **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. diff --git a/README.md b/README.md index bc443052d..8ca049555 100644 --- a/README.md +++ b/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 - 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-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 ### 🔧 Integrations diff --git a/backend/tests/integration/test_cost_statistics.py b/backend/tests/integration/test_cost_statistics.py index 5e58cda1d..4c54dd5fa 100644 --- a/backend/tests/integration/test_cost_statistics.py +++ b/backend/tests/integration/test_cost_statistics.py @@ -324,7 +324,7 @@ class TestCostCalculationScenarios: async def test_archive_cost_with_archive_id_and_print_name( 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_usage_history import SpoolUsageHistory @@ -357,13 +357,6 @@ class TestCostCalculationScenarios: status="completed", 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( spool_id=spool_new.id, @@ -377,19 +370,13 @@ class TestCostCalculationScenarios: ) 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( printer.id, print_name="LegacyPrint", status="completed", 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 await db_session.commit() @@ -407,20 +394,19 @@ class TestCostCalculationScenarios: 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") - - assert response_new.status_code == 200 - 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 + # Recalculate costs for all archives 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 + 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.json()["cost"] == 0.50 + + # 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() diff --git a/backend/tests/unit/services/test_usage_tracker.py b/backend/tests/unit/services/test_usage_tracker.py index 260dc1a6f..aad8cd890 100644 --- a/backend/tests/unit/services/test_usage_tracker.py +++ b/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.tray_uuid = tray_uuid spool.last_used = None + spool.cost_per_kg = None + spool.material = "PLA" return spool @@ -112,6 +114,15 @@ class TestOnPrintCompleteAMSDelta: yield _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 async def test_computes_delta_and_updates_spool(self): """Spool weight_used updated by remain% delta * label_weight.""" @@ -414,6 +425,15 @@ class TestSpoolAssignmentSnapshot: yield _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 async def test_on_print_start_snapshots_assignments_with_db(self): """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"}] - # 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! db = 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=None)), 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)), ] ) diff --git a/backend/tests/unit/test_cost_tracking.py b/backend/tests/unit/test_cost_tracking.py index d579ff909..115e9ffa2 100644 --- a/backend/tests/unit/test_cost_tracking.py +++ b/backend/tests/unit/test_cost_tracking.py @@ -497,16 +497,13 @@ class TestCostAggregation: """Tests for cost aggregation to PrintArchive.""" @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 = [ {"spool_id": 1, "weight_used": 20.0, "cost": 0.50}, {"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) assert total_cost == 1.25 @@ -519,10 +516,184 @@ class TestCostAggregation: {"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) 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 async def test_cost_with_archive_id(self): """Test cost aggregation using archive_id (3MF path).""" diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index 1628fe6bb..91a685222 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/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.tray_uuid = tray_uuid spool.last_used = None + spool.cost_per_kg = None + spool.material = "PLA" return spool @@ -86,6 +88,8 @@ def _mock_db_sequential(responses): result.scalar_one_or_none.return_value = responses[idx] else: 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 db.execute = mock_execute @@ -167,6 +171,15 @@ class TestOnPrintComplete: yield _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 async def test_bl_spool_uses_3mf(self): """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta.""" diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 821f1ac2e..c356cda03 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -93,6 +93,7 @@ describe('SpoolFormModal weightTouched', () => { isOpen={true} onClose={vi.fn()} spool={existingSpool} + currencySymbol="$" /> ); @@ -124,6 +125,7 @@ describe('SpoolFormModal weightTouched', () => { isOpen={true} onClose={vi.fn()} spool={existingSpool} + currencySymbol="$" /> ); @@ -158,6 +160,7 @@ describe('SpoolFormModal weightTouched', () => { ); @@ -196,6 +199,7 @@ describe('SpoolFormModal weightTouched', () => { isOpen={true} onClose={vi.fn()} spool={spoolWithCatalogId} + currencySymbol="$" /> ); @@ -237,6 +241,7 @@ describe('SpoolFormModal weightTouched', () => { ); @@ -277,6 +282,70 @@ describe('SpoolFormModal weightTouched', () => { 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( + + ); + + 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( + + ); + + 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 () => { const spoolWithCatalogId: InventorySpool = { ...existingSpool, @@ -297,6 +366,7 @@ describe('SpoolFormModal weightTouched', () => { isOpen={true} onClose={vi.fn()} spool={spoolWithCatalogId} + currencySymbol="$" /> );