eduai-backend / tests /test_eduscanner_api.py
Alstears's picture
Upload 64 files
f18a082 verified
"""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 ==============
@pytest.fixture(scope="module")
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 ==============
@pytest.fixture(scope="module")
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