Files
wecom_it_smart_desk/backend/tests/test_neo4j_client.py
T

334 lines
12 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — Neo4j 客户端单元测试
# =============================================================================
# 说明:测试 Neo4jClient 的连接、CRUD、图遍历功能。
# 优先使用 testcontainers 启动 Docker Neo4j 容器,
# 不可用时降级为内存 Mockmemory 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 不可用,降级到 Mockgenerator 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)