# ============================================================================= # 企微IT智能服务台 — Neo4j 客户端单元测试 # ============================================================================= # 说明:测试 Neo4jClient 的连接、CRUD、图遍历功能。 # 优先使用 testcontainers 启动 Docker Neo4j 容器, # 不可用时降级为内存 Mock(memory mock)。 # ============================================================================= import os import uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge # -------------------------------------------------------------------------- # 尝试导入 testcontainers,不可用时降级为 MemoryMock # -------------------------------------------------------------------------- try: from testcontainers.neo4j import Neo4jContainer # type: ignore HAS_TESTCONTAINERS = True except ImportError: HAS_TESTCONTAINERS = False # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture(scope="session") def neo4j_container(): """启动 Neo4j 测试容器(session 级别,所有测试复用)。 如果 testcontainers 不可用(CI 环境无 Docker),则跳过并返回 None。 """ if not HAS_TESTCONTAINERS: yield None return # 检查是否在 CI 环境(无 Docker)中 if os.environ.get("CI") and not os.environ.get("DOCKER_HOST"): yield None return try: container = Neo4jContainer( image="neo4j:5-enterprise", username="neo4j", password="test1234", ) container.start() yield container container.stop() except Exception: # Docker 不可用,降级到 Mock(generator fixture 必须用 yield,不能用 return) yield None @pytest_asyncio.fixture async def neo4j_client(neo4j_container): """提供 Neo4jClient 实例(真实容器或 Mock)。 优先使用 Docker Neo4j 容器,不可用时降级为内存 Mock。 """ if neo4j_container is not None: from app.services.neo4j_client import Neo4jClient bolt_url = neo4j_container.get_connection_url() client = Neo4jClient( uri=bolt_url, user="neo4j", password="test1234", database="neo4j", ) await client.initialize() yield client await client.close() else: # 降级:使用 Mock yield _create_mock_neo4j_client() def _create_mock_neo4j_client(): """创建内存 Mock Neo4jClient,用于无 Docker 环境测试。 使用内存字典模拟图数据库,实现基本的 CRUD 语义。 """ from app.services.neo4j_client import Neo4jClient client = Neo4jClient.__new__(Neo4jClient) client.uri = "mock://memory" client.user = "mock" client.password = "mock" client.database = "mock" client._driver = None # 标记为 Mock 模式 # 内存存储 client._nodes: dict = {} # uuid → dict client._rels: list = [] # [(from_uuid, to_uuid, rel_type, props)] async def mock_initialize(): pass async def mock_close(): pass async def mock_health_check(): return True async def mock_create_issue_node(issue: IssueNode) -> IssueNode: node_uuid = str(uuid.uuid4()) now = issue.created_at.isoformat() if issue.created_at else "2026-01-01T00:00:00" node = { "uuid": node_uuid, "name": issue.name, "category": issue.category, "created_at": now, "updated_at": now, "source_suggestion_id": issue.source_suggestion_id, } client._nodes[node_uuid] = node return IssueNode(**node) async def mock_merge_issue(name: str, category: str, props=None) -> IssueNode: # 查找已有节点 for n in client._nodes.values(): if n.get("name") == name and n.get("__type") == "Issue": n["category"] = category return IssueNode(**{k: v for k, v in n.items() if not k.startswith("__")}) # 创建新节点 node_uuid = str(uuid.uuid4()) now = "2026-01-01T00:00:00" node = { "uuid": node_uuid, "name": name, "category": category, "created_at": now, "updated_at": now, "source_suggestion_id": props.get("source_suggestion_id") if props else None, "__type": "Issue", } client._nodes[node_uuid] = node return IssueNode( uuid=node_uuid, name=name, category=category, source_suggestion_id=props.get("source_suggestion_id") if props else None, ) async def mock_create_action_node(action: ActionNode) -> ActionNode: node_uuid = str(uuid.uuid4()) now = action.created_at.isoformat() if action.created_at else "2026-01-01T00:00:00" node = { "uuid": node_uuid, "name": action.name, "description": action.description, "created_at": now, "source_suggestion_id": action.source_suggestion_id, } client._nodes[node_uuid] = node return ActionNode(**node) async def mock_merge_action(name: str, props=None) -> ActionNode: for n in client._nodes.values(): if n.get("name") == name and n.get("__type") == "Action": return ActionNode(**{k: v for k, v in n.items() if not k.startswith("__")}) node_uuid = str(uuid.uuid4()) now = "2026-01-01T00:00:00" node = { "uuid": node_uuid, "name": name, "description": (props or {}).get("description", ""), "created_at": now, "source_suggestion_id": (props or {}).get("source_suggestion_id"), "__type": "Action", } client._nodes[node_uuid] = node return ActionNode( uuid=node_uuid, name=name, description=(props or {}).get("description", ""), source_suggestion_id=(props or {}).get("source_suggestion_id"), ) async def mock_create_relation(from_uuid: str, to_uuid: str, rel: RelationEdge) -> bool: client._rels.append((from_uuid, to_uuid, str(rel.type), {"order": rel.order, "weight": rel.weight})) return True async def mock_find_issue_by_name(name: str): for n in client._nodes.values(): if n.get("name") == name and n.get("__type") == "Issue": return IssueNode(**{k: v for k, v in n.items() if not k.startswith("__")}) return None async def mock_find_related_issues(node_uuid: str, rel_type=None): results = [] for from_u, to_u, rtype, _ in client._rels: if from_u == node_uuid: if rel_type is None or rtype == rel_type: target = client._nodes.get(to_u) if target: results.append(IssueNode(**{k: v for k, v in target.items() if not k.startswith("__")})) return results client.initialize = mock_initialize client.close = mock_close client.health_check = mock_health_check client.create_issue_node = mock_create_issue_node client.merge_issue = mock_merge_issue client.create_action_node = mock_create_action_node client.merge_action = mock_merge_action client.create_relation = mock_create_relation client.find_issue_by_name = mock_find_issue_by_name client.find_related_issues = mock_find_related_issues return client # ============================================================================= # 测试用例 # ============================================================================= class TestNeo4jHealthCheck: """Neo4j 健康检查测试。""" @pytest.mark.asyncio async def test_health_check(self, neo4j_client): """验证健康检查返回 True。""" healthy = await neo4j_client.health_check() assert healthy is True class TestNeo4jIssueCRUD: """Issue 节点 CRUD 测试。""" @pytest.mark.asyncio async def test_create_issue_node(self, neo4j_client): """验证创建 Issue 节点。""" issue = IssueNode(name="VPN问题", category="网络") result = await neo4j_client.create_issue_node(issue) assert result.uuid != "" assert result.name == "VPN问题" assert result.category == "网络" @pytest.mark.asyncio async def test_merge_issue_idempotent(self, neo4j_client): """验证 MERGE Issue 幂等性。""" # 第一次 merge → 创建 issue1 = await neo4j_client.merge_issue("测试问题", "软件") uuid1 = issue1.uuid # 第二次 merge → 返回已有节点 issue2 = await neo4j_client.merge_issue("测试问题", "软件") assert issue2.uuid == uuid1 assert issue2.name == "测试问题" @pytest.mark.asyncio async def test_find_issue_by_name(self, neo4j_client): """验证按名称查找 Issue。""" await neo4j_client.merge_issue("查找测试问题", "网络") found = await neo4j_client.find_issue_by_name("查找测试问题") assert found is not None assert found.name == "查找测试问题" assert found.category == "网络" @pytest.mark.asyncio async def test_find_issue_by_name_not_found(self, neo4j_client): """验证查找不存在的 Issue 返回 None。""" found = await neo4j_client.find_issue_by_name("不存在的问题XYZ123") assert found is None class TestNeo4jActionCRUD: """Action 节点 CRUD 测试。""" @pytest.mark.asyncio async def test_create_action_node(self, neo4j_client): """验证创建 Action 节点。""" action = ActionNode(name="个人VPN开通", description="为员工开通个人VPN") result = await neo4j_client.create_action_node(action) assert result.uuid != "" assert result.name == "个人VPN开通" assert result.description == "为员工开通个人VPN" @pytest.mark.asyncio async def test_merge_action_idempotent(self, neo4j_client): """验证 MERGE Action 幂等性。""" action1 = await neo4j_client.merge_action("重置密码", {"description": "帮助员工重置域密码"}) action2 = await neo4j_client.merge_action("重置密码", {"description": "帮助员工重置域密码"}) assert action1.uuid == action2.uuid class TestNeo4jRelation: """关系 CRUD 测试。""" @pytest.mark.asyncio async def test_create_relation(self, neo4j_client): """验证创建关系。""" issue = await neo4j_client.merge_issue("关系测试问题", "硬件") action = await neo4j_client.merge_action("关系测试动作") rel = RelationEdge( from_uuid=issue.uuid, to_uuid=action.uuid, type="LEADS_TO", order=1, weight=1.0, ) result = await neo4j_client.create_relation( issue.uuid, action.uuid, rel ) assert result is True @pytest.mark.asyncio async def test_find_related_issues(self, neo4j_client): """验证查找关联 Issue。""" issue1 = await neo4j_client.merge_issue("父问题", "网络") issue2 = await neo4j_client.merge_issue("子问题", "网络") rel = RelationEdge( from_uuid=issue1.uuid, to_uuid=issue2.uuid, type="LEADS_TO", order=1, weight=0.8, ) await neo4j_client.create_relation(issue1.uuid, issue2.uuid, rel) related = await neo4j_client.find_related_issues(issue1.uuid, "LEADS_TO") assert len(related) >= 1 assert any(r.name == "子问题" for r in related)