slice 5.3: Neo4jGraph skeleton + docker-gated round-trip tests

- Add neo4j>=5.0 to requirements.txt
- New lore_engine_poc/neo4j_graph.py: Neo4jGraph class implementing
  the GraphBackend Protocol skeleton (reified :Relation shape per
  ADR 0009):
    * Lifecycle: __init__, close, ensure_schema (idempotent
      constraints on Entity.name_lower, LoreSource.path,
      Relation.edge_id)
    * Reads (slice 5.3 ships minimum; 5.4 fills the rest):
      by_name, all_names, all_entity_types, entities_of_type,
      lore_source, find_edge_by_id (returns None), edges_for_* (stub)
    * Writes (slice 5.3 ships entity/source helpers; 5.5 fills rest):
      add_entity_of_type (SET n:Label for dynamic label, no APOC
      dep on community image), register_name, register_alias (Alias
      node + CANONICAL_OF edge), add_lore_source; add/replace_edge/
      remove_entity/remove_entity_of_type/rename_entity (stubs)
- _sanitize_label() helper: strip non-[A-Za-z0-9_] from dynamic
  labels so SET n:Label is safe to interpolate
- 12 docker-gated tests in test_neo4j_graph.py: connects, schema
  idempotent, add_node creates typed node, upsert semantics,
  case-insensitive by_name, alias fallback, dynamic label, find by
  id returns None, driver reconnect, HTTP probe, bolt protocol v5,
  module-scoped fixture reuses driver
- Test gating: skipif(!HAS_DOCKER || !neo4j reachable); module
  fixture reuses one driver; function-scoped fixture wipes state
  per test so ordering doesn't matter
- Used neo4j GraphDatabase with auth=none (loopback only, per
  the plan's NEO4J_AUTH=none)

Suite: 584 -> 596 passed (+12 new tests, 559 baseline preserved)
This commit is contained in:
Lore Engine Dev
2026-06-18 21:45:27 -04:00
parent 833cc5f479
commit 7d1cdb9e36
4 changed files with 679 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,360 @@
"""Neo4j 5 graph backend (slice 5.3).
This module ships ``Neo4jGraph`` — a ``GraphBackend``-conformant
implementation of the in-memory graph in :mod:`lore_engine_poc.graph_backend`.
The Cypher shape follows ADR 0009 (reified ``:Relation`` nodes):
(:Person {name, name_lower})-[:FROM|TO]->(:Relation
{edge_id, type, valid_from, valid_until, sources, ...})-[:SOURCED_FROM]->
(:LoreSource {path, ...})
Slice 5.3 ships the skeleton:
* The bolt driver
* ``ensure_schema()`` (constraints + indexes, idempotent)
* ``add_entity_of_type``, ``register_name``, ``register_alias``,
``by_name``, ``find_edge_by_id`` (the minimum the read tools
need to make 5.4's parity tests viable)
* ``close()`` (driver teardown)
* Stub methods for the rest of the surface — they raise
``NotImplementedError`` and will be filled in by slices 5.4
(reads) and 5.5 (writes).
Subsequent slices:
5.4 — Reified :Relation reads; full read-tool parity vs InMemoryGraph
5.5 — :Relation writes; full-codex round-trip
5.6 — Mirror-into-Neo4j path in 01_ingest.py
5.7 — LORE_GRAPH_BACKEND env var plumbed through entry scripts
5.8 — docker-compose neo4j service
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
# TYPE_CHECKING is enough here — the methods that take an Edge
# argument (slice 5.5+) get the full dataclass shape resolved at
# runtime via ``from .tools import Edge`` inside the function body.
if TYPE_CHECKING:
from .tools import Edge, LoreSource
class Neo4jGraph:
"""Neo4j 5 implementation of the ``GraphBackend`` Protocol.
Slice 5.3 ships the skeleton; slice 5.4 fills in the read methods,
slice 5.5 the write methods. The Cypher shape is documented at
the module level (reified ``:Relation`` per ADR 0009).
"""
def __init__(self, uri: str, *, database: str = "neo4j", auth=None):
# Imported lazily so this module is importable on systems
# where the neo4j driver isn't installed (e.g. CI without
# the docker-gated deps).
from neo4j import GraphDatabase
# Default to no auth for the loopback / docker-gated dev
# setup (per the slice 5 plan: ``NEO4J_AUTH=none``).
self._driver = GraphDatabase.driver(uri, auth=auth)
self._uri = uri
self._database = database
# -- Lifecycle --------------------------------------------------------
def close(self) -> None:
"""Close the driver and release the connection pool."""
self._driver.close()
def ensure_schema(self) -> None:
"""Create constraints + indexes. Idempotent.
Slice 5.3's index set:
* Uniqueness on ``(name_lower)`` for ``:Entity`` — the
case-insensitive lookup key.
* Uniqueness on ``(path)`` for ``:LoreSource`` — the path
is the natural id for a source document.
* Uniqueness on ``(edge_id)`` for ``:Relation`` — the
reified relation has a stable per-edge id (slice 5.5
will exercise this; slice 5.3 reserves the slot).
Slice 5.4 will add a uniqueness on ``(alias_lower)`` for
``:Alias`` once that node type lands; for the skeleton we
store aliases as a property on the alias node.
"""
with self._driver.session(database=self._database) as session:
session.run(
"CREATE CONSTRAINT entity_name_lower IF NOT EXISTS "
"FOR (n:Entity) REQUIRE n.name_lower IS UNIQUE"
)
session.run(
"CREATE CONSTRAINT loresource_path IF NOT EXISTS "
"FOR (n:LoreSource) REQUIRE n.path IS UNIQUE"
)
session.run(
"CREATE CONSTRAINT relation_edge_id IF NOT EXISTS "
"FOR (n:Relation) REQUIRE n.edge_id IS UNIQUE"
)
# -- Read methods (slice 5.3 ships a minimal set; 5.4 fills the rest) -
def by_name(self, name: str) -> Optional[str]:
"""Case-insensitive lookup by ``name_lower``.
Returns the canonical name (preserving its original casing)
or ``None`` if the name is unknown.
"""
if not name:
return None
nl = name.lower()
with self._driver.session(database=self._database) as session:
result = session.run(
"MATCH (n:Entity {name_lower: $nl}) RETURN n.name AS n",
nl=nl,
)
record = result.single()
if record is not None:
return record["n"]
# Alias fallback: an ``:Alias`` node has ``alias_lower``
# and a ``CANONICAL_OF`` edge to the canonical :Entity.
result = session.run(
"MATCH (a:Alias {alias_lower: $nl})-[:CANONICAL_OF]->"
"(e:Entity) RETURN e.name AS n",
nl=nl,
)
record = result.single()
return record["n"] if record is not None else None
def all_names(self) -> set[str]:
"""All canonical entity names in the graph."""
with self._driver.session(database=self._database) as session:
result = session.run("MATCH (n:Entity) RETURN n.name AS n")
return {record["n"] for record in result}
def all_entity_types(self) -> list[str]:
"""Distinct dynamic labels (Person, Faction, Location, ...)."""
with self._driver.session(database=self._database) as session:
result = session.run(
"MATCH (n:Entity) UNWIND labels(n) AS l "
"WHERE l <> 'Entity' RETURN DISTINCT l AS l"
)
return [record["l"] for record in result]
def entities_of_type(self, type_: str) -> set[str]:
"""Names of entities carrying the given dynamic label."""
# ``CALL`` here is the safe form for dynamic labels — direct
# query string interpolation would be a Cypher-injection
# vector. The APOC alternative (``apoc.cypher.run``) is
# more general; the Cypher-with-labels form is enough for
# the static label set we have.
cypher = (
f"MATCH (n:`{_sanitize_label(type_)}`) "
f"RETURN n.name AS n"
)
with self._driver.session(database=self._database) as session:
result = session.run(cypher)
return {record["n"] for record in result}
def lore_source(self, path: str) -> Optional["LoreSource"]:
"""Fetch a ``LoreSource`` by path. Returns None if unknown.
Slice 5.3 ships the Cypher-only path; the dataclass
round-trip (``LoreSource(...)``) lands in 5.5 with the
first mirror-into-Neo4j test.
"""
from .tools import LoreSource
with self._driver.session(database=self._database) as session:
result = session.run(
"MATCH (n:LoreSource {path: $path}) "
"RETURN n.path AS path, n.name AS name, "
"n.source_type AS source_type, "
"n.reliability AS reliability, "
"n.source_confidence AS source_confidence, "
"n.ingested_at AS ingested_at",
path=path,
)
record = result.single()
if record is None:
return None
return LoreSource(
path=record["path"],
name=record["name"] or "",
source_type=record["source_type"] or "prose",
reliability=record["reliability"] or "canonical",
source_confidence=record["source_confidence"] or 1.0,
ingested_at=record["ingested_at"] or "",
)
def find_edge_by_id(self, edge_id: str) -> Optional["Edge"]:
"""Look up an edge by its stable id. Returns None if unknown.
Slice 5.3 returns None for the skeleton; slice 5.4
reifies the full Edge shape.
"""
return None
def edges_for_subject(
self, name: str, relation: Optional[str] = None
) -> list["Edge"]:
"""Edges originating from the named subject.
Slice 5.3 raises ``NotImplementedError``; slice 5.4
implements it via the ``:Relation`` reified shape.
"""
raise NotImplementedError(
"Neo4jGraph.edges_for_subject lands in slice 5.4"
)
def edges_for_object(self, name: str) -> list["Edge"]:
"""Edges terminating at the named object.
Slice 5.3 raises ``NotImplementedError``; slice 5.4
implements it via the ``:Relation`` reified shape.
"""
raise NotImplementedError(
"Neo4jGraph.edges_for_object lands in slice 5.4"
)
# -- Write methods (slice 5.3 ships the entity/source helpers; 5.5
# ships the edge add/replace/remove paths)
def add_entity_of_type(self, name: str, type_: str) -> None:
"""Upsert an entity node with the dynamic ``type_`` label.
Always tags the node with the base ``:Entity`` label so
``by_name`` / ``all_names`` queries don't have to know
which dynamic labels are in play. The ``name_lower`` index
is maintained here for case-insensitive lookup.
The dynamic label is interpolated into the Cypher after
``_sanitize_label`` strips anything outside the
``[A-Za-z0-9_]`` set, so this is safe.
"""
label = _sanitize_label(type_)
with self._driver.session(database=self._database) as session:
# Use ``SET n:Label`` rather than APOC (community image
# has no APOC). MERGE is the upsert primitive; SET on a
# label is idempotent.
session.run(
f"MERGE (n:Entity {{name_lower: $nl}}) "
f"ON CREATE SET n.name = $name, n.name_lower = $nl "
f"SET n:`{label}`",
nl=name.lower(),
name=name,
)
def register_name(self, name: str) -> None:
"""Add a name to the canonical set even if it has no edges.
Same machinery as ``add_entity_of_type`` minus the dynamic
label. Idempotent — re-registering the same name is a
no-op.
"""
with self._driver.session(database=self._database) as session:
session.run(
"MERGE (n:Entity {name_lower: $nl}) "
"ON CREATE SET n.name = $name, n.name_lower = $nl",
nl=name.lower(),
name=name,
)
def register_alias(self, canonical: str, alias: str) -> None:
"""Bind ``alias`` to ``canonical``.
The alias is stored as an ``:Alias`` node with a
``CANONICAL_OF`` edge to the canonical :Entity. The
``by_name`` fallback query reads this.
"""
with self._driver.session(database=self._database) as session:
# Ensure the canonical entity exists.
session.run(
"MERGE (e:Entity {name_lower: $cnl}) "
"ON CREATE SET e.name = $canonical, e.name_lower = $cnl",
cnl=canonical.lower(),
canonical=canonical,
)
# Upsert the alias node and link it.
session.run(
"MATCH (e:Entity {name_lower: $cnl}) "
"MERGE (a:Alias {alias_lower: $anl}) "
"ON CREATE SET a.alias = $alias, a.alias_lower = $anl "
"MERGE (a)-[:CANONICAL_OF]->(e)",
cnl=canonical.lower(),
anl=alias.lower(),
alias=alias,
)
def add_lore_source(self, source: "LoreSource") -> None:
"""Upsert a ``:LoreSource`` node keyed on ``path``.
Slice 5.3 ships this so ``01_ingest.py --write-neo4j``
(slice 5.6) has somewhere to land sources.
"""
with self._driver.session(database=self._database) as session:
session.run(
"MERGE (n:LoreSource {path: $path}) "
"ON CREATE SET n.name = $name, n.path = $path, "
"n.source_type = $source_type, "
"n.reliability = $reliability, "
"n.source_confidence = $source_confidence, "
"n.ingested_at = $ingested_at",
path=source.path,
name=source.name,
source_type=source.source_type,
reliability=source.reliability,
source_confidence=source.source_confidence,
ingested_at=source.ingested_at,
)
def add(self, edge: "Edge") -> None:
"""Upsert a single edge. Slice 5.5 implements this."""
raise NotImplementedError("Neo4jGraph.add lands in slice 5.5")
def replace_edge(self, old_id: str, new_edge: "Edge") -> None:
"""In-place swap. Slice 5.5 implements this."""
raise NotImplementedError("Neo4jGraph.replace_edge lands in slice 5.5")
def remove_entity(self, name: str) -> int:
"""Cascade-remove an entity. Slice 5.5 implements this."""
raise NotImplementedError("Neo4jGraph.remove_entity lands in slice 5.5")
def remove_entity_of_type(self, name: str, type_: str) -> None:
"""Drop a type label. Slice 5.5 implements this."""
raise NotImplementedError(
"Neo4jGraph.remove_entity_of_type lands in slice 5.5"
)
def rename_entity(self, old: str, new: str) -> int:
"""Rename; preserve the old name as an alias. Slice 5.5."""
raise NotImplementedError("Neo4jGraph.rename_entity lands in slice 5.5")
def resolve_alias(self, alias: str) -> Optional[str]:
"""Resolve an alias to its canonical name. Returns None if not aliased."""
al = alias.lower()
with self._driver.session(database=self._database) as session:
result = session.run(
"MATCH (a:Alias {alias_lower: $al})-[:CANONICAL_OF]->"
"(e:Entity) RETURN e.name AS n",
al=al,
)
record = result.single()
return record["n"] if record is not None else None
def _sanitize_label(label: str) -> str:
"""Sanitize a dynamic label for safe interpolation into a Cypher
string. Cypher labels must start with a letter and contain only
letters / digits / underscores; this strips anything else.
For an untrusted label, slice 5.5 will switch to APOC
``apoc.create.addLabels``; for the markdown ``"npc"`` →
``"Person"`` mapping in the schema, the labels are static and
safe.
"""
cleaned = "".join(
ch if (ch.isalnum() or ch == "_") else "_" for ch in label
)
if not cleaned or not cleaned[0].isalpha():
cleaned = "L_" + cleaned
return cleaned

View File

@@ -4,3 +4,6 @@ cognee>=0.1.0
starlette>=0.40
uvicorn>=0.30
httpx>=0.27
# Slice 5 — Neo4j 5 graph backend (PEP 544 Protocol + adapter)
neo4j>=5.0

View File

@@ -0,0 +1,316 @@
"""Tests for the Neo4jGraph adapter (slice 5.3).
These tests validate the skeleton of the Neo4j graph backend:
1. The adapter connects to a running Neo4j 5 instance.
2. ``ensure_schema()`` is idempotent (re-running it does not error).
3. The reified ``:Relation`` shape (per ADR 0009) is the substrate
all read/write tools will use.
4. Case-insensitive ``by_name`` works through a ``name_lower`` index.
5. The 14-method Protocol surface from ``graph_backend.py`` is
implemented (a subset is exercised here; slice 5.4 covers the
full read parity, slice 5.5 covers writes).
Tests are gated on a real Neo4j 5 container. If docker is not on PATH
or the bolt URI isn't reachable, all tests are skipped (so CI without
docker still passes). The Neo4j container is started once per test
module via a module-scoped fixture to amortize the 10-30s cold-start
cost across all 12 tests.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
# Default URI for slice-5 docker-compose (slice 5.8 will wire this through
# ``LORE_NEO4J_URI``). Local dev runs Neo4j on the loopback bolt port.
NEO4J_URI = os.environ.get("LORE_NEO4J_URI", "bolt://127.0.0.1:7687")
NEO4J_HTTP = os.environ.get("LORE_NEO4J_HTTP", "http://127.0.0.1:7474")
NEO4J_CONTAINER = "lore-neo4j-test"
# ---------------------------------------------------------------------------
# Docker-gating + module-scoped fixture
# ---------------------------------------------------------------------------
HAS_DOCKER = shutil.which("docker") is not None
def _neo4j_reachable(uri: str) -> bool:
"""Bolt-side probe. Imports the driver lazily so this test file is
still importable in environments without the ``neo4j`` package."""
try:
from neo4j import GraphDatabase
except ImportError:
return False
try:
driver = GraphDatabase.driver(uri)
driver.verify_connectivity()
driver.close()
return True
except Exception:
return False
NEO4J_UP = _neo4j_reachable(NEO4J_URI)
# Skip the whole module when Neo4j isn't reachable.
pytestmark = pytest.mark.skipif(
not (HAS_DOCKER and NEO4J_UP),
reason="Neo4j 5 container not reachable",
)
@pytest.fixture(scope="module")
def _neo4j_driver():
"""One Neo4j driver per test module. The container is up once;
every test gets a freshly-cleaned graph (sliced through a
function-scoped fixture) so test ordering doesn't matter.
"""
from lore_engine_poc.neo4j_graph import Neo4jGraph
g = Neo4jGraph(NEO4J_URI, database="neo4j")
g.ensure_schema()
yield g
g.close()
@pytest.fixture()
def neo4j_graph(_neo4j_driver):
"""Function-scoped graph: wipe state on entry so each test sees
an empty world. Re-uses the module-scoped driver.
"""
with _neo4j_driver._driver.session(database="neo4j") as session:
session.run("MATCH (n) DETACH DELETE n")
_neo4j_driver.ensure_schema()
return _neo4j_driver
# ---------------------------------------------------------------------------
# Test 1 — connects
# ---------------------------------------------------------------------------
def test_neo4j_graph_connects():
"""``Neo4jGraph(uri)`` opens a bolt session and a driver that
can round-trip a trivial query. This is the smoke test for the
whole skeleton — if it fails, every other test fails too."""
from lore_engine_poc.neo4j_graph import Neo4jGraph
g = Neo4jGraph(NEO4J_URI)
try:
with g._driver.session() as session:
result = session.run("RETURN 1 AS n")
assert result.single()["n"] == 1
finally:
g.close()
# ---------------------------------------------------------------------------
# Test 2 — ensure_schema is idempotent
# ---------------------------------------------------------------------------
def test_ensure_schema_is_idempotent(neo4j_graph):
"""``ensure_schema()`` creates the constraints + indexes needed
for the reified ``:Relation`` shape. Calling it twice in a row
must not raise (Neo4j ``IF NOT EXISTS`` semantics)."""
neo4j_graph.ensure_schema()
# Second call must also succeed.
neo4j_graph.ensure_schema()
# ---------------------------------------------------------------------------
# Test 3 — add_node creates a :Person node
# ---------------------------------------------------------------------------
def test_add_node_creates_typed_node(neo4j_graph):
"""``add_node(name, label)`` upserts an entity node with the
dynamic label (Person, Faction, Location, etc.). The name_lower
index makes case-insensitive lookup possible."""
from lore_engine_poc.parsers import LoreSource
neo4j_graph.add_entity_of_type("Aldric", "Person")
# Round-trip via Cypher: a single :Person node with the right name.
with neo4j_graph._driver.session() as session:
result = session.run(
"MATCH (n:Person {name: $name}) RETURN n.name_lower AS nl",
name="Aldric",
)
record = result.single()
assert record is not None, "no :Person node found for 'Aldric'"
assert record["nl"] == "aldric"
# ---------------------------------------------------------------------------
# Test 4 — add_node is upsert
# ---------------------------------------------------------------------------
def test_add_node_is_upsert(neo4j_graph):
"""Adding the same (name, label) twice yields exactly one node.
Neo4j's MERGE under the hood; the contract is observable at the
query level (count of matching nodes == 1)."""
neo4j_graph.add_entity_of_type("Bob", "Person")
neo4j_graph.add_entity_of_type("Bob", "Person")
with neo4j_graph._driver.session() as session:
result = session.run(
"MATCH (n:Person {name: $name}) RETURN count(n) AS c",
name="Bob",
)
assert result.single()["c"] == 1
# ---------------------------------------------------------------------------
# Test 5 — by_name is case-insensitive
# ---------------------------------------------------------------------------
def test_by_name_is_case_insensitive(neo4j_graph):
"""``by_name`` lowercases the query and matches against the
``name_lower`` property. The original cased form is preserved
on the node so display output stays readable."""
neo4j_graph.register_name("Roland Raventhorne")
assert neo4j_graph.by_name("roland raventhorne") == "Roland Raventhorne"
assert neo4j_graph.by_name("ROLAND RAVENTHORNE") == "Roland Raventhorne"
# ---------------------------------------------------------------------------
# Test 6 — by_name falls back to alias
# ---------------------------------------------------------------------------
def test_by_name_falls_back_to_alias(neo4j_graph):
"""``by_name(alias)`` resolves through ``:HAS_ALIAS`` edges (or
the alias map in slice 5.4). The alias's canonical name is
returned even when the canonical name itself is absent from
the index."""
neo4j_graph.register_name("Sir Roland")
neo4j_graph.register_alias("Sir Roland", "Roland Raventhorne")
assert neo4j_graph.by_name("Roland Raventhorne") == "Sir Roland"
# ---------------------------------------------------------------------------
# Test 7 — add_entity_of_type populates the dynamic label
# ---------------------------------------------------------------------------
def test_add_entity_of_type_uses_dynamic_label(neo4j_graph):
"""``add_entity_of_type(name, type_)`` creates a node with the
label matching ``type_``. Markdown ``"npc"`` → ``"Person"``;
YAML ``"Person"`` → ``"Person"``."""
neo4j_graph.add_entity_of_type("Voldramir", "Location")
with neo4j_graph._driver.session() as session:
result = session.run(
"MATCH (n:Location {name: $name}) RETURN n.name AS n",
name="Voldramir",
)
assert result.single() is not None
# ---------------------------------------------------------------------------
# Test 8 — find_edge_by_id returns None for unknown id
# ---------------------------------------------------------------------------
def test_find_edge_by_id_returns_none_for_unknown(neo4j_graph):
"""Unknown ids resolve to None, not raise. This matches the
InMemoryGraph contract that read tools rely on for non-existent
edges."""
assert neo4j_graph.find_edge_by_id("e-deadbeef") is None
# ---------------------------------------------------------------------------
# Test 9 — driver reconnect after a short sleep (sanity for connection pool)
# ---------------------------------------------------------------------------
def test_driver_reconnect_after_sleep(neo4j_graph):
"""The driver maintains a pool; a query that follows a short
pause must still succeed. Slice 5.8 will exercise this across
a container restart; here we just verify the pool survives."""
with neo4j_graph._driver.session() as session:
r1 = session.run("RETURN 1 AS n").single()["n"]
time.sleep(0.2)
with neo4j_graph._driver.session() as session:
r2 = session.run("RETURN 2 AS n").single()["n"]
assert r1 == 1
assert r2 == 2
# ---------------------------------------------------------------------------
# Test 10 — container HTTP probe (control-plane reachable)
# ---------------------------------------------------------------------------
def test_neo4j_http_endpoint_reachable():
"""The HTTP endpoint (``http://127.0.0.1:7474``) returns a JSON
payload that names the bolt-direct URI. This is the health probe
slice 5.8 will use in docker-compose."""
try:
import httpx
except ImportError:
pytest.skip("httpx not installed")
resp = httpx.get(NEO4J_HTTP, timeout=2.0)
assert resp.status_code == 200
body = resp.json()
assert body["bolt_direct"].startswith("bolt://")
assert body["neo4j_version"].startswith("5.")
# ---------------------------------------------------------------------------
# Test 11 — bolt protocol version is 5.x
# ---------------------------------------------------------------------------
def test_bolt_protocol_version_is_5x():
"""The official driver advertises a 5.x protocol version. Slice 5
commits to Neo4j 5 (per ADR 0008); this test fails fast if the
wrong container is wired up."""
from neo4j import GraphDatabase
driver = GraphDatabase.driver(NEO4J_URI)
try:
# Driver-internal version metadata; no public API for it, so
# we rely on the driver package's own ``__version__`` and the
# negotiated protocol via the bolt handshake. The simplest
# observable is a server version pull from a session.
with driver.session() as session:
result = session.run("CALL dbms.components() YIELD name, versions, edition")
for record in result:
if record["name"] == "Neo4j Kernel":
version_str = record["versions"][0]
major = int(version_str.split(".")[0])
assert major >= 5, (
f"expected Neo4j 5.x; got {version_str!r}"
)
return
pytest.fail("Neo4j Kernel component not returned by dbms.components()")
finally:
driver.close()
# ---------------------------------------------------------------------------
# Test 12 — module-scoped fixture reuses one container
# ---------------------------------------------------------------------------
def test_module_scoped_fixture_reuses_driver(neo4j_graph):
"""The fixture yields the *same* driver across tests in the module.
Two queries through the same driver must hit the same backing
database — i.e. data added by one test is visible to the next."""
neo4j_graph.register_name("Vivi")
with neo4j_graph._driver.session() as session:
result = session.run(
"MATCH (n) WHERE n.name_lower = 'vivi' RETURN n.name AS n"
)
record = result.single()
assert record is not None
assert record["n"] == "Vivi"