570 lines
23 KiB
Python
570 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Contract tests for the reduced, bounded agent-memory surface."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
|
|
def load_module():
|
|
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
|
sys.path.insert(0, str(script_path.parent))
|
|
spec = importlib.util.spec_from_file_location("napseer_mcp_server_surface_test", script_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(module)
|
|
module.DEFAULT_PROJECT_ID = "project-1"
|
|
return module
|
|
|
|
|
|
@pytest.fixture
|
|
def mod(monkeypatch):
|
|
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
|
|
module = load_module()
|
|
module._auth_state = tempfile.TemporaryDirectory()
|
|
module.AUTH_PATH = pathlib.Path(module._auth_state.name) / "auth.json"
|
|
module.AUTH = {"account_id": "acct-1", "token": "token"}
|
|
module.CONFIGURED_TOOL_PROFILES = None
|
|
return module
|
|
|
|
|
|
def test_default_surface_includes_knowledge_files_and_coordination(mod):
|
|
assert [tool["name"] for tool in mod.tools()] == [
|
|
"nap_apropos", "nap_man", "nap_doctor", "nap_whoami",
|
|
"nap_operation_list", "nap_operation", "nap_file_list", "nap_file_upload", "nap_file_download", "nap_file_archive",
|
|
"nap_discover", "nap_context", "nap_node_by_path", "nap_node_get",
|
|
"nap_create_node", "nap_node_patch", "nap_bulk", "nap_batch", "nap_ln",
|
|
"nap_mv", "nap_rm",
|
|
]
|
|
|
|
|
|
def test_profiles_expand_surface_without_restoring_pruned_tools(mod, monkeypatch):
|
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "workflow,maintenance")
|
|
names = {tool["name"] for tool in mod.tools()}
|
|
assert {"nap_plan_list_active", "nap_kanban_complete", "nap_index_status"} <= names
|
|
assert not ({"nap_recent", "nap_find_related", "nap_backlinks", "nap_ls_folders", "nap_ls_tags"} & names)
|
|
|
|
|
|
def test_apropos_can_describe_inactive_profile_tools(mod):
|
|
result = mod.explore_tools({"q": "kanban complete"})
|
|
card = result["groups"]["kanban"][0]
|
|
assert card["name"] == "nap_kanban_complete"
|
|
assert card["enabled"] is False
|
|
assert card["profile"] == "workflow"
|
|
|
|
|
|
def test_pruned_and_profile_gated_calls_are_actionable(mod):
|
|
with pytest.raises(mod.SafeToolError) as removed:
|
|
mod.call_tool_impl("nap_recent", {})
|
|
assert removed.value.code == "tool_removed"
|
|
assert "nap_discover" in removed.value.safe_message
|
|
|
|
with pytest.raises(mod.SafeToolError) as gated:
|
|
mod.call_tool_impl("nap_kanban_list", {})
|
|
assert gated.value.code == "tool_profile_required"
|
|
assert "workflow" in gated.value.safe_message
|
|
|
|
|
|
def test_removed_write_tools_and_unknown_manual_lookups_are_actionable(mod):
|
|
tee = mod.man_tool({"tool": "nap_tee"})
|
|
assert tee["removed"] is True
|
|
assert "nap_create_node" in tee["replacement"]
|
|
assert "nap_bulk" in tee["replacement"]
|
|
|
|
patch = mod.man_tool({"tool": "nap_patch"})
|
|
assert patch["removed"] is True
|
|
assert "nap_node_patch" in patch["replacement"]
|
|
assert "nap_bulk" in patch["replacement"]
|
|
|
|
for name in ("nap_tee", "nap_patch"):
|
|
with pytest.raises(mod.SafeToolError) as removed:
|
|
mod.call_tool_impl(name, {})
|
|
assert removed.value.code == "tool_removed"
|
|
|
|
with pytest.raises(mod.SafeToolError) as unknown:
|
|
mod.man_tool({"tool": "not_a_real_tool"})
|
|
assert unknown.value.code == "unknown_tool"
|
|
assert "nap_apropos" in unknown.value.safe_message
|
|
|
|
|
|
def test_node_patch_rejects_malformed_arguments_before_project_or_node_reads(mod):
|
|
mod.resolve_project_id = lambda args: (_ for _ in ()).throw(
|
|
AssertionError("invalid arguments must fail before project resolution")
|
|
)
|
|
mod.get_node_by_id = lambda args: (_ for _ in ()).throw(
|
|
AssertionError("invalid arguments must fail before node reads")
|
|
)
|
|
base = {
|
|
"node_id": "node-1",
|
|
"precondition": {"revision": "r1", "read_fingerprint": "fp1"},
|
|
}
|
|
|
|
with pytest.raises(mod.SafeToolError) as legacy_metadata:
|
|
mod.guarded_node_patch({**base, "metadata_patch": {"set": {"status": "done"}}})
|
|
assert legacy_metadata.value.code == "invalid_arguments"
|
|
assert "metadata_patch" in legacy_metadata.value.safe_message
|
|
|
|
with pytest.raises(mod.SafeToolError) as legacy_content:
|
|
mod.guarded_node_patch({
|
|
**base,
|
|
"content_op": {"kind": "replace_all", "content_text": "updated"},
|
|
})
|
|
assert legacy_content.value.code == "invalid_arguments"
|
|
assert "use 'op'" in legacy_content.value.safe_message
|
|
|
|
|
|
def test_node_patch_argument_validation_accepts_canonical_shapes(mod):
|
|
mod.validate_node_patch_arguments({
|
|
"node_id": "node-1",
|
|
"precondition": {"revision": "r1", "read_fingerprint": "fp1"},
|
|
"content_op": {"op": "replace_all", "content_text": "updated"},
|
|
"merge_metadata": {"status": "done"},
|
|
"tag_ops": {"add": ["verified"]},
|
|
"link_ops": [
|
|
{"op": "add", "path": "/plans/example", "relation": "implements"}
|
|
],
|
|
})
|
|
|
|
|
|
def test_context_and_node_read_schemas_are_bounded(mod):
|
|
by_name = {tool["name"]: tool for tool in mod.all_tools()}
|
|
context = by_name["nap_context"]["inputSchema"]["properties"]
|
|
assert context["depth"]["maximum"] == 3
|
|
assert context["direction"]["enum"] == ["outgoing", "incoming", "both"]
|
|
assert context["mode"]["enum"] == ["hybrid", "local", "server"]
|
|
assert "relations" in context
|
|
assert "max_edges" in context
|
|
assert "max_per_level" in context
|
|
assert context["node_ids"]["maxItems"] == 10
|
|
assert context["paths"]["maxItems"] == 10
|
|
assert context["shortcuts"]["maxItems"] == 10
|
|
assert context["uris"]["maxItems"] == 10
|
|
|
|
node_get = by_name["nap_node_get"]["inputSchema"]["properties"]
|
|
assert node_get["view"]["enum"] == ["content", "outline", "edit"]
|
|
assert node_get["view"]["default"] == "content"
|
|
|
|
|
|
def test_tool_text_does_not_duplicate_large_structured_payload(mod):
|
|
payload = {"ok": True, "items": [{"content_text": "x" * 20_000}]}
|
|
result = mod.tool_result(payload)
|
|
assert result["structuredContent"] == payload
|
|
assert len(result["content"][0]["text"]) < 2_000
|
|
assert "20000" not in result["content"][0]["text"]
|
|
|
|
|
|
def test_tool_text_keeps_small_structured_results_useful_for_text_only_clients(mod):
|
|
payload = {
|
|
"status": "current",
|
|
"full_path": "/docs/useful",
|
|
"view": "content",
|
|
"content_text": "A small useful body.",
|
|
}
|
|
|
|
result = mod.tool_result(payload)
|
|
|
|
assert json.loads(result["content"][0]["text"]) == payload
|
|
|
|
|
|
def test_large_graph_text_fallback_contains_bounded_node_and_edge_previews(mod):
|
|
payload = {
|
|
"ok": True,
|
|
"nodes": [
|
|
{"id": f"node-{index}", "full_path": f"/docs/{index}", "title": f"Node {index}"}
|
|
for index in range(100)
|
|
],
|
|
"edges": [
|
|
{"source": f"node-{index}", "target": f"node-{index + 1}", "relation": "references"}
|
|
for index in range(99)
|
|
],
|
|
"warnings": ["Use first-class links."],
|
|
}
|
|
|
|
fallback = json.loads(mod.tool_result(payload)["content"][0]["text"])
|
|
|
|
assert fallback["nodes_count"] == 100
|
|
assert fallback["edges_count"] == 99
|
|
assert len(fallback["nodes_preview"]) == 5
|
|
assert len(fallback["edges_preview"]) == 5
|
|
assert fallback["warnings"] == ["Use first-class links."]
|
|
assert fallback["structured_result"] is True
|
|
|
|
|
|
def test_local_context_returns_title_radius_without_node_bodies(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
root = {
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note",
|
|
"metadata": {"title": "Root", "status": "active"}, "tags": [],
|
|
"links": [{"path": "/docs/child", "relation": "explains"}],
|
|
"content_text": "secret body", "updated_at": "2026-08-16T12:00:00Z",
|
|
}
|
|
child = {
|
|
"id": "child", "project_id": "project-1", "full_path": "/docs/child",
|
|
"folder_path": "/docs", "name": "child", "type": "note",
|
|
"metadata": {"title": "Child", "status": "active"}, "tags": [],
|
|
"links": [], "content_text": "another body", "updated_at": "2026-08-16T12:01:00Z",
|
|
}
|
|
mod.index_node(root)
|
|
mod.index_node(child)
|
|
|
|
result = mod.get_memory_context({
|
|
"node_id": "root", "mode": "local", "depth": 2,
|
|
"direction": "both", "relations": ["explains"],
|
|
})
|
|
assert [(item["title"], item["depth"]) for item in result["nodes"]] == [("Root", 0), ("Child", 1)]
|
|
assert result["edges"] == [{"source": "root", "target": "child", "relation": "explains"}]
|
|
assert result["levels"] == [
|
|
{"depth": 0, "node_ids": ["root"]},
|
|
{"depth": 1, "node_ids": ["child"]},
|
|
]
|
|
assert "content_text" not in result["nodes"][0]
|
|
assert result["source"] == "local_index"
|
|
|
|
|
|
def test_local_context_resolves_anchor_shortcut_without_extra_tool(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_shortcut_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
mod.index_node({
|
|
"id": "anchor", "project_id": "project-1",
|
|
"full_path": "/routes/project/core", "folder_path": "/routes/project",
|
|
"name": "core", "type": "anchor_route", "aliases": ["shortcut.project"],
|
|
"metadata": {"title": "Project"}, "tags": [],
|
|
"links": [{"node_id": "definition", "relation": "defines"}],
|
|
"content_text": "route body", "updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
mod.index_node({
|
|
"id": "definition", "project_id": "project-1",
|
|
"full_path": "/documentation/product/what-we-are-building",
|
|
"folder_path": "/documentation/product", "name": "what-we-are-building",
|
|
"type": "definition", "aliases": [], "metadata": {"title": "Product"},
|
|
"tags": [], "links": [], "content_text": "definition body",
|
|
"updated_at": "2026-08-16T12:01:00Z",
|
|
})
|
|
|
|
result = mod.get_memory_context({
|
|
"shortcut": "@Project", "mode": "local", "depth": 1,
|
|
"direction": "outgoing",
|
|
})
|
|
|
|
assert result["seed_node_ids"] == ["anchor"]
|
|
assert [item["id"] for item in result["nodes"]] == ["anchor", "definition"]
|
|
assert all("content_text" not in item for item in result["nodes"])
|
|
|
|
|
|
def test_duplicate_local_anchor_shortcuts_fail_closed(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_duplicate_shortcut_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
for index in range(2):
|
|
mod.index_node({
|
|
"id": f"anchor-{index}", "project_id": "project-1",
|
|
"full_path": f"/routes/project/{index}", "folder_path": "/routes/project",
|
|
"name": str(index), "type": "anchor_route", "aliases": ["shortcut.project"],
|
|
"metadata": {}, "tags": [], "links": [], "content_text": "",
|
|
"updated_at": f"2026-08-16T12:0{index}:00Z",
|
|
})
|
|
|
|
with pytest.raises(mod.SafeToolError) as ambiguous:
|
|
mod.get_memory_context({"shortcut": "@project", "mode": "local"})
|
|
|
|
assert ambiguous.value.code == "anchor_shortcut_ambiguous"
|
|
|
|
|
|
def test_anchor_integrity_reports_budgets_duplicates_and_broken_links(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_anchor_health_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
for index in range(2):
|
|
mod.index_node({
|
|
"id": f"anchor-{index}", "project_id": "project-1",
|
|
"full_path": f"/routes/project/{index}", "folder_path": "/routes/project",
|
|
"name": str(index), "type": "anchor_route", "aliases": ["shortcut.project"],
|
|
"metadata": {}, "tags": [],
|
|
"links": ([{"path": "/missing", "relation": "routes-to"}] if index == 0 else []),
|
|
"content_text": "", "updated_at": f"2026-08-16T12:0{index}:00Z",
|
|
})
|
|
mod.set_local_graph_index_complete("project-1", True)
|
|
|
|
report = mod.local_anchor_integrity()
|
|
|
|
assert report["ok"] is False
|
|
assert report["graph_complete"] is True
|
|
assert report["duplicate_shortcuts"][0]["shortcut"] == "@project"
|
|
assert report["broken_anchor_links"] == [{
|
|
"source": "/routes/project/0",
|
|
"target": "/missing",
|
|
"relation": "routes-to",
|
|
}]
|
|
|
|
|
|
def test_local_context_reports_partial_and_total_missing_seeds_actionably(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_missing_seed_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
mod.index_node({
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note",
|
|
"metadata": {"title": "Root"}, "tags": [], "links": [],
|
|
"content_text": "body", "updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
|
|
partial = mod.get_memory_context({
|
|
"node_ids": ["root", "missing"], "mode": "local", "depth": 0,
|
|
})
|
|
|
|
assert partial["requested_seed_node_ids"] == ["root", "missing"]
|
|
assert partial["seed_node_ids"] == ["root"]
|
|
assert partial["missing_seed_node_ids"] == ["missing"]
|
|
assert any("active project" in warning for warning in partial["warnings"])
|
|
|
|
with pytest.raises(mod.SafeToolError) as missing:
|
|
mod.get_memory_context({"node_id": "missing", "mode": "local"})
|
|
assert missing.value.code == "context_seed_not_found"
|
|
assert "nap_whoami" in missing.value.safe_message
|
|
assert "nap_discover" in missing.value.safe_message
|
|
|
|
|
|
def test_context_translates_server_not_found_into_seed_diagnostic(mod):
|
|
def missing_request(*_args, **_kwargs):
|
|
raise mod.SafeToolError(
|
|
"http_404",
|
|
"Napseer GET request failed with HTTP 404.",
|
|
status=404,
|
|
service_code="not_found",
|
|
)
|
|
|
|
mod.request_json = missing_request
|
|
|
|
with pytest.raises(mod.SafeToolError) as missing:
|
|
mod.get_memory_context({"node_id": "missing", "mode": "server"})
|
|
|
|
assert missing.value.code == "context_seed_not_found"
|
|
assert missing.value.status == 404
|
|
assert mod.safe_exception_payload(missing.value)["service_code"] == "not_found"
|
|
|
|
|
|
def test_context_rejects_cross_project_uri_before_graph_reads(mod):
|
|
with pytest.raises(mod.SafeToolError) as mismatch:
|
|
mod.get_memory_context({
|
|
"uri": "nap://other-project/project-2/node-1",
|
|
"mode": "server",
|
|
})
|
|
|
|
assert mismatch.value.code == "context_project_mismatch"
|
|
assert "nap_whoami" in mismatch.value.safe_message
|
|
|
|
|
|
def test_local_context_projects_requested_view_and_bounds_frontier(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_view_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
children = []
|
|
for index in range(5):
|
|
children.append({
|
|
"id": f"child-{index}", "project_id": "project-1",
|
|
"full_path": f"/docs/child-{index}", "folder_path": "/docs",
|
|
"name": f"child-{index}", "type": "note", "metadata": {},
|
|
"tags": [], "links": [], "content_text": "",
|
|
"updated_at": f"2026-08-16T12:0{index + 1}:00Z",
|
|
})
|
|
mod.index_node({
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note",
|
|
"metadata": {"title": "Root"}, "tags": [],
|
|
"links": [
|
|
{"node_id": child["id"], "relation": "references"}
|
|
for child in children
|
|
],
|
|
"content_text": "", "updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
for child in children:
|
|
mod.index_node(child)
|
|
|
|
result = mod.get_memory_context({
|
|
"node_id": "root", "mode": "local", "depth": 1,
|
|
"max_nodes": 2, "max_per_level": 1, "view": "paths",
|
|
})
|
|
|
|
assert result["view"] == "paths"
|
|
assert "title" not in result["nodes"][0]
|
|
assert set(result["nodes"][0]) == {
|
|
"id", "full_path", "type", "status", "updated_at", "depth"
|
|
}
|
|
assert len(result["frontier"]) <= 1
|
|
assert "max_frontier" in result["truncation_reasons"]
|
|
assert result["node_count"] == 2
|
|
assert result["edge_count"] == 1
|
|
|
|
|
|
def test_context_explains_empty_first_class_graph(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_empty_graph_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
mod.index_node({
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note",
|
|
"metadata": {"title": "Root"}, "tags": [], "links": [],
|
|
"content_text": "mentions /docs/other only in prose",
|
|
"updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
|
|
result = mod.get_memory_context({"node_id": "root", "mode": "local", "depth": 1})
|
|
|
|
assert any("Prose mentions are not traversed" in warning for warning in result["warnings"])
|
|
assert "nap_ln" in result["next_action"]
|
|
|
|
|
|
def test_hybrid_context_falls_back_until_local_graph_coverage_is_complete(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_fallback_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
mod.index_node({
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note",
|
|
"metadata": {"title": "Root"}, "tags": [], "links": [],
|
|
"content_text": "body", "updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
calls = []
|
|
|
|
def server_context(_args, seed_ids):
|
|
calls.append(seed_ids)
|
|
return {"ok": True, "nodes": [{"id": "root"}], "edges": [], "source": "server"}
|
|
|
|
mod.server_memory_context = server_context
|
|
|
|
result = mod.get_memory_context({"node_id": "root", "mode": "hybrid"})
|
|
|
|
assert result["source"] == "server"
|
|
assert result["local_fallback"]
|
|
assert calls == [["root"]]
|
|
|
|
|
|
def test_local_context_does_not_emit_edges_beyond_requested_depth(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_depth_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
nodes = [
|
|
{
|
|
"id": "root", "project_id": "project-1", "full_path": "/docs/root",
|
|
"folder_path": "/docs", "name": "root", "type": "note", "metadata": {},
|
|
"tags": [], "links": [
|
|
{"node_id": "left", "relation": "references"},
|
|
{"node_id": "right", "relation": "references"},
|
|
], "content_text": "", "updated_at": "2026-08-16T12:00:00Z",
|
|
},
|
|
{
|
|
"id": "left", "project_id": "project-1", "full_path": "/docs/left",
|
|
"folder_path": "/docs", "name": "left", "type": "note", "metadata": {},
|
|
"tags": [], "links": [{"node_id": "right", "relation": "explains"}],
|
|
"content_text": "", "updated_at": "2026-08-16T12:01:00Z",
|
|
},
|
|
{
|
|
"id": "right", "project_id": "project-1", "full_path": "/docs/right",
|
|
"folder_path": "/docs", "name": "right", "type": "note", "metadata": {},
|
|
"tags": [], "links": [], "content_text": "",
|
|
"updated_at": "2026-08-16T12:02:00Z",
|
|
},
|
|
]
|
|
for item in nodes:
|
|
mod.index_node(item)
|
|
|
|
result = mod.get_memory_context({
|
|
"node_id": "root", "mode": "local", "depth": 1, "direction": "both",
|
|
})
|
|
|
|
assert {item["id"] for item in result["nodes"]} == {"root", "left", "right"}
|
|
assert result["edges"] == [
|
|
{"source": "root", "target": "left", "relation": "references"},
|
|
{"source": "root", "target": "right", "relation": "references"},
|
|
]
|
|
|
|
|
|
def test_index_sync_upgrades_pre_graph_index_with_full_reindex(mod):
|
|
state = tempfile.TemporaryDirectory()
|
|
mod._surface_upgrade_state = state
|
|
mod.AUTH_DIR = pathlib.Path(state.name)
|
|
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock"
|
|
mod.index_node({
|
|
"id": "old", "project_id": "project-1", "full_path": "/docs/old",
|
|
"folder_path": "/docs", "name": "old", "type": "note",
|
|
"metadata": {}, "tags": [], "links": [], "content_text": "old",
|
|
"updated_at": "2026-08-16T12:00:00Z",
|
|
})
|
|
fresh = {
|
|
"id": "fresh", "project_id": "project-1", "full_path": "/docs/fresh",
|
|
"folder_path": "/docs", "name": "fresh", "type": "note",
|
|
"metadata": {}, "tags": [],
|
|
"links": [{"node_id": "target", "relation": "references"}],
|
|
"content_text": "fresh", "updated_at": "2026-08-16T12:01:00Z",
|
|
}
|
|
mod.request_json = lambda *_args, **_kwargs: {"items": [fresh], "next_cursor": None}
|
|
|
|
result = mod.sync_local_index({})
|
|
|
|
assert result["mode"] == "full_reindex"
|
|
assert result["reason"] == "local_graph_index_incomplete"
|
|
assert result["graph_complete"] is True
|
|
assert result["index"]["graph_complete"] is True
|
|
with mod.index_connect() as conn:
|
|
assert conn.execute(
|
|
"SELECT COUNT(*) FROM local_index_edges WHERE project_id = ?",
|
|
("project-1",),
|
|
).fetchone()[0] == 1
|
|
|
|
|
|
def test_discovery_sort_and_returned_item_facets(mod):
|
|
mod.list_project_nodes = lambda args: {
|
|
"items": [
|
|
{"name": "Beta", "folder_path": "/b", "tags": ["x"], "type": "note", "status": "active", "updated_at": "2026-01-01T00:00:00Z"},
|
|
{"name": "Alpha", "folder_path": "/a", "tags": ["x", "y"], "type": "plan", "status": "planned", "updated_at": "2026-02-01T00:00:00Z"},
|
|
]
|
|
}
|
|
result = mod.discover_memory({"sort": "title_asc", "facets": ["folder", "tag", "status"]})
|
|
assert [item["name"] for item in result["items"]] == ["Alpha", "Beta"]
|
|
assert result["facets"]["scope"] == "returned_items"
|
|
assert result["facets"]["counts"]["tag"][0] == {"value": "x", "count": 2}
|
|
|
|
|
|
def test_node_get_content_and_outline_hide_mutation_envelopes(mod):
|
|
node = {
|
|
"id": "node-1", "project_id": "project-1", "full_path": "/docs/one",
|
|
"name": "one", "type": "note", "metadata": {"title": "One", "status": "active"},
|
|
"links": [], "content_text": "# First\nBody\n## Second\nMore",
|
|
"encrypted_content_envelope": {"ciphertext": "not-for-read-view"},
|
|
"updated_at": "2026-08-16T12:00:00Z",
|
|
}
|
|
mod.get_node_by_id = lambda args: node
|
|
content = mod.node_get({"node_id": "node-1"})
|
|
outline = mod.node_get({"node_id": "node-1", "view": "outline"})
|
|
assert content["content_text"].startswith("# First")
|
|
assert "encrypted_content_envelope" not in content
|
|
assert [heading["title"] for heading in outline["headings"]] == ["First", "Second"]
|
|
assert "content_text" not in outline
|