| 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 |
| 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): |
| |
| 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): |
| |
| 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 |
|
|
|
|
| |
|
|
| 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" |
| |
| assert any("No source uploaded" in n for n in data["trust_notes"]) |
|
|
|
|
| |
|
|
| 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 = body.get("trust_note") or "" |
| assert "No source attached" not in trust |
|
|
|
|
| |
|
|
| 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 |
| |
| 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.""" |
| |
| 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 |
|
|
|
|
| |
|
|
| 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() |
| |
| 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 |
|
|