mathpulse-api-v3test / tests /test_practice_router.py
github-actions[bot]
🚀 Auto-deploy backend from GitHub (c55d5fa)
65ba59e
"""Contract tests for Practice Center router endpoints."""
from __future__ import annotations
import json
import os
import sys
import uuid
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fastapi.testclient import TestClient
# We import the app via main to get router registered
import main as main_module
from main import app
client = TestClient(app)
@pytest.fixture(autouse=True)
def _patch_firebase_auth():
"""Ensure firebase_auth extracts uid from mock tokens for practice tests.
test_api.py sets a static uid mock at module level; this fixture overrides
it per-test so practice auth checks (uid == userId) pass.
"""
def _verify(token, **kwargs):
if token and token.startswith("mock_token_"):
uid = token[len("mock_token_"):]
return {"uid": uid, "email": f"{uid}@test.mathpulse.ai", "role": "student"}
return {"uid": "unknown", "role": "student"}
original = main_module.firebase_auth
mock_auth = MagicMock()
mock_auth.verify_id_token = _verify
main_module.firebase_auth = mock_auth
yield
main_module.firebase_auth = original
def _mock_user(uid: str):
user = MagicMock()
user.uid = uid
return user
def _auth_header(uid: str):
return {"Authorization": f"Bearer mock_token_{uid}"}
class TestPracticeGenerate:
"""POST /api/practice/generate"""
@patch("routes.practice._call_deepseek")
def test_generate_returns_session_and_questions(self, mock_call, monkeypatch):
"""Should return session_id, questions list, and generated_at."""
mock_call.return_value = json.dumps({
"questions": [
{
"id": "q1",
"question": "What is 2+2?",
"options": ["3", "4", "5", "6"],
"correct_index": 1,
"explanation": "Basic addition",
"competency": "Arithmetic",
"difficulty": "Practice",
"bloomsLevel": "Remember",
}
]
})
user_id = "test_user_123"
response = client.post(
"/api/practice/generate",
json={
"userId": user_id,
"subject": "Algebra",
"competency": "Linear Equations",
"difficulty": "Practice",
"count": 1,
},
headers=_auth_header(user_id),
)
assert response.status_code == 200
data = response.json()
assert "session_id" in data
assert "questions" in data
assert len(data["questions"]) == 1
assert "generated_at" in data
assert data["questions"][0]["id"] == "q1"
def test_generate_rejects_mismatched_user(self):
"""Request with mismatched auth userId should return 403."""
response = client.post(
"/api/practice/generate",
json={
"userId": "user_a",
"subject": "Algebra",
"competency": "Linear Equations",
"difficulty": "Practice",
"count": 5,
},
headers=_auth_header("user_b"), # mismatch
)
assert response.status_code == 403
def test_generate_requires_auth(self):
"""Request without auth should return 401/403."""
response = client.post(
"/api/practice/generate",
json={
"userId": "user_a",
"subject": "Algebra",
"competency": "Linear Equations",
"difficulty": "Practice",
"count": 5,
},
)
assert response.status_code in (401, 403)
class TestPracticeSubmit:
"""POST /api/practice/submit"""
@patch("routes.practice._get_firestore")
@patch("routes.practice._call_deepseek")
def test_submit_scores_correctly(self, mock_call, mock_get_db, monkeypatch):
"""XP = correct*10 + 50 bonus if score>=80%."""
mock_call.return_value = json.dumps({
"questions": [
{
"id": "q1", "question": "Q1", "options": ["A", "B", "C", "D"],
"correct_index": 1, "explanation": "Exp", "competency": "Arith",
"difficulty": "Practice", "bloomsLevel": "Remember",
},
{
"id": "q2", "question": "Q2", "options": ["A", "B", "C", "D"],
"correct_index": 2, "explanation": "Exp", "competency": "Arith",
"difficulty": "Practice", "bloomsLevel": "Remember",
},
]
})
# First generate a session
user_id = "test_user_123"
gen_response = client.post(
"/api/practice/generate",
json={
"userId": user_id,
"subject": "Algebra",
"competency": "Arithmetic",
"difficulty": "Practice",
"count": 2,
},
headers=_auth_header(user_id),
)
session_id = gen_response.json()["session_id"]
# Mock DB: return session for generate, skip update for submit
mock_db = MagicMock()
mock_get_db.return_value = mock_db
mock_session_doc = MagicMock()
mock_session_doc.exists = True
mock_session_doc.to_dict.return_value = {
"questions": [
{"id": "q1", "correct_index": 1, "explanation": "Exp", "competency": "Arith", "difficulty": "Practice", "question": "Q1", "options": ["A", "B", "C", "D"], "bloomsLevel": "Remember"},
{"id": "q2", "correct_index": 2, "explanation": "Exp", "competency": "Arith", "difficulty": "Practice", "question": "Q2", "options": ["A", "B", "C", "D"], "bloomsLevel": "Remember"},
],
"subject": "Algebra",
"competency": "Arithmetic",
"difficulty": "Practice",
}
mock_db.collection.return_value.document.return_value.get.return_value = mock_session_doc
mock_db.collection.return_value.document.return_value.set.return_value = None
mock_db.collection.return_value.document.return_value.update.return_value = None
mock_user_doc = MagicMock()
mock_user_doc.exists = True
mock_user_doc.to_dict.return_value = {"totalXP": 100, "quizzesCompleted": 5, "averageScore": 75.0}
mock_db.collection.return_value.document.return_value.get.return_value = mock_session_doc
# Submit: q1 correct (index 1), q2 wrong (index 0 vs correct 2)
submit_response = client.post(
"/api/practice/submit",
json={
"session_id": session_id,
"userId": user_id,
"answers": [
{"question_id": "q1", "selected_index": 1},
{"question_id": "q2", "selected_index": 0},
],
},
headers=_auth_header(user_id),
)
assert submit_response.status_code == 200
data = submit_response.json()
assert data["correct_count"] == 1 # 1 out of 2
assert data["total"] == 2
# XP: 1*10 + 0 (no bonus, score=50% < 80%)
assert data["xp_earned"] == 10
assert data["score_percent"] == 50.0
assert "per_question_feedback" in data
assert "updated_stats" in data
def test_submit_rejects_mismatched_user(self):
"""Submit with auth userId != payload userId should return 403."""
response = client.post(
"/api/practice/submit",
json={
"session_id": "some_session",
"userId": "user_a",
"answers": [],
},
headers=_auth_header("user_b"),
)
assert response.status_code == 403
class TestPracticeStats:
"""GET /api/practice/stats/{userId}"""
def test_stats_requires_auth(self):
"""Stats endpoint should require authentication."""
response = client.get("/api/practice/stats/test_user")
assert response.status_code in (401, 403)
def test_stats_rejects_mismatched_user(self):
"""Getting stats for different user should return 403."""
response = client.get(
"/api/practice/stats/user_a",
headers=_auth_header("user_b"),
)
assert response.status_code == 403
class TestPracticeHistory:
"""GET /api/practice/history/{userId}"""
def test_history_requires_auth(self):
"""History endpoint should require authentication."""
response = client.get("/api/practice/history/test_user")
assert response.status_code in (401, 403)
def test_history_pagination_params(self):
"""History should accept page and limit query params."""
user_id = "test_user"
response = client.get(
f"/api/practice/history/{user_id}?page=2&limit=5",
headers=_auth_header(user_id),
)
# Should not 422 (validation error) - params are optional ints
assert response.status_code != 422
def test_history_rejects_mismatched_user(self):
"""History for different user should return 403."""
response = client.get(
"/api/practice/history/user_a?page=1&limit=10",
headers=_auth_header("user_b"),
)
assert response.status_code == 403