Spaces:
Running
Running
| """ | |
| 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: | |
| 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"] | |
| 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 | |
| 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: | |
| 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 | |
| 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 | |
| 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: | |
| 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 | |
| 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: | |
| 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: | |
| 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 | |
| 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 | |
| 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: | |
| 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"] == [] | |
| 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"]) | |