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