Files
semantica/tests/explorer/test_explorer_deterministic_rendering_e2e.py
T
dex0shubham 47446ebdde test(explorer): guard the deterministic-rendering e2e module on fastapi
This module landed after the branch was opened and imports
semantica.explorer.app at module scope, so it reproduced the same
collection error on a clean [dev] install.
2026-08-29 13:31:00 +01:00

225 lines
8.6 KiB
Python

"""
End-to-end integration test for deterministic Explorer rendering (Issue #1037).
Validates:
1. Building the canonical 4-node, 3-edge graph using ContextGraph.
2. Serialization via save_to_file().
3. Reloading via load_from_file() and GraphSession.from_file() without mutation.
4. Explorer HTTP API serving exact nodes, edges, edge types, and connectivity.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the explorer imports below, which pull fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient required. Install semantica[explorer].",
allow_module_level=True,
)
def _build_deterministic_graph() -> ContextGraph:
"""Build the exact graph requested in Semantica #1037."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node("alice", "Person", content="Alice")
graph.add_node("bob", "Person", content="Bob")
graph.add_node("acme", "Organization", content="Acme")
graph.add_node("new_york", "Location", content="New York")
graph.add_edge("alice", "acme", edge_type="WORKS_AT")
graph.add_edge("bob", "alice", edge_type="KNOWS")
graph.add_edge("acme", "new_york", edge_type="LOCATED_IN")
return graph
class TestExplorerDeterministicRenderingE2E:
"""E2E suite for deterministic graph rendering and session loading."""
def test_build_and_in_memory_structure(self):
graph = _build_deterministic_graph()
assert len(graph.nodes) == 4
assert len(graph.edges) == 3
node_map = {
nid: (node.node_type, node.content) for nid, node in graph.nodes.items()
}
assert node_map["alice"] == ("Person", "Alice")
assert node_map["bob"] == ("Person", "Bob")
assert node_map["acme"] == ("Organization", "Acme")
assert node_map["new_york"] == ("Location", "New York")
edge_tuples = {(e.source_id, e.target_id, e.edge_type) for e in graph.edges}
assert edge_tuples == {
("alice", "acme", "WORKS_AT"),
("bob", "alice", "KNOWS"),
("acme", "new_york", "LOCATED_IN"),
}
def test_serialization_and_deserialization(self, tmp_path: Path):
graph = _build_deterministic_graph()
output_file = tmp_path / "explorer_deterministic_graph.json"
graph.save_to_file(str(output_file))
assert output_file.exists()
# Validate JSON structure
with open(output_file, "r", encoding="utf-8") as f:
raw_data = json.load(f)
assert "nodes" in raw_data
assert "edges" in raw_data
assert len(raw_data["nodes"]) == 4
assert len(raw_data["edges"]) == 3
# Reload with ContextGraph.load_from_file
reloaded_graph = ContextGraph(advanced_analytics=False)
reloaded_graph.load_from_file(str(output_file))
assert len(reloaded_graph.nodes) == 4
assert len(reloaded_graph.edges) == 3
reloaded_nodes = {
nid: (node.node_type, node.content)
for nid, node in reloaded_graph.nodes.items()
}
assert reloaded_nodes["alice"] == ("Person", "Alice")
assert reloaded_nodes["bob"] == ("Person", "Bob")
assert reloaded_nodes["acme"] == ("Organization", "Acme")
assert reloaded_nodes["new_york"] == ("Location", "New York")
reloaded_edges = {
(e.source_id, e.target_id, e.edge_type) for e in reloaded_graph.edges
}
assert reloaded_edges == {
("alice", "acme", "WORKS_AT"),
("bob", "alice", "KNOWS"),
("acme", "new_york", "LOCATED_IN"),
}
def test_session_loading_and_stats(self, tmp_path: Path):
graph = _build_deterministic_graph()
output_file = tmp_path / "explorer_deterministic_graph.json"
graph.save_to_file(str(output_file))
session = GraphSession.from_file(str(output_file))
stats = session.get_stats()
assert stats["node_count"] == 4
assert stats["edge_count"] == 3
def test_explorer_api_endpoints_with_deterministic_graph(self, tmp_path: Path):
graph = _build_deterministic_graph()
output_file = tmp_path / "explorer_deterministic_graph.json"
graph.save_to_file(str(output_file))
session = GraphSession.from_file(str(output_file))
app = create_app(session=session)
with TestClient(app) as client:
# 1. Health & Info
health = client.get("/api/health")
assert health.status_code == 200
assert health.json() == {"status": "ok"}
info = client.get("/api/info")
assert info.status_code == 200
assert info.json()["status"] == "active"
# 2. Stats
stats = client.get("/api/graph/stats")
assert stats.status_code == 200
stats_data = stats.json()
assert stats_data["node_count"] == 4
assert stats_data["edge_count"] == 3
# 3. Nodes endpoint
nodes_res = client.get("/api/graph/nodes")
assert nodes_res.status_code == 200
nodes_data = nodes_res.json()
assert nodes_data["total"] == 4
assert len(nodes_data["nodes"]) == 4
returned_nodes = {
n["id"]: (n["type"], n["content"]) for n in nodes_data["nodes"]
}
assert returned_nodes["alice"] == ("Person", "Alice")
assert returned_nodes["bob"] == ("Person", "Bob")
assert returned_nodes["acme"] == ("Organization", "Acme")
assert returned_nodes["new_york"] == ("Location", "New York")
# 4. Individual node lookups
for node_id in ["alice", "bob", "acme", "new_york"]:
node_res = client.get(f"/api/graph/node/{node_id}")
assert node_res.status_code == 200
assert node_res.json()["id"] == node_id
# 5. Edges endpoint
edges_res = client.get("/api/graph/edges")
assert edges_res.status_code == 200
edges_data = edges_res.json()
assert edges_data["total"] == 3
assert len(edges_data["edges"]) == 3
returned_edges = {
(e["source"], e["target"], e["type"]) for e in edges_data["edges"]
}
assert returned_edges == {
("alice", "acme", "WORKS_AT"),
("bob", "alice", "KNOWS"),
("acme", "new_york", "LOCATED_IN"),
}
def test_deterministic_graph_auth_enforcement(self, tmp_path: Path, monkeypatch):
graph = _build_deterministic_graph()
output_file = tmp_path / "explorer_deterministic_graph.json"
graph.save_to_file(str(output_file))
session = GraphSession.from_file(str(output_file))
app = create_app(session=session)
with TestClient(app) as client:
# 1. Unconfigured auth (no SEMANTICA_ALLOW_ANONYMOUS, no SEMANTICA_API_KEY) -> 503
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
unconfigured_res = client.get("/api/graph/stats")
assert unconfigured_res.status_code == 503
# 2. Configured SEMANTICA_API_KEY without header -> 401
test_key = "secret-test-key-1037"
monkeypatch.setenv("SEMANTICA_API_KEY", test_key)
unauthorized_res = client.get("/api/graph/nodes")
assert unauthorized_res.status_code == 401
# 3. Configured SEMANTICA_API_KEY with valid X-API-Key header -> 200
authorized_res = client.get(
"/api/graph/nodes",
headers={"X-API-Key": test_key},
)
assert authorized_res.status_code == 200
assert authorized_res.json()["total"] == 4
# 4. Explicit local development opt-in: SEMANTICA_ALLOW_ANONYMOUS=true -> 200 without header
monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true")
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
anon_res = client.get("/api/graph/edges")
assert anon_res.status_code == 200
assert anon_res.json()["total"] == 3