From fee7c722f54bb2d81e0c35fb68df55a86aa1cf97 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 3 Jun 2026 09:45:10 +0200 Subject: [PATCH] fix(usage-tracker): #1607 filter empty AMS slots from position-based fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no explicit slot-to-tray mapping is captured (path 5 of 6 in _track_from_3mf — fires before the request-topic subscription that catches ams_mapping is accepted), the tracker builds available_trays from build_ams_tray_lookup and uses position to map the slicer's Nth filament to the Nth available tray. The helper enumerated every AMS tray by id regardless of whether a spool was loaded, so AMS slots 0-2 loaded + slot 3 empty + external yielded available_trays = [0, 1, 2, 3, 254]. The slicer compacts its filament UI to hide empty AMS slots, so its 4th filament is the external — but position mapping routed it to AMS0-T3 (the empty slot) instead of 254 (external). No spool assigned there → usage silently skipped → external never decremented. Filter the fallback to slots with a non-empty tray_type. build_ams_tray_lookup stays unchanged for its other callers (spoolman_tracking.store_print_data, routes/printers, spool_assignment_notifications); the filter is applied at the usage-tracker call site only. Mirrors the existing vt_tray filter in build_ams_tray_lookup line 174. --- CHANGELOG.md | 1 + backend/app/services/usage_tracker.py | 16 ++- backend/tests/unit/test_usage_tracker.py | 152 +++++++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 728573708..1a6ced39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to Bambuddy will be documented in this file. ### Security - **Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside** — A private path-traversal report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to `library_dir` with no resolve + containment check: (a) `linked_folders[*].name` from the request's `project.json` ("Vector A" — an absolute path in this field collapsed `library_dir / "/anywhere"` to `Path("/anywhere")` because pathlib discards the left side when the right is absolute, letting the next `write_bytes` land anywhere the backend could write), and (b) per-entry `zf.namelist()` paths from the ZIP itself ("Vector B" — ZIP filenames carry `..` segments by spec and the join `library_dir / folder_name / relative_path` had no per-component check). Concrete escalation: drop a `.pth` file into the venv's `site-packages` directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite `~/.ssh/authorized_keys` or `~/.bashrc` on native installs. **Fix is structural, not just patch the diff** (per [[feedback_dont_dismiss_preexisting]]). New `backend/app/utils/safe_path.py::safe_join_under(parent, *parts)` helper joins under a trusted parent, resolves both sides, asserts `is_relative_to(parent.resolve())`, and rejects up-front empty / null-byte / absolute path components. Wired into `import_project_file` at both vectors. **Adjacent fix from the routes audit**: `GET /api/v1/archives/{id}/photos/{filename}` had NO validation on `filename` and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against `archive.photos` (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through `safe_join_under` for defence-in-depth on top of the membership check. **Second adjacent fix from the services audit**: `ArchiveService.attach_timelapse(archive_id, data, filename)` in `backend/app/services/archive.py:1456` wrote `archive_dir / filename` where `filename` ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the `?filename=...` query param on `POST /api/v1/archives/{id}/timelapse/select`. A malicious printer that returns a directory listing entry with `..` segments could write the timelapse bytes outside the archive directory; the `f.get("name") == filename` gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. `attach_timelapse` now routes through `safe_join_under(..., http=False)` and returns `False` (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. **Audit sweep methodology**: AST-walked every Python file under `backend/app/api/routes/` AND `backend/app/services/` for `Path / Name` shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, `_safe_filename(...)` / `Path(arg).name` basename-stripped inputs, `os.walk`-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a `# SEC-PATH-OK: ` marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (`library.py` external upload, `archives.py` timelapse output, `projects.py` attachment download/delete, `settings.py` backup extractall) carry the same marker shape. **Fifth CI backstop** `test_route_path_arithmetic_is_safe_joined_or_marked` (`backend/tests/unit/test_no_unsafe_path_joins.py`) AST-walks every Python file in `backend/app/api/routes/` AND `backend/app/services/` and fails the build on any ` / ` join that doesn't either route through `safe_join_under` or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, `str(...)` call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). **Tests**: 17 unit tests for `safe_join_under` covering every escape vector (absolute path, Windows abs path, `..` segments, embedded `..`, null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against `POST /api/v1/projects/import/file` exercising the full FastAPI stack with the verbatim shape from the report (absolute path in `folder_name` → 400 + filesystem assertion that the target file doesn't exist; `..` in `folder_name` → 400; `..` in `relative_path` → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against `ArchiveService.attach_timelapse` exercising the compromised-printer threat model (filename with `..` segments → returns False + no file at the escape target; absolute filename → returns False + no file at `/tmp`; legitimate `timelapse_YYYY-MM-DD_HH-MM-SS.mp4` → returns True + file lands inside archive_dir, guarding against the fix being over-strict). **SECURITY.md** gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean. ### Fixed +- **External-spool usage is now tracked when the AMS has empty slots in between loaded ones (#1607, reported by @ahmtcnby)** — Reporter had AMS slots 0–2 loaded, slot 3 empty, and an external spool. After every multi-filament print, the external's weight never decremented; assigning the external spool to the empty AMS slot 3 in Bambuddy made the deduction appear correctly. Root cause: when no explicit slot-to-tray mapping is available (path 5 of 6 in `usage_tracker.py::_track_from_3mf`, e.g. the very first print after a fresh container start before the request-topic subscription that captures `ams_mapping` from `print_command` is accepted — the bundle showed `Request topic subscription accepted. ams_mapping capture enabled` only fired at 19:36:29), the tracker falls back to a position-based mapping built from `spoolman_tracking.py::build_ams_tray_lookup`. That helper enumerated every AMS tray by `id` regardless of whether a spool was loaded, so the reporter's layout yielded `available_trays = [0, 1, 2, 3, 254]`. BambuStudio / OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS slots — the slicer's 4th filament is the external, so the 3MF carries slot_ids 1–4 with slot 4 = external. Position-based mapping then routed slot 4 → `available_trays[3]` = AMS0-T3 (the empty slot) instead of 254 (the external). No spool assignment exists at AMS0-T3, so usage was silently skipped at line 1273-1274 and the external spool's weight stayed unchanged. **Fix** (`backend/app/services/usage_tracker.py:1232-1245`): the position-based fallback now filters `build_ams_tray_lookup`'s output to slots whose `tray_type` is non-empty before sorting. `build_ams_tray_lookup` itself is unchanged (its other callers — `spoolman_tracking.store_print_data`, `routes/printers`, `spool_assignment_notifications` — want every physical slot for AMS-state purposes); the filter is applied at the call site so we only narrow what the fallback uses. vt_tray entries are already filtered the same way inside `build_ams_tray_lookup` at line 174 (`if vt.get("tray_type"):`) — this mirrors that behaviour for the AMS side. **Why the reporter's workaround helped**: assigning the external spool to AMS0-T3 in Bambuddy made the wrong-target rewrite accidentally land on the right spool — the empty AMS slot resolved to the same spool the external was fed from. The fix removes the need for that workaround. **Tests**: 2 new in `backend/tests/unit/test_usage_tracker.py::TestPositionBasedFallbackEmptyAmsSlot` — `test_external_routed_correctly_when_ams_has_empty_middle_slot` is the literal #1607 regression (3 AMS slots loaded + 1 empty + external loaded, slicer's slot 4 must charge spool at AMS255-T0, **must NOT** charge anything at AMS0-T3 — explicit `(0, 3) not in handled_trays` assertion); `test_dense_ams_unchanged_no_empty_slots` is the no-empty-slots sanity check that confirms the fix doesn't regress the everyday case (4 AMS slots all loaded + external → slot 5 still maps to external). Path priority order unchanged: explicit `print_cmd` / MQTT / queue / color-match mappings still override the position-based fallback, so this only changes behaviour when none of those paths fired. Full backend pytest 3669 passed under `-n 30`; ruff clean. - **Custom maintenance type "documentation URL" now persists on create (#1596, reported by @BurntOutHylian — with the exact root cause pre-triaged in the issue body)** — POST `/api/v1/maintenance/types` hard-coded every field on the `MaintenanceType` constructor by name (`name`, `description`, `default_interval_hours`, `interval_type`, `icon`, `is_system`) and silently dropped `wiki_url`, even though the Pydantic schema accepted it and the response model echoed it back as `null`. PATCH was fine because it used `data.model_dump(exclude_unset=True) + setattr`, which is why editing a freshly-created type DID save the URL — masking the bug under any "save then immediately fix it" test. **Fix**: add `wiki_url=data.wiki_url` to the constructor call at `routes/maintenance.py:206`. **Frontend nit also addressed in the same drop** (#1596 nit section): `MaintenancePage.tsx:1131` `updateTypeMutation`'s inline `Partial<{...}>` shape listed `name | default_interval_hours | interval_type | icon` only. The value reached the API correctly at runtime because `api.updateMaintenanceType` accepts `Partial` (which includes `wiki_url`), but the local type was misleading — anyone reading the mutation would wrongly conclude `wiki_url` wasn't part of the update payload. Extended the inline shape to include `wiki_url?: string | null`. **Tests**: one new integration test in `test_maintenance_api.py::test_create_custom_type_persists_wiki_url` — POSTs a custom type with a `wiki_url`, asserts the POST response carries it, and verifies via a separate GET round-trip that the value actually committed (defending against the "response echoes request body" failure mode the bug would have masked). Full 5565-test backend suite green; ruff clean; frontend build clean; ESLint zero output; touched MaintenancePage vitest green. - **External-folder `.gcode.3mf` files now show thumbnails, and every ingest path stores the same canonical `file_type` for sliced outputs (#1600, reported by @maziggy)** — Reporter noticed external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classified `LibraryFile.file_type` differently for the same `.gcode.3mf` family. The upload, ZIP-extract, and in-process paths used `os.path.splitext(filename)[1]` which returns `.3mf` for `foo.gcode.3mf`, stored `file_type="3mf"`, and matched the thumbnail-extraction gate at `library.py:1467` (`if file_type == "3mf":`). The external-folder scan path explicitly detected the compound and set `file_type="gcode.3mf"` — preserving the "sliced output" identity — but then skipped both `if file_type == "3mf":` (mismatch) and `if file_type == "gcode":` (also mismatch), so the file landed with `thumbnail_path = None`. Same compound-extension drift that bit #1543's 3D preview gates, just in a different surface that the #1543 frontend audit didn't trace back to. **Unified fix** (per the user's "unify if it's safe" directive): new `classify_file_type(filename)` helper in `library.py` is now the single source of truth — returns `gcode.3mf` for sliced outputs and `ext[1:]` otherwise. Applied to every ingest path: upload (`routes/library.py:1704`), ZIP-extract (`routes/library.py:1998`), external-folder scan (the bug site, plus the manual compound check is replaced), and the in-process `save_3mf_from_bytes()` helper (`routes/library.py:471` — used by MakerWorld import). The external-scan thumbnail gate is widened to `if file_type in ("3mf", "gcode.3mf"):` so a sliced output now goes through ThreeMFParser (a `.gcode.3mf` IS a 3MF zip with `Metadata/plate_1.png` thumbnail; the parser doesn't care about the trailing extension). The gcode-download endpoint at `GET /api/v1/library/files/{id}/gcode` (`routes/library.py:4390`) had the same drift in reverse — its gate was `elif file.file_type == "3mf":` so a row stored with `file_type="gcode.3mf"` (the external-scan path's pre-unification behaviour, and now the canonical going forward) was rejected with HTTP 400. Widened to `elif file.file_type in ("3mf", "gcode.3mf"):` so both ingest histories work. **One-shot DB migration** in `backend/app/core/database.py::run_migrations` backfills existing legacy rows: `UPDATE library_files SET file_type='gcode.3mf' WHERE file_type='3mf' AND LOWER(filename) LIKE '%.gcode.3mf'`. Idempotent (post-update rows no longer match the `file_type='3mf'` predicate, so re-runs at every boot are no-ops) and dialect-neutral (`LOWER` + `LIKE` are identical under SQLite and Postgres per the [[feedback_sqlite_and_postgres_upfront]] HARD RULE; behaviour-identical on Postgres by construction, tested explicitly on SQLite in the new regression suite). Without the backfill, users would have a permanent split state in the DB — old uploads at `3mf`, new uploads at `gcode.3mf` — which would (a) double-bucket sliced outputs in the dashboard stats query at `routes/library.py:4615` (`SELECT file_type, count(*) GROUP BY file_type`) and (b) show two entries in the file-manager filter dropdown for the same conceptual type. **Frontend untouched** — `FileManagerPage.tsx` and `ProjectDetailPage.tsx` already accept both `'3mf'` and `'gcode.3mf'` for Preview-3D, type-pill colour, and the file action gate per the #1543 fix. After the migration the DB only contains canonical values, so the legacy `'3mf'` branches in the frontend become dead code for sliced files — they stay in place to handle any future ingest path I missed (defence in depth — better a redundant gate than an empty card). **Tests**: 13 new in `test_library_classify_file_type.py` covering the helper across every compound / casing / no-extension case; 3 new in `test_library_file_type_backfill_migration.py` (legacy `.gcode.3mf`/`3mf` row backfilled, mixed-case filenames upgraded via `LOWER()`, unrelated `.bak`-suffixed compound substring left untouched, plain `.3mf` / raw `.gcode` / `.stl` untouched, idempotent on re-run); 2 new integration tests in `test_library_api.py` (upload of `.gcode.3mf` now stores `file_type="gcode.3mf"` via the unified path; the gcode-download endpoint accepts a row with `file_type="gcode.3mf"` and returns the embedded gcode). Full backend pytest 5564 passed under `-n 30`; ruff clean; frontend build clean; eslint zero output; i18n parity green at 5007 leaves × 9 locales. - **Virtual-printer "Send file" IP rewrite now also fires for VPs without a dedicated bind IP (#1429 follow-up, residual case confirmed by @Mape6 on the 2026-06-02 daily)** — The first #1429 fix's `_refresh_ip_encoding` early-returned when `mqtt_server.bind_address` was `0.0.0.0` or empty (which is the default for any VP created without a bind IP selected — covered by the "Deferred (fix D)" note in the original #1429 changelog entry). On a flat-LAN install that's the typical case, so for those VPs the encoding never armed, `_rewrite_net_info_ips` was a no-op on every push, and the slicer kept following the real-printer IP to the printer's SD card — the exact symptom @Mape6 reported after pulling the 2026-06-02 daily that supposedly fixed this. **Fix (`backend/app/services/virtual_printer/mqtt_bridge.py`)**: new `_resolve_host_interface_for_target()` helper consults the existing `network_utils.find_interface_for_ip()` to pick the host interface in the same subnet as the printer's IP when `bind_address` is unspecified. `_refresh_ip_encoding` now falls back to that auto-resolved IP instead of returning early; an explicit bind IP still takes precedence. INFO log line distinguishes the two paths (`armed: ... (bind_address)` vs `armed: ... (auto-resolved)`) so support bundles answer "which IP did the rewrite pick?" without re-reasoning. If no interface matches the printer's subnet (the helper returns None), the bridge leaves encoding unarmed and the cache flows through as before — no crash, no wrong rewrite. **Tests** — 4 new in `backend/tests/unit/test_vp_mqtt_bridge.py::TestBindAddressAutoResolve`: rewrite arms via auto-resolved IP when bind_address is `0.0.0.0`; rewrite stays disabled when no host interface matches (no crash); explicit bind_ip takes precedence over auto-resolve; helper itself returns None when `find_interface_for_ip` does. All 39 mqtt_bridge tests pass; full backend unit suite (3667 tests) green; ruff clean. **Note on subnet matching**: the helper is best-effort — it picks the interface whose subnet contains the printer's IP, which is the right answer when slicer + printer + Bambuddy share a LAN (the typical home-lab case). Setups where the slicer reaches Bambuddy via a different interface than Bambuddy uses to reach the printer (multi-homed hosts, Tailscale + LAN where the slicer is on Tailscale and the printer on LAN) may still need an explicit bind IP — there's no leak in that case, just a rewritten value the slicer can't route to. The full audit-shaped resolution (enumerate accepted connections, per-slicer rewrite) is still a separate change. diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 7107fbdce..5217d4d2d 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -1223,14 +1223,26 @@ async def _track_from_3mf( if isinstance(mapped, int) and mapped >= 0: global_tray_id = mapped # Position-based default: sort available tray IDs so external spools (254/255) - # naturally follow standard AMS trays, matching slicer slot numbering + # naturally follow standard AMS trays, matching slicer slot numbering. + # + # Filter out AMS slots that have no spool loaded (empty `tray_type`) — + # BambuStudio/OrcaSlicer compact the slot list when assigning filaments + # and don't expose empty AMS slots to the user, so the slicer's 3MF + # slot N maps to the Nth *loaded* tray, not the Nth physical position. + # Without this filter a "3 AMS slots loaded + 1 empty + external" + # layout routes the slicer's 4th filament to the empty AMS slot + # instead of the external (#1607), and the external's spool usage + # never gets recorded. vt_tray entries are already filtered the + # same way inside `build_ams_tray_lookup` (line 174 checks + # `tray_type`), so this just mirrors that for the AMS side. if global_tray_id is None: _state = printer_manager.get_status(printer_id) _raw = getattr(_state, "raw_data", None) if _state else None if _raw: from backend.app.services.spoolman_tracking import build_ams_tray_lookup - available_trays = sorted(build_ams_tray_lookup(_raw).keys()) + _lookup = build_ams_tray_lookup(_raw) + available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type")) if slot_id <= len(available_trays): global_tray_id = available_trays[slot_id - 1] # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools) diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py index b56dd89f9..9b6756c60 100644 --- a/backend/tests/unit/test_usage_tracker.py +++ b/backend/tests/unit/test_usage_tracker.py @@ -1753,6 +1753,158 @@ class TestMqttMappingIntegration: assert results[0]["tray_id"] == 2 # From print_cmd mapping, not MQTT +class TestPositionBasedFallbackEmptyAmsSlot: + """Position-based mapping fallback (#1607): when no explicit mapping is + available, the slicer's Nth filament must map to the Nth *loaded* AMS tray + (skipping empty slots), not the Nth physical slot position. BambuStudio / + OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS + slots, so the 3MF slot list is dense even when the AMS itself has gaps.""" + + @pytest.mark.asyncio + async def test_external_routed_correctly_when_ams_has_empty_middle_slot(self): + """Reporter's scenario: AMS trays 0-2 loaded, tray 3 empty, external + loaded. Slicer emits 4 filaments — slot 4 = external. Without the fix + the position-based fallback maps slot 4 to the empty AMS tray 3 + (since `available_trays = [0, 1, 2, 3, 254]`) and external usage is + silently dropped because no spool is assigned to AMS0-T3. + After the fix, empty AMS slots are filtered (tray_type is empty) so + `available_trays = [0, 1, 2, 254]` and slot 4 correctly resolves to + the external (global tray 254 → AMS255-T0).""" + # Spool fed via external (vt_tray 254 → AMS255-T0) + spool = _make_spool(spool_id=42, label_weight=1000) + assignment = _make_assignment(spool_id=42, ams_id=255, tray_id=0) + archive = _make_archive(archive_id=70) + + # db: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + # AMS reports 4 physical tray slots but slot 3 has no spool (empty + # tray_type); external spool is loaded in vt_tray. + # No `mapping` field on the state — forces fallback through path 5. + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={ + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_type": "PLA"}, + {"id": 1, "tray_type": "PETG"}, + {"id": 2, "tray_type": "ABS"}, + {"id": 3, "tray_type": ""}, # empty slot + ], + } + ], + "vt_tray": [{"id": 254, "tray_type": "PLA"}], + }, + progress=100, + layer_num=50, + tray_now=254, + tray_change_log=[], + ) + + # 3MF has 4 dense filament slots — slot 4 is the external. Only slot 4 + # has weight (other slots came from AMS spools handled separately). + filament_usage = [{"slot_id": 4, "used_g": 12.3, "type": "PLA", "color": "#00AABB"}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + 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 _track_from_3mf( + printer_id=1, + archive_id=70, + status="completed", + print_name="External + AMS print", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + # The external spool was charged, NOT the empty AMS slot. + assert results[0]["spool_id"] == 42 + assert results[0]["ams_id"] == 255 + assert results[0]["tray_id"] == 0 + assert results[0]["weight_used"] == 12.3 + assert (255, 0) in handled_trays + # Critical assertion: AMS0-T3 (the empty slot) was NOT charged. + assert (0, 3) not in handled_trays + + @pytest.mark.asyncio + async def test_dense_ams_unchanged_no_empty_slots(self): + """Sanity check: when every AMS slot is loaded, the position-based + fallback still works for the slicer's external = last slot case.""" + spool = _make_spool(spool_id=99, label_weight=1000) + assignment = _make_assignment(spool_id=99, ams_id=255, tray_id=0) + archive = _make_archive(archive_id=71) + + db = _mock_db_sequential([archive, None, assignment, spool]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={ + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_type": "PLA"}, + {"id": 1, "tray_type": "PETG"}, + {"id": 2, "tray_type": "ABS"}, + {"id": 3, "tray_type": "TPU"}, + ], + } + ], + "vt_tray": [{"id": 254, "tray_type": "PLA"}], + }, + progress=100, + layer_num=50, + tray_now=254, + tray_change_log=[], + ) + + # 5 filaments, slot 5 = external. available_trays = [0,1,2,3,254] → + # slot_id=5 → available_trays[4] = 254. + filament_usage = [{"slot_id": 5, "used_g": 7.5, "type": "PLA", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + 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 _track_from_3mf( + printer_id=1, + archive_id=71, + status="completed", + print_name="Dense AMS + external", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 99 + assert results[0]["ams_id"] == 255 + assert results[0]["tray_id"] == 0 + + class TestNotificationVariables: """Tests for filament_details formatting in notifications."""