Add GET /api/vision/health, POST /api/jobs/{id}/survey/analyze,
POST /api/jobs/{id}/survey/responses, and GET /api/jobs/{id}/survey/responses
to dev-api.py. All 10 TDD tests pass; 549 total suite tests pass (0 regressions).
164 lines
6.9 KiB
Python
164 lines
6.9 KiB
Python
"""Tests for survey endpoints: vision health, analyze, save response, get history."""
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
import sys
|
|
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa")
|
|
from dev_api import app
|
|
return TestClient(app)
|
|
|
|
|
|
# ── GET /api/vision/health ───────────────────────────────────────────────────
|
|
|
|
def test_vision_health_available(client):
|
|
"""Returns available=true when vision service responds 200."""
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
with patch("dev_api.requests.get", return_value=mock_resp):
|
|
resp = client.get("/api/vision/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"available": True}
|
|
|
|
|
|
def test_vision_health_unavailable(client):
|
|
"""Returns available=false when vision service times out or errors."""
|
|
with patch("dev_api.requests.get", side_effect=Exception("timeout")):
|
|
resp = client.get("/api/vision/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"available": False}
|
|
|
|
|
|
# ── POST /api/jobs/{id}/survey/analyze ──────────────────────────────────────
|
|
|
|
def test_analyze_text_quick(client):
|
|
"""Text mode quick analysis returns output and source=text_paste."""
|
|
mock_router = MagicMock()
|
|
mock_router.complete.return_value = "1. B — best option"
|
|
mock_router.config.get.return_value = ["claude_code", "vllm"]
|
|
with patch("dev_api.LLMRouter", return_value=mock_router):
|
|
resp = client.post("/api/jobs/1/survey/analyze", json={
|
|
"text": "Q1: Do you prefer teamwork?\nA. Solo B. Together",
|
|
"mode": "quick",
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["source"] == "text_paste"
|
|
assert "B" in data["output"]
|
|
# System prompt must be passed for text path
|
|
call_kwargs = mock_router.complete.call_args[1]
|
|
assert "system" in call_kwargs
|
|
assert "culture-fit survey" in call_kwargs["system"]
|
|
|
|
|
|
def test_analyze_text_detailed(client):
|
|
"""Text mode detailed analysis passes correct prompt."""
|
|
mock_router = MagicMock()
|
|
mock_router.complete.return_value = "Option A: good for... Option B: better because..."
|
|
mock_router.config.get.return_value = []
|
|
with patch("dev_api.LLMRouter", return_value=mock_router):
|
|
resp = client.post("/api/jobs/1/survey/analyze", json={
|
|
"text": "Q1: Describe your work style.",
|
|
"mode": "detailed",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["source"] == "text_paste"
|
|
|
|
|
|
def test_analyze_image(client):
|
|
"""Image mode routes through vision path with NO system prompt."""
|
|
mock_router = MagicMock()
|
|
mock_router.complete.return_value = "1. C — collaborative choice"
|
|
mock_router.config.get.return_value = ["vision_service", "claude_code"]
|
|
with patch("dev_api.LLMRouter", return_value=mock_router):
|
|
resp = client.post("/api/jobs/1/survey/analyze", json={
|
|
"image_b64": "aGVsbG8=",
|
|
"mode": "quick",
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["source"] == "screenshot"
|
|
# No system prompt on vision path
|
|
call_kwargs = mock_router.complete.call_args[1]
|
|
assert "system" not in call_kwargs
|
|
|
|
|
|
def test_analyze_llm_failure(client):
|
|
"""Returns 500 when LLM raises an exception."""
|
|
mock_router = MagicMock()
|
|
mock_router.complete.side_effect = Exception("LLM unavailable")
|
|
mock_router.config.get.return_value = []
|
|
with patch("dev_api.LLMRouter", return_value=mock_router):
|
|
resp = client.post("/api/jobs/1/survey/analyze", json={
|
|
"text": "Q1: test",
|
|
"mode": "quick",
|
|
})
|
|
assert resp.status_code == 500
|
|
|
|
|
|
# ── POST /api/jobs/{id}/survey/responses ────────────────────────────────────
|
|
|
|
def test_save_response_text(client):
|
|
"""Save text response writes to DB and returns id."""
|
|
mock_db = MagicMock()
|
|
with patch("dev_api._get_db", return_value=mock_db):
|
|
with patch("dev_api.insert_survey_response", return_value=42) as mock_insert:
|
|
resp = client.post("/api/jobs/1/survey/responses", json={
|
|
"mode": "quick",
|
|
"source": "text_paste",
|
|
"raw_input": "Q1: test question",
|
|
"llm_output": "1. B — good reason",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == 42
|
|
# received_at generated by backend — not None
|
|
call_args = mock_insert.call_args
|
|
assert call_args[1]["received_at"] is not None or call_args[0][3] is not None
|
|
|
|
|
|
def test_save_response_with_image(client, tmp_path, monkeypatch):
|
|
"""Save image response writes PNG file and stores path in DB."""
|
|
monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db"))
|
|
with patch("dev_api.insert_survey_response", return_value=7) as mock_insert:
|
|
with patch("dev_api.Path") as mock_path_cls:
|
|
mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o
|
|
resp = client.post("/api/jobs/1/survey/responses", json={
|
|
"mode": "quick",
|
|
"source": "screenshot",
|
|
"image_b64": "aGVsbG8=", # valid base64
|
|
"llm_output": "1. B — reason",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == 7
|
|
|
|
|
|
# ── GET /api/jobs/{id}/survey/responses ─────────────────────────────────────
|
|
|
|
def test_get_history_empty(client):
|
|
"""Returns empty list when no history exists."""
|
|
with patch("dev_api.get_survey_responses", return_value=[]):
|
|
resp = client.get("/api/jobs/1/survey/responses")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
def test_get_history_populated(client):
|
|
"""Returns history rows newest first."""
|
|
rows = [
|
|
{"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste",
|
|
"raw_input": None, "image_path": None, "llm_output": "Option A is best",
|
|
"reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"},
|
|
{"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste",
|
|
"raw_input": "Q1: test", "image_path": None, "llm_output": "1. B",
|
|
"reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"},
|
|
]
|
|
with patch("dev_api.get_survey_responses", return_value=rows):
|
|
resp = client.get("/api/jobs/1/survey/responses")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
assert data[0]["id"] == 2
|
|
assert data[0]["survey_name"] == "Round 2"
|