mathpulse-api / tests /test_api.py
github-actions[bot]
๐Ÿš€ Auto-deploy from GitHub (664a608)
9ea3d39
"""
backend/tests/test_api.py
Comprehensive tests for all FastAPI endpoints.
Tests cover:
- Successful requests with valid data
- Input validation errors (422)
- HuggingFace API failures (502 fallback)
- Timeout handling
- Malformed response data
- Error status-code mapping
Run with: pytest backend/tests/test_api.py -v
"""
import asyncio
import json
import os
import sys
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest # type: ignore[import-not-found]
from fastapi.testclient import TestClient
# Add backend directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# automation_engine has Firebase dependencies - mock its heavy parts
# but keep the Pydantic model classes
mock_ae = MagicMock()
# Define minimal Pydantic-like classes for payloads automation_engine exports
from pydantic import BaseModel as _BM
class _DiagnosticCompletionPayload(_BM):
studentId: str
results: list
gradeLevel: str | None = None
questionBreakdown: dict | None = None
class _QuizSubmissionPayload(_BM):
studentId: str
quizId: str
subject: str
score: float
totalQuestions: int
correctAnswers: int
timeSpentSeconds: int
class _StudentEnrollmentPayload(_BM):
studentId: str
name: str
email: str
gradeLevel: str | None = None
teacherId: str | None = None
class _DataImportPayload(_BM):
teacherId: str
students: list
columnMapping: dict
class _ContentUpdatePayload(_BM):
adminId: str
action: str
contentType: str
contentId: str
subjectId: str | None = None
details: str | None = None
class _AutomationResult(_BM):
success: bool = True
message: str = ""
actions: list = []
mock_ae.automation_engine = MagicMock()
mock_ae.DiagnosticCompletionPayload = _DiagnosticCompletionPayload
mock_ae.QuizSubmissionPayload = _QuizSubmissionPayload
mock_ae.StudentEnrollmentPayload = _StudentEnrollmentPayload
mock_ae.DataImportPayload = _DataImportPayload
mock_ae.ContentUpdatePayload = _ContentUpdatePayload
mock_ae.AutomationResult = _AutomationResult
sys.modules["automation_engine"] = mock_ae
# Override HF_TOKEN so client init doesn't fail
os.environ["HF_TOKEN"] = "test-token-for-testing"
# analytics.py is importable directly (its heavy deps are guarded)
from main import app # noqa: E402
client = TestClient(app)
# โ”€โ”€โ”€ Fixtures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class FakeClassificationElement:
"""Mimics huggingface_hub ZeroShotClassificationOutputElement."""
def __init__(self, label: str, score: float):
self.label = label
self.score = score
def make_zsc_client(
classification: list | None = None,
):
"""Create a mock InferenceClient with predictable zero-shot outputs.
Used only for risk-prediction tests (the only endpoint still using
``get_client()`` / ``InferenceClient``).
"""
mock_client = MagicMock()
if classification is None:
classification = [
FakeClassificationElement("low risk academically stable", 0.85),
FakeClassificationElement("medium academic risk", 0.10),
FakeClassificationElement("high risk of failing", 0.05),
]
mock_client.zero_shot_classification.return_value = classification
return mock_client
# โ”€โ”€โ”€ Health & Root โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestHealthEndpoints:
def test_health_returns_200(self):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "models" in data
def test_root_returns_api_info(self):
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["name"] == "MathPulse AI API"
assert "version" in data
def test_health_includes_request_id_header(self):
response = client.get("/health")
assert "x-request-id" in response.headers
# โ”€โ”€โ”€ Chat Endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestChatEndpoint:
@patch("main.call_hf_chat")
def test_chat_success(self, mock_chat):
mock_chat.return_value = "Hello! 2+2=4."
response = client.post("/api/chat", json={
"message": "What is 2+2?",
"history": [],
})
assert response.status_code == 200
assert "4" in response.json()["response"]
@patch("main.call_hf_chat")
def test_chat_with_history(self, mock_chat):
mock_chat.return_value = "Yes, that's right."
response = client.post("/api/chat", json={
"message": "Is that correct?",
"history": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
],
})
assert response.status_code == 200
# Verify history was included in messages
call_args = mock_chat.call_args
messages = call_args.args[0] if call_args.args else call_args.kwargs.get("messages", [])
assert len(messages) >= 3 # system + 2 history + 1 current
def test_chat_missing_message_returns_422(self):
response = client.post("/api/chat", json={"history": []})
assert response.status_code == 422
@patch("main.call_hf_chat")
def test_chat_hf_failure_returns_502(self, mock_chat):
mock_chat.side_effect = Exception("HF API down")
response = client.post("/api/chat", json={
"message": "Hello",
"history": [],
})
assert response.status_code == 502
# โ”€โ”€โ”€ Risk Prediction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestRiskPrediction:
@patch("main.get_client")
def test_predict_risk_success(self, mock_get):
mock_get.return_value = make_zsc_client()
response = client.post("/api/predict-risk", json={
"engagementScore": 80,
"avgQuizScore": 75,
"attendance": 90,
"assignmentCompletion": 85,
})
assert response.status_code == 200
data = response.json()
assert data["riskLevel"] in ("High", "Medium", "Low")
assert 0 <= data["confidence"] <= 1
def test_predict_risk_invalid_score_range(self):
response = client.post("/api/predict-risk", json={
"engagementScore": 150, # > 100
"avgQuizScore": 75,
"attendance": 90,
"assignmentCompletion": 85,
})
assert response.status_code == 422
def test_predict_risk_negative_score(self):
response = client.post("/api/predict-risk", json={
"engagementScore": -5,
"avgQuizScore": 75,
"attendance": 90,
"assignmentCompletion": 85,
})
assert response.status_code == 422
def test_predict_risk_missing_fields(self):
response = client.post("/api/predict-risk", json={
"engagementScore": 80,
})
assert response.status_code == 422
@patch("main.get_client")
def test_predict_risk_hf_failure(self, mock_get):
hf = make_zsc_client()
hf.zero_shot_classification.side_effect = Exception("HF down")
mock_get.return_value = hf
response = client.post("/api/predict-risk", json={
"engagementScore": 80,
"avgQuizScore": 75,
"attendance": 90,
"assignmentCompletion": 85,
})
assert response.status_code == 502
@patch("main.get_client")
def test_batch_risk_prediction(self, mock_get):
mock_get.return_value = make_zsc_client()
response = client.post("/api/predict-risk/batch", json={
"students": [
{"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
{"engagementScore": 30, "avgQuizScore": 40, "attendance": 50, "assignmentCompletion": 35},
],
})
assert response.status_code == 200
assert len(response.json()) == 2
# โ”€โ”€โ”€ Learning Path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestLearningPath:
@patch("main.call_hf_chat")
def test_learning_path_success(self, mock_chat):
mock_chat.return_value = "1. Review fractions\n2. Practice decimals"
response = client.post("/api/learning-path", json={
"weaknesses": ["fractions", "decimals"],
"gradeLevel": "Grade 7",
})
assert response.status_code == 200
assert "fractions" in response.json()["learningPath"].lower()
def test_learning_path_missing_weaknesses(self):
response = client.post("/api/learning-path", json={
"gradeLevel": "Grade 7",
})
assert response.status_code == 422
def test_learning_path_missing_grade(self):
response = client.post("/api/learning-path", json={
"weaknesses": ["fractions"],
})
assert response.status_code == 422
@patch("main.call_hf_chat")
def test_learning_path_hf_failure(self, mock_chat):
mock_chat.side_effect = Exception("HF down")
response = client.post("/api/learning-path", json={
"weaknesses": ["algebra"],
"gradeLevel": "Grade 8",
})
assert response.status_code == 502
# โ”€โ”€โ”€ Daily Insight โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestDailyInsight:
@patch("main.call_hf_chat")
def test_daily_insight_success(self, mock_chat):
mock_chat.return_value = "Class is doing well."
response = client.post("/api/analytics/daily-insight", json={
"students": [
{"name": "Alice", "engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "riskLevel": "Low"},
],
})
assert response.status_code == 200
assert response.json()["insight"]
def test_daily_insight_empty_students(self):
response = client.post("/api/analytics/daily-insight", json={
"students": [],
})
assert response.status_code == 200
assert "No student data" in response.json()["insight"]
# โ”€โ”€โ”€ Quiz Topics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestQuizTopics:
def test_get_all_topics(self):
response = client.get("/api/quiz/topics")
assert response.status_code == 200
assert "allTopics" in response.json()
def test_get_topics_by_grade(self):
response = client.get("/api/quiz/topics?gradeLevel=Grade%207")
assert response.status_code == 200
data = response.json()
assert data["gradeLevel"] == "Grade 7"
assert "topics" in data
def test_get_topics_invalid_grade(self):
response = client.get("/api/quiz/topics?gradeLevel=Grade%2099")
assert response.status_code == 404
# โ”€โ”€โ”€ Quiz Generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestQuizGeneration:
@patch("main.call_hf_chat")
def test_generate_quiz_success(self, mock_chat):
quiz_json = json.dumps([{
"questionType": "multiple_choice",
"question": "What is 2+2?",
"correctAnswer": "4",
"options": ["A) 3", "B) 4", "C) 5", "D) 6"],
"bloomLevel": "remember",
"difficulty": "easy",
"topic": "Arithmetic",
"points": 1,
"explanation": "2+2=4",
}])
mock_chat.return_value = quiz_json
response = client.post("/api/quiz/generate", json={
"topics": ["Arithmetic"],
"gradeLevel": "Grade 7",
"numQuestions": 1,
})
assert response.status_code == 200
data = response.json()
assert len(data["questions"]) >= 1
assert data["totalPoints"] > 0
def test_generate_quiz_missing_topics(self):
response = client.post("/api/quiz/generate", json={
"gradeLevel": "Grade 7",
})
assert response.status_code == 422
@patch("main.call_hf_chat")
def test_generate_quiz_bad_llm_output(self, mock_chat):
mock_chat.return_value = "This is not valid JSON at all."
response = client.post("/api/quiz/generate", json={
"topics": ["Algebra"],
"gradeLevel": "Grade 8",
"numQuestions": 1,
})
assert response.status_code == 500
@patch("main.call_hf_chat")
def test_preview_quiz(self, mock_chat):
quiz_json = json.dumps([{
"questionType": "identification",
"question": "Define slope.",
"correctAnswer": "Rise over run",
"bloomLevel": "remember",
"difficulty": "easy",
"topic": "Algebra",
"points": 1,
"explanation": "Slope = rise/run.",
}])
mock_chat.return_value = quiz_json
response = client.post("/api/quiz/preview", json={
"topics": ["Algebra"],
"gradeLevel": "Grade 8",
})
assert response.status_code == 200
# โ”€โ”€โ”€ Calculator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestCalculator:
def test_evaluate_simple_expression(self):
response = client.post("/api/calculator/evaluate", json={
"expression": "2 + 3",
})
# sympy may not be installed in test env โ€” accept 200 or 500
assert response.status_code in (200, 500)
if response.status_code == 200:
data = response.json()
assert data["result"] == "5"
def test_evaluate_with_variables(self):
response = client.post("/api/calculator/evaluate", json={
"expression": "x**2 + 2*x + 1",
})
# Accept 200 (sympy available) or 500 (sympy missing)
assert response.status_code in (200, 500)
def test_evaluate_dangerous_expression(self):
response = client.post("/api/calculator/evaluate", json={
"expression": "__import__('os').system('rm -rf /')",
})
# 400 if validation catches it, 500 if sympy missing or general error
assert response.status_code in (400, 500)
def test_evaluate_empty_expression(self):
response = client.post("/api/calculator/evaluate", json={
"expression": "",
})
assert response.status_code == 422
def test_evaluate_too_long_expression(self):
response = client.post("/api/calculator/evaluate", json={
"expression": "x + " * 200,
})
# 400 if length validation, 422 if pydantic validation, 500 if sympy missing
assert response.status_code in (400, 422, 500)
# โ”€โ”€โ”€ Error Handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestErrorHandling:
def test_404_for_unknown_endpoint(self):
response = client.get("/api/nonexistent")
assert response.status_code == 404
def test_method_not_allowed(self):
response = client.get("/api/chat")
assert response.status_code == 405
def test_request_id_in_error_response(self):
response = client.get("/api/nonexistent")
assert "x-request-id" in response.headers
def test_invalid_json_body(self):
response = client.post(
"/api/chat",
content="this is not json",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 422
# โ”€โ”€โ”€ Student Competency โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestStudentCompetency:
@patch("main.call_hf_chat")
def test_competency_no_history(self, mock_chat):
mock_chat.return_value = ""
response = client.post("/api/quiz/student-competency", json={
"studentId": "student123",
"quizHistory": [],
})
assert response.status_code == 200
data = response.json()
assert data["studentId"] == "student123"
assert data["competencies"] == []
@patch("main.call_hf_chat")
def test_competency_with_history(self, mock_chat):
mock_chat.return_value = "Good progress overall."
response = client.post("/api/quiz/student-competency", json={
"studentId": "student123",
"quizHistory": [
{"topic": "Algebra", "score": 8, "total": 10, "timeTaken": 300},
{"topic": "Algebra", "score": 9, "total": 10, "timeTaken": 250},
{"topic": "Geometry", "score": 4, "total": 10, "timeTaken": 500},
],
})
assert response.status_code == 200
data = response.json()
assert len(data["competencies"]) > 0
# Algebra should be higher competency than Geometry
algebra = next((c for c in data["competencies"] if c["topic"] == "Algebra"), None)
geometry = next((c for c in data["competencies"] if c["topic"] == "Geometry"), None)
if algebra and geometry:
assert algebra["efficiencyScore"] > geometry["efficiencyScore"]
# โ”€โ”€โ”€ Run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if __name__ == "__main__":
pytest.main([__file__, "-v"])