|
|
@@ -5,7 +5,13 @@ 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_detection import (
|
|
|
+ FRAME_CACHE_TTL,
|
|
|
+ ObicoDetectionService,
|
|
|
+ _frame_cache,
|
|
|
+ pop_frame,
|
|
|
+ stash_frame,
|
|
|
+)
|
|
|
from backend.app.services.obico_smoothing import WARMUP_FRAMES
|
|
|
|
|
|
FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
|
|
@@ -133,6 +139,7 @@ class TestPollOneStateLifecycle:
|
|
|
"action": "notify",
|
|
|
"poll_interval": 10,
|
|
|
"enabled_printers": None,
|
|
|
+ "external_url": "http://bambuddy:8000",
|
|
|
}
|
|
|
status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
|
|
|
|
|
|
@@ -140,7 +147,7 @@ class TestPollOneStateLifecycle:
|
|
|
mock_response.json.return_value = {"detections": []}
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
mock_client = MagicMock()
|
|
|
- mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
+ mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
@@ -165,11 +172,12 @@ class TestPollOneStateLifecycle:
|
|
|
"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.post = AsyncMock(side_effect=RuntimeError("connection refused"))
|
|
|
+ mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
@@ -182,6 +190,41 @@ class TestPollOneStateLifecycle:
|
|
|
assert svc._last_error is not None
|
|
|
assert "connection refused" in svc._last_error
|
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_ml_api_empty_exception_message_falls_back_to_type(self):
|
|
|
+ """If str(exc) is empty, log the exception class name instead of a blank suffix."""
|
|
|
+ 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="")
|
|
|
+
|
|
|
+ class _SilentError(Exception):
|
|
|
+ def __str__(self) -> str:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ mock_client = MagicMock()
|
|
|
+ mock_client.get = AsyncMock(side_effect=_SilentError())
|
|
|
+ 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.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
|
|
|
+ ):
|
|
|
+ await svc._check_printer(1, status, settings)
|
|
|
+
|
|
|
+ assert svc._last_error is not None
|
|
|
+ assert "_SilentError" in svc._last_error
|
|
|
+ # The suffix is never blank
|
|
|
+ assert not svc._last_error.rstrip().endswith(":")
|
|
|
+
|
|
|
@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."""
|
|
|
@@ -193,6 +236,7 @@ class TestPollOneStateLifecycle:
|
|
|
"action": "notify",
|
|
|
"poll_interval": 10,
|
|
|
"enabled_printers": None,
|
|
|
+ "external_url": "http://bambuddy:8000",
|
|
|
}
|
|
|
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
|
|
|
|
|
|
@@ -210,7 +254,7 @@ class TestPollOneStateLifecycle:
|
|
|
mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
mock_client = MagicMock()
|
|
|
- mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
+ mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
@@ -226,11 +270,70 @@ class TestPollOneStateLifecycle:
|
|
|
assert mock_action.call_count == 1
|
|
|
|
|
|
|
|
|
-class TestCheckPrinterPostsImageDirectly:
|
|
|
- """The detection loop must POST JPEG bytes directly to the ML API."""
|
|
|
+class TestFrameCache:
|
|
|
+ """One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.
|
|
|
+
|
|
|
+ Obico's ML API fetches snapshots via `GET /p/?img=URL` with `timeout=(0.1, 5)`.
|
|
|
+ Our /camera/snapshot can exceed that on cold calls (RTSP keyframe wait). So the
|
|
|
+ detection loop captures locally, stashes the JPEG bytes under a nonce, then hands
|
|
|
+ Obico a URL that returns those bytes instantly. The cache is single-use + TTLed
|
|
|
+ so a leaked nonce can't be replayed.
|
|
|
+ """
|
|
|
+
|
|
|
+ def setup_method(self):
|
|
|
+ _frame_cache.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
- async def test_ml_api_called_with_post_and_image_bytes(self):
|
|
|
+ async def test_stash_and_pop_roundtrip(self):
|
|
|
+ nonce = await stash_frame(FAKE_JPEG)
|
|
|
+ assert nonce # non-empty URL-safe token
|
|
|
+ data = await pop_frame(nonce)
|
|
|
+ assert data == FAKE_JPEG
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_nonce_is_single_use(self):
|
|
|
+ nonce = await stash_frame(FAKE_JPEG)
|
|
|
+ assert await pop_frame(nonce) == FAKE_JPEG
|
|
|
+ # Second pop returns None — caches replay protection
|
|
|
+ assert await pop_frame(nonce) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_unknown_nonce_returns_none(self):
|
|
|
+ assert await pop_frame("not-a-real-nonce") is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_stash_produces_unique_nonces(self):
|
|
|
+ nonces = {await stash_frame(FAKE_JPEG) for _ in range(10)}
|
|
|
+ assert len(nonces) == 10
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_expired_entries_are_pruned_on_stash(self):
|
|
|
+ """New entries trigger pruning of TTL-expired ones — prevents unbounded growth."""
|
|
|
+ # Manually seed an entry with a stale timestamp
|
|
|
+ import time as time_module
|
|
|
+
|
|
|
+ _frame_cache["stale-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
|
|
|
+ await stash_frame(FAKE_JPEG)
|
|
|
+ # Stale entry was pruned
|
|
|
+ assert "stale-nonce" not in _frame_cache
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_pop_rejects_expired_nonce(self):
|
|
|
+ """Even if the entry is still in the dict, an expired TTL returns None."""
|
|
|
+ import time as time_module
|
|
|
+
|
|
|
+ _frame_cache["aging-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
|
|
|
+ assert await pop_frame("aging-nonce") is None
|
|
|
+
|
|
|
+
|
|
|
+class TestCheckPrinterUsesCachedFrameUrl:
|
|
|
+ """The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot."""
|
|
|
+
|
|
|
+ def setup_method(self):
|
|
|
+ _frame_cache.clear()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_ml_api_called_with_cached_frame_url(self):
|
|
|
svc = ObicoDetectionService()
|
|
|
settings = {
|
|
|
"enabled": True,
|
|
|
@@ -239,6 +342,7 @@ class TestCheckPrinterPostsImageDirectly:
|
|
|
"action": "notify",
|
|
|
"poll_interval": 10,
|
|
|
"enabled_printers": None,
|
|
|
+ "external_url": "http://bambuddy:8000",
|
|
|
}
|
|
|
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
|
|
|
|
|
|
@@ -246,7 +350,7 @@ class TestCheckPrinterPostsImageDirectly:
|
|
|
mock_response.json.return_value = {"detections": []}
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
mock_client = MagicMock()
|
|
|
- mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
+ mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
@@ -256,18 +360,16 @@ class TestCheckPrinterPostsImageDirectly:
|
|
|
):
|
|
|
await svc._check_printer(1, status, settings)
|
|
|
|
|
|
- # ML API was called via POST
|
|
|
- mock_client.post.assert_called_once()
|
|
|
- _args, kwargs = mock_client.post.call_args
|
|
|
- # URL is the ML API /p/ endpoint
|
|
|
+ # ML API was called via GET (Obico's /p/ is GET-only)
|
|
|
+ mock_client.get.assert_called_once()
|
|
|
+ _args, kwargs = mock_client.get.call_args
|
|
|
assert _args[0] == "http://obico:3333/p/"
|
|
|
- # Image bytes sent as multipart file upload
|
|
|
- files = kwargs["files"]
|
|
|
- assert "img" in files
|
|
|
- filename, data, content_type = files["img"]
|
|
|
- assert filename == "snapshot.jpg"
|
|
|
- assert data == FAKE_JPEG
|
|
|
- assert content_type == "image/jpeg"
|
|
|
+ img_url = kwargs["params"]["img"]
|
|
|
+ assert img_url.startswith("http://bambuddy:8000/api/v1/obico/cached-frame/")
|
|
|
+ # The path segment after /cached-frame/ is the nonce itself — that nonce must
|
|
|
+ # resolve back to our stashed frame (single-use guarantees freshness).
|
|
|
+ nonce = img_url.rsplit("/", 1)[-1]
|
|
|
+ assert await pop_frame(nonce) == FAKE_JPEG
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
async def test_capture_failure_skips_ml_call(self):
|
|
|
@@ -280,11 +382,12 @@ class TestCheckPrinterPostsImageDirectly:
|
|
|
"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.post = AsyncMock()
|
|
|
+ mock_client.get = AsyncMock()
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
|
|
@@ -294,6 +397,36 @@ class TestCheckPrinterPostsImageDirectly:
|
|
|
):
|
|
|
await svc._check_printer(1, status, settings)
|
|
|
|
|
|
- mock_client.post.assert_not_called()
|
|
|
+ mock_client.get.assert_not_called()
|
|
|
assert svc._last_error is not None
|
|
|
assert "Failed to capture snapshot" in svc._last_error
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_missing_external_url_skips_ml_call(self):
|
|
|
+ """Without external_url, Obico can't reach our cached-frame endpoint."""
|
|
|
+ svc = ObicoDetectionService()
|
|
|
+ settings = {
|
|
|
+ "enabled": True,
|
|
|
+ "ml_url": "http://obico:3333",
|
|
|
+ "sensitivity": "medium",
|
|
|
+ "action": "notify",
|
|
|
+ "poll_interval": 10,
|
|
|
+ "enabled_printers": None,
|
|
|
+ "external_url": "",
|
|
|
+ }
|
|
|
+ status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
|
|
|
+
|
|
|
+ mock_client = MagicMock()
|
|
|
+ mock_client.get = AsyncMock()
|
|
|
+ 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.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
|
|
|
+ ):
|
|
|
+ await svc._check_printer(1, status, settings)
|
|
|
+
|
|
|
+ mock_client.get.assert_not_called()
|
|
|
+ assert svc._last_error is not None
|
|
|
+ assert "external_url" in svc._last_error
|