mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
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
237 lines
9.5 KiB
Python
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
|