Files
PlantAI-rag-new/sdk/app/services/test_neo4j.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

589 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""services/test_neo4j.py —— Neo4jService 单元测试(FakeDriver,不连真实实例)。
覆盖:实例选择与回退、5s 探测缓存、kg_status / kg_overview /
search_entities / entity / neighborhood 的 Cypher 与结果组装、
非 canonical 实例的 TODO 行为、超时与驱动错误 → Neo4jUnavailable。
"""
from __future__ import annotations
import asyncio
import pytest
from app.services.neo4j import (
CONNECT_TIMEOUT,
DIRECT_ACTIVE_NAME,
FULLTEXT_INDEX,
NAME_INDEX,
NORMALIZED_ACTIVE_NAME,
Neo4jService,
Neo4jUnavailable,
_Instance,
)
def inst(
name: str = DIRECT_ACTIVE_NAME,
uri: str = "bolt://fake-direct:18788",
schema: str = "canonical",
label: str | None = "SRT2CanonicalEntity",
rel: str | None = "SRT2_CANONICAL_ASSERTS",
) -> _Instance:
return _Instance(name, uri, "neo4j", "pw", "neo4j", schema, label, rel)
# ---------- FakeDriver ----------
class FakeResult:
"""模拟 neo4j AsyncResult:可迭代 record(dict 样式)。"""
def __init__(self, rows: list[dict]):
self.rows = rows
def __iter__(self):
return iter(self.rows)
def __len__(self):
return len(self.rows)
class FakeDriver:
"""内存脚本表:{(cypher 关键片段): rows}。匹配取最长注册片段(避免短键误配)。"""
_NO_SCRIPT = object()
def __init__(
self,
scripts: dict[str, list[dict]] | None = None,
delays: dict[str, float] | None = None,
errors: dict[str, Exception] | None = None,
connect_error: Exception | None = None,
):
self.scripts = scripts or {}
self.delays = delays or {}
self.errors = errors or {}
self.connect_error = connect_error
self.calls: list[tuple[str, dict]] = []
self.closed = False
def _match(self, cypher: str):
# 大小写不敏感 + 最长片段优先,避免 "canonical_id: $id" 误配邻域查询
lowered = cypher.lower()
matches = [k for k in self.scripts if k.lower() in lowered]
return max(matches, key=len) if matches else self._NO_SCRIPT
async def execute_query(
self, cypher: str, parameters_: dict | None = None, **kwargs
) -> tuple[FakeResult, object, object]:
self.calls.append((cypher, dict(parameters_ or {})))
if self.connect_error is not None:
raise self.connect_error
key = self._match(cypher)
if key is self._NO_SCRIPT:
raise AssertionError(
f"未注册的 Cypher:{cypher!r}(已注册 {list(self.scripts)})"
)
if key in self.errors:
raise self.errors[key]
if key in self.delays:
await asyncio.sleep(self.delays[key])
return FakeResult(self.scripts[key]), None, None
async def close(self):
self.closed = True
def _canonical_instance(
name: str = DIRECT_ACTIVE_NAME,
uri: str = "bolt://fake-direct:18788",
) -> _Instance:
return inst(name, uri, "canonical", "SRT2CanonicalEntity", "SRT2_CANONICAL_ASSERTS")
def make_service(
driver: FakeDriver,
instances: list[_Instance] | None = None,
) -> Neo4jService:
"""构建已选中实例(跳过探测)的服务:默认只有 canonical 实例,
连接指向测试 driver。"""
if instances is None:
instances = [_canonical_instance()]
service = Neo4jService(instances=instances)
service._drivers = {i.name: driver for i in instances}
service._selected = True
service._active = instances[0]
return service
COUNTS_SCRIPT = "RETURN entity_count, assertion_count"
INDEXES_SCRIPT = "SHOW INDEXES"
ENTITY_ROW = {
"canonical_id": "cent_ospl14",
"name": "OsSPL14",
"entity_type": "gene",
"support_count": 18,
}
def node_row(canonical_id: str, name: str, entity_type: str):
return {
"canonical_id": canonical_id,
"name": name,
"name_norm": name.lower(),
"entity_type": entity_type,
"support_count": 1,
}
def edge_row(src: str, tgt: str, category: str, *, title: str = "Paper"):
return {
"assertion_id": f"cass_{src}_{tgt}",
"source_canonical_id": src,
"target_canonical_id": tgt,
"relation_norm": category,
"relation_category": category,
"paper_count": 3,
"support_count": 2,
"title": title,
"doi": "10.1/x",
"pmid": "1",
}
# ---------- 实例选择与探测 ----------
@pytest.fixture
def tcp_ok(monkeypatch):
"""绕过 TCP 预检(fake URI 无法真实连接),让 FakeDriver 接管。"""
async def reachable(uri: str) -> bool:
return True
monkeypatch.setattr(Neo4jService, "_tcp_reachable", staticmethod(reachable))
async def test_select_defaults_to_direct():
service = make_service(FakeDriver({"RETURN 1": [{"x": 1}]}))
instance = await service.select()
assert instance.name == DIRECT_ACTIVE_NAME
assert instance.uri == "bolt://fake-direct:18788"
async def test_probe_falls_back_to_next_instance_on_connect_error(tcp_ok):
from neo4j import exceptions as neo4j_exc
direct = _canonical_instance()
failing = FakeDriver(
{"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [], INDEXES_SCRIPT: []},
connect_error=neo4j_exc.ServiceUnavailable("down"),
)
healthy = FakeDriver(
{"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [], INDEXES_SCRIPT: []}
)
normal = inst(NORMALIZED_ACTIVE_NAME, "bolt://fake-normalized:19788",
schema="normalized", label=None, rel=None)
service = Neo4jService([direct, normal])
service._drivers = {direct.name: failing, normal.name: healthy}
result = await service.probe()
assert result["ok"] is True
assert result["active"] == NORMALIZED_ACTIVE_NAME
async def test_probe_all_down_returns_ok_false(tcp_ok):
from neo4j import exceptions as neo4j_exc
direct = _canonical_instance()
failing = FakeDriver(
{"RETURN 1": [{"x": 1}]},
connect_error=neo4j_exc.ServiceUnavailable("down"),
)
service = Neo4jService([direct])
service._drivers = {direct.name: failing}
result = await service.probe()
assert result["ok"] is False
assert result["active"] is None
assert result["detail"]
# 失败结果缓存 5s:select() 也应抛
with pytest.raises(Neo4jUnavailable):
await service.select()
async def test_probe_result_cached_5s(tcp_ok):
direct = _canonical_instance()
driver = FakeDriver({"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [
{"entity_count": 1, "assertion_count": 2}
], INDEXES_SCRIPT: [{"name": NAME_INDEX}]})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result1 = await service.probe()
assert result1["ok"] is True
assert result1["active"] == DIRECT_ACTIVE_NAME
assert result1["detail"] is None
n_calls = len(driver.calls)
result2 = await service.probe() # 缓存命中:不再打实例
assert result2 == result1
assert len(driver.calls) == n_calls
async def test_probe_extra_indexes_not_reported_as_missing(tcp_ok):
"""BUG-R1-08 回归:同一标签上存在唯一约束索引等其它索引是正常现象
(DISCOVERY §2.2 实测有 srt2_canonical_entity_id),probe 不得把
「索引列表不止 name_norm 一个」误判为缺失或异常。"""
direct = _canonical_instance()
driver = FakeDriver({
"RETURN 1": [{"x": 1}],
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
# 真实 SHOW INDEXES:name_norm RANGE 索引 + canonical_id 唯一约束索引
INDEXES_SCRIPT: [
{"name": NAME_INDEX},
{"name": "srt2_canonical_entity_id"},
],
})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result = await service.probe()
assert result["ok"] is True
assert result["detail"] is None # 修复前此处误报「缺失或异常」
async def test_probe_missing_name_index_reports_detail(tcp_ok):
direct = _canonical_instance()
driver = FakeDriver({
"RETURN 1": [{"x": 1}],
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
# 只有约束索引,没有 name_norm 索引
INDEXES_SCRIPT: [{"name": "srt2_canonical_entity_id"}],
})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result = await service.probe()
assert result["ok"] is True # 索引缺失不导致整体失败
assert result["detail"] is not None
assert "name_norm 索引缺失" in result["detail"]
# ---------- kg_status ----------
async def test_kg_status_shape():
driver = FakeDriver(
{
COUNTS_SCRIPT: [
{"entity_count": 3820996, "assertion_count": 8246875}
],
INDEXES_SCRIPT: [{"name": NAME_INDEX}],
}
)
service = make_service(driver)
status = await service.kg_status()
assert status == {
"ok": True,
"active": DIRECT_ACTIVE_NAME,
"schema": "canonical",
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
"entity_count": 3820996,
"assertion_count": 8246875,
"name_index": NAME_INDEX,
"fulltext_index": FULLTEXT_INDEX,
}
async def test_kg_status_missing_index_reports_null():
driver = FakeDriver(
{
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
INDEXES_SCRIPT: [],
}
)
service = make_service(driver)
status = await service.kg_status()
assert status["name_index"] is None
assert status["fulltext_index"] is None
async def test_non_canonical_instance_raises_unavailable():
normal = inst(NORMALIZED_ACTIVE_NAME, "bolt://fake-normalized:19788",
schema="normalized", label=None, rel=None)
driver = FakeDriver({"RETURN 1": [{"x": 1}]})
service = make_service(driver, [normal])
with pytest.raises(Neo4jUnavailable):
await service.kg_status()
with pytest.raises(Neo4jUnavailable):
await service.search_entities("OsSPL14")
# ---------- kg_overview ----------
async def test_kg_overview_counts():
driver = FakeDriver(
{
"WITH e.entity_type AS t, count(*) AS c": [
{"type": "gene", "count": 43210},
{"type": "gene_family", "count": 1288},
],
"WITH r.relation_category AS cat, count(*) AS c": [
{"type": "characterized_in", "count": 12033},
],
}
)
service = make_service(driver)
overview = await service.kg_overview()
assert overview["entities_by_type"] == [
{"type": "gene", "count": 43210},
{"type": "gene_family", "count": 1288},
]
assert overview["relations_by_category"] == [
{"type": "characterized_in", "count": 12033}
]
# 不能有拉全量边/实体的危险查询
for cypher, _ in driver.calls:
assert "RETURN e" not in cypher and "RETURN r" not in cypher
# ---------- search_entities ----------
async def test_search_entities_maps_fields_and_normalizes_query():
driver = FakeDriver(
{
"e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm": [
ENTITY_ROW,
{**ENTITY_ROW, "canonical_id": "cent_ospl7", "name": "OsSPL7", "support_count": 9},
]
}
)
service = make_service(driver)
results = await service.search_entities("OsSPL14", limit=5)
assert results[0] == {
"id": "cent_ospl14",
"name": "OsSPL14",
"type": "gene",
"support_count": 18,
}
assert len(results) == 2
# toLower 归一化已传到 Cypher 参数
call_params = driver.calls[0][1]
assert call_params["q_norm"] == "osspl14"
assert call_params["limit"] == 5
async def test_search_entities_caps_limit_at_20():
driver = FakeDriver(
{"e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm": [ENTITY_ROW]}
)
service = make_service(driver)
await service.search_entities("x", limit=999)
params = driver.calls[0][1]
assert params["limit"] == 20
# ---------- entity ----------
async def test_entity_by_canonical_id():
props = {**node_row("cent_ospl14", "OsSPL14", "gene"), "rule": "exact"}
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_ospl14"}],
"RETURN e {.*} AS node": [{"node": props}],
}
)
service = make_service(driver)
entity = await service.entity("cent_ospl14")
assert entity == {
"id": "cent_ospl14",
"name": "OsSPL14",
"type": "gene",
"properties": props,
}
async def test_entity_by_name_uses_name_norm():
driver = FakeDriver(
{
"name_norm: $name_norm": [{"canonical_id": "cent_ospl14"}],
"RETURN e {.*} AS node": [{"node": node_row("cent_ospl14", "OsSPL14", "gene")}],
}
)
service = make_service(driver)
entity = await service.entity("OsSPL14")
assert entity is not None and entity["id"] == "cent_ospl14"
assert driver.calls[0][1]["name_norm"] == "osspl14"
async def test_entity_miss_returns_none():
driver = FakeDriver({"name_norm: $name_norm": []})
service = make_service(driver)
assert await service.entity("NoSuchGene") is None
# ---------- neighborhood ----------
async def test_neighborhood_depth1_shape():
driver = FakeDriver(
{
COUNTS_SCRIPT: [],
INDEXES_SCRIPT: [],
"name_norm: $name_norm": [{"canonical_id": "cent_ospl14"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_ospl14", "OsSPL14", "gene"), # 重复,应去重
],
"edges": [
edge_row("cent_ospl14", "cent_panicle", "expressed_in"),
],
}
],
}
)
service = make_service(driver)
result = await service.neighborhood("OsSPL14", depth=1, limit=50)
assert result["center"] == "cent_ospl14"
assert result["center_name"] == "OsSPL14"
assert result["depth"] == 1
assert result["nodes"] == [
{"id": "cent_ospl14", "label": "OsSPL14", "type": "gene"},
{"id": "cent_panicle", "label": "panicle", "type": "experimental_material"},
]
assert len(result["edges"]) == 1
edge = result["edges"][0]
assert edge["source"] == "cent_ospl14"
assert edge["target"] == "cent_panicle"
assert edge["relation"] == "expressed_in"
assert edge["label"] == "expressed_in"
assert edge["assertion_id"] == "cass_cent_ospl14_cent_panicle"
assert edge["paper_count"] == 3
assert edge["support_count"] == 2
assert edge["title"] == "Paper"
assert edge["doi"] == "10.1/x"
assert edge["pmid"] == "1"
assert edge["chain_id"] is None and edge["hop"] is None
# limit 已下压进 Cypher 参数
params = driver.calls[-1][1]
assert params["max_nodes"] == 50 and params["max_edges"] == 50
# depth=1 绝不能执行第二跳(两跳实测约 13s,不默认开启)
for cypher, _ in driver.calls:
assert "r2:SRT2_CANONICAL_ASSERTS" not in cypher
async def test_neighborhood_depth2_uses_second_hop():
driver = FakeDriver(
{
COUNTS_SCRIPT: [],
INDEXES_SCRIPT: [],
"canonical_id: $id": [{"canonical_id": "cent_ospl14"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_branching", "panicle branching", "process"),
],
"edges": [edge_row("cent_ospl14", "cent_panicle", "expressed_in")],
}
],
"OPTIONAL MATCH (n1)-[r2:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_mir156", "miR156", "sequence_feature"),
],
"edges": [
edge_row("cent_ospl14", "cent_panicle", "expressed_in"),
edge_row("cent_panicle", "cent_mir156", "regulated_by"),
],
}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_ospl14", depth=2, limit=50)
assert result["depth"] == 2
# 一跳 + 二跳节点合并去重
ids = [n["id"] for n in result["nodes"]]
assert "cent_mir156" in ids
assert len(ids) == len(set(ids))
# 二跳边也出现(最长匹配选中的是 r2 脚本)
relations = {e["relation"] for e in result["edges"]}
assert "regulated_by" in relations
# 确认深度 2 时执行了带 r2 的 Cypher
assert any("r2:SRT2_CANONICAL_ASSERTS" in c for c, _ in driver.calls)
async def test_neighborhood_isolated_node_keeps_center():
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_lonely"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{"start": node_row("cent_lonely", "lonely gene", "gene"),
"nodes": [], "edges": []}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_lonely", depth=1, limit=50)
assert result["center"] == "cent_lonely"
assert result["nodes"] == [
{"id": "cent_lonely", "label": "lonely gene", "type": "gene"}
]
assert result["edges"] == []
async def test_neighborhood_miss_returns_empty_center():
driver = FakeDriver({"name_norm: $name_norm": []})
service = make_service(driver)
result = await service.neighborhood("NoSuch", depth=1, limit=50)
assert result["center"] is None
assert result["nodes"] == [] and result["edges"] == []
async def test_neighborhood_clamps_depth_and_limit():
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_x"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{"start": node_row("cent_x", "x", "gene"), "nodes": [], "edges": []}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_x", depth=5, limit=9999)
assert result["depth"] == 1
params = driver.calls[-1][1]
assert params["max_nodes"] == 200
# ---------- 超时与驱动错误 ----------
async def test_query_timeout_raises_unavailable(monkeypatch):
import app.services.neo4j as neo4j_mod
# 调短查询超时以在单测内触发 asyncio.wait_for
monkeypatch.setattr(neo4j_mod, "QUERY_TIMEOUT", 0.05)
slow = FakeDriver(
{"name_norm: $name_norm": []},
delays={"name_norm: $name_norm": 0.5},
)
service = make_service(slow)
with pytest.raises(Neo4jUnavailable):
await service.entity("OsSPL14")
async def test_driver_error_raises_unavailable():
from neo4j import exceptions as neo4j_exc
failing = FakeDriver(
{"name_norm: $name_norm": []},
errors={"name_norm: $name_norm": neo4j_exc.ServiceUnavailable("boom")},
)
service = make_service(failing)
with pytest.raises(Neo4jUnavailable):
await service.entity("OsSPL14")
async def test_close_closes_all_drivers():
driver = FakeDriver({})
service = make_service(driver)
await service.close()
assert driver.closed is True