mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
The live Obico classification was only visible under Settings -> Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray Idle while no print is being watched, then green Safe, amber Warning, or red Failure while a print is actively monitored. The tooltip carries the current smoothed score; clicking jumps to the full detection status and history in Settings. Printers excluded from the monitored subset show no badge. Served by a new lightweight /obico/printer-status endpoint readable with printer permissions alone - it exposes only the enabled flag, the monitored-printer set, and per-printer classification, keeping ML URL and other configuration behind the existing settings-gated endpoint.
145 lines
6.4 KiB
Python
145 lines
6.4 KiB
Python
"""Integration tests for Obico API endpoints (#172 follow-up).
|
|
|
|
Verifies the /obico/cached-frame/{nonce} endpoint used by Obico's ML API to fetch
|
|
pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
|
|
hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
|
|
from backend.app.services.obico_smoothing import PrintState
|
|
|
|
FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_cache():
|
|
_frame_cache.clear()
|
|
yield
|
|
_frame_cache.clear()
|
|
|
|
|
|
class TestObicoCachedFrame:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):
|
|
"""A stashed nonce returns the stored JPEG bytes with image/jpeg."""
|
|
nonce = await stash_frame(FAKE_JPEG)
|
|
response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "image/jpeg"
|
|
assert response.content == FAKE_JPEG
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_unknown_nonce_is_404(self, async_client: AsyncClient):
|
|
"""An unguessable URL must not leak that the endpoint exists — return 404."""
|
|
response = await async_client.get("/api/v1/obico/cached-frame/definitely-not-a-real-nonce")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_nonce_is_single_use(self, async_client: AsyncClient):
|
|
"""A second fetch with the same nonce returns 404 — prevents replay."""
|
|
nonce = await stash_frame(FAKE_JPEG)
|
|
first = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
|
|
assert first.status_code == 200
|
|
second = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
|
|
assert second.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_endpoint_is_public(self, async_client: AsyncClient):
|
|
"""Obico's ML API can't send auth headers, so the nonce IS the credential.
|
|
The path must be in PUBLIC_API_PATTERNS (no auth wall)."""
|
|
nonce = await stash_frame(FAKE_JPEG)
|
|
# Intentionally omit any auth headers even if the fixture would normally inject them
|
|
response = await async_client.get(
|
|
f"/api/v1/obico/cached-frame/{nonce}",
|
|
headers={}, # no Authorization header
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_response_is_not_cached(self, async_client: AsyncClient):
|
|
"""Browsers/proxies must not hold onto the image after Obico consumes it."""
|
|
nonce = await stash_frame(FAKE_JPEG)
|
|
response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
|
|
assert response.status_code == 200
|
|
assert "no-store" in response.headers.get("cache-control", "")
|
|
|
|
|
|
class TestObicoPrinterStatus:
|
|
"""The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_detection_state(self):
|
|
obico_detection_service._states.clear()
|
|
obico_detection_service._last_class.clear()
|
|
obico_detection_service._last_error = None
|
|
yield
|
|
obico_detection_service._states.clear()
|
|
obico_detection_service._last_class.clear()
|
|
obico_detection_service._last_error = None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_returns_per_printer_classification(self, async_client: AsyncClient):
|
|
state = PrintState()
|
|
state.update(0.5)
|
|
obico_detection_service._states[1] = state
|
|
obico_detection_service._last_class[1] = "warning"
|
|
|
|
response = await async_client.get("/api/v1/obico/printer-status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "enabled" in data
|
|
# None = all printers monitored (no obico_enabled_printers subset configured)
|
|
assert data["monitored_printers"] is None
|
|
entry = data["per_printer"]["1"]
|
|
assert entry["class"] == "warning"
|
|
assert entry["frame_count"] == 1
|
|
assert isinstance(entry["score"], float)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
|
|
response = await async_client.get("/api/v1/obico/printer-status")
|
|
assert response.status_code == 200
|
|
assert response.json()["per_printer"] == {}
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
|
|
"""A configured obico_enabled_printers subset surfaces (as a sorted list) so
|
|
the frontend can show the idle badge only on monitored printers."""
|
|
update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
|
|
assert update.status_code == 200
|
|
try:
|
|
response = await async_client.get("/api/v1/obico/printer-status")
|
|
assert response.json()["monitored_printers"] == [1, 3]
|
|
finally:
|
|
await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_last_error_is_surfaced(self, async_client: AsyncClient):
|
|
"""The badge modal shows the service's last error (auth disabled in the
|
|
test env, so the settings:read gate on the field is open)."""
|
|
obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
|
|
response = await async_client.get("/api/v1/obico/printer-status")
|
|
assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_does_not_leak_settings(self, async_client: AsyncClient):
|
|
"""Unlike /obico/status, this endpoint is readable with printers:read only,
|
|
so it must not expose the ML URL or other configuration."""
|
|
response = await async_client.get("/api/v1/obico/printer-status")
|
|
data = response.json()
|
|
for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
|
|
assert key not in data
|