mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
Two printers went on printing while every archive they produced held nothing but a filename. Bambuddy opened port 990, the printer accepted the connection and answered with something that was not TLS, and connect() logged a warning and returned False -- indistinguishable, to every caller, from "the file is not at this path". So the 3MF lookup walked all six filename variants across five directories with four retries each, the cover endpoint ran its own sixteen-path sweep, and the timelapse scan added four more, all against a sixteen-path sweep, and the timelapse scan added four more, all against a printer that could not have answered any of them. One reporter's log carried 1813 identical handshake failures, another's 3511. The evidence says this is the printer's own file service getting stuck, not a model, firmware or TLS-configuration problem. In #2780's bundle the same two printers ran clean from 22 July to 4 August and failed again from the 5th; a second bundle shows an X2D serving files for five days, flipping on 19 July, then failing every connection for eight days with zero successes. The same models and firmware appear in roughly twenty other bundles with no occurrences at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly this symptom, and the reporter's own debug line proves they do not. An ssl.SSLError from connect() now opens a five-minute cool-off for that printer. Subsequent connects return False without touching the network, so a wedged printer is contacted twice an hour instead of hundreds of times a minute, and the single warning that is logged names the remedy. The cool-off is dropped on expiry rather than kept, so the map holds one key per currently wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup abandons the remaining paths and skips the directory-walk fallback, the cover endpoint returns 503 naming the file service instead of a 404 that reads as "this print has no thumbnail", and the timelapse scan separates 503 (cannot reach the printer) from 404 (no timelapse directory) -- one 500 used to cover both, which is what the reporter hit when reproducing. The Connection Diagnostic completed a bare TCP connect to 990, which is why it reported the port green throughout: the port is open, it is what is behind it that is broken. It now completes a real implicit-TLS handshake using the model's own ftp_profiles cap, so a pass means the FTP client would also get through. An open port that cannot negotiate reports warn with reason no_tls, selecting a new message in all 13 locales that points at a printer restart rather than at the firewall. No login is attempted, so this stays valid in the pre-save Add Printer flow. The cool-off tests run against a real socket that accepts on 990 and replies with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off map too -- every test here talks to 127.0.0.1, so one left behind would make the next test's connect() a no-op.
191 lines
8.2 KiB
Python
191 lines
8.2 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).
|
|
monkeypatch.setattr(
|
|
"backend.app.services.archive.settings",
|
|
MagicMock(base_dir=tmp_path),
|
|
)
|
|
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()
|