"""Tests for the REST API — research lifecycle (Stage 14).""" from __future__ import annotations from typing import Generator from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def client() -> Generator[TestClient, None, None]: """Synchronous test client.""" from nsct.api.main import create_app from nsct.security.api_keys import require_api_key app = create_app() # The lifecycle tests exercise research behavior, not authentication. API # key behavior has its own integration tests below. app.dependency_overrides[require_api_key] = lambda: None with TestClient(app) as c: yield c # --------------------------------------------------------------------------- # Depth config validation # --------------------------------------------------------------------------- def test_depth_config_quick_exists(client: TestClient) -> None: """Depth 'quick' is defined.""" from nsct.api.rest_research import DEPTH_CONFIGS assert "quick" in DEPTH_CONFIGS assert DEPTH_CONFIGS["quick"].max_search_queries > 0 def test_depth_config_normal_exists(client: TestClient) -> None: """Depth 'normal' is defined.""" from nsct.api.rest_research import DEPTH_CONFIGS assert "normal" in DEPTH_CONFIGS assert DEPTH_CONFIGS["normal"].max_search_queries > 0 def test_depth_config_deep_exists(client: TestClient) -> None: """Depth 'deep' is defined.""" from nsct.api.rest_research import DEPTH_CONFIGS assert "deep" in DEPTH_CONFIGS assert DEPTH_CONFIGS["deep"].max_search_queries > 0 def test_depth_budgets_increase_with_depth(client: TestClient) -> None: """deep > normal > quick for budget limits.""" from nsct.api.rest_research import DEPTH_CONFIGS quick = DEPTH_CONFIGS["quick"] normal = DEPTH_CONFIGS["normal"] deep = DEPTH_CONFIGS["deep"] assert quick.max_search_queries < normal.max_search_queries < deep.max_search_queries assert quick.max_sources < normal.max_sources < deep.max_sources assert quick.max_llm_requests < normal.max_llm_requests < deep.max_llm_requests def test_invalid_depth_rejected(client: TestClient) -> None: """Invalid depth values must return 400.""" resp = client.post( "/v1/research", json={"query": "Test query", "language": "de", "depth": "ultra"}, ) assert resp.status_code == 400 body = resp.json() assert "detail" in body detail_lower = body["detail"].lower() assert "ungültige tiefe" in detail_lower or "invalid" in detail_lower # --------------------------------------------------------------------------- # POST /v1/research — create research # --------------------------------------------------------------------------- def test_post_research_returns_200(client: TestClient) -> None: """POST /v1/research returns 200 with research_id.""" resp = client.post( "/v1/research", json={"query": "Test research query", "language": "de", "depth": "normal"}, ) assert resp.status_code == 200 body = resp.json() assert "research_id" in body assert body["status"] == "pending" assert body["query"] == "Test research query" assert body["depth"] == "normal" def test_post_research_invalid_query(client: TestClient) -> None: """Empty query must be rejected.""" resp = client.post( "/v1/research", json={"query": "", "language": "de", "depth": "normal"}, ) assert resp.status_code == 422 # Pydantic validation error def test_post_research_defaults(client: TestClient) -> None: """Default values: language=de, depth=normal.""" resp = client.post( "/v1/research", json={"query": "Default test"}, ) assert resp.status_code == 200 body = resp.json() assert body["query"] == "Default test" assert body["depth"] == "normal" def test_post_research_creates_entry_in_store(client: TestClient) -> None: """POST creates an entry in the in-memory store.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Test store entry", "language": "en", "depth": "quick"}, ) assert resp.status_code == 200 research_id = resp.json()["research_id"] assert research_id in _research_store run = _research_store[research_id] assert run.query == "Test store entry" assert run.language == "en" assert run.depth == "quick" # State is set by background pipeline — can evolve from created assert run.state in ("created", "completed", "failed") # --------------------------------------------------------------------------- # GET /v1/research — list # --------------------------------------------------------------------------- def test_get_research_list_empty(client: TestClient) -> None: """GET /v1/research returns empty list when no research exists.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.get("/v1/research") assert resp.status_code == 200 body = resp.json() assert body["total"] == 0 assert body["items"] == [] def test_get_research_list_populated(client: TestClient) -> None: """GET /v1/research returns all research entries.""" from nsct.api.rest_research import _research_store _research_store.clear() # Create test entries for i in range(5): client.post( "/v1/research", json={"query": f"Query {i}", "language": "de", "depth": "quick"}, ) resp = client.get("/v1/research") assert resp.status_code == 200 body = resp.json() assert body["total"] == 5 assert len(body["items"]) == 5 for item in body["items"]: assert "research_id" in item assert "query" in item assert "state" in item assert "created_at" in item def test_get_research_list_pagination(client: TestClient) -> None: """GET /v1/research supports limit and offset pagination.""" from nsct.api.rest_research import _research_store _research_store.clear() for i in range(10): client.post( "/v1/research", json={"query": f"Query {i}", "language": "de", "depth": "quick"}, ) resp = client.get("/v1/research?limit=3&offset=0") assert resp.status_code == 200 body = resp.json() assert body["total"] == 10 assert len(body["items"]) == 3 resp2 = client.get("/v1/research?limit=3&offset=7") body2 = resp2.json() assert len(body2["items"]) == 3 # 8, 9 remaining # --------------------------------------------------------------------------- # GET /v1/research/{id} — metadata # --------------------------------------------------------------------------- def test_get_research_not_found(client: TestClient) -> None: """GET /v1/research/{id} returns 404 for non-existent ID.""" resp = client.get("/v1/research/nonexistent-id") assert resp.status_code == 404 # --------------------------------------------------------------------------- # GET /v1/research/{id}/status — status # --------------------------------------------------------------------------- def test_get_status_not_found(client: TestClient) -> None: """GET /v1/research/{id}/status returns 404 for non-existent ID.""" resp = client.get("/v1/research/nonexistent-id/status") assert resp.status_code == 404 def test_get_status_returns_state(client: TestClient) -> None: """GET /v1/research/{id}/status returns correct state and fields.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Status test", "language": "de", "depth": "normal"}, ) research_id = resp.json()["research_id"] # The background task runs immediately and advances state. # Wait for it to finish, then check status. import time time.sleep(0.5) resp = client.get(f"/v1/research/{research_id}/status") assert resp.status_code == 200 body = resp.json() assert body["research_id"] == research_id assert body["query"] == "Status test" assert body["depth"] == "normal" # State is set by background pipeline — can be anything from created to failed assert body["state"] in ("created", "planning", "failed", "completed") assert "created_at" in body assert isinstance(body["is_completed"], bool) assert isinstance(body["is_running"], bool) # --------------------------------------------------------------------------- # GET /v1/research/{id}/sources — sources # --------------------------------------------------------------------------- def test_get_sources_empty(client: TestClient) -> None: """GET /v1/research/{id}/sources returns empty list for new research.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Sources test", "language": "de", "depth": "quick"}, ) research_id = resp.json()["research_id"] resp = client.get(f"/v1/research/{research_id}/sources") assert resp.status_code == 200 body = resp.json() assert body["research_id"] == research_id assert body["total"] == 0 assert body["sources"] == [] # --------------------------------------------------------------------------- # GET /v1/research/{id}/claims — claims # --------------------------------------------------------------------------- def test_get_claims_empty(client: TestClient) -> None: """GET /v1/research/{id}/claims returns empty list for new research.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Claims test", "language": "de", "depth": "quick"}, ) research_id = resp.json()["research_id"] resp = client.get(f"/v1/research/{research_id}/claims") assert resp.status_code == 200 body = resp.json() assert body["research_id"] == research_id assert body["total"] == 0 assert body["claims"] == [] # --------------------------------------------------------------------------- # GET /v1/research/{id}/evidence — evidence # --------------------------------------------------------------------------- def test_get_evidence_empty(client: TestClient) -> None: """GET /v1/research/{id}/evidence returns empty list for new research.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Evidence test", "language": "de", "depth": "quick"}, ) research_id = resp.json()["research_id"] resp = client.get(f"/v1/research/{research_id}/evidence") assert resp.status_code == 200 body = resp.json() assert body["research_id"] == research_id assert body["total"] == 0 assert body["evidence"] == [] # --------------------------------------------------------------------------- # GET /v1/research/{id}/plan — plan # --------------------------------------------------------------------------- def test_get_plan_returns_stored_validated_plan(client: TestClient) -> None: """The plan endpoint exposes stored planner output without raw LLM data.""" from nsct.api.rest_research import _ResearchRunState, _research_store from nsct.orchestration.budget import HardBudgetConfig _research_store.clear() research_id = "plan-test" _research_store[research_id] = _ResearchRunState( research_id=research_id, query="Plan test", language="de", depth="quick", budget=HardBudgetConfig(max_search_queries=10, max_sources=5), plan={"topic": "Plan test", "queries": [{"query": "Plan test", "purpose": "test"}]}, ) resp = client.get(f"/v1/research/{research_id}/plan") assert resp.status_code == 200 assert resp.json() == { "research_id": research_id, "plan": {"topic": "Plan test", "queries": [{"query": "Plan test", "purpose": "test"}]}, "available": True, } def test_get_plan_returns_unavailable_while_planning(client: TestClient) -> None: """A newly created run has a valid empty plan response until planning ends.""" from nsct.api.rest_research import _ResearchRunState, _research_store from nsct.orchestration.budget import HardBudgetConfig _research_store.clear() research_id = "no-plan-test" _research_store[research_id] = _ResearchRunState( research_id=research_id, query="Plan test", language="de", depth="quick", budget=HardBudgetConfig(max_search_queries=10, max_sources=5), ) resp = client.get(f"/v1/research/{research_id}/plan") assert resp.status_code == 200 assert resp.json()["plan"] is None assert resp.json()["available"] is False # --------------------------------------------------------------------------- # GET /v1/research/{id}/report — report # --------------------------------------------------------------------------- def test_get_report_not_completed(client: TestClient) -> None: """GET /v1/research/{id}/report returns a summary even when not completed.""" from nsct.api.rest_research import _research_store _research_store.clear() resp = client.post( "/v1/research", json={"query": "Report test", "language": "de", "depth": "quick"}, ) research_id = resp.json()["research_id"] resp = client.get(f"/v1/research/{research_id}/report") assert resp.status_code == 200 body = resp.json() assert body["research_id"] == research_id # The report endpoint should return valid JSON with research_id assert "research_id" in body # --------------------------------------------------------------------------- # DELETE /v1/research/{id} — delete # --------------------------------------------------------------------------- def test_delete_research_soft_deletes_all_states(client: TestClient) -> None: """DELETE hides a run, including a completed one, instead of destroying it.""" from nsct.api.rest_research import _research_store, ResearchRunState _research_store.clear() resp = client.post( "/v1/research", json={"query": "Delete test", "language": "de", "depth": "quick"}, ) research_id = resp.json()["research_id"] # Wait a moment for background pipeline import time time.sleep(1) # Check the current state run = _research_store.get(research_id) if run is None: # Already deleted by previous tests — skip return resp = client.delete(f"/v1/research/{research_id}") assert resp.status_code == 200 body = resp.json() assert body["status"] == "hidden" assert body["research_id"] == research_id assert research_id not in _research_store or _research_store[research_id].is_hidden def test_delete_research_not_found(client: TestClient) -> None: """DELETE on non-existent ID returns 404.""" resp = client.delete("/v1/research/nonexistent-id") assert resp.status_code == 404 # --------------------------------------------------------------------------- # State machine integration # --------------------------------------------------------------------------- def test_state_machine_values_available(client: TestClient) -> None: """All ResearchRunState values are available for API use.""" from nsct.orchestration.state import ResearchRunState expected = { "created", "planning", "searching", "fetching", "extracting", "analyzing", "expanding", "comparing", "synthesizing", "completed", "failed", "cancelled", } actual = {s.value for s in ResearchRunState} assert expected == actual # --------------------------------------------------------------------------- # In-memory store operations # --------------------------------------------------------------------------- def test_save_and_load_run(client: TestClient) -> None: """_save_run and _load_run work correctly.""" from nsct.api.rest_research import _load_run, _research_store from nsct.api.rest_research import _ResearchRunState from nsct.orchestration.budget import HardBudgetConfig from nsct.orchestration.state import ResearchRunState _research_store.clear() budget = HardBudgetConfig(max_search_queries=10, max_sources=5) run = _ResearchRunState( research_id="test-save-load", query="Save test", language="de", depth="quick", budget=budget, state=ResearchRunState.CREATED.value, ) from nsct.api.rest_research import _save_run _save_run(run) loaded = _load_run("test-save-load") assert loaded.research_id == "test-save-load" assert loaded.query == "Save test" assert loaded.state == ResearchRunState.CREATED.value # Clean up del _research_store["test-save-load"] def test_load_missing_run_raises_404(client: TestClient) -> None: """_load_run raises 404 for missing research_id.""" from nsct.api.rest_research import _load_run from fastapi import HTTPException with pytest.raises(HTTPException) as exc_info: _load_run("non-existent-id") assert exc_info.value.status_code == 404