fix: update interview + survey tests for hired_feedback column and async analyze endpoint
This commit is contained in:
parent
9101e716ba
commit
5020144f8d
2 changed files with 167 additions and 99 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue