Spaces:
Running
Running
| """ | |
| 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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 | |
| def mock_firestore_unavailable(): | |
| """Simulate Firestore not being initialized.""" | |
| with patch("routes.teacher_materials._get_firestore", return_value=None): | |
| yield | |
| 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 | |
| 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" | |