mathpulse-api-v3test / tests /test_teacher_materials.py
github-actions[bot]
๐Ÿš€ Auto-deploy backend from GitHub (82c290f)
174b574
"""
backend/tests/test_teacher_materials.py
Tests for /api/teacher-materials/upload endpoint.
Covers:
- Rejects unauthenticated requests
- Rejects non-teacher roles (student)
- Validates file type (PDF, DOCX, TXT only)
- Handles empty/missing metadata gracefully
- Handles Firestore unavailability gracefully
- Handles DeepSeek generation failure gracefully
- Returns proper TeacherMaterialUploadResponse shape
Run with: pytest backend/tests/test_teacher_materials.py -v
Or safe runner: python -m pytest backend/tests/test_teacher_materials.py -v
"""
import io
import os
import sys
from typing import Any, Dict
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__), ".."))
# Import the app after path is set
from main import AuthenticatedUser, app
# โ”€โ”€โ”€ Test client (shared across all tests) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Uses teacher role header โ€” matches ROLE_POLICIES teacher role
client = TestClient(app, headers={"Authorization": "Bearer test-auth-token"})
# โ”€โ”€โ”€ Helper: minimal PDF in bytes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _make_pdf(text: str = b"%PDF-1.4\nfake pdf content") -> bytes:
return text
# โ”€โ”€โ”€ Helper: minimal DOCX in bytes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _make_docx() -> bytes:
# DOCX is a ZIP; we only need the header bytes for type detection
return b"PK\x03\x04" + b"fake docx content"
# โ”€โ”€โ”€ Helper: TXT in bytes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _make_txt(text: str = "Sample lesson plan content.") -> bytes:
return text.encode()
# โ”€โ”€โ”€ Fixtures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@pytest.fixture
def mock_firestore_client():
"""Mock Firestore client that does NOT raise."""
mock_db = MagicMock()
mock_fs = MagicMock()
mock_fs.Client.return_value = mock_db
return mock_db, mock_fs
@pytest.fixture
def mock_firestore_unavailable():
"""Simulate Firestore not being initialized."""
with patch("routes.teacher_materials._get_firestore", return_value=None):
yield
@pytest.fixture
def mock_deepseek_success():
"""DeepSeek returns a well-formed TeacherModule JSON."""
module_json = {
"moduleId": "quadratic-equations-test-teacher-2026-05-13",
"title": "Quadratic Equations",
"gradeLevel": "Grade 11",
"subject": "General Mathematics",
"quarter": "Q1",
"strandOrTrack": "Academic",
"competencyTags": ["M11ALG-IIa-1"],
"moduleType": "teacher_uploaded",
"sourceLabel": "Teacher Upload",
"originNote": "Generated from uploaded lesson plan.",
"summary": "This module covers solving quadratic equations by factoring, completing the square, and the quadratic formula.",
"learningObjectives": [
"Solve quadratic equations by factoring.",
"Solve quadratic equations by completing the square.",
"Apply the quadratic formula to find roots.",
],
"sections": [
{
"title": "Introduction to Quadratic Equations",
"content": "A quadratic equation is of the form axยฒ + bx + c = 0 where a โ‰  0.",
},
{
"title": "Solving by Factoring",
"content": "If (x - rโ‚)(x - rโ‚‚) = 0 then x = rโ‚ or x = rโ‚‚.",
},
],
"practice": [
{
"question": "Solve xยฒ - 5x + 6 = 0 by factoring.",
"options": [
{"label": "A", "text": "x = 1, x = 6"},
{"label": "B", "text": "x = 2, x = 3"},
{"label": "C", "text": "x = -2, x = -3"},
{"label": "D", "text": "x = 1, x = -6"},
],
"answer": "B",
"explanation": "xยฒ - 5x + 6 = (x-2)(x-3) = 0 โ†’ x = 2 or x = 3.",
},
],
"aiSafety": {
"requiresGrounding": True,
"allowedModels": ["deepseek-chat"],
"groundingSources": ["teacher_file", "deped_rag"],
},
}
import json
json_string = json.dumps(module_json)
with patch("services.inference_client.call_hf_chat_async", new_callable=AsyncMock, return_value=json_string):
yield
@pytest.fixture
def mock_deepseek_failure():
"""DeepSeek raises an exception."""
with patch(
"services.inference_client.call_hf_chat_async",
side_effect=Exception("DeepSeek unavailable"),
):
yield
# โ”€โ”€โ”€ Auth guard tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsAuth:
"""Endpoints require valid teacher/admin auth."""
def test_upload_rejects_missing_auth_header(self):
"""No Authorization header โ†’ 401."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
response = TestClient(app).post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code == 401
def test_upload_rejects_student_role(self):
"""Student auth โ†’ 403 Forbidden."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
student_client = TestClient(
app,
headers={"Authorization": "Bearer student-auth-token"},
)
mock_student_user = AuthenticatedUser(uid="student123", role="student", email="student@test.com")
with (
patch("main.get_current_user", return_value=mock_student_user),
patch("routes.teacher_materials._get_firestore", return_value=None),
):
response = student_client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code in (401, 403)
# โ”€โ”€โ”€ File validation tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsFileValidation:
"""Only PDF, DOCX, TXT files are accepted."""
def test_accepts_pdf(self, mock_firestore_unavailable):
"""PDF uploads return 200 (even if Firestore fails downstream)."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
# Patch the entire parsing + generation chain so it short-circuits
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._generate_teacher_module", return_value={"moduleId": "test-pdf", "title": "Test PDF Module", "sections": [], "practiceQuestions": []}),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
# Accept 200 (success) or 500 (parsing/generation failure) โ€” we're testing
# that PDF is not rejected at the file-type layer.
assert response.status_code in (200, 500)
def test_accepts_docx(self, mock_firestore_unavailable):
"""DOCX uploads are accepted."""
files = {"file": ("lesson.docx", _make_docx(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._generate_teacher_module", new_callable=AsyncMock, return_value={"moduleId": "test", "title": "Test"}),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code in (200, 500)
def test_accepts_txt(self, mock_firestore_unavailable):
"""TXT uploads are accepted."""
files = {"file": ("outline.txt", _make_txt(), "text/plain")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._generate_teacher_module", new_callable=AsyncMock, return_value={"moduleId": "test", "title": "Test"}),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code in (200, 500)
def test_rejects_executable(self):
"""Malicious extension is rejected with 400."""
files = {"file": ("lesson.exe", b"\x00" * 64, "application/octet-stream")}
data = {}
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code == 400
assert "Unsupported file format" in response.json().get("detail", "")
# โ”€โ”€โ”€ Metadata & missing file tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsRequestShape:
"""Request validation โ€” missing file, empty metadata."""
def test_rejects_missing_file(self):
"""No file part โ†’ 422 Unprocessable Entity."""
data = {"gradeLevel": "Grade 11", "subject": "Mathematics"}
response = client.post(
"/api/teacher-materials/upload",
files={},
data=data,
)
assert response.status_code == 422
def test_handles_empty_optional_metadata(self, mock_firestore_unavailable):
"""Optional fields (gradeLevel, subject, quarter) can all be empty.
The route should still attempt to process the file.
"""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
# No data fields at all
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._generate_teacher_module", return_value=None),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
# Intentionally no data= โ€” only the file is sent
)
# Should not 422 โ€” optional fields are truly optional
assert response.status_code in (200, 500)
# โ”€โ”€โ”€ DeepSeek generation tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsGeneration:
"""DeepSeek module generation failure modes."""
def test_returns_500_when_deepseek_fails(self, mock_firestore_unavailable, mock_deepseek_failure):
"""When DeepSeek is unavailable, response has success=False."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Mathematics", "quarter": "Q1"}
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("Quadratic equations lesson text", 200, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
payload = response.json()
assert payload.get("success") is False
assert "error" in payload or "message" in payload
def test_returns_success_payload_when_module_generated(
self, mock_firestore_unavailable, mock_deepseek_success
):
"""Happy path: DeepSeek returns module JSON, Firestore is mocked.
We mock Firestore so the document is not actually written.
"""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {
"gradeLevel": "Grade 11",
"subject": "General Mathematics",
"quarter": "Q1",
"strandOrTrack": "Academic",
}
mock_db = MagicMock()
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("Quadratic equations lesson", 200, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._get_firestore_client", return_value=mock_db),
patch("routes.teacher_materials._index_teacher_material_chunks", return_value=True),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code == 200
payload = response.json()
assert payload.get("success") is True
assert payload.get("moduleId") is not None
assert payload.get("title") == "Quadratic Equations"
# โ”€โ”€โ”€ Response shape tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsResponseShape:
"""TeacherMaterialUploadResponse schema is respected."""
def test_success_response_has_required_fields(self, mock_firestore_unavailable, mock_deepseek_success):
"""Success payload contains: success=True, moduleId, title, message."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Math", "quarter": "Q1"}
mock_db = MagicMock()
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
patch("routes.teacher_materials._get_firestore_client", return_value=mock_db),
patch("routes.teacher_materials._index_teacher_material_chunks", return_value=True),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
payload = response.json()
# Shape check
assert "success" in payload
assert isinstance(payload["success"], bool)
if payload["success"] is True:
assert "moduleId" in payload or "message" in payload
def test_error_response_has_error_field(self, mock_firestore_unavailable, mock_deepseek_failure):
"""Error payload has error or message string."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Math", "quarter": "Q1"}
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
payload = response.json()
assert "error" in payload or "message" in payload
assert isinstance(payload["error"] or payload["message"], str)
# โ”€โ”€โ”€ RAG context tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class TestTeacherMaterialsRAG:
"""RAG context is retrieved and passed to the generator."""
def test_rag_context_is_passed_to_generator(self, mock_firestore_unavailable, mock_deepseek_success):
"""When RAG returns passages, they should be included in module generation."""
files = {"file": ("lesson.pdf", _make_pdf(), "application/pdf")}
data = {"gradeLevel": "Grade 11", "subject": "Math", "quarter": "Q1"}
rag_passages = [
{"content": "Quadratic equations: axยฒ + bx + c = 0 (DepEd curriculum)."},
{"content": "Solving by factoring: (x-r1)(x-r2)=0."},
]
captured_args: Dict[str, Any] = {}
async def capture_generate(course_material_text, rag_results, metadata):
captured_args["raw_text"] = course_material_text
captured_args["rag_results"] = rag_results
captured_args["metadata"] = metadata
return {
"moduleId": "test-id",
"title": "Test",
"gradeLevel": "Grade 11",
"subject": "Math",
"quarter": "Q1",
"strandOrTrack": None,
"competencyTags": [],
"summary": "Test summary.",
"learningObjectives": [],
"sections": [],
"practice": [],
"aiSafety": {
"requiresGrounding": True,
"allowedModels": [],
"groundingSources": [],
},
"originNote": "",
}
mock_db = MagicMock()
with (
patch("routes.teacher_materials._parse_uploaded_file", return_value=("Lesson about quadratics.", 100, {})),
patch("routes.teacher_materials._retrieve_rag_context", return_value=rag_passages),
patch("routes.teacher_materials._generate_teacher_module", side_effect=capture_generate),
patch("routes.teacher_materials._get_firestore_client", return_value=mock_db),
patch("routes.teacher_materials._index_teacher_material_chunks", return_value=True),
):
response = client.post(
"/api/teacher-materials/upload",
files=files,
data=data,
)
assert response.status_code == 200
assert "raw_text" in captured_args
assert "rag_results" in captured_args
assert len(captured_args["rag_results"]) == 2
assert captured_args["metadata"]["gradeLevel"] == "Grade 11"
assert captured_args["metadata"]["subject"] == "Math"