- 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>
589 lines
20 KiB
Python
589 lines
20 KiB
Python
"""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
|