""" 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"])