Files
boltage/tests/smoke_test.py
T
Bart BroereandClaude Opus 5.5 47b8f5f746 Add peo4c: neo4j-browser on PostgreSQL + Apache AGE
A single container image with:
- neo4j-browser built from source and served on :7474
- PostgreSQL with Apache AGE (based on apache/age)
- a Python bridge speaking Bolt 4.4/5.0-5.4 over TCP and WebSocket on
  :7687 that runs Cypher through AGE's cypher() function, emulates the
  Neo4j procedures neo4j-browser relies on, and maps databases to AGE
  graphs

Includes workarounds for AGE limitations (parameters are inlined as
literals, `id(x) IN [...]` and the browser's expand-node query are
rewritten) and an end-to-end smoke test using the Neo4j Python driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:46:20 +02:00

122 lines
5.2 KiB
Python

"""End-to-end smoke test for a running peo4c bridge, using the official Neo4j driver.
Usage: python tests/smoke_test.py [bolt-uri] [user] [password]
Requires: pip install neo4j
"""
import sys
import neo4j
from neo4j.graph import Node, Path, Relationship
uri = sys.argv[1] if len(sys.argv) > 1 else "neo4j://localhost:7687"
user = sys.argv[2] if len(sys.argv) > 2 else "postgres"
password = sys.argv[3] if len(sys.argv) > 3 else "postgres"
GRAPH = "peo4c_smoke"
def check(label, condition):
print(("ok " if condition else "FAIL ") + label)
if not condition:
check.failed = True
check.failed = False
with neo4j.GraphDatabase.driver(uri, auth=(user, password)) as driver:
driver.verify_connectivity()
with driver.session(database="system") as s:
s.run(f"DROP DATABASE {GRAPH} IF EXISTS").consume()
s.run(f"CREATE DATABASE {GRAPH}").consume()
names = [r["name"] for r in s.run("SHOW DATABASES")]
check("SHOW DATABASES lists system and new graph", "system" in names and GRAPH in names)
comp = s.run("CALL dbms.components() YIELD name, versions, edition").single()
check("dbms.components", comp["name"] == "Neo4j Kernel")
with driver.session(database=GRAPH) as s:
s.run("CREATE (a:Person {name: $a, age: 30})-[:KNOWS {since: 2020}]->(b:Person {name: $b})",
a="Ann", b="Bob's").consume()
s.run("CREATE (:City {name: 'Utrecht', pop: 1.5})").consume()
rec = s.run("MATCH (n:Person) WHERE n.name = $name RETURN n, n.age AS age", name="Ann").single()
check("node returned as Node", isinstance(rec["n"], Node) and rec["n"]["name"] == "Ann")
check("labels mapped", set(rec["n"].labels) == {"Person"})
check("alias column", rec["age"] == 30)
rec = s.run("MATCH p=(a)-[r:KNOWS]->(b) RETURN p, r, b.name").single()
check("path returned as Path", isinstance(rec["p"], Path) and len(rec["p"]) == 1)
check("relationship returned", isinstance(rec["r"], Relationship) and rec["r"].type == "KNOWS")
check("unaliased column name", rec.keys() == ["p", "r", "b.name"] and rec["b.name"] == "Bob's")
ids = [r["id"] for r in s.run("MATCH (n) RETURN id(n) AS id")]
rels = list(s.run("MATCH (a)-[r]->(b) WHERE id(a) IN $existingNodeIds AND id(b) IN $newNodeIds "
"RETURN r;", existingNodeIds=ids, newNodeIds=ids))
check("browser relationship autocomplete query", len(rels) == 1)
ann = s.run("MATCH (n {name: 'Ann'}) RETURN id(n) AS id").single()["id"]
expand = list(s.run(f"""MATCH (a) WHERE id(a) = {ann}
WITH a, size([(a)--() | 1]) AS allNeighboursCount
MATCH path = (a)--(o) WHERE NOT id(o) IN []
RETURN path, allNeighboursCount
ORDER BY id(o)
LIMIT 100"""))
check("browser expand-node query", len(expand) == 1 and expand[0]["allNeighboursCount"] == 1)
meta = list(s.run("""
CALL db.labels() YIELD label
RETURN {name:'labels', data:COLLECT(label)[..1000]} AS result
UNION ALL
CALL db.relationshipTypes() YIELD relationshipType
RETURN {name:'relationshipTypes', data:COLLECT(relationshipType)[..1000]} AS result
UNION ALL
CALL db.propertyKeys() YIELD propertyKey
RETURN {name:'propertyKeys', data:COLLECT(propertyKey)[..1000]} AS result
"""))
check("browser metadata query", meta[0]["result"]["data"] == ["City", "Person"]
and meta[1]["result"]["data"] == ["KNOWS"] and "name" in meta[2]["result"]["data"])
counts = list(s.run("""
MATCH () RETURN { name:'nodes', data:count(*) } AS result
UNION ALL
MATCH ()-[]->() RETURN { name:'relationships', data: count(*)} AS result
"""))
check("browser count query", [c["result"]["data"] for c in counts] == [3, 1])
schema = s.run("CALL db.schema.visualization()").single()
check("schema visualization", len(schema["nodes"]) == 2 and len(schema["relationships"]) == 1)
rows = list(s.run("UNWIND range(1, 2500) AS i RETURN i"))
check("large result over several PULLs", len(rows) == 2500 and rows[-1]["i"] == 2500)
check("string escaping roundtrip",
s.run("RETURN $v AS v", v="a'b\\c\n\"d\" $x").single()["v"] == "a'b\\c\n\"d\" $x")
check("map/list params",
s.run("RETURN $m AS m", m={"k": [1, 2.5, None, True]}).single()["m"] == {"k": [1, 2.5, None, True]})
try:
s.run("MATCH (n RETURN n").consume()
check("syntax error reported", False)
except neo4j.exceptions.CypherSyntaxError:
check("syntax error reported", True)
# The session must still be usable after a failure.
check("recovers after error", s.run("RETURN 1 AS x").single()["x"] == 1)
with driver.session(database=GRAPH) as s:
with s.begin_transaction() as tx:
tx.run("CREATE (:Temp {n: 1})").consume()
tx.rollback()
check("explicit rollback", s.run("MATCH (n:Temp) RETURN count(n) AS c").single()["c"] == 0)
def work(tx):
tx.run("CREATE (:Temp {n: 2})").consume()
return tx.run("MATCH (n:Temp) RETURN count(n) AS c").single()["c"]
check("managed write transaction", s.execute_write(work) == 1)
with driver.session(database="system") as s:
s.run(f"DROP DATABASE {GRAPH}").consume()
sys.exit(1 if check.failed else 0)