Files
bambuddy/backend/tests/unit/test_no_3mf_archive_paths.py
maziggy 6488a33488 Stop a print with no 3MF borrowing another model's data (#2843)
H2-series and P2S firmware keeps a slicer-sent file on internal eMMC.
Port 990 serves external storage only, so there is no file to fetch, and
the print becomes an archive with no 3MF -- the ordinary outcome for
anyone who sends from Bambu Studio rather than through Bambuddy.
Confirmed on the maintainer's own machines: an H2C and an H2D both
dispatched brtc://emmc for the same model one minute apart, while an X1C
sent ftp:// for it. Three separate defects live in what happens next.

The first is the serious one. An archive with no 3MF keeps the path the
printer is executing as its filename, and on a sliced job that is always
Metadata/plate_1.gcode. The fallback that looks for the same model in the
Library or among earlier prints took its search term from there, so it
searched for `plate_1` -- a name every Bambu print in existence has --
and matched on a substring, so it also matched any name merely ending
that way. On the H2D a 1.6 g Cube resolved to lid_plate_1.gcode.3mf and
was costed at 207 g across three real spools. It was not confined to
plate names either: in the same database `Bank.3mf` matched "Piggo the
piggy bank", and `x1c.gcode.3mf` matched "slice-test-x1c". The matcher
now takes the model name the printer reports when the filename is only a
plate path, refuses a bare plate stem rather than searching for it, and
anchors to a whole filename with LIKE metacharacters escaped, because `_`
is a wildcard and model names are full of them. A print that cannot be
identified is now left untracked, which is the honest answer -- the
previous behaviour was to charge the operator's spools for a model they
had not printed.

Checked against every row rather than argued from the code. Across 273
library stems the result sets are identical. Across 241 archive stems 14
differ, all of them strictly narrower, and every dropped match is one of
the false positives above; all 233 archives still match their own
filename, so no legitimate donor was lost. Of the eight no-3MF archives
on that install the old matcher picked a wrong donor for two -- one of
them a calibration run that would have been charged the 207 g -- and the
new one picks none.

The second defect is that those archives could not receive a timelapse at
all. attach_timelapse derived its destination from the missing file's
path, and (base_dir / "").parent is the parent of base_dir, one level
outside the data directory. In Docker that is /app, so every attempt
failed EACCES and the scan retried and discarded the video 25 times over
twelve minutes, roughly a hundred FTPS connections for bytes that had
already downloaded successfully. Where that location happened to be
writable it was worse: the file landed beside the installation and the
attach then failed anyway, because the path could not be made relative to
base_dir. #1820 introduced a shared helper precisely so these derivations
could not drift apart, and this was the one site still doing it by hand.
The directory is created only after the filename has passed the traversal
check, so a rejected name still leaves nothing behind.

The third is silence. When a print's filament cannot be read from a 3MF,
the remaining-percentage delta is the fallback, and that needs a reading
at print start -- which a spool without RFID does not have until someone
sets a remaining amount by hand. Those slots were skipped with a bare
continue. Every other reason for skipping a slot in that loop is logged,
and the comment a few lines below argues the case explicitly: charging
nothing silently is indistinguishable from having nothing to charge. It
now says so, for slots the print actually used.

Four existing tests needed updating rather than the production path.
They patch backend.app.services.archive.settings by name, and the shared
helper reads its own module-level binding, so they kept the real data
directory and wrote outside tmp_path -- which is how the first draft of
this change littered a working tree. They now patch both bindings.
2026-08-17 09:26:13 +02:00

170 lines
6.5 KiB
Python

"""A no-3MF archive still owns a directory, and still explains itself (#2843).
Both cases below are the same underlying situation: an H2-series or P2S print
sent from the slicer goes to internal eMMC, Bambuddy cannot fetch the 3MF, and
the archive is created with ``file_path == ""``.
"""
import logging
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def data_dirs(monkeypatch, tmp_path):
"""Point base_dir/archive_dir at a scratch tree, as a real install has them."""
from backend.app.core.config import settings
base = tmp_path / "data"
(base / "archive").mkdir(parents=True)
monkeypatch.setattr(settings, "base_dir", base)
monkeypatch.setattr(settings, "archive_dir", base / "archive")
return base
class TestTimelapseDestination:
"""``attach_timelapse`` must stay inside the data directory."""
@staticmethod
async def _archive(db_session, file_path: str):
from backend.app.models.archive import PrintArchive
archive = PrintArchive(
printer_id=1,
print_name="Cube",
filename="/data/Metadata/plate_1.gcode" if not file_path else "Cube.gcode.3mf",
file_path=file_path,
file_size=0,
status="completed",
)
db_session.add(archive)
await db_session.commit()
return archive
@pytest.mark.asyncio
async def test_no_3mf_archive_writes_under_the_data_dir(self, db_session, data_dirs):
"""Regression: this resolved to ``base_dir.parent`` — /app in Docker, so
the write failed EACCES; where the parent was writable it dropped a stray
video beside the install and then failed on relative_to() anyway."""
from backend.app.services.archive import ArchiveService
archive = await self._archive(db_session, "")
ok = await ArchiveService(db_session).attach_timelapse(archive.id, b"video-bytes", "video_2026.mp4")
assert ok is True
written = data_dirs / "archive" / str(archive.id) / "video_2026.mp4"
assert written.read_bytes() == b"video-bytes"
# Nothing may appear above the data directory.
assert not list(data_dirs.parent.glob("*.mp4"))
@pytest.mark.asyncio
async def test_timelapse_path_is_stored_relative_to_base_dir(self, db_session, data_dirs):
"""The old path could not be made relative to base_dir at all, which is
what raised ValueError and lost the video after a successful download."""
from backend.app.services.archive import ArchiveService
archive = await self._archive(db_session, "")
await ArchiveService(db_session).attach_timelapse(archive.id, b"video-bytes", "video_2026.mp4")
assert archive.timelapse_path == f"archive/{archive.id}/video_2026.mp4"
@pytest.mark.asyncio
async def test_a_normal_archive_is_unaffected(self, db_session, data_dirs):
"""An archive with a 3MF keeps writing beside it, exactly as before."""
from backend.app.services.archive import ArchiveService
archive = await self._archive(db_session, "archive/1/20260817_Cube/Cube.gcode.3mf")
(data_dirs / "archive" / "1" / "20260817_Cube").mkdir(parents=True)
ok = await ArchiveService(db_session).attach_timelapse(archive.id, b"video-bytes", "video_2026.mp4")
assert ok is True
assert archive.timelapse_path == "archive/1/20260817_Cube/video_2026.mp4"
class TestUnchargeableTrayIsAnnounced:
"""A tray the print used but could not be charged must say so."""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
from backend.app.services.usage_tracker import _active_sessions
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_used_tray_with_no_start_remain_is_logged(self, db_session, caplog):
"""Non-RFID spools report remain = -1, so they never enter
``tray_remain_start`` — and the loop skipped them with a bare
``continue``. Nothing deducted, no reason given anywhere."""
from backend.app.services.usage_tracker import PrintSession, _active_sessions, on_print_complete
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Cube",
started_at=datetime.now(timezone.utc),
# AMS0-T0 is missing: it read -1 when the print began.
tray_remain_start={(1, 0): 50},
tray_now_at_start=0,
)
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 40}]}]},
progress=100,
layer_num=50,
tray_now=0,
tray_change_log=[],
)
with caplog.at_level(logging.INFO, logger="backend.app.services.usage_tracker"):
await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=printer_manager,
db=db_session,
archive_id=None,
ams_mapping=[0],
)
assert "AMS0-T0: no valid remain% at print start" in caplog.text
@pytest.mark.asyncio
async def test_a_tray_the_print_never_touched_stays_quiet(self, db_session, caplog):
"""The loop walks every tray on the printer, so logging unconditionally
would narrate slots that had nothing to do with this print."""
from backend.app.services.usage_tracker import PrintSession, _active_sessions, on_print_complete
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="Cube",
started_at=datetime.now(timezone.utc),
tray_remain_start={(1, 0): 50},
tray_now_at_start=0,
)
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 3, "tray": [{"id": 2, "remain": 40}]}]},
progress=100,
layer_num=50,
tray_now=0,
tray_change_log=[],
)
with caplog.at_level(logging.INFO, logger="backend.app.services.usage_tracker"):
await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=printer_manager,
db=db_session,
archive_id=None,
ams_mapping=[0],
)
assert "AMS3-T2: no valid remain%" not in caplog.text