From 5020144f8da6c1dbb216e132c3515e9001952a90 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 11:48:22 -0700 Subject: [PATCH] fix: update interview + survey tests for hired_feedback column and async analyze endpoint --- tests/test_dev_api_interviews.py | 3 +- tests/test_dev_api_survey.py | 263 +++++++++++++++++++------------ 2 files changed, 167 insertions(+), 99 deletions(-) diff --git a/tests/test_dev_api_interviews.py b/tests/test_dev_api_interviews.py index 1a3aa64..eeb1eb5 100644 --- a/tests/test_dev_api_interviews.py +++ b/tests/test_dev_api_interviews.py @@ -19,7 +19,8 @@ def tmp_db(tmp_path): match_score REAL, keyword_gaps TEXT, status TEXT, interview_date TEXT, rejection_stage 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 ( id INTEGER PRIMARY KEY, diff --git a/tests/test_dev_api_survey.py b/tests/test_dev_api_survey.py index 4a03336..4c201ae 100644 --- a/tests/test_dev_api_survey.py +++ b/tests/test_dev_api_survey.py @@ -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 from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient +from scripts.db_migrate import migrate_db + @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) +def fresh_db(tmp_path, monkeypatch): + """Isolated DB + dev_api wired to it via _request_db and DB_PATH.""" + db = tmp_path / "test.db" + migrate_db(db) + 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): """Returns available=true when vision service responds 200.""" @@ -32,133 +50,182 @@ def test_vision_health_unavailable(client): assert resp.json() == {"available": False} -# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── +# ── 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): +def test_analyze_queues_task_and_returns_task_id(client): + """POST analyze queues a background task and returns task_id + is_new.""" + with patch("scripts.task_runner.submit_task", return_value=(42, True)) as mock_submit: 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"] + assert data["task_id"] == 42 + assert data["is_new"] is True + # submit_task called with survey_analyze type + call_kwargs = mock_submit.call_args + assert call_kwargs.kwargs["task_type"] == "survey_analyze" + 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): - """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): +def test_analyze_silently_attaches_to_existing_task(client): + """is_new=False when task already running for same input.""" + with patch("scripts.task_runner.submit_task", return_value=(7, False)): resp = client.post("/api/jobs/1/survey/analyze", json={ - "text": "Q1: Describe your work style.", - "mode": "detailed", + "text": "Q1: test", "mode": "quick", }) assert resp.status_code == 200 - assert resp.json()["source"] == "text_paste" + assert resp.json()["is_new"] is False -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): +def test_analyze_invalid_mode_returns_400(client): + resp = client.post("/api/jobs/1/survey/analyze", json={"text": "Q1: test", "mode": "wrong"}) + assert resp.status_code == 400 + + +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={ "image_b64": "aGVsbG8=", "mode": "quick", }) 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() - assert data["source"] == "screenshot" - # No system prompt on vision path - call_kwargs = mock_router.complete.call_args[1] - assert "system" not in call_kwargs + assert data["status"] == "completed" + assert data["result"]["source"] == "text_paste" + assert "B" in data["result"]["output"] + assert data["message"] is None -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 +def test_task_poll_completed_screenshot(client, fresh_db): + """Completed task with image result returns source=screenshot.""" + result_json = json.dumps({"output": "1. C — collaborative", "source": "screenshot"}) + 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 + 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): - """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", - }) + """Save a text-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": "Culture Fit", + "mode": "quick", + "source": "text_paste", + "raw_input": "Q1: Teamwork?", + "llm_output": "1. B is best", + "reported_score": "85", + }) 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 + assert "id" in resp.json() -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", - }) +def test_save_response_with_image(client): + """Save a screenshot-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": None, + "mode": "quick", + "source": "screenshot", + "image_b64": "aGVsbG8=", + "llm_output": "1. C collaborative", + "reported_score": None, + }) 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): - """Returns empty list when no history exists.""" - with patch("dev_api.get_survey_responses", return_value=[]): - resp = client.get("/api/jobs/1/survey/responses") + """History is empty for a fresh job.""" + 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") + """History returns all saved responses for a job in reverse order.""" + for i in range(2): + client.post("/api/jobs/1/survey/responses", json={ + "survey_name": f"Survey {i}", + "mode": "quick", + "source": "text_paste", + "llm_output": f"Output {i}", + }) + 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" + assert len(resp.json()) == 2