import asyncio 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 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_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_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_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"