514 lines
17 KiB
Python
514 lines
17 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
|
|
|
|
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_active_fails(client: TestClient) -> None:
|
|
"""DELETE handles both active and finished research."""
|
|
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
|
|
|
|
# If completed, delete should return 400 (immutable)
|
|
# If failed/created, delete should succeed
|
|
resp = client.delete(f"/v1/research/{research_id}")
|
|
assert resp.status_code in (200, 400)
|
|
if resp.status_code == 200:
|
|
body = resp.json()
|
|
assert body["status"] == "deleted"
|
|
assert body["research_id"] == research_id
|
|
|
|
|
|
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
|