Skip to content

Commit 4876121

Browse files
committed
test(sharing): add unit and e2e tests for update_entity_metadata
Unit tests (postgres + milvus): - Verifies native metadata merge SQL / upsert path - Missing entity raises EvolveException - Non-numeric ID rejected early E2E tests (filesystem + milvus, via --run-e2e): - publish_entity / unpublish_entity full MCP flow - create_entity with visibility=public - Content preserved after metadata patch - Extracted shared mcp fixture into tests/e2e/conftest.py and extended params to include milvus
1 parent 06dc007 commit 4876121

5 files changed

Lines changed: 314 additions & 102 deletions

File tree

tests/e2e/conftest.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import os
2+
import uuid
3+
import pytest
4+
from altk_evolve.config.milvus import milvus_client_settings
5+
6+
7+
@pytest.fixture(params=["filesystem", "milvus"])
8+
def mcp(request, tmp_path):
9+
backend_type = request.param
10+
os.environ["EVOLVE_NAMESPACE_ID"] = "test"
11+
os.environ["EVOLVE_BACKEND"] = backend_type
12+
os.environ["EVOLVE_SQLITE_PATH"] = str(tmp_path / f"test_{backend_type}.sqlite.db")
13+
14+
milvus_db_file = None
15+
original_milvus_uri = None
16+
17+
if backend_type == "milvus":
18+
original_milvus_uri = milvus_client_settings.uri
19+
_env_uri = os.getenv("EVOLVE_URI", "")
20+
if _env_uri.startswith("http"):
21+
milvus_client_settings.uri = _env_uri
22+
else:
23+
milvus_db_file = str(tmp_path / f"test_{uuid.uuid4().hex[:8]}.db")
24+
milvus_client_settings.uri = milvus_db_file
25+
elif backend_type == "filesystem":
26+
os.environ["EVOLVE_DATA_DIR"] = str(tmp_path)
27+
28+
from altk_evolve.frontend.client.evolve_client import EvolveClient
29+
from altk_evolve.config.evolve import evolve_config
30+
31+
evolve_config.__init__()
32+
33+
import altk_evolve.frontend.mcp.mcp_server as mcp_server_module
34+
35+
mcp_server_module._client = None
36+
mcp_server_module._namespace_initialized = False
37+
38+
evolve_client = EvolveClient()
39+
try:
40+
evolve_client.create_namespace("test")
41+
except Exception:
42+
pass
43+
44+
yield mcp_server_module.mcp
45+
46+
try:
47+
evolve_client.backend.close()
48+
except Exception:
49+
pass
50+
51+
if backend_type == "milvus":
52+
try:
53+
from pymilvus import connections
54+
for alias, _ in connections.list_connections():
55+
try:
56+
connections.disconnect(alias)
57+
except Exception:
58+
pass
59+
except Exception:
60+
pass
61+
62+
try:
63+
from milvus_lite.server_manager import server_manager_instance
64+
server_manager_instance.release_all()
65+
except Exception:
66+
pass
67+
68+
if original_milvus_uri:
69+
milvus_client_settings.uri = original_milvus_uri
70+
71+
for path in [milvus_db_file, f"{milvus_db_file}.lock"] if milvus_db_file else []:
72+
if path and os.path.exists(path):
73+
try:
74+
os.remove(path)
75+
except Exception:
76+
pass
77+
78+
mcp_server_module._client = None
79+
mcp_server_module._namespace_initialized = False

tests/e2e/test_mcp.py

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,13 @@
1-
import os
21
import pytest
32
import json
43
from fastmcp.client import Client
54
from pathlib import Path
65
from dotenv import load_dotenv
76

8-
import uuid
9-
from altk_evolve.config.milvus import milvus_client_settings
10-
117
__data__ = Path(__file__).parent.parent / "data"
128
load_dotenv()
139

1410

15-
@pytest.fixture(params=["filesystem"])
16-
def mcp(request, tmp_path):
17-
backend_type = request.param
18-
os.environ["EVOLVE_NAMESPACE_ID"] = "test"
19-
os.environ["EVOLVE_BACKEND"] = backend_type
20-
21-
# Common SQLite setup
22-
os.environ["EVOLVE_SQLITE_PATH"] = str(tmp_path / f"test_{backend_type}.sqlite.db")
23-
24-
# Backend-specific setup
25-
milvus_db_file = None
26-
original_milvus_uri = None
27-
28-
if backend_type == "milvus":
29-
original_milvus_uri = milvus_client_settings.uri
30-
_env_uri = os.getenv("EVOLVE_URI", "")
31-
if _env_uri.startswith("http"):
32-
# Running against a real Milvus server (e.g. in CI via Docker sidecar).
33-
# Use the server URI directly; skip milvus-lite local file creation.
34-
milvus_db_file = None
35-
milvus_client_settings.uri = _env_uri
36-
else:
37-
# Local dev: use a unique milvus-lite DB file per test run.
38-
milvus_db_file = str(tmp_path / f"test_{uuid.uuid4().hex[:8]}.db")
39-
milvus_client_settings.uri = milvus_db_file
40-
# Note: currently milvus-lite creates a file, not a dir for the uri
41-
elif backend_type == "filesystem":
42-
os.environ["EVOLVE_DATA_DIR"] = str(tmp_path)
43-
44-
from altk_evolve.frontend.client.evolve_client import EvolveClient
45-
from altk_evolve.config.evolve import evolve_config
46-
47-
# Reset loaded settings to pick up new env vars
48-
evolve_config.__init__()
49-
50-
# Reset the MCP server client
51-
import altk_evolve.frontend.mcp.mcp_server as mcp_server_module
52-
53-
mcp_server_module._client = None
54-
mcp_server_module._namespace_initialized = False
55-
56-
evolve_client = EvolveClient()
57-
# Create the test namespace
58-
try:
59-
evolve_client.create_namespace("test")
60-
except Exception:
61-
pass
62-
63-
yield mcp_server_module.mcp
64-
65-
# Cleanup
66-
try:
67-
evolve_client.backend.close()
68-
except Exception:
69-
pass
70-
71-
if backend_type == "milvus":
72-
# Disconnect all pymilvus connections
73-
try:
74-
from pymilvus import connections
75-
76-
for alias, _ in connections.list_connections():
77-
try:
78-
connections.disconnect(alias)
79-
except Exception:
80-
pass
81-
except Exception:
82-
pass
83-
84-
# Release all Milvus Lite servers
85-
try:
86-
from milvus_lite.server_manager import server_manager_instance
87-
88-
server_manager_instance.release_all()
89-
except Exception:
90-
pass
91-
92-
# Restore original URI
93-
if original_milvus_uri:
94-
milvus_client_settings.uri = original_milvus_uri
95-
96-
# Clean up temp DB files
97-
if milvus_db_file and os.path.exists(milvus_db_file):
98-
try:
99-
os.remove(milvus_db_file)
100-
except Exception:
101-
pass
102-
if milvus_db_file and os.path.exists(f"{milvus_db_file}.lock"):
103-
try:
104-
os.remove(f"{milvus_db_file}.lock")
105-
except Exception:
106-
pass
107-
108-
# Reset the MCP server client
109-
mcp_server_module._client = None
110-
mcp_server_module._namespace_initialized = False
111-
112-
11311
@pytest.mark.e2e
11412
async def test_save_trajectory_and_retrieve_guidelines(mcp):
11513
async with Client(transport=mcp) as evolve_mcp:

tests/e2e/test_sharing.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
E2E tests for Phase 1B entity sharing: publish, unpublish, cross-namespace public recall.
3+
4+
Exercises the full MCP tool stack against real backends (filesystem + milvus-lite).
5+
The `mcp` fixture is parameterized in conftest.py — every test here runs twice.
6+
"""
7+
8+
import json
9+
import pytest
10+
from fastmcp.client import Client
11+
12+
13+
@pytest.mark.e2e
14+
async def test_publish_entity_makes_it_retrievable_publicly(mcp):
15+
"""publish_entity sets visibility=public; get_entities(include_public=True) finds it."""
16+
async with Client(transport=mcp) as client:
17+
# Create a private entity first
18+
create_resp = await client.call_tool_mcp(
19+
"create_entity",
20+
{"content": "always use type hints", "entity_type": "guideline", "enable_conflict_resolution": False},
21+
)
22+
entity = json.loads(create_resp.content[0].text)
23+
entity_id = entity["id"]
24+
25+
# Publish it
26+
pub_resp = await client.call_tool_mcp(
27+
"publish_entity", {"entity_id": entity_id, "user_id": "alice"}
28+
)
29+
published = json.loads(pub_resp.content[0].text)
30+
assert published["metadata"]["visibility"] == "public"
31+
assert published["metadata"]["owner_id"] == "alice"
32+
assert "published_at" in published["metadata"]
33+
34+
# Should appear in a public search
35+
get_resp = await client.call_tool_mcp(
36+
"get_entities",
37+
{"task": "type hints python", "entity_type": "guideline", "include_public": True},
38+
)
39+
output = get_resp.content[0].text
40+
assert "type hints" in output
41+
42+
43+
@pytest.mark.e2e
44+
async def test_unpublish_entity_removes_it_from_public_results(mcp):
45+
"""unpublish_entity reverts visibility to private."""
46+
async with Client(transport=mcp) as client:
47+
create_resp = await client.call_tool_mcp(
48+
"create_entity",
49+
{"content": "prefer list comprehensions", "entity_type": "guideline", "enable_conflict_resolution": False},
50+
)
51+
entity_id = json.loads(create_resp.content[0].text)["id"]
52+
53+
await client.call_tool_mcp("publish_entity", {"entity_id": entity_id})
54+
55+
unpub_resp = await client.call_tool_mcp("unpublish_entity", {"entity_id": entity_id})
56+
unpublished = json.loads(unpub_resp.content[0].text)
57+
assert unpublished["metadata"]["visibility"] == "private"
58+
59+
60+
@pytest.mark.e2e
61+
async def test_publish_entity_not_found_returns_error(mcp):
62+
"""publish_entity on a missing entity returns an error, not a 500."""
63+
async with Client(transport=mcp) as client:
64+
resp = await client.call_tool_mcp("publish_entity", {"entity_id": "99999"})
65+
result = json.loads(resp.content[0].text)
66+
assert "error" in result
67+
68+
69+
@pytest.mark.e2e
70+
async def test_create_entity_with_visibility_public(mcp):
71+
"""create_entity(visibility=public) stores the visibility flag immediately."""
72+
async with Client(transport=mcp) as client:
73+
resp = await client.call_tool_mcp(
74+
"create_entity",
75+
{
76+
"content": "document all public functions",
77+
"entity_type": "guideline",
78+
"visibility": "public",
79+
"owner_id": "bob",
80+
"enable_conflict_resolution": False,
81+
},
82+
)
83+
result = json.loads(resp.content[0].text)
84+
assert result["metadata"]["visibility"] == "public"
85+
assert result["metadata"]["owner_id"] == "bob"
86+
87+
88+
@pytest.mark.e2e
89+
async def test_patch_metadata_preserves_content(mcp):
90+
"""publish_entity must not alter entity content — only metadata."""
91+
original_content = "avoid mutable default arguments"
92+
async with Client(transport=mcp) as client:
93+
create_resp = await client.call_tool_mcp(
94+
"create_entity",
95+
{"content": original_content, "entity_type": "guideline", "enable_conflict_resolution": False},
96+
)
97+
entity_id = json.loads(create_resp.content[0].text)["id"]
98+
99+
pub_resp = await client.call_tool_mcp("publish_entity", {"entity_id": entity_id, "user_id": "carol"})
100+
published = json.loads(pub_resp.content[0].text)
101+
102+
assert published["content"] == original_content

tests/unit/test_milvus_backend.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,72 @@ def test_delete_entity_nonexistent_namespace(milvus_backend: MilvusEntityBackend
409409
milvus_backend.delete_entity_by_id(namespace_id="nonexistent_namespace", entity_id="12345")
410410

411411

412+
# ── update_entity_metadata ────────────────────────────────────────────────────
413+
414+
415+
@pytest.mark.unit
416+
def test_update_entity_metadata_merges_and_returns(milvus_backend: MilvusEntityBackend, monkeypatch):
417+
"""Fetches via milvus.query, merges patch, upserts with partial_update, returns updated entity."""
418+
now_ts = int(datetime.datetime.now(datetime.UTC).timestamp())
419+
raw_entity = {
420+
"id": 42,
421+
"type": "guideline",
422+
"content": "be concise",
423+
"created_at": now_ts,
424+
"metadata": {"creation_mode": "manual"},
425+
}
426+
427+
query = Mock(return_value=[raw_entity])
428+
upsert = Mock(return_value={"ids": [42]})
429+
flush = Mock()
430+
load = Mock()
431+
432+
monkeypatch.setattr(milvus_backend.milvus, "has_collection", always_has_collection)
433+
monkeypatch.setattr(milvus_backend.milvus, "query", query)
434+
monkeypatch.setattr(milvus_backend.milvus, "upsert", upsert)
435+
monkeypatch.setattr(milvus_backend.milvus, "flush", flush)
436+
monkeypatch.setattr(milvus_backend.milvus, "load_collection", load)
437+
monkeypatch.setattr(milvus_backend.embedding_model, "encode", arbitrary_embedding)
438+
439+
result = milvus_backend.update_entity_metadata(
440+
"test_namespace", "42", {"visibility": "public", "owner_id": "alice"}
441+
)
442+
443+
# query must use direct id filter, not search_entities fan-out
444+
query.assert_called_once_with(
445+
collection_name="test_namespace",
446+
filter="id == 42",
447+
output_fields=["id", "type", "content", "created_at", "metadata"],
448+
limit=1,
449+
)
450+
451+
# upsert must carry merged metadata
452+
upserted_data = upsert.call_args[1]["data"]
453+
assert upserted_data["metadata"]["creation_mode"] == "manual"
454+
assert upserted_data["metadata"]["visibility"] == "public"
455+
assert upserted_data["metadata"]["owner_id"] == "alice"
456+
457+
assert result.metadata["visibility"] == "public"
458+
assert result.metadata["creation_mode"] == "manual"
459+
460+
461+
@pytest.mark.unit
462+
def test_update_entity_metadata_raises_for_missing_entity(milvus_backend: MilvusEntityBackend, monkeypatch):
463+
"""Raises EvolveException when milvus.query returns no results."""
464+
monkeypatch.setattr(milvus_backend.milvus, "has_collection", always_has_collection)
465+
monkeypatch.setattr(milvus_backend.milvus, "query", Mock(return_value=[]))
466+
467+
with pytest.raises(EvolveException, match="not found"):
468+
milvus_backend.update_entity_metadata("test_namespace", "99", {"visibility": "public"})
469+
470+
471+
@pytest.mark.unit
472+
def test_update_entity_metadata_rejects_non_numeric_id(milvus_backend: MilvusEntityBackend):
473+
"""Raises EvolveException immediately for non-numeric entity IDs."""
474+
with pytest.raises(EvolveException, match="must be numeric"):
475+
milvus_backend.update_entity_metadata("test_namespace", "not-an-id", {"visibility": "public"})
476+
477+
412478
@pytest.mark.unit
413479
def test_parse_milvus_entity_accepts_epoch_zero_created_at():
414480
parsed = parse_milvus_entity(

0 commit comments

Comments
 (0)