deer-flow/backend/tests/test_memory_router.py
RongfuShuiping 4bf028d048
feat(memory): add incremental agent-scoped Markdown fact storage (#4279)
* feat(memory): add Markdown fact storage repository

* docs(memory): explain storage rewrite for beginners

* docs(memory): fix plan markdown formatting

* refactor(memory): separate global summaries from agent facts

* fix(memory): make Markdown fact updates incremental and safe

* Update STORAGE_REWRITE_CHANGES.md

* Delete docs/plans/STORAGE_REWRITE_PLAN.md

* Delete docs/plans/STORAGE_REWRITE_CHANGES.md

* fix(memory): address Markdown storage review feedback

* fix(memory): complete review follow-ups

* fix(memory): resolve storage review findings

* feat(memory): add proactive Markdown migration CLI

* fix(memory): harden Markdown storage concurrency

* fix(memory): harden markdown storage migration

* fix(memory): close migration review gaps

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: qin-chenghan <qinchenghan@Huawei.com>
2026-07-22 07:46:03 +08:00

466 lines
20 KiB
Python

import asyncio
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import memory
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
def _sample_memory(facts: list[dict] | None = None) -> dict:
return {
"version": "1.0",
"lastUpdated": "2026-03-26T12:00:00Z",
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": facts or [],
}
# ── export ─────────────────────────────────────────────────────────────────
def test_export_memory_route_returns_current_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
exported_memory = _sample_memory(facts=[{"id": "fact_export", "content": "User prefers concise responses.", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
mock_mgr = MagicMock()
mock_mgr.get_memory.return_value = exported_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.get("/api/memory/export")
assert response.status_code == 200
assert response.json()["facts"] == exported_memory["facts"]
def test_export_memory_route_preserves_source_error() -> None:
app = FastAPI()
app.include_router(memory.router)
exported_memory = _sample_memory(
facts=[
{
"id": "fact_correction",
"content": "Use make dev for local development.",
"category": "correction",
"confidence": 0.95,
"createdAt": "2026-03-20T00:00:00Z",
"source": "thread-1",
"sourceError": "The agent previously suggested npm start.",
}
]
)
mock_mgr = MagicMock()
mock_mgr.get_memory.return_value = exported_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.get("/api/memory/export")
assert response.status_code == 200
assert response.json()["facts"][0]["sourceError"] == "The agent previously suggested npm start."
# ── import ─────────────────────────────────────────────────────────────────
def test_import_memory_route_returns_imported_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
imported_memory = _sample_memory(facts=[{"id": "fact_import", "content": "User works on DeerFlow.", "category": "context", "confidence": 0.87, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
mock_mgr = MagicMock()
mock_mgr.import_memory.return_value = imported_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.post("/api/memory/import", json=imported_memory)
assert response.status_code == 200
assert response.json()["facts"] == imported_memory["facts"]
def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_path) -> None:
app = FastAPI()
app.include_router(memory.router)
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
imported_memory = _sample_memory(
facts=[
{
"id": "fact_gateway_import",
"content": "Gateway imports use the default agent bucket.",
"category": "context",
"confidence": 0.9,
"createdAt": "2026-07-21T00:00:00Z",
"source": "import",
}
]
)
with (
patch("app.gateway.routers.memory.get_memory_manager", return_value=manager),
patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"),
TestClient(app) as client,
):
response = client.post("/api/memory/import", json=imported_memory)
assert response.status_code == 200
assert [fact["id"] for fact in response.json()["facts"]] == ["fact_gateway_import"]
facts_root = tmp_path / "users" / "alice" / "agents" / "__default__" / "facts"
assert [path.stem for path in facts_root.glob("**/*.md")] == ["fact_gateway_import"]
def test_import_memory_route_preserves_source_error() -> None:
app = FastAPI()
app.include_router(memory.router)
imported_memory = _sample_memory(
facts=[
{
"id": "fact_correction",
"content": "Use make dev for local development.",
"category": "correction",
"confidence": 0.95,
"createdAt": "2026-03-20T00:00:00Z",
"source": "thread-1",
"sourceError": "The agent previously suggested npm start.",
}
]
)
mock_mgr = MagicMock()
mock_mgr.import_memory.return_value = imported_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.post("/api/memory/import", json=imported_memory)
assert response.status_code == 200
assert response.json()["facts"][0]["sourceError"] == "The agent previously suggested npm start."
# ── clear ──────────────────────────────────────────────────────────────────
def test_clear_memory_route_returns_cleared_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.clear_memory.return_value = _sample_memory()
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.delete("/api/memory")
assert response.status_code == 200
assert response.json()["facts"] == []
# ── fact CRUD (normal / error) ─────────────────────────────────────────────
def test_create_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_new", "content": "User prefers concise code reviews.", "category": "preference", "confidence": 0.88, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
mock_mgr = MagicMock()
mock_mgr.create_fact.return_value = (updated_memory, "fact_new")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.post("/api/memory/facts", json={"content": "User prefers concise code reviews.", "category": "preference", "confidence": 0.88})
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_create_memory_fact_route_maps_conflict_to_409() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.create_fact.side_effect = MemoryConflictError("stale write")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.post("/api/memory/facts", json={"content": "fact"})
assert response.status_code == 409
assert response.json()["detail"] == "Memory changed concurrently; reload and retry."
def test_get_memory_route_maps_corruption_to_stable_500() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = MemoryCorruptionError("private path and parser detail")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.get("/api/memory")
assert response.status_code == 500
assert response.json()["detail"] == "Stored memory data is corrupted."
def test_delete_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_keep", "content": "User likes Python", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
mock_mgr = MagicMock()
mock_mgr.delete_fact.return_value = updated_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.delete("/api/memory/facts/fact_delete")
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.delete_fact.side_effect = KeyError("fact_missing")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.delete("/api/memory/facts/fact_missing")
assert response.status_code == 404
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
def test_update_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "workflow", "confidence": 0.91, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
mock_mgr = MagicMock()
mock_mgr.update_fact.return_value = updated_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.patch("/api/memory/facts/fact_edit", json={"content": "User prefers spaces", "category": "workflow", "confidence": 0.91})
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> None:
"""The current Settings API sends no agent_name; it must remain usable."""
app = FastAPI()
app.include_router(memory.router)
memory_path = tmp_path / "users" / "alice" / "memory.json"
memory_path.parent.mkdir(parents=True)
legacy = _sample_memory(
facts=[
{
"id": "fact_legacy",
"content": "Legacy global fact",
"category": "context",
"confidence": 0.9,
"createdAt": "2026-03-20T00:00:00Z",
"source": "manual",
}
]
)
memory_path.write_text(json.dumps(legacy), encoding="utf-8")
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
with (
patch("app.gateway.routers.memory.get_memory_manager", return_value=manager),
patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"),
TestClient(app) as client,
):
fetched = client.get("/api/memory")
assert fetched.status_code == 200
assert [fact["content"] for fact in fetched.json()["facts"]] == ["Legacy global fact"]
exported = client.get("/api/memory/export")
assert exported.status_code == 200
assert [fact["content"] for fact in exported.json()["facts"]] == ["Legacy global fact"]
created = client.post("/api/memory/facts", json={"content": "Project uses Python", "category": "context", "confidence": 0.8})
assert created.status_code == 200
assert all(isinstance(fact["source"], str) for fact in created.json()["facts"])
fact_id = next(fact["id"] for fact in created.json()["facts"] if fact["content"] == "Project uses Python")
updated = client.patch(f"/api/memory/facts/{fact_id}", json={"content": "Project uses Python 3.12"})
assert updated.status_code == 200
assert updated.json()["facts"][0]["content"] == "Project uses Python 3.12"
deleted = client.delete(f"/api/memory/facts/{fact_id}")
assert deleted.status_code == 200
assert [fact["id"] for fact in deleted.json()["facts"]] == ["fact_legacy"]
deleted_legacy = client.delete("/api/memory/facts/fact_legacy")
assert deleted_legacy.status_code == 200
assert deleted_legacy.json()["facts"] == []
facts_root = tmp_path / "users" / "alice" / "agents" / "__default__" / "facts"
assert facts_root.exists()
assert not list(facts_root.glob("**/*.md"))
assert "facts" not in json.loads(memory_path.read_text(encoding="utf-8"))
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "preference", "confidence": 0.8, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
mock_mgr = MagicMock()
mock_mgr.update_fact.return_value = updated_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.patch("/api/memory/facts/fact_edit", json={"content": "User prefers spaces"})
assert response.status_code == 200
# The router calls _require_capability("update_fact") -> getattr(mgr, "update_fact")
# which returns mock_mgr.update_fact (a MagicMock). Then the call is
# update_fact(fact_id=..., content=..., category=..., confidence=..., user_id=...)
mock_mgr.update_fact.assert_called_once()
call_kwargs = mock_mgr.update_fact.call_args.kwargs
assert call_kwargs.get("fact_id") == "fact_edit"
assert call_kwargs.get("content") == "User prefers spaces"
assert call_kwargs.get("category") is None
assert call_kwargs.get("confidence") is None
assert "user_id" in call_kwargs
assert response.json()["facts"] == updated_memory["facts"]
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.update_fact.side_effect = KeyError("fact_missing")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.patch("/api/memory/facts/fact_missing", json={"content": "User prefers spaces", "category": "workflow", "confidence": 0.91})
assert response.status_code == 404
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
app = FastAPI()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.update_fact.side_effect = ValueError("confidence")
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with TestClient(app) as client:
response = client.patch("/api/memory/facts/fact_edit", json={"content": "User prefers spaces", "confidence": 0.91})
assert response.status_code == 400
assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1."
# ── bound-owner (internal caller) ──────────────────────────────────────────
def _internal_owner_request(owner_user_id: str) -> SimpleNamespace:
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
from deerflow.runtime.user_context import DEFAULT_USER_ID
return SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_user_id},
state=SimpleNamespace(user=SimpleNamespace(id=DEFAULT_USER_ID, system_role=INTERNAL_SYSTEM_ROLE)),
)
def test_get_memory_honors_bound_owner_header() -> None:
seen: dict[str, str] = {}
def fake_get_memory(*, user_id: str) -> dict:
seen["user_id"] = user_id
return _sample_memory(facts=[{"id": "f", "content": "owner fact", "category": "context", "confidence": 0.9, "createdAt": "", "source": "owner"}])
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
response = asyncio.run(memory.get_memory(_internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
assert response.facts[0].content == "owner fact"
def test_get_memory_sanitizes_unsafe_owner_header() -> None:
from deerflow.config.paths import make_safe_user_id
raw_owner = "feishu|ou_AbC/123"
seen: dict[str, str] = {}
def fake_get_memory(*, user_id: str) -> dict:
seen["user_id"] = user_id
return _sample_memory()
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.get_memory(_internal_owner_request(raw_owner)))
expected = make_safe_user_id(raw_owner)
assert seen["user_id"] == expected
assert seen["user_id"] != raw_owner
def test_get_memory_falls_back_to_effective_user_for_browser_requests() -> None:
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
seen: dict[str, str] = {}
def fake_get_memory(*, user_id: str) -> dict:
seen["user_id"] = user_id
return _sample_memory()
browser_request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="real-user", system_role="user")),
)
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.get_memory(browser_request))
assert seen["user_id"] == "real-user"
def _browser_request_with_spoofed_owner_header() -> SimpleNamespace:
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
return SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="real-user", system_role="user")),
)
def test_clear_memory_scopes_destructive_write_to_bound_owner() -> None:
seen: dict[str, str] = {}
def fake_clear(*, user_id: str) -> dict:
seen["user_id"] = user_id
return _sample_memory()
mock_mgr = MagicMock()
mock_mgr.clear_memory.side_effect = fake_clear
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.clear_memory(_internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.clear_memory(_browser_request_with_spoofed_owner_header()))
assert seen["user_id"] == "real-user"
def test_import_memory_scopes_overwrite_to_bound_owner() -> None:
seen: dict[str, str] = {}
payload = memory.MemoryResponse(**_sample_memory())
def fake_import(_data: dict, *, user_id: str) -> dict:
seen["user_id"] = user_id
return _sample_memory()
mock_mgr = MagicMock()
mock_mgr.import_memory.side_effect = fake_import
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.import_memory(payload, _internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.import_memory(payload, _browser_request_with_spoofed_owner_header()))
assert seen["user_id"] == "real-user"