test_obico_api.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """Integration tests for Obico API endpoints (#172 follow-up).
  2. Verifies the /obico/cached-frame/{nonce} endpoint used by Obico's ML API to fetch
  3. pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
  4. hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
  5. """
  6. import pytest
  7. from httpx import AsyncClient
  8. from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
  9. from backend.app.services.obico_smoothing import PrintState
  10. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  11. @pytest.fixture(autouse=True)
  12. def clear_cache():
  13. _frame_cache.clear()
  14. yield
  15. _frame_cache.clear()
  16. class TestObicoCachedFrame:
  17. @pytest.mark.asyncio
  18. @pytest.mark.integration
  19. async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):
  20. """A stashed nonce returns the stored JPEG bytes with image/jpeg."""
  21. nonce = await stash_frame(FAKE_JPEG)
  22. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  23. assert response.status_code == 200
  24. assert response.headers["content-type"] == "image/jpeg"
  25. assert response.content == FAKE_JPEG
  26. @pytest.mark.asyncio
  27. @pytest.mark.integration
  28. async def test_unknown_nonce_is_404(self, async_client: AsyncClient):
  29. """An unguessable URL must not leak that the endpoint exists — return 404."""
  30. response = await async_client.get("/api/v1/obico/cached-frame/definitely-not-a-real-nonce")
  31. assert response.status_code == 404
  32. @pytest.mark.asyncio
  33. @pytest.mark.integration
  34. async def test_nonce_is_single_use(self, async_client: AsyncClient):
  35. """A second fetch with the same nonce returns 404 — prevents replay."""
  36. nonce = await stash_frame(FAKE_JPEG)
  37. first = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  38. assert first.status_code == 200
  39. second = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  40. assert second.status_code == 404
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_endpoint_is_public(self, async_client: AsyncClient):
  44. """Obico's ML API can't send auth headers, so the nonce IS the credential.
  45. The path must be in PUBLIC_API_PATTERNS (no auth wall)."""
  46. nonce = await stash_frame(FAKE_JPEG)
  47. # Intentionally omit any auth headers even if the fixture would normally inject them
  48. response = await async_client.get(
  49. f"/api/v1/obico/cached-frame/{nonce}",
  50. headers={}, # no Authorization header
  51. )
  52. assert response.status_code == 200
  53. @pytest.mark.asyncio
  54. @pytest.mark.integration
  55. async def test_response_is_not_cached(self, async_client: AsyncClient):
  56. """Browsers/proxies must not hold onto the image after Obico consumes it."""
  57. nonce = await stash_frame(FAKE_JPEG)
  58. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  59. assert response.status_code == 200
  60. assert "no-store" in response.headers.get("cache-control", "")
  61. class TestObicoPrinterStatus:
  62. """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
  63. @pytest.fixture(autouse=True)
  64. def clear_detection_state(self):
  65. obico_detection_service._states.clear()
  66. obico_detection_service._last_class.clear()
  67. obico_detection_service._last_error = None
  68. yield
  69. obico_detection_service._states.clear()
  70. obico_detection_service._last_class.clear()
  71. obico_detection_service._last_error = None
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_returns_per_printer_classification(self, async_client: AsyncClient):
  75. state = PrintState()
  76. state.update(0.5)
  77. obico_detection_service._states[1] = state
  78. obico_detection_service._last_class[1] = "warning"
  79. response = await async_client.get("/api/v1/obico/printer-status")
  80. assert response.status_code == 200
  81. data = response.json()
  82. assert "enabled" in data
  83. # None = all printers monitored (no obico_enabled_printers subset configured)
  84. assert data["monitored_printers"] is None
  85. entry = data["per_printer"]["1"]
  86. assert entry["class"] == "warning"
  87. assert entry["frame_count"] == 1
  88. assert isinstance(entry["score"], float)
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
  92. response = await async_client.get("/api/v1/obico/printer-status")
  93. assert response.status_code == 200
  94. assert response.json()["per_printer"] == {}
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
  98. """A configured obico_enabled_printers subset surfaces (as a sorted list) so
  99. the frontend can show the idle badge only on monitored printers."""
  100. update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
  101. assert update.status_code == 200
  102. try:
  103. response = await async_client.get("/api/v1/obico/printer-status")
  104. assert response.json()["monitored_printers"] == [1, 3]
  105. finally:
  106. await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
  107. @pytest.mark.asyncio
  108. @pytest.mark.integration
  109. async def test_last_error_is_surfaced(self, async_client: AsyncClient):
  110. """The badge modal shows the service's last error (auth disabled in the
  111. test env, so the settings:read gate on the field is open)."""
  112. obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
  113. response = await async_client.get("/api/v1/obico/printer-status")
  114. assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_does_not_leak_settings(self, async_client: AsyncClient):
  118. """Unlike /obico/status, this endpoint is readable with printers:read only,
  119. so it must not expose the ML URL or other configuration."""
  120. response = await async_client.get("/api/v1/obico/printer-status")
  121. data = response.json()
  122. for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
  123. assert key not in data