mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
190 lines
8.3 KiB
Python
190 lines
8.3 KiB
Python
"""Regression tests for the #2572 timelapse-scan session-boundary refactor.
|
|
|
|
``POST /archives/{id}/timelapse/scan`` used to hold its ``Depends(get_db)``
|
|
session open across the FTP directory listing *and* the multi-MB video
|
|
download. It now (1) reads the archive + printer in a short session and
|
|
releases the pooled connection *before* the FTP work, then (2) re-opens a
|
|
fresh short session only to attach the downloaded file.
|
|
|
|
Two things that refactor could have broken, one test each:
|
|
|
|
* The matching logic reads ``archive.filename/started_at/completed_at/
|
|
created_at`` and ``printer.ip_address/...`` AFTER the read session has
|
|
closed. If any were a lazy-loaded relationship (or an expired column) that
|
|
would raise ``DetachedInstanceError``. The not-found test drives every
|
|
match strategy, exercising all of those detached reads.
|
|
|
|
* The attach write runs in a *fresh* ``async_session()``, which — unlike
|
|
``get_db`` — does NOT auto-commit on block exit. If ``attach_timelapse``
|
|
didn't commit internally the write would be silently dropped. The attach
|
|
test asserts the row is actually persisted.
|
|
|
|
FTP is fully mocked, so no printer is contacted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_no_match_reads_detached_archive_scalars(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Two non-matching videos → 200 not_found, driving every match strategy.
|
|
|
|
Strategies 2-4 read archive.started_at/completed_at/created_at after the
|
|
read session closed; this fails with DetachedInstanceError if the refactor
|
|
left one of those as a lazy load.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
# Two videos, neither matching by name, no mtime, and the archive has no
|
|
# started_at — so strategy 1 (name) misses, 2 (start time) and 3 (mtime)
|
|
# are skipped, and 4 (single-file fallback) is disqualified by len == 2.
|
|
listing = [
|
|
{"name": "clip_a.mp4", "path": "/timelapse/clip_a.mp4", "is_directory": False, "size": 10, "mtime": None},
|
|
{"name": "clip_b.mp4", "path": "/timelapse/clip_b.mp4", "is_directory": False, "size": 20, "mtime": None},
|
|
]
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.get_ftp_retry_settings",
|
|
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
|
|
),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.download_file_bytes_async",
|
|
AsyncMock(return_value=b"should-not-be-called"),
|
|
) as mock_download,
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 200, response.text
|
|
data = response.json()
|
|
assert data["status"] == "not_found"
|
|
assert {f["name"] for f in data["available_files"]} == {"clip_a.mp4", "clip_b.mp4"}
|
|
# No match → we never download.
|
|
mock_download.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
|
|
):
|
|
"""A name-matched video is downloaded and the attach PERSISTS.
|
|
|
|
Guards the fresh-session write boundary: attach_timelapse runs in a new
|
|
async_session that does not auto-commit on exit, so this only passes if
|
|
the service commits internally.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
# attach_timelapse writes into settings.base_dir / archive.file_path's
|
|
# parent, then stores a base_dir-relative timelapse_path. Point base_dir at
|
|
# tmp and stage the archive dir so the real write succeeds (mirrors
|
|
# test_attach_timelapse_safe_path).
|
|
fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
|
|
monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
|
|
monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
|
|
archive_dir = tmp_path / "archives" / "test"
|
|
archive_dir.mkdir(parents=True)
|
|
|
|
# base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
|
|
# video matches by name (strategy 1). .mp4 → no background conversion task.
|
|
video_bytes = b"fake-timelapse-video-bytes"
|
|
matched = {
|
|
"name": "test_print.gcode.mp4",
|
|
"path": "/timelapse/test_print.gcode.mp4",
|
|
"is_directory": False,
|
|
# Must equal len(video_bytes): the download is checked against the
|
|
# listing, and the file is re-listed afterwards to confirm the printer
|
|
# has stopped writing it (#2704).
|
|
"size": len(video_bytes),
|
|
"mtime": None,
|
|
}
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.get_ftp_retry_settings",
|
|
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
|
|
),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.download_file_bytes_async",
|
|
AsyncMock(return_value=video_bytes),
|
|
) as mock_download,
|
|
# A successful attach now removes the printer's copy (#2704); without
|
|
# this the endpoint would open a real FTP connection to the fixture IP.
|
|
patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 200, response.text
|
|
data = response.json()
|
|
assert data["status"] == "attached"
|
|
assert data["filename"] == "test_print.gcode.mp4"
|
|
mock_download.assert_awaited_once()
|
|
mock_delete.assert_awaited_once()
|
|
|
|
# The write happened in the route's fresh session; confirm it was committed
|
|
# by re-reading the row on the separate test session.
|
|
await db_session.refresh(archive)
|
|
assert archive.timelapse_path is not None
|
|
assert archive.timelapse_path.endswith("test_print.gcode.mp4")
|
|
# And the bytes actually landed on disk under the staged archive dir.
|
|
assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_reports_a_wedged_file_service_as_503(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""A printer that cannot negotiate TLS is named as such, not as a 500.
|
|
|
|
#2780's reporter triggered this scan to reproduce their problem and got
|
|
HTTP 500 with "Failed to connect to printer or no timelapse directory
|
|
found" — one message for two unrelated causes, neither of which pointed at
|
|
the printer's file service being wedged.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=True),
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])) as mock_list,
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 503, response.text
|
|
assert "TLS" in response.json()["detail"]
|
|
# And we did not walk all four candidate directories to find that out.
|
|
mock_list.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_reports_a_missing_directory_as_404(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""A reachable printer with no timelapse directory is a 404, not a 500."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=False),
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 404, response.text
|
|
assert "timelapse" in response.json()["detail"].lower()
|