feat(stage14): implement REST API for research lifecycle
- POST /v1/research — start research (non-blocking, background pipeline)
- GET /v1/research/{id} — research metadata
- GET /v1/research/{id}/status — detailed state machine status
- GET /v1/research/{id}/sources — sources list
- GET /v1/research/{id}/claims — claims list
- GET /v1/research/{id}/evidence — evidence scores
- GET /v1/research/{id}/report — research report
- DELETE /v1/research/{id} — delete research (non-completed)
- GET /v1/research — paginated list of all research runs
Depth budgets (quick/normal/deep) control only resource limits.
In-memory store for now, to be replaced with PostgreSQL later.
Router mounted in main.py as tag "research-api".
This commit is contained in:
456
tests/test_rest_research.py
Normal file
456
tests/test_rest_research.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""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
|
||||
|
||||
app = create_app()
|
||||
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}/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
|
||||
Reference in New Issue
Block a user