Files
GEWU/tests/api/test_search_api.py
T

709 lines
34 KiB
Python
Raw Normal View History

import asyncio
import hashlib
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx2
import yaml
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import TextContent
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.application.reading import CanonicalNodeReader
from gewu.application.search import CanonicalSearchService
from gewu.bootstrap.app import create_app
from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None:
async def exercise() -> None:
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
stale_node = NODE.model_copy(
update={
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
}
)
problem_node = NODE.model_copy(
update={
"node_id": "dome:problem-browse-fixture",
"node_type": NodeType.PROBLEM,
"title": "Problem browse fixture",
"value": None,
"unit": None,
}
)
problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest()
older_source = SOURCE_REVISION.model_copy(
update={
"source_id": "dome:older-pilot-report",
"revision_id": "dome:older-pilot-report:r1",
"title": "Older pilot installation report",
"captured_at": datetime(2025, 1, 1, tzinfo=UTC),
}
)
related = NODE.model_copy(update={"node_id": "z:dome-related"})
related_key = hashlib.sha256(related.node_id.encode()).hexdigest()
related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"})
related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest()
edge = TypedEdge(
edge_id="dome:related-edge",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id=NODE.node_id),
target=EdgeEndpoint(kind="node", id=related.node_id),
)
edge_two = TypedEdge(
edge_id="dome:related-edge-two",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id=NODE.node_id),
target=EdgeEndpoint(kind="node", id=related_two.node_id),
)
repository.publish_files(
expected_head=repository.head(),
files={
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
stale_node.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump(
problem_node.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump(
related.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump(
related_two.model_dump(mode="json")
).encode(),
(
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
f"{source_key}.yaml"
): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(),
(
f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/"
f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml"
): yaml.safe_dump(older_source.model_dump(mode="json")).encode(),
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
edge.model_dump(mode="json")
).encode(),
(
f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml"
): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(),
},
message="Add searchable node",
index_directory=tmp_path / "publishing",
)
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
token = identities.provision(
credential_id="search-key",
principal_id="search-reader",
roles=("viewer",),
project_ids=("dome-performance",),
)
search = CanonicalSearchService(
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
project_ids_provider=lambda: PROJECTS.keys(),
canonical_head=repository.head,
)
app = create_app(
identity_provider=identities,
node_reader=CanonicalNodeReader(repository),
search_service=search,
)
async with app.router.lifespan_context(app):
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
headers={"Authorization": f"Bearer {token}"},
) as http:
diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_unbuilt.status_code == 200
unbuilt_state = diagnostic_unbuilt.json()
assert unbuilt_state["index_state"] == "not_built"
assert unbuilt_state["index_revision"] is None
assert unbuilt_state["index_schema_version"] == 5
result = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"limit": 10,
"budget_tokens": 5000,
"include_evidence": False,
},
)
assert result.status_code == 200, result.text
envelope = result.json()
assert envelope["retrieval_mode"] == "keyword"
assert envelope["index_revision"] == repository.head()
assert envelope["data"][0]["node"]["node_id"] == NODE.node_id
assert envelope["data"][0]["evidence_included"] is False
compact_claim = envelope["data"][0]["node"]["claims"][0]
assert "evidence" not in compact_claim
assert compact_claim["evidence_count"] == len(NODE.claims[0].evidence)
assert envelope["citations"] == []
full_result = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
"include_evidence": True,
},
)
assert full_result.status_code == 200, full_result.text
full_row = full_result.json()["data"][0]
assert full_row["evidence_included"] is True
assert full_row["node"]["claims"][0]["evidence"]
assert full_result.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
browse = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2},
)
assert browse.status_code == 200, browse.text
browse_body = browse.json()
assert browse_body["project_node_count"] == 4
assert browse_body["filtered_node_count"] == 4
assert len(browse_body["nodes"]) == 2
assert browse_body["next_cursor"]
assert [item["title"] for item in browse_body["nodes"]] == sorted(
item["title"] for item in browse_body["nodes"]
)
assert browse_body["facets"]["tags"]
browse_page_two = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2, "cursor": browse_body["next_cursor"]},
)
assert browse_page_two.status_code == 200
assert len(browse_page_two.json()["nodes"]) == 2
assert browse_page_two.json()["next_cursor"] is None
filtered_browse = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "node_types": ["problem"]},
)
assert filtered_browse.status_code == 200
assert filtered_browse.json()["filtered_node_count"] == 1
assert filtered_browse.json()["nodes"][0]["node_type"] == "problem"
assert filtered_browse.json()["project_node_count"] == 4
graph = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}},
)
assert graph.status_code == 200, graph.text
assert len(graph.json()["nodes"]) == 4
assert len(graph.json()["edges"]) == 2
assert graph.json()["truncated"] is False
neighborhood = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id},
)
assert neighborhood.status_code == 200, neighborhood.text
assert neighborhood.json()["center_node_id"] == NODE.node_id
assert NODE.node_id in {item["node_id"] for item in neighborhood.json()["nodes"]}
assert len(neighborhood.json()["nodes"]) == 3
center_only = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id, "max_nodes": 1},
)
assert center_only.status_code == 200
assert [item["node_id"] for item in center_only.json()["nodes"]] == [NODE.node_id]
assert center_only.json()["edges"] == []
denied_neighborhood = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["other-project"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id},
)
assert denied_neighborhood.status_code == 404
capped_graph = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "max_nodes": 1},
)
assert capped_graph.status_code == 200
assert len(capped_graph.json()["nodes"]) == 1
assert capped_graph.json()["truncated"] is True
listed_sources = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
},
)
assert listed_sources.status_code == 200, listed_sources.text
source_nodes_resp = await http.get(
f"/v1/projects/dome-performance/sources/{SOURCE_REVISION.revision_id}/nodes"
)
assert source_nodes_resp.status_code == 200, source_nodes_resp.text
source_nodes_data = source_nodes_resp.json()
assert source_nodes_data["total"] >= 1
assert source_nodes_data["nodes"][0]["relation_basis"] in ("citation", "edge", "both")
assert listed_sources.json()["filtered_count"] == 2
assert listed_sources.json()["data"][0]["source"]["revision_id"] == (
SOURCE_REVISION.revision_id
)
source_page_one = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
source_cursor = source_page_one.json()["next_cursor"]
assert source_cursor
source_page_two = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": source_cursor,
"budget_tokens": 5000,
},
)
assert source_page_two.status_code == 200
assert source_page_two.json()["data"][0]["source"]["revision_id"] == (
older_source.revision_id
)
denied_sources = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["interactive-video"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
},
)
assert denied_sources.status_code == 403
problem_filter = await http.post(
"/v1/search",
json={
"query": "unmatched phrase",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"budget_tokens": 5000,
},
)
assert problem_filter.status_code == 200, problem_filter.text
assert problem_filter.json()["data"] == []
problem_browse = await http.post(
"/v1/search",
json={
"query": "",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"budget_tokens": 5000,
},
)
assert problem_browse.status_code == 200, problem_browse.text
assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [
problem_node.node_id
]
unbounded_browse = await http.post(
"/v1/search",
json={
"query": "",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"budget_tokens": 5000,
},
)
assert unbounded_browse.status_code == 422
stale_filter = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"stale_only": True,
"budget_tokens": 5000,
},
)
assert stale_filter.status_code == 200, stale_filter.text
assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
facet_filter = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"tags": ["attendance"],
"audience": ["product"],
"use_for": ["comparison"],
"domain": ["production"],
"budget_tokens": 5000,
},
)
assert facet_filter.status_code == 200, facet_filter.text
assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
unmatched_facet = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"domain": ["unrelated-domain"],
"budget_tokens": 5000,
},
)
assert unmatched_facet.status_code == 200, unmatched_facet.text
assert unmatched_facet.json()["data"] == []
diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_current.status_code == 200
current_state = diagnostic_current.json()
assert current_state["index_state"] == "current"
assert current_state["index_revision"] == repository.head()
assert "proposal_submit" not in current_state["authorized_operations"]
projects = await http.get("/v1/projects")
assert projects.status_code == 200
assert projects.json() == ["dome-performance"]
page_one = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
assert page_one.status_code == 200
cursor = page_one.json()["next_cursor"]
assert cursor
page_two = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert page_two.status_code == 200
assert (
page_two.json()["data"][0]["node"]["node_id"]
!= page_one.json()["data"][0]["node"]["node_id"]
)
reused_cursor = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert reused_cursor.status_code == 422
changed_facet_cursor = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert changed_facet_cursor.status_code == 422
changed_tag_cursor = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"tags": ["attendance"],
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert changed_tag_cursor.status_code == 422
adjacent = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
assert adjacent.status_code == 200, adjacent.text
assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id
neighbor_cursor = adjacent.json()["next_cursor"]
assert neighbor_cursor
adjacent_two = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": neighbor_cursor,
"budget_tokens": 5000,
},
)
assert adjacent_two.status_code == 200
assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id
assert adjacent_two.json()["next_cursor"] is None
assert {citation["quote"] for citation in adjacent.json()["citations"]} == {
NODE.claims[0].evidence[0].quote
}
newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"})
newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest()
repository.publish_files(
expected_head=repository.head(),
files={
f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump(
newer.model_dump(mode="json")
).encode()
},
message="Advance canonical index for cursor-expiry check",
index_directory=tmp_path / "publishing",
)
diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_lag.status_code == 200
lag_state = diagnostic_lag.json()
assert lag_state["index_state"] == "lagging"
assert lag_state["index_revision"] != repository.head()
assert lag_state["canonical_revision"] == repository.head()
expired_search = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert expired_search.status_code == 409
expired_neighbors = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": neighbor_cursor,
"budget_tokens": 5000,
},
)
assert expired_neighbors.status_code == 409
too_small = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"budget_tokens": 256,
},
)
assert too_small.status_code == 413
dated_confidence_search = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["medium"],
"as_of_from": "2024-01-01",
"as_of_to": "2024-12-31",
"budget_tokens": 5000,
},
)
assert dated_confidence_search.status_code == 200, dated_confidence_search.text
assert [
row["node"]["node_id"] for row in dated_confidence_search.json()["data"]
] == [NODE.node_id]
filtered_page = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["medium"],
"limit": 1,
"budget_tokens": 5000,
},
)
assert filtered_page.status_code == 200
filter_cursor = filtered_page.json()["next_cursor"]
assert filter_cursor
changed_filter_page = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["low"],
"limit": 1,
"cursor": filter_cursor,
"budget_tokens": 5000,
},
)
assert changed_filter_page.status_code == 422
forbidden = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["interactive-video"],
"include_shared": False,
},
"budget_tokens": 5000,
},
)
assert forbidden.status_code == 403
async with Client(
streamable_http_client("http://localhost/mcp", http_client=http)
) as mcp:
tools = await mcp.list_tools()
assert {tool.name for tool in tools.tools} == {
"diagnostics",
"get_node",
"node_history",
"get_source",
"list_projects",
"search",
"neighbors",
"list_sources",
}
listed = await mcp.call_tool("list_projects", {})
assert not listed.is_error
assert isinstance(listed.content[0], TextContent)
assert json.loads(listed.content[0].text) == ["dome-performance"]
searched = await mcp.call_tool(
"search",
{
"query": "400 visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
},
)
assert not searched.is_error
assert isinstance(searched.content[0], TextContent)
assert (
json.loads(searched.content[0].text)["data"][0]["node"]["node_id"]
== NODE.node_id
)
dated_confidence_tool = await mcp.call_tool(
"search",
{
"query": "visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"confidences": ["medium"],
"as_of_from": "2024-01-01",
"as_of_to": "2024-12-31",
},
)
assert not dated_confidence_tool.is_error
assert [
row["node"]["node_id"]
for row in json.loads(dated_confidence_tool.content[0].text)["data"]
] == [NODE.node_id]
filtered_search = await mcp.call_tool(
"search",
{
"query": "unmatched phrase",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"node_types": ["problem"],
},
)
assert not filtered_search.is_error
assert json.loads(filtered_search.content[0].text)["data"] == []
browsed_problems = await mcp.call_tool(
"search",
{
"query": "",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"node_types": ["problem"],
},
)
assert not browsed_problems.is_error
assert [
row["node"]["node_id"]
for row in json.loads(browsed_problems.content[0].text)["data"]
] == [problem_node.node_id]
facet_search = await mcp.call_tool(
"search",
{
"query": "400 visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"tags": ["attendance"],
"audience": ["product"],
"use_for": ["comparison"],
"domain": ["production"],
},
)
assert not facet_search.is_error
assert (
json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"]
== NODE.node_id
)
listed_sources_tool = await mcp.call_tool(
"list_sources",
{
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
},
)
assert not listed_sources_tool.is_error
assert (
json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][
"revision_id"
]
== SOURCE_REVISION.revision_id
)
adjacent_tool = await mcp.call_tool(
"neighbors",
{
"node_id": NODE.node_id,
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"edge_types": [],
"limit": 10,
"budget_tokens": 5000,
},
)
assert not adjacent_tool.is_error
assert isinstance(adjacent_tool.content[0], TextContent)
assert (
json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"]
== related.node_id
)
asyncio.run(exercise())