DocDoeAI / tests /test_backend_endpoints.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
3bcdb36
Raw
History Blame Contribute Delete
19.3 kB
from __future__ import annotations
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_analyze_study_request_endpoint(client):
response = client.post(
"/study/analyze-request",
json={"raw_text": "Kerala +2 Physics electromagnetic induction exam tomorrow, I need A+"},
)
assert response.status_code == 200
body = response.json()
assert body["subject"] == "Physics"
assert body["exam"] == "Kerala +2"
def test_study_profile_round_trip(client):
payload = {
"exam": "Kerala +2",
"subject": "Physics",
"topic": "Electromagnetic Induction",
"goal": "A+",
"time_left": "tomorrow",
}
create = client.post("/study-profile", json=payload)
assert create.status_code == 201, create.text
assert create.json()["subject"] == "Physics"
me = client.get("/study-profile/me")
assert me.status_code == 200
assert me.json()["topic"] == "Electromagnetic Induction"
patch = client.patch("/study-profile/me", json={"level": "intermediate"})
assert patch.status_code == 200
assert patch.json()["level"] == "intermediate"
def test_text_source_create_and_retrieve(client):
payload = {
"title": "EMI syllabus",
"text": (
"Electromagnetic induction is the process by which a changing magnetic "
"field induces an EMF in a conductor. Faraday's law and Lenz's law are core."
),
"source_type": "syllabus_text",
"subject": "Physics",
"chapter": "Electromagnetic Induction",
}
create = client.post("/sources/text", json=payload)
assert create.status_code == 201, create.text
source = create.json()
assert source["status"] == "ready"
assert source["chunk_count"] >= 1
source_id = source["id"]
listing = client.get("/sources")
assert listing.status_code == 200
assert any(item["id"] == source_id for item in listing.json()["sources"])
retrieve = client.post(
f"/sources/{source_id}/retrieve",
json={"query": "Faraday's law", "limit": 3},
)
assert retrieve.status_code == 200
chunks = retrieve.json()["chunks"]
assert chunks # at least one chunk
assert "Faraday" in chunks[0]["chunk_text"] or "induction" in chunks[0]["chunk_text"].lower()
def test_study_path_generate_without_pyq(client):
response = client.post(
"/study-path/generate",
json={
"raw_text": "Kerala +2 Physics electromagnetic induction exam tomorrow, I need A+",
},
)
assert response.status_code == 200
data = response.json()
assert data["topic"]
assert data["study_timeline"]
assert any("PYQ data is not available yet" in note for note in data["trust_notes"])
def test_pyq_analyze_unavailable(client):
response = client.post("/pyq/analyze", json={"subject": "Physics"})
assert response.status_code == 200
body = response.json()
assert body["available"] is False
assert "PYQ data is not available yet" in body["trust_note"]
def test_ask_without_source(client):
response = client.post(
"/ask",
json={"question": "Define electromagnetic induction.", "mode": "explain_simple"},
)
assert response.status_code == 200
body = response.json()
assert "answer" in body
assert body["citations"] == []
assert body["trust_note"]
def test_generate_notes_endpoint(client):
response = client.post(
"/generate/notes",
json={
"topic": "Electromagnetic Induction",
"subject": "Physics",
"level": "intermediate",
"language_preference": "English",
},
)
assert response.status_code == 200
body = response.json()
assert body["type"] == "notes"
assert body["output"]["key_points"]
def test_billing_flow(client):
me = client.get("/billing/me")
assert me.status_code == 200
assert me.json()["selected_plan"] == "free_trial"
assert me.json()["coming_soon"] is False
select = client.post("/billing/select-plan", json={"plan": "popular_299"})
assert select.status_code == 200
assert select.json()["selected_plan"] == "popular_299"
assert select.json()["monthly_video_limit"] == 20
trial = client.post("/billing/start-trial")
assert trial.status_code == 200
assert trial.json()["status"] == "trialing"
def test_billing_all_plan_ids(client):
for plan_id in ("free_trial", "starter_199", "popular_299", "premium_599", "advanced_1299"):
r = client.post("/billing/select-plan", json={"plan": plan_id})
assert r.status_code == 200, f"plan {plan_id} rejected: {r.text}"
assert r.json()["selected_plan"] == plan_id
def test_billing_invalid_plan_rejected(client):
r = client.post("/billing/select-plan", json={"plan": "free"})
assert r.status_code == 400
r2 = client.post("/billing/select-plan", json={"plan": "pro"})
assert r2.status_code == 400
r3 = client.post("/billing/select-plan", json={"plan": "exam_war"})
assert r3.status_code == 400
def test_billing_advanced_plan_coming_soon(client):
r = client.post("/billing/select-plan", json={"plan": "advanced_1299"})
assert r.status_code == 200
assert r.json()["coming_soon"] is True
def test_billing_checkout_only_active_299_and_599_plans(client):
stale = client.post("/billing/create-checkout-session", json={"plan": "starter_199"})
assert stale.status_code == 402
assert "299 or 599" in stale.json()["detail"]
advanced = client.post("/billing/create-checkout-session", json={"plan": "advanced_1299"})
assert advanced.status_code == 402
assert "299 or 599" in advanced.json()["detail"]
for plan_id in ("popular_299", "premium_599"):
checkout = client.post("/billing/create-checkout-session", json={"plan": plan_id})
assert checkout.status_code == 503, checkout.text
assert "No payment was started" in checkout.json()["detail"]
def test_video_plan_quick_concept(client):
response = client.post(
"/video-generator/plan",
json={"topic": "Photosynthesis", "video_mode": "quick_concept"},
)
assert response.status_code == 200
body = response.json()
assert body["scenes"]
assert body["target_duration_range"] == "5 minutes"
assert body["estimated_duration_minutes"] == 5
assert body["render_supported"] is True
assert body["planning_only"] is False
def test_video_plan_exam_focus(client):
response = client.post(
"/video-generator/plan",
json={"topic": "Electromagnetic Induction", "video_mode": "exam_focus"},
)
assert response.status_code == 200
body = response.json()
assert body["target_duration_range"] == "10 minutes"
assert body["estimated_duration_minutes"] == 10
assert body["render_supported"] is True
assert body["planning_only"] is False
def test_flashcards_due_endpoint(client):
r = client.get("/flashcards/due")
assert r.status_code == 200
body = r.json()
assert "due_count" in body
assert "cards" in body
assert "also_from_notes" in body
def test_flashcards_review_smoke(client):
# Smoke: due then review first if any; expect 200 or graceful 4xx if no cards in test db
r = client.get("/flashcards/due")
assert r.status_code == 200
cards = r.json().get("cards", [])
if cards:
c = cards[0]
r2 = client.post(
"/flashcards/review",
json={"set_id": c["set_id"], "card_index": c["card_index"], "quality": 3},
)
assert r2.status_code in (200, 422, 404)
def test_ai_provider_has_streaming(client):
# Presence check for new streaming StudyCast/Ask support
from app.services.ai_provider import get_ai_provider
p = get_ai_provider()
assert hasattr(p, "generate_streaming")
assert callable(getattr(p, "generate_streaming"))
def test_video_plan_deep_masterclass_30_minutes(client):
response = client.post(
"/video-generator/plan",
json={"topic": "Photosynthesis", "video_mode": "deep_masterclass"},
)
assert response.status_code == 200
body = response.json()
assert body["target_duration_range"] == "30 minutes"
assert body["estimated_duration_minutes"] == 30
assert body["render_supported"] is True
assert body["planning_only"] is False
def test_video_plan_war_mode_maps_to_30_minutes(client):
response = client.post(
"/video-generator/plan",
json={"topic": "Full Chapter Biology", "video_mode": "full_chapter_war_mode"},
)
assert response.status_code == 200
body = response.json()
assert body["target_duration_range"] == "30 minutes"
assert body["estimated_duration_minutes"] == 30
assert body["render_supported"] is True
assert body["planning_only"] is False
# ── Phase 7: multi-source study path ─────────────────────────────────────────
def test_study_path_single_source_works(client):
"""A study path with a single source_id returns a valid plan."""
create = client.post(
"/sources/text",
json={
"title": "EMI notes",
"text": "Electromagnetic induction: Faraday's law says EMF = -dΦ/dt.",
"source_type": "notes",
"subject": "Physics",
},
)
assert create.status_code == 201
source_id = create.json()["id"]
response = client.post(
"/study-path/generate",
json={
"raw_text": "Kerala +2 Physics electromagnetic induction exam tomorrow",
"source_id": source_id,
},
)
assert response.status_code == 200
data = response.json()
assert data["study_timeline"]
assert data["source_basis"] == "user_sources"
def test_study_path_multi_source_returns_valid_plan(client):
"""A study path with source_ids list returns a merged plan."""
src1 = client.post(
"/sources/text",
json={"title": "EMI src1", "text": "Faraday's law of electromagnetic induction.", "source_type": "notes"},
)
src2 = client.post(
"/sources/text",
json={"title": "EMI src2", "text": "Lenz's law explains direction of induced current.", "source_type": "notes"},
)
assert src1.status_code == 201
assert src2.status_code == 201
response = client.post(
"/study-path/generate",
json={
"raw_text": "Physics electromagnetic induction exam tomorrow",
"source_ids": [src1.json()["id"], src2.json()["id"]],
},
)
assert response.status_code == 200
data = response.json()
assert data["study_timeline"]
assert data["source_basis"] == "user_sources"
def test_study_path_without_source_uses_generic_template(client):
response = client.post(
"/study-path/generate",
json={"raw_text": "Kerala +2 Physics electromagnetic induction exam tomorrow"},
)
assert response.status_code == 200
data = response.json()
assert data["source_basis"] == "generic_topic_template"
# Trust note should mention no source
assert any("No source uploaded" in n for n in data["trust_notes"])
# ── Phase 8: multi-source Ask DocDoe ─────────────────────────────────────────
def test_studio_notes_with_source_ids_returns_answer(client):
"""Studio generation accepts source_ids list and returns structured output."""
src1 = client.post(
"/sources/text",
json={"title": "Notes src1", "text": "Faraday's law of electromagnetic induction.", "source_type": "notes"},
)
src2 = client.post(
"/sources/text",
json={"title": "Notes src2", "text": "Lenz's law gives the direction of induced EMF.", "source_type": "notes"},
)
assert src1.status_code == 201
assert src2.status_code == 201
response = client.post(
"/generate/notes",
json={
"topic": "Electromagnetic induction",
"source_ids": [src1.json()["id"], src2.json()["id"]],
"language_preference": "English",
},
)
assert response.status_code == 200
body = response.json()
assert body["type"] == "notes"
assert body["output"]
assert "evidence_label" in body
def test_ask_with_source_ids_returns_answer(client):
"""Ask endpoint accepts source_ids list and returns an answer."""
src = client.post(
"/sources/text",
json={
"title": "EMI ask test",
"text": "Faraday's law: EMF = -dΦ/dt. Lenz's law gives direction.",
"source_type": "notes",
},
)
assert src.status_code == 201
source_id = src.json()["id"]
response = client.post(
"/ask",
json={
"question": "State Faraday's law.",
"mode": "explain_simple",
"source_ids": [source_id],
},
)
assert response.status_code == 200
body = response.json()
assert body["answer"]
assert body["model_used"]
def test_ask_citation_shape(client):
"""Citations returned by Ask should have the expected fields."""
src = client.post(
"/sources/text",
json={
"title": "Citation test",
"text": "Electromagnetic induction produces an EMF when flux changes.",
"source_type": "notes",
},
)
assert src.status_code == 201
source_id = src.json()["id"]
response = client.post(
"/ask",
json={
"question": "What is electromagnetic induction?",
"mode": "explain_simple",
"source_id": source_id,
},
)
assert response.status_code == 200
body = response.json()
for citation in body.get("citations", []):
assert "chunk_id" in citation
assert "snippet" in citation
assert len(citation["snippet"]) <= 240
def test_ask_multi_source_trust_note_absent_when_sources_ready(client):
"""When a valid source is given, trust_note should not flag 'no source'."""
src = client.post(
"/sources/text",
json={"title": "Trust test", "text": "Photosynthesis converts CO2 to glucose.", "source_type": "notes"},
)
source_id = src.json()["id"]
response = client.post(
"/ask",
json={"question": "What is photosynthesis?", "mode": "explain_simple", "source_id": source_id},
)
body = response.json()
# trust_note should either be None or not say "No source attached"
trust = body.get("trust_note") or ""
assert "No source attached" not in trust
# ── Phase 9: usage accounting ─────────────────────────────────────────────────
def test_generation_usage_increments_after_notes(client):
"""Calling /generate/notes should increment monthly_generation_used."""
before = client.get("/billing/me").json()["monthly_generation_used"]
client.post(
"/generate/notes",
json={"topic": "Photosynthesis", "subject": "Biology", "language_preference": "English"},
)
after = client.get("/billing/me").json()["monthly_generation_used"]
assert after == before + 1
def test_generation_usage_increments_after_quiz(client):
before = client.get("/billing/me").json()["monthly_generation_used"]
client.post(
"/generate/quiz",
json={"topic": "Trigonometry", "subject": "Mathematics", "language_preference": "English"},
)
after = client.get("/billing/me").json()["monthly_generation_used"]
assert after == before + 1
def test_video_plan_usage_increments(client):
"""Calling /video-generator/plan should increment monthly_video_used."""
before = client.get("/billing/me").json()["monthly_video_used"]
client.post(
"/video-generator/plan",
json={"topic": "Photosynthesis", "video_mode": "quick_concept"},
)
after = client.get("/billing/me").json()["monthly_video_used"]
assert after == before + 1
def test_billing_me_includes_derived_usage_fields(client):
"""Billing /me response should include remaining counts and optional warning."""
body = client.get("/billing/me").json()
assert "remaining_generations" in body
assert "remaining_videos" in body
# usage_warning is optional (None when under limit)
assert "usage_warning" in body
def test_billing_stripe_webhook_rejects_when_secret_is_missing(client):
"""Unsigned JSON must never mutate entitlements, including in development."""
# Simulate a minimal checkout.session.completed event body (Stripe format)
fake_event = {
"id": "evt_test_123",
"type": "checkout.session.completed",
"data": {
"object": {
"client_reference_id": "usr_webhook_test",
"metadata": {"plan_key": "popular_299", "user_id": "usr_webhook_test"},
}
},
}
r = client.post(
"/billing/webhook",
json=fake_event,
headers={"stripe-signature": "t=123,v1=fake"},
)
assert r.status_code == 503
# ── Phase 11: time-based study path via endpoint ──────────────────────────────
def test_study_path_endpoint_1_hour_plan_has_few_steps(client):
"""1_hour time_left via endpoint should result in a short plan."""
response = client.post(
"/study-path/generate",
json={
"raw_text": "Physics electromagnetic induction in 1 hour",
},
)
assert response.status_code == 200
data = response.json()
# Analyzer should pick up "1_hour" from "in 1 hour"
if data.get("time_left") == "1_hour":
assert len(data["study_timeline"]) <= 3
def test_study_path_endpoint_7_days_has_day_blocks(client):
"""7_days plan via endpoint should carry day_block on each step."""
response = client.post(
"/study-path/generate",
json={
"raw_text": "Physics electromagnetic induction 7 days left",
},
)
assert response.status_code == 200
data = response.json()
if data.get("time_left") == "7_days":
for step in data["study_timeline"]:
assert "day_block" in step
def test_study_path_endpoint_pyq_trust_note_present(client):
"""PYQ unavailable note must always appear when no PYQ data uploaded."""
response = client.post(
"/study-path/generate",
json={"raw_text": "Kerala +2 Physics electromagnetic induction exam tomorrow, I need A+"},
)
assert response.status_code == 200
data = response.json()
assert any("PYQ data is not available yet" in note for note in data["trust_notes"])
def test_study_path_plan_type_and_label_present(client):
"""Study path response should include plan_type and plan_label fields."""
response = client.post(
"/study-path/generate",
json={"raw_text": "Kerala +2 Physics electromagnetic induction tonight"},
)
assert response.status_code == 200
data = response.json()
assert "plan_type" in data
assert "plan_label" in data