mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-08 04:00:15 +00:00
* feat(explorer): add Markdown editing write path * fix(explorer): address markdown editor review findings * feat(explorer): add Markdown editor write path --------- Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""Integration tests for Explorer Markdown and memory selection routes."""
|
|
|
|
import threading
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from semantica.context.agent_memory import AgentMemory
|
|
from semantica.context.context_graph import ContextGraph
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
from starlette.testclient import TestClient # noqa: E402
|
|
|
|
from semantica.explorer.app import create_app # noqa: E402
|
|
from semantica.explorer.session import GraphSession # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def markdown_client():
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("editable-node", "Note", "Original node")
|
|
events = []
|
|
graph.mutation_callback = lambda *event: events.append(event)
|
|
memory = AgentMemory()
|
|
memory.store(
|
|
"Original memory",
|
|
memory_id="editable-memory",
|
|
metadata={
|
|
"type": "note",
|
|
"updated_at": "2026-07-22T10:00:00+00:00",
|
|
},
|
|
timestamp=datetime.fromisoformat("2026-07-22T09:00:00+00:00"),
|
|
)
|
|
app = create_app(session=GraphSession(graph), agent_memory=memory)
|
|
with TestClient(app) as test_client:
|
|
yield test_client, graph, memory, events
|
|
|
|
|
|
def test_gets_context_node_and_agent_memory(markdown_client):
|
|
client, _, _, _ = markdown_client
|
|
|
|
info = client.get("/api/info")
|
|
node = client.get("/api/markdown/context-node/editable-node")
|
|
memory = client.get("/api/markdown/agent-memory/editable-memory")
|
|
|
|
assert info.json()["capabilities"]["agent_memory"] is True
|
|
assert node.status_code == 200
|
|
assert node.json()["body"] == "Original node"
|
|
assert node.json()["resource"] == {
|
|
"kind": "context-node",
|
|
"id": "editable-node",
|
|
}
|
|
assert node.json()["source"].startswith("---\n")
|
|
assert node.json()["revision"].startswith("sha256:")
|
|
assert node.json()["editable"] is True
|
|
assert memory.status_code == 200
|
|
assert memory.json()["body"] == "Original memory"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("kind", "resource_id", "original", "updated"),
|
|
[
|
|
("context-node", "editable-node", "Original node", "Updated node"),
|
|
("agent-memory", "editable-memory", "Original memory", "Updated memory"),
|
|
],
|
|
)
|
|
def test_put_updates_exact_resource(
|
|
markdown_client, kind, resource_id, original, updated
|
|
):
|
|
client, graph, memory, _ = markdown_client
|
|
current = client.get(f"/api/markdown/{kind}/{resource_id}").json()
|
|
|
|
response = client.put(
|
|
f"/api/markdown/{kind}/{resource_id}",
|
|
json={
|
|
"markdown": current["source"].replace(original, updated),
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["changed"] is True
|
|
assert response.json()["body"] == updated
|
|
assert client.get(f"/api/markdown/{kind}/{resource_id}").json()["body"] == updated
|
|
if kind == "context-node":
|
|
assert graph.nodes[resource_id].content == updated
|
|
assert memory.get("editable-memory")["content"] == "Original memory"
|
|
else:
|
|
assert memory.get(resource_id)["content"] == updated
|
|
assert graph.nodes["editable-node"].content == "Original node"
|
|
|
|
|
|
def test_validation_and_identity_errors_preserve_resource(markdown_client):
|
|
client, graph, _, events = markdown_client
|
|
current = client.get("/api/markdown/context-node/editable-node").json()
|
|
|
|
invalid = client.put(
|
|
"/api/markdown/context-node/editable-node",
|
|
json={
|
|
"markdown": "---\nid: [\n---\n\nBroken",
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
mismatch = client.put(
|
|
"/api/markdown/context-node/editable-node",
|
|
json={
|
|
"markdown": current["source"].replace(
|
|
"id: editable-node", "id: different-node"
|
|
),
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
|
|
assert invalid.status_code == 422
|
|
assert invalid.json()["detail"]["code"] == "invalid_markdown_frontmatter"
|
|
assert "invalid YAML" in invalid.json()["detail"]["message"]
|
|
assert mismatch.status_code == 422
|
|
assert mismatch.json()["detail"] == {
|
|
"code": "resource_identity_mismatch",
|
|
"message": (
|
|
"Frontmatter id 'different-node' does not match resource id "
|
|
"'editable-node'."
|
|
),
|
|
"field": "id",
|
|
}
|
|
assert graph.nodes["editable-node"].content == "Original node"
|
|
assert events == []
|
|
|
|
|
|
def test_stale_revision_returns_conflict(markdown_client):
|
|
client, _, _, _ = markdown_client
|
|
current = client.get("/api/markdown/context-node/editable-node").json()
|
|
first = client.put(
|
|
"/api/markdown/context-node/editable-node",
|
|
json={
|
|
"markdown": current["source"].replace("Original node", "First update"),
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
stale = client.put(
|
|
"/api/markdown/context-node/editable-node",
|
|
json={
|
|
"markdown": current["source"].replace("Original node", "Stale update"),
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
|
|
assert first.status_code == 200
|
|
assert stale.status_code == 409
|
|
assert stale.json()["detail"]["code"] == "markdown_revision_conflict"
|
|
assert stale.json()["detail"]["current_revision"] == first.json()["revision"]
|
|
|
|
|
|
def test_missing_and_failed_apply_are_structured(markdown_client, monkeypatch):
|
|
client, graph, _, _ = markdown_client
|
|
missing = client.get("/api/markdown/context-node/missing")
|
|
current = client.get("/api/markdown/context-node/editable-node").json()
|
|
|
|
def fail(_resource_id, _document):
|
|
raise RuntimeError("private storage failure")
|
|
|
|
monkeypatch.setattr(graph, "apply_node_markdown", fail)
|
|
failed = client.put(
|
|
"/api/markdown/context-node/editable-node",
|
|
json={
|
|
"markdown": current["source"].replace("Original node", "Will fail"),
|
|
"expected_revision": current["revision"],
|
|
},
|
|
)
|
|
|
|
assert missing.status_code == 404
|
|
assert missing.json()["detail"]["code"] == "markdown_resource_not_found"
|
|
assert failed.status_code == 500
|
|
assert failed.json()["detail"] == {
|
|
"code": "markdown_save_failed",
|
|
"message": "The edit could not be applied. The existing item was not changed.",
|
|
}
|
|
assert "private storage failure" not in failed.text
|
|
|
|
|
|
def test_lists_memories_for_selection(markdown_client):
|
|
client, _, _, _ = markdown_client
|
|
|
|
response = client.get("/api/memories?skip=0&limit=100")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"items": [
|
|
{
|
|
"id": "editable-memory",
|
|
"type": "note",
|
|
"excerpt": "Original memory",
|
|
"updated_at": "2026-07-22T10:00:00+00:00",
|
|
}
|
|
],
|
|
"total": 1,
|
|
"skip": 0,
|
|
"limit": 100,
|
|
}
|
|
|
|
|
|
def test_memory_listing_is_atomic_with_concurrent_mutation(
|
|
markdown_client, monkeypatch
|
|
):
|
|
client, _, memory, _ = markdown_client
|
|
snapshot_ready = threading.Event()
|
|
mutation_attempted = threading.Event()
|
|
mutation_finished = threading.Event()
|
|
|
|
def pause_after_key_snapshot(*, offset=0, limit=100, **_filters):
|
|
memory_ids = list(memory.memory_items.keys())[offset : offset + limit]
|
|
snapshot_ready.set()
|
|
assert mutation_attempted.wait(timeout=1)
|
|
mutation_finished.wait(timeout=0.25)
|
|
|
|
records = []
|
|
for memory_id in memory_ids:
|
|
# Match AgentMemory.list()'s second dictionary lookup after its
|
|
# key snapshot; this raises if a concurrent writer is not excluded.
|
|
memory.memory_items[memory_id]
|
|
record = memory.get_memory(memory_id)
|
|
if record is not None:
|
|
records.append(record)
|
|
return records
|
|
|
|
monkeypatch.setattr(memory, "list", pause_after_key_snapshot)
|
|
|
|
def delete_memory() -> None:
|
|
assert snapshot_ready.wait(timeout=1)
|
|
mutation_attempted.set()
|
|
memory.delete_memory("editable-memory")
|
|
mutation_finished.set()
|
|
|
|
writer = threading.Thread(target=delete_memory)
|
|
writer.start()
|
|
try:
|
|
response = client.get("/api/memories?skip=0&limit=100")
|
|
finally:
|
|
writer.join(timeout=2)
|
|
|
|
assert not writer.is_alive()
|
|
assert response.status_code == 200
|
|
assert [item["id"] for item in response.json()["items"]] == ["editable-memory"]
|
|
assert response.json()["total"] == 1
|
|
|
|
|
|
def test_default_app_does_not_expose_agent_memory():
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
with TestClient(create_app(session=GraphSession(graph))) as client:
|
|
info = client.get("/api/info")
|
|
memories = client.get("/api/memories")
|
|
|
|
assert info.json()["capabilities"]["agent_memory"] is False
|
|
assert memories.status_code == 503
|
|
assert memories.json()["detail"] == (
|
|
"AgentMemory is not configured for this Explorer instance."
|
|
)
|
|
|
|
|
|
def test_slash_and_unicode_node_id_round_trip():
|
|
"""GET and PUT work for node IDs containing '/' and Unicode characters.
|
|
|
|
Tests that:
|
|
- encodeURIComponent-style %2F encoding is transparent to the route
|
|
- the returned resource.id is the original unencoded string
|
|
- a successful PUT persists to the correct node
|
|
- the node ID is never rewritten by the apply path
|
|
"""
|
|
node_id = "policy/\u6771\u4eac" # "policy/東京"
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node(node_id, "Policy", "Original slash body")
|
|
app = create_app(session=GraphSession(graph))
|
|
|
|
with TestClient(app) as client:
|
|
# GET via percent-encoded path (what encodeURIComponent produces)
|
|
import urllib.parse
|
|
encoded_id = urllib.parse.quote(node_id, safe="")
|
|
get_response = client.get(f"/api/markdown/context-node/{encoded_id}")
|
|
|
|
assert get_response.status_code == 200, (
|
|
f"GET returned {get_response.status_code}: {get_response.text}"
|
|
)
|
|
doc = get_response.json()
|
|
assert doc["resource"]["id"] == node_id, (
|
|
f"resource.id should be {node_id!r}, got {doc['resource']['id']!r}"
|
|
)
|
|
assert doc["resource"]["kind"] == "context-node"
|
|
assert doc["body"] == "Original slash body"
|
|
assert doc["revision"].startswith("sha256:")
|
|
|
|
# PUT an edit back via the same percent-encoded URL
|
|
updated_markdown = doc["source"].replace("Original slash body", "Updated slash body")
|
|
put_response = client.put(
|
|
f"/api/markdown/context-node/{encoded_id}",
|
|
json={
|
|
"markdown": updated_markdown,
|
|
"expected_revision": doc["revision"],
|
|
},
|
|
)
|
|
|
|
assert put_response.status_code == 200, (
|
|
f"PUT returned {put_response.status_code}: {put_response.text}"
|
|
)
|
|
result = put_response.json()
|
|
assert result["changed"] is True
|
|
assert result["body"] == "Updated slash body"
|
|
assert result["resource"]["id"] == node_id, (
|
|
f"PUT response resource.id should be {node_id!r}, got {result['resource']['id']!r}"
|
|
)
|
|
|
|
# Verify the live graph node was updated
|
|
assert graph.nodes[node_id].content == "Updated slash body"
|
|
|
|
# Also verify GET via literal slash URL works identically
|
|
get_literal = client.get(f"/api/markdown/context-node/{node_id}")
|
|
assert get_literal.status_code == 200
|
|
assert get_literal.json()["resource"]["id"] == node_id
|
|
assert get_literal.json()["body"] == "Updated slash body"
|