mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
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.
225 lines
8.6 KiB
Python
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
|
|
|