From 23d539f2848064461443a83e51f2cc7a2f1b3484 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 10 Feb 2026 17:49:58 +0100 Subject: [PATCH 1/3] Fix CI backend-tests failing to collect FTP test suite CI only installed requirements.txt, missing pyOpenSSL from requirements-dev.txt. This caused an ImportError on TLS_FTPHandler during test collection, blocking all unit/services tests. Also adds pytest-timeout to dev deps instead of ad-hoc pip install in CI. --- .github/workflows/ci.yml | 2 +- requirements-dev.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c73de5220..243ebd39b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest pytest-asyncio pytest-cov pytest-timeout + pip install -r requirements-dev.txt - name: Run tests timeout-minutes: 10 diff --git a/requirements-dev.txt b/requirements-dev.txt index a08c02f62..522e76236 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,6 +3,7 @@ pytest>=8.0.0 pytest-asyncio>=0.23.0 pytest-cov>=4.1.0 pytest-xdist>=3.5.0 +pytest-timeout>=2.4.0 httpx>=0.27.0 ruff>=0.8.0 From 1e5b263acb024b02fdf8d6063536151c7a9748c5 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 10 Feb 2026 17:57:40 +0100 Subject: [PATCH 2/3] Added -n auto to run tests in parallel via pytest-xdist --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 243ebd39b..f74b0fd56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: timeout-minutes: 10 run: | cd backend - python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread + python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto # ============================================================================ # Frontend Checks From b8865903394727aaacfda0f3db4b6322a9b42415 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 10 Feb 2026 18:11:25 +0100 Subject: [PATCH 3/3] Speed up FTP test suite with class-scoped server fixtures Function-scoped ftp_server meant 67 TLS server start/stop cycles. Class-scoped reduces to 10. Adds per-test cleanup fixture to reset failure injections and filesystem between tests. Isolates test_disconnect_after_server_gone into its own class to prevent close_all() from nuking other servers' asyncore sockets. --- backend/tests/unit/services/conftest.py | 49 +++++++++++++-- backend/tests/unit/services/test_bambu_ftp.py | 60 +++++++++++-------- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/backend/tests/unit/services/conftest.py b/backend/tests/unit/services/conftest.py index 2429d7fd6..8934fa168 100644 --- a/backend/tests/unit/services/conftest.py +++ b/backend/tests/unit/services/conftest.py @@ -2,8 +2,13 @@ Provides a real implicit FTPS server (via mock_ftp_server) and client factory for integration-style testing of BambuFTPClient against a live server. + +The server fixture is class-scoped to avoid the overhead of starting a new +TLS server for every test (~67 TLS handshakes → ~9 per class). """ +import os +import shutil import socket from unittest.mock import patch @@ -13,6 +18,8 @@ from backend.app.services.bambu_ftp import BambuFTPClient from backend.app.services.virtual_printer.certificate import CertificateService from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer +BAMBU_DIRS = ("cache", "timelapse", "model", "data", "data/Metadata") + @pytest.fixture(scope="session") def ftp_certs(tmp_path_factory): @@ -30,15 +37,16 @@ def _find_free_port() -> int: return s.getsockname()[1] -@pytest.fixture() -def ftp_root(tmp_path): +@pytest.fixture(scope="class") +def ftp_root(tmp_path_factory): """Create temp directory with standard Bambu printer directory structure.""" - for d in ("cache", "timelapse", "model", "data", "data/Metadata"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - return tmp_path + root = tmp_path_factory.mktemp("ftp_root") + for d in BAMBU_DIRS: + (root / d).mkdir(parents=True, exist_ok=True) + return root -@pytest.fixture() +@pytest.fixture(scope="class") def ftp_server(ftp_certs, ftp_root): """Start a mock implicit FTPS server, yield it, stop on cleanup.""" cert_path, key_path = ftp_certs @@ -56,6 +64,35 @@ def ftp_server(ftp_certs, ftp_root): server.stop() +@pytest.fixture(autouse=True) +def _ftp_test_cleanup(request): + """Reset server state between tests within a class. + + Clears injected failures and restores the Bambu directory structure + so each test starts with a clean filesystem. Skips cleanup for test + classes that don't use the class-scoped ftp_server (e.g. + TestDisconnectServerGone). + """ + yield + # Only clean up if this test class uses the class-scoped fixtures + ftp_root = request.node.funcargs.get("ftp_root") + if ftp_root is None: + return + server = request.node.funcargs.get("ftp_server") + if server is not None: + server.clear_failures() + # Restore clean directory structure + root = str(ftp_root) + for entry in os.listdir(root): + path = os.path.join(root, entry) + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + for d in BAMBU_DIRS: + os.makedirs(os.path.join(root, d), exist_ok=True) + + @pytest.fixture() def ftp_client_factory(ftp_server): """Factory that creates BambuFTPClient instances pointed at the mock server.""" diff --git a/backend/tests/unit/services/test_bambu_ftp.py b/backend/tests/unit/services/test_bambu_ftp.py index f342680d8..b7b448705 100644 --- a/backend/tests/unit/services/test_bambu_ftp.py +++ b/backend/tests/unit/services/test_bambu_ftp.py @@ -85,32 +85,6 @@ class TestConnection: client.disconnect() # Should not raise assert client._ftp is None - def test_disconnect_after_server_gone(self, ftp_certs, ftp_root): - """Disconnect after server has stopped raises EOFError. - - Note: The current disconnect() catches (OSError, ftplib.Error) but - EOFError is neither. This documents actual behavior — a future fix - could add EOFError to the except clause. - """ - from backend.tests.unit.services.mock_ftp_server import ( - MockBambuFTPServer, - ) - - from .conftest import _find_free_port - - cert_path, key_path = ftp_certs - port = _find_free_port() - server = MockBambuFTPServer("127.0.0.1", port, str(ftp_root), cert_path, key_path) - server.start() - - client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0) - client.FTP_PORT = port - client.connect() - - server.stop() - with pytest.raises(EOFError): - client.disconnect() - def test_x1c_uses_prot_p(self, ftp_client_factory): """X1C model connects with prot_p (protected data channel).""" client = ftp_client_factory(printer_model="X1C") @@ -141,6 +115,40 @@ class TestConnection: client.disconnect() +# --------------------------------------------------------------------------- +# TestDisconnectServerGone — isolated class because server.stop() calls +# close_all() which nukes all asyncore sockets globally. +# --------------------------------------------------------------------------- +class TestDisconnectServerGone: + """Test disconnect behavior when the server has stopped.""" + + def test_disconnect_after_server_gone(self, ftp_certs, tmp_path): + """Disconnect after server has stopped raises EOFError. + + Note: The current disconnect() catches (OSError, ftplib.Error) but + EOFError is neither. This documents actual behavior — a future fix + could add EOFError to the except clause. + """ + from backend.tests.unit.services.mock_ftp_server import ( + MockBambuFTPServer, + ) + + from .conftest import _find_free_port + + cert_path, key_path = ftp_certs + port = _find_free_port() + server = MockBambuFTPServer("127.0.0.1", port, str(tmp_path), cert_path, key_path) + server.start() + + client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0) + client.FTP_PORT = port + client.connect() + + server.stop() + with pytest.raises(EOFError): + client.disconnect() + + # --------------------------------------------------------------------------- # TestListFiles # ---------------------------------------------------------------------------