Files
bambuddy/backend/tests/unit/test_obico_detection.py
T
maziggy 0198226db1 Root cause: the Obico detection service handed the ML API a bare /camera/snapshot URL, and Bambuddy's auth returned 401 on that unauthenticated GET. The ML API then
surfaced it as "Failed to get image", which Bambuddy reported back as a 400.

  Fix: the snapshot endpoint already accepts a reusable camera-stream token (the same mechanism used by <img>-based camera consumers, since browsers can't send auth headers
  on image loads). The detection service now appends that token to the URL it gives the ML API. The token is cached on the service, refreshed 5 min before its 60-min expiry,
   and is simply ignored when Bambuddy auth is disabled — so no behavior change for users without auth.

  Refs #172
2026-04-14 11:36:46 +02:00

237 lines
9.5 KiB
Python

"""Unit tests for Obico detection service (#172)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.schemas.settings import AppSettingsUpdate
from backend.app.services.obico_detection import ObicoDetectionService
from backend.app.services.obico_smoothing import WARMUP_FRAMES
class TestSettingsSchemaValidators:
"""Guard rails on the new obico_* AppSettings fields."""
def test_sensitivity_accepts_valid_values(self):
for value in ("low", "medium", "high"):
u = AppSettingsUpdate(obico_sensitivity=value)
assert u.obico_sensitivity == value
def test_sensitivity_rejects_garbage(self):
with pytest.raises(ValueError, match="obico_sensitivity"):
AppSettingsUpdate(obico_sensitivity="extreme")
def test_action_accepts_valid_values(self):
for value in ("notify", "pause", "pause_and_off"):
assert AppSettingsUpdate(obico_action=value).obico_action == value
def test_action_rejects_garbage(self):
with pytest.raises(ValueError, match="obico_action"):
AppSettingsUpdate(obico_action="explode")
def test_enabled_printers_accepts_empty(self):
assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
def test_enabled_printers_accepts_int_array(self):
u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
assert u.obico_enabled_printers == "[1, 2, 3]"
def test_enabled_printers_rejects_non_json(self):
with pytest.raises(ValueError, match="valid JSON"):
AppSettingsUpdate(obico_enabled_printers="1,2,3")
def test_enabled_printers_rejects_non_list(self):
with pytest.raises(ValueError, match="JSON array"):
AppSettingsUpdate(obico_enabled_printers='{"1": true}')
def test_enabled_printers_rejects_non_int_elements(self):
with pytest.raises(ValueError, match="JSON array"):
AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
def test_poll_interval_bounds(self):
with pytest.raises(ValueError):
AppSettingsUpdate(obico_poll_interval=4)
with pytest.raises(ValueError):
AppSettingsUpdate(obico_poll_interval=121)
assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
class TestGetStatus:
def test_empty_initial_status(self):
svc = ObicoDetectionService()
s = svc.get_status()
assert s["is_running"] is False
assert s["per_printer"] == {}
assert s["history"] == []
assert "low" in s["thresholds"] and "high" in s["thresholds"]
class TestTestConnection:
@pytest.mark.asyncio
async def test_empty_url_via_route(self):
"""Service does not special-case empty URL — the route does."""
svc = ObicoDetectionService()
# This will fail DNS/connect, but should return ok=False
result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
assert result["ok"] is False
assert result["error"] is not None
@pytest.mark.asyncio
async def test_healthy_response_is_ok(self):
svc = ObicoDetectionService()
mock_response = MagicMock(status_code=200, text="ok")
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
result = await svc.test_connection("http://obico:3333")
assert result["ok"] is True
assert result["status_code"] == 200
assert result["body"] == "ok"
assert result["error"] is None
@pytest.mark.asyncio
async def test_non_ok_body_is_not_ok(self):
svc = ObicoDetectionService()
mock_response = MagicMock(status_code=200, text="something else")
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
result = await svc.test_connection("http://obico:3333/")
assert result["ok"] is False
assert result["body"] == "something else"
class TestPollOneStateLifecycle:
"""Confirms per-printer state is reset when a new print starts."""
@pytest.mark.asyncio
async def test_new_task_name_resets_state(self):
svc = ObicoDetectionService()
# Seed a state that has been running for a while
from backend.app.services.obico_smoothing import PrintState
seeded = PrintState()
for _ in range(WARMUP_FRAMES + 5):
seeded.update(0.5)
svc._states[1] = seeded
svc._state_keys[1] = "old_task"
svc._action_fired[1] = True
settings = {
"enabled": True,
"ml_url": "http://obico:3333",
"sensitivity": "medium",
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
mock_response = MagicMock()
mock_response.json.return_value = {"detections": []}
mock_response.raise_for_status = MagicMock()
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
patch(
"backend.app.services.obico_detection.create_camera_stream_token",
new=AsyncMock(return_value="tok"),
),
):
await svc._check_printer(1, status, settings)
# State was reset (frame_count is 1 after the single update, not 36)
assert svc._states[1].frame_count == 1
assert svc._state_keys[1] == "new_task"
assert svc._action_fired[1] is False
@pytest.mark.asyncio
async def test_ml_api_error_does_not_crash(self):
svc = ObicoDetectionService()
settings = {
"enabled": True,
"ml_url": "http://obico:3333",
"sensitivity": "medium",
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
patch(
"backend.app.services.obico_detection.create_camera_stream_token",
new=AsyncMock(return_value="tok"),
),
):
await svc._check_printer(1, status, settings)
assert svc._last_error is not None
assert "connection refused" in svc._last_error
@pytest.mark.asyncio
async def test_failure_fires_action_only_once(self):
"""Once a failure has fired for a print, subsequent failures should not re-fire."""
svc = ObicoDetectionService()
settings = {
"enabled": True,
"ml_url": "http://obico:3333",
"sensitivity": "medium",
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
# Seed state so the next frame crosses HIGH immediately
from backend.app.services.obico_smoothing import PrintState
seeded = PrintState()
for _ in range(WARMUP_FRAMES + 500):
seeded.update(1.0)
svc._states[1] = seeded
svc._state_keys[1] = "job"
svc._action_fired[1] = False
mock_response = MagicMock()
mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
mock_response.raise_for_status = MagicMock()
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
patch(
"backend.app.services.obico_detection.create_camera_stream_token",
new=AsyncMock(return_value="tok"),
),
):
await svc._check_printer(1, status, settings)
assert mock_action.call_count == 1
await svc._check_printer(1, status, settings)
# Second call must not dispatch again
assert mock_action.call_count == 1