fix: update interview + survey tests for hired_feedback column and async analyze endpoint

This commit is contained in:
pyr0ball 2026-04-20 11:48:22 -07:00
parent 9101e716ba
commit 5020144f8d
2 changed files with 167 additions and 99 deletions

View file

@ -19,7 +19,8 @@ def tmp_db(tmp_path):
match_score REAL, keyword_gaps TEXT, status TEXT, match_score REAL, keyword_gaps TEXT, status TEXT,
interview_date TEXT, rejection_stage TEXT, interview_date TEXT, rejection_stage TEXT,
applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT, applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT,
offer_at TEXT, hired_at TEXT, survey_at TEXT offer_at TEXT, hired_at TEXT, survey_at TEXT,
hired_feedback TEXT
); );
CREATE TABLE job_contacts ( CREATE TABLE job_contacts (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,

View file

@ -1,18 +1,36 @@
"""Tests for survey endpoints: vision health, analyze, save response, get history.""" """Tests for survey endpoints: vision health, async analyze task queue, save response, history."""
import json
import sqlite3
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from scripts.db_migrate import migrate_db
@pytest.fixture @pytest.fixture
def client(): def fresh_db(tmp_path, monkeypatch):
import sys """Isolated DB + dev_api wired to it via _request_db and DB_PATH."""
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") db = tmp_path / "test.db"
from dev_api import app migrate_db(db)
return TestClient(app) monkeypatch.setenv("STAGING_DB", str(db))
import dev_api
monkeypatch.setattr(dev_api, "DB_PATH", str(db))
monkeypatch.setattr(
dev_api,
"_request_db",
type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(),
)
return db
# ── GET /api/vision/health ─────────────────────────────────────────────────── @pytest.fixture
def client(fresh_db):
import dev_api
return TestClient(dev_api.app)
# ── GET /api/vision/health ────────────────────────────────────────────────────
def test_vision_health_available(client): def test_vision_health_available(client):
"""Returns available=true when vision service responds 200.""" """Returns available=true when vision service responds 200."""
@ -32,133 +50,182 @@ def test_vision_health_unavailable(client):
assert resp.json() == {"available": False} assert resp.json() == {"available": False}
# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── # ── POST /api/jobs/{id}/survey/analyze ──────────────────────────────────────
def test_analyze_text_quick(client): def test_analyze_queues_task_and_returns_task_id(client):
"""Text mode quick analysis returns output and source=text_paste.""" """POST analyze queues a background task and returns task_id + is_new."""
mock_router = MagicMock() with patch("scripts.task_runner.submit_task", return_value=(42, True)) as mock_submit:
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={ resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together",
"mode": "quick", "mode": "quick",
}) })
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["source"] == "text_paste" assert data["task_id"] == 42
assert "B" in data["output"] assert data["is_new"] is True
# System prompt must be passed for text path # submit_task called with survey_analyze type
call_kwargs = mock_router.complete.call_args[1] call_kwargs = mock_submit.call_args
assert "system" in call_kwargs assert call_kwargs.kwargs["task_type"] == "survey_analyze"
assert "culture-fit survey" in call_kwargs["system"] assert call_kwargs.kwargs["job_id"] == 1
params = json.loads(call_kwargs.kwargs["params"])
assert params["mode"] == "quick"
assert params["text"] == "Q1: Do you prefer teamwork?\nA. Solo B. Together"
def test_analyze_text_detailed(client): def test_analyze_silently_attaches_to_existing_task(client):
"""Text mode detailed analysis passes correct prompt.""" """is_new=False when task already running for same input."""
mock_router = MagicMock() with patch("scripts.task_runner.submit_task", return_value=(7, False)):
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={ resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Describe your work style.", "text": "Q1: test", "mode": "quick",
"mode": "detailed",
}) })
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["source"] == "text_paste" assert resp.json()["is_new"] is False
def test_analyze_image(client): def test_analyze_invalid_mode_returns_400(client):
"""Image mode routes through vision path with NO system prompt.""" resp = client.post("/api/jobs/1/survey/analyze", json={"text": "Q1: test", "mode": "wrong"})
mock_router = MagicMock() assert resp.status_code == 400
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): def test_analyze_image_mode_passes_image_in_params(client):
"""Image payload is forwarded in task params."""
with patch("scripts.task_runner.submit_task", return_value=(1, True)) as mock_submit:
resp = client.post("/api/jobs/1/survey/analyze", json={ resp = client.post("/api/jobs/1/survey/analyze", json={
"image_b64": "aGVsbG8=", "image_b64": "aGVsbG8=",
"mode": "quick", "mode": "quick",
}) })
assert resp.status_code == 200 assert resp.status_code == 200
params = json.loads(mock_submit.call_args.kwargs["params"])
assert params["image_b64"] == "aGVsbG8="
assert params["text"] is None
# ── GET /api/jobs/{id}/survey/analyze/task ────────────────────────────────────
def test_task_poll_completed_text(client, fresh_db):
"""Completed task with text result returns parsed source + output."""
result_json = json.dumps({"output": "1. B — best option", "source": "text_paste"})
con = sqlite3.connect(fresh_db)
con.execute(
"INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)",
("survey_analyze", 1, "completed", result_json),
)
task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
con.commit(); con.close()
resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}")
assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["source"] == "screenshot" assert data["status"] == "completed"
# No system prompt on vision path assert data["result"]["source"] == "text_paste"
call_kwargs = mock_router.complete.call_args[1] assert "B" in data["result"]["output"]
assert "system" not in call_kwargs assert data["message"] is None
def test_analyze_llm_failure(client): def test_task_poll_completed_screenshot(client, fresh_db):
"""Returns 500 when LLM raises an exception.""" """Completed task with image result returns source=screenshot."""
mock_router = MagicMock() result_json = json.dumps({"output": "1. C — collaborative", "source": "screenshot"})
mock_router.complete.side_effect = Exception("LLM unavailable") con = sqlite3.connect(fresh_db)
mock_router.config.get.return_value = [] con.execute(
with patch("dev_api.LLMRouter", return_value=mock_router): "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)",
resp = client.post("/api/jobs/1/survey/analyze", json={ ("survey_analyze", 1, "completed", result_json),
"text": "Q1: test", )
"mode": "quick", task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
}) con.commit(); con.close()
assert resp.status_code == 500
resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}")
assert resp.status_code == 200
assert resp.json()["result"]["source"] == "screenshot"
# ── POST /api/jobs/{id}/survey/responses ──────────────────────────────────── def test_task_poll_failed_returns_message(client, fresh_db):
"""Failed task returns status=failed with error message."""
con = sqlite3.connect(fresh_db)
con.execute(
"INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)",
("survey_analyze", 1, "failed", "LLM unavailable"),
)
task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
con.commit(); con.close()
resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "failed"
assert data["message"] == "LLM unavailable"
assert data["result"] is None
def test_task_poll_running_returns_stage(client, fresh_db):
"""Running task returns status=running with current stage."""
con = sqlite3.connect(fresh_db)
con.execute(
"INSERT INTO background_tasks (task_type, job_id, status, stage) VALUES (?,?,?,?)",
("survey_analyze", 1, "running", "analyzing survey"),
)
task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
con.commit(); con.close()
resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "running"
assert data["stage"] == "analyzing survey"
def test_task_poll_none_when_no_task(client):
"""Returns status=none when no task exists for the job."""
resp = client.get("/api/jobs/999/survey/analyze/task")
assert resp.status_code == 200
assert resp.json()["status"] == "none"
# ── POST /api/jobs/{id}/survey/responses ─────────────────────────────────────
def test_save_response_text(client): def test_save_response_text(client):
"""Save text response writes to DB and returns id.""" """Save a text-mode survey response returns an id."""
mock_db = MagicMock() resp = client.post("/api/jobs/1/survey/responses", json={
with patch("dev_api._get_db", return_value=mock_db): "survey_name": "Culture Fit",
with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: "mode": "quick",
resp = client.post("/api/jobs/1/survey/responses", json={ "source": "text_paste",
"mode": "quick", "raw_input": "Q1: Teamwork?",
"source": "text_paste", "llm_output": "1. B is best",
"raw_input": "Q1: test question", "reported_score": "85",
"llm_output": "1. B — good reason", })
})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["id"] == 42 assert "id" in resp.json()
# 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): def test_save_response_with_image(client):
"""Save image response writes PNG file and stores path in DB.""" """Save a screenshot-mode survey response returns an id."""
monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) resp = client.post("/api/jobs/1/survey/responses", json={
with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: "survey_name": None,
with patch("dev_api.Path") as mock_path_cls: "mode": "quick",
mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o "source": "screenshot",
resp = client.post("/api/jobs/1/survey/responses", json={ "image_b64": "aGVsbG8=",
"mode": "quick", "llm_output": "1. C collaborative",
"source": "screenshot", "reported_score": None,
"image_b64": "aGVsbG8=", # valid base64 })
"llm_output": "1. B — reason",
})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["id"] == 7 assert "id" in resp.json()
# ── GET /api/jobs/{id}/survey/responses ─────────────────────────────────────
def test_get_history_empty(client): def test_get_history_empty(client):
"""Returns empty list when no history exists.""" """History is empty for a fresh job."""
with patch("dev_api.get_survey_responses", return_value=[]): resp = client.get("/api/jobs/1/survey/responses")
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == [] assert resp.json() == []
def test_get_history_populated(client): def test_get_history_populated(client):
"""Returns history rows newest first.""" """History returns all saved responses for a job in reverse order."""
rows = [ for i in range(2):
{"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", client.post("/api/jobs/1/survey/responses", json={
"raw_input": None, "image_path": None, "llm_output": "Option A is best", "survey_name": f"Survey {i}",
"reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, "mode": "quick",
{"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", "source": "text_paste",
"raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", "llm_output": f"Output {i}",
"reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, })
] resp = client.get("/api/jobs/1/survey/responses")
with patch("dev_api.get_survey_responses", return_value=rows):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() assert len(resp.json()) == 2
assert len(data) == 2
assert data[0]["id"] == 2
assert data[0]["survey_name"] == "Round 2"