Files
NSCT---Neutral-Search-Crawl…/tests/test_rest_research.py
2026-09-07 13:01:46 +02:00

555 lines
19 KiB
Python

"""Tests for the REST API — research lifecycle (Stage 14)."""
from __future__ import annotations
from typing import Generator
from unittest.mock import MagicMock, patch
from uuid import uuid4
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"] == []
def test_detail_endpoints_serialize_pipeline_uuid_identifiers(client: TestClient) -> None:
"""Completed pipeline data must satisfy the string-based REST contract."""
from nsct.api.rest_research import _ResearchRunState, _research_store
from nsct.orchestration.budget import HardBudgetConfig
_research_store.clear()
research_id = str(uuid4())
source_id = uuid4()
claim_id = uuid4()
_research_store[research_id] = _ResearchRunState(
research_id=research_id,
query="UUID serialization",
language="de",
depth="quick",
budget=HardBudgetConfig(),
sources=[{"id": source_id, "url": "https://example.org/article"}],
claims=[
{
"id": claim_id,
"source_id": source_id,
"claim_text": "Eine überprüfbare Behauptung.",
"evidence_span": "Überprüfbare Passage.",
"claim_type": "claim",
}
],
evidence_scores=[{"claim_id": claim_id, "research_run_id": uuid4()}],
report={"summary": "Zusammenfassung", "findings": [], "methodology": "Methodik"},
)
sources = client.get(f"/v1/research/{research_id}/sources")
claims = client.get(f"/v1/research/{research_id}/claims")
evidence = client.get(f"/v1/research/{research_id}/evidence")
report = client.get(f"/v1/research/{research_id}/report")
assert [response.status_code for response in (sources, claims, evidence, report)] == [200] * 4
assert sources.json()["sources"][0]["id"] == str(source_id)
assert claims.json()["claims"][0]["id"] == str(claim_id)
assert claims.json()["claims"][0]["source_id"] == str(source_id)
assert evidence.json()["evidence"][0]["claim_id"] == str(claim_id)
assert report.json()["methodology"] == "Methodik"
# ---------------------------------------------------------------------------
# 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