Spaces:
Sleeping
Sleeping
| """End-to-end backend tests for EduScanner AI — polling pattern (iteration 2).""" | |
| import os | |
| import re | |
| import time | |
| import pytest | |
| import requests | |
| BASE_URL = os.environ.get('REACT_APP_BACKEND_URL', '').rstrip('/') | |
| API = f"{BASE_URL}/api" | |
| POLL_INTERVAL = 3 # seconds between polls | |
| POLL_TIMEOUT = 180 # max seconds to wait for status='ready' | |
| def _poll_until_ready(client, url, timeout=POLL_TIMEOUT): | |
| """Poll the GET endpoint until status='ready' or 'failed'. Returns final json.""" | |
| deadline = time.time() + timeout | |
| last = None | |
| while time.time() < deadline: | |
| r = client.get(url, timeout=30) | |
| if r.status_code != 200: | |
| return {"status": "http_error", "code": r.status_code, "text": r.text[:300]} | |
| last = r.json() | |
| status = last.get("status") | |
| if status in ("ready", "failed"): | |
| return last | |
| time.sleep(POLL_INTERVAL) | |
| return last or {"status": "timeout"} | |
| # ============== Auth ============== | |
| class TestAuth: | |
| def test_auth_session_missing_id(self): | |
| r = requests.post(f"{API}/auth/session", json={}) | |
| assert r.status_code == 400 | |
| def test_auth_session_invalid_id(self): | |
| r = requests.post(f"{API}/auth/session", json={"session_id": "definitely_fake_id_12345"}) | |
| assert r.status_code == 401 | |
| def test_auth_me_without_token(self): | |
| r = requests.get(f"{API}/auth/me") | |
| assert r.status_code == 401 | |
| def test_documents_requires_auth(self): | |
| r = requests.get(f"{API}/documents") | |
| assert r.status_code == 401 | |
| def test_progress_requires_auth(self): | |
| r = requests.get(f"{API}/progress") | |
| assert r.status_code == 401 | |
| def test_audit_logs_requires_auth(self): | |
| r = requests.get(f"{API}/audit-logs") | |
| assert r.status_code == 401 | |
| def test_auth_me_with_valid_bearer(self, auth_client, test_session): | |
| r = auth_client.get(f"{API}/auth/me") | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert data["user_id"] == test_session["user_id"] | |
| assert "_id" not in data | |
| assert "email" in data | |
| assert data["onboarded"] is False | |
| # ============== Profile ============== | |
| class TestProfile: | |
| def test_update_profile_universitas(self, auth_client, test_session): | |
| payload = { | |
| "education_level": "Universitas", | |
| "major": "Informatika", | |
| "institution": "UBSI", | |
| "current_semester": 4, | |
| } | |
| r = auth_client.put(f"{API}/profile", json=payload) | |
| assert r.status_code == 200, r.text | |
| data = r.json() | |
| assert data["education_level"] == "Universitas" | |
| assert data["major"] == "Informatika" | |
| assert data["institution"] == "UBSI" | |
| assert data["current_semester"] == 4 | |
| assert data["onboarded"] is True | |
| assert "_id" not in data | |
| r2 = auth_client.get(f"{API}/auth/me") | |
| assert r2.status_code == 200 | |
| assert r2.json()["major"] == "Informatika" | |
| def test_update_profile_sd_no_major(self, auth_client, mongo_db, test_session): | |
| payload = { | |
| "education_level": "SD", | |
| "major": "Should Be Ignored", | |
| "institution": "SD Negeri 1", | |
| "current_semester": 5, | |
| } | |
| r = auth_client.put(f"{API}/profile", json=payload) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert data["education_level"] == "SD" | |
| assert data["major"] is None | |
| user = mongo_db.users.find_one({"user_id": test_session["user_id"]}) | |
| assert user["major"] is None | |
| # Restore to Universitas for downstream tests | |
| auth_client.put(f"{API}/profile", json={ | |
| "education_level": "Universitas", | |
| "major": "Informatika", | |
| "institution": "UBSI", | |
| "current_semester": 4, | |
| }) | |
| # ============== Documents list ============== | |
| class TestDocumentsList: | |
| def test_documents_empty(self, auth_client): | |
| r = auth_client.get(f"{API}/documents") | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert isinstance(data, list) | |
| assert all("_id" not in d for d in data) | |
| # ============== Upload + polling ============== | |
| def uploaded_doc(auth_client, sample_pdf): | |
| """Upload PDF, expect immediate 200 with status='processing', then poll to 'ready'.""" | |
| with open(sample_pdf, "rb") as f: | |
| files = {"file": ("sample.pdf", f, "application/pdf")} | |
| headers = {k: v for k, v in auth_client.headers.items() if k.lower() != "content-type"} | |
| t0 = time.time() | |
| r = requests.post(f"{API}/documents/upload", files=files, headers=headers, timeout=120) | |
| elapsed = time.time() - t0 | |
| if r.status_code != 200: | |
| pytest.skip(f"PDF upload failed: {r.status_code} {r.text[:300]}") | |
| initial = r.json() | |
| print(f"[upload] elapsed={elapsed:.2f}s status={initial.get('status')}") | |
| assert elapsed < 10, f"Upload took {elapsed:.1f}s — must return immediately (bg task pattern not effective; event loop is blocked)" | |
| assert initial["status"] == "processing", f"Expected processing, got {initial.get('status')}" | |
| doc_id = initial["document_id"] | |
| final = _poll_until_ready(auth_client, f"{API}/documents/{doc_id}") | |
| final["_initial_elapsed"] = elapsed | |
| return final | |
| class TestDocumentUpload: | |
| def test_upload_returns_immediately_then_ready(self, uploaded_doc): | |
| d = uploaded_doc | |
| if d.get("status") == "failed": | |
| pytest.fail(f"Background analyze failed: {d.get('error')}") | |
| assert d.get("status") == "ready", f"status={d.get('status')}" | |
| assert d["filename"] == "sample.pdf" | |
| assert isinstance(d.get("summary"), str) and len(d["summary"]) > 20 | |
| assert isinstance(d.get("key_concepts"), list) and len(d["key_concepts"]) >= 1 | |
| assert isinstance(d.get("learning_objectives"), list) | |
| assert "_id" not in d | |
| assert "file_path" not in d | |
| def test_documents_list_after_upload(self, auth_client, uploaded_doc): | |
| r = auth_client.get(f"{API}/documents") | |
| assert r.status_code == 200 | |
| ids = [d["document_id"] for d in r.json()] | |
| assert uploaded_doc["document_id"] in ids | |
| # ============== Quiz generate + polling ============== | |
| def generated_quiz(auth_client, uploaded_doc): | |
| if uploaded_doc.get("status") != "ready": | |
| pytest.skip("Document not ready, skipping quiz tests") | |
| t0 = time.time() | |
| r = auth_client.post(f"{API}/quiz/generate", json={ | |
| "document_id": uploaded_doc["document_id"], | |
| "question_count": 3, | |
| }, timeout=120) | |
| elapsed = time.time() - t0 | |
| if r.status_code != 200: | |
| pytest.skip(f"Quiz generation failed: {r.status_code} {r.text[:300]}") | |
| initial = r.json() | |
| print(f"[quiz/generate] elapsed={elapsed:.2f}s status={initial.get('status')}") | |
| assert elapsed < 10, f"Quiz generate took {elapsed:.1f}s — must return immediately" | |
| assert initial.get("status") == "processing" | |
| quiz_id = initial["quiz_id"] | |
| final = _poll_until_ready(auth_client, f"{API}/quiz/{quiz_id}") | |
| return final | |
| class TestQuiz: | |
| def test_quiz_generate_returns_immediately_then_ready(self, generated_quiz): | |
| q = generated_quiz | |
| if q.get("status") == "failed": | |
| pytest.fail(f"Background quiz gen failed: {q.get('error')}") | |
| assert q.get("status") == "ready" | |
| assert "quiz_id" in q | |
| assert 3 <= len(q["questions"]) <= 5 | |
| for question in q["questions"]: | |
| assert "correct_index" not in question, "Leaked correct_index!" | |
| assert len(question["options"]) == 4 | |
| assert "question" in question | |
| assert "id" in question | |
| assert "_id" not in q | |
| def test_quiz_generate_nonexistent_doc_404(self, auth_client): | |
| r = auth_client.post(f"{API}/quiz/generate", json={ | |
| "document_id": "does-not-exist-xyz", | |
| "question_count": 3, | |
| }, timeout=20) | |
| assert r.status_code == 404, r.text | |
| def test_quiz_submit_on_processing_quiz_400(self, auth_client, uploaded_doc, mongo_db, test_session): | |
| # Create a stub quiz with status='processing' directly in DB | |
| from datetime import datetime, timezone | |
| stub_id = f"stub-quiz-{int(time.time()*1000)}" | |
| mongo_db.quizzes.insert_one({ | |
| "quiz_id": stub_id, | |
| "user_id": test_session["user_id"], | |
| "document_id": uploaded_doc["document_id"], | |
| "questions": [], | |
| "status": "processing", | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| }) | |
| r = auth_client.post(f"{API}/quiz/submit", json={ | |
| "quiz_id": stub_id, | |
| "answers": [0, 0, 0], | |
| }, timeout=20) | |
| mongo_db.quizzes.delete_one({"quiz_id": stub_id}) | |
| assert r.status_code == 400, r.text | |
| def test_quiz_submit_returns_immediately_then_ready(self, auth_client, generated_quiz): | |
| if generated_quiz.get("status") != "ready": | |
| pytest.skip("Quiz not ready") | |
| answers = [0] * len(generated_quiz["questions"]) | |
| t0 = time.time() | |
| r = auth_client.post(f"{API}/quiz/submit", json={ | |
| "quiz_id": generated_quiz["quiz_id"], | |
| "answers": answers, | |
| }, timeout=120) | |
| elapsed = time.time() - t0 | |
| assert r.status_code == 200, r.text | |
| result = r.json() | |
| print(f"[quiz/submit] elapsed={elapsed:.2f}s status={result.get('status')}") | |
| assert elapsed < 10, f"Quiz submit took {elapsed:.1f}s — must return immediately" | |
| assert result.get("status") == "processing" | |
| assert "result_id" in result | |
| assert "_id" not in result | |
| # Poll result endpoint | |
| final = _poll_until_ready(auth_client, f"{API}/quiz/result/{result['result_id']}") | |
| if final.get("status") == "failed": | |
| pytest.fail(f"Background grading failed: {final.get('error')}") | |
| assert final.get("status") == "ready" | |
| assert isinstance(final.get("score"), int) | |
| assert 0 <= final["score"] <= 100 | |
| assert isinstance(final.get("summary"), str) and len(final["summary"]) > 0 | |
| assert isinstance(final.get("items"), list) and len(final["items"]) >= 1 | |
| for it in final["items"]: | |
| assert "explanation" in it | |
| assert "references" in it | |
| assert "_id" not in final | |
| # ============== Audit logs & progress ============== | |
| class TestAuditAndProgress: | |
| def test_audit_logs_format(self, auth_client): | |
| r = auth_client.get(f"{API}/audit-logs") | |
| assert r.status_code == 200 | |
| logs = r.json() | |
| assert isinstance(logs, list) | |
| assert len(logs) >= 1 | |
| for log in logs: | |
| assert "_id" not in log | |
| assert re.match(r"^AUD-\d{8}-\d{4}$", log["log_id"]), f"Bad log_id: {log['log_id']}" | |
| assert "action" in log | |
| def test_progress_endpoint(self, auth_client): | |
| r = auth_client.get(f"{API}/progress") | |
| assert r.status_code == 200 | |
| p = r.json() | |
| assert "documents" in p | |
| assert "quizzes" in p | |
| assert "average_score" in p | |
| assert "recent_results" in p | |
| assert isinstance(p["recent_results"], list) | |
| # ============== Logout (must be last) ============== | |
| class TestZLogout: | |
| def test_logout_invalidates_session(self, test_session): | |
| token = test_session["token"] | |
| s = requests.Session() | |
| s.headers.update({"Authorization": f"Bearer {token}"}) | |
| r = s.post(f"{API}/auth/logout", cookies={"session_token": token}) | |
| assert r.status_code == 200 | |
| r2 = s.get(f"{API}/auth/me") | |
| assert r2.status_code == 401 | |