module Main where import Control.Exception (SomeException, fromException, try) import Control.Monad.IO.Class (liftIO) import Test.Sandwich import Data.Default (def) import qualified Data.HashMap.Lazy as H import qualified Data.Text as T import qualified Data.Vector as V import qualified Validation as Val import System.Environment (lookupEnv) import Data.Int (Int64) import Data.IORef (newIORef, readIORef, writeIORef) import Database.Bolty import Database.Bolty.Connection (queryIO, queryPIO, queryIO') import Database.Bolty.Message.Response (SuccessPull(..)) import qualified Database.Bolty.Message.Response as Resp import Database.Bolty.Decode (DecodeError(..)) import Database.Bolty.Logging (QueryLog(..)) import Database.Bolty.Notification (Notification(..), Severity(..)) import Database.Bolty.Stats (QueryStats(..)) import Database.Bolty.Value.Type (Node(..), Relationship(..), Path(..) , Date(..), Time(..), LocalTime(..) , DateTime(..), DateTimeZoneId(..) , LocalDateTime(..), Duration(..) , Point2D(..), Point3D(..)) import Database.Bolty.Value.Helpers (supportsLogonLogoff, versionMajor, versionMinor) import Data.PackStream.Ps (Ps(..)) -- ================================================================ -- Test types -- ================================================================ -- | Example type used to test 'FromBolt' decoding. data PersonResult = PersonResult T.Text Int64 deriving (Show, Eq) instance FromBolt PersonResult where rowDecoder = PersonResult <$> field "name" Database.Bolty.text <*> field "age" int64 -- ================================================================ -- Config helpers -- ================================================================ -- | Neo4j 5 config: reads NEO4J_HOST/NEO4J_PORT, defaults to 127.0.0.1:7687 neo4j5Config :: IO Config neo4j5Config = do h <- maybe "127.0.0.1" T.pack <$> lookupEnv "NEO4J_HOST" p <- maybe 7687 read <$> lookupEnv "NEO4J_PORT" pure def { host = h , port = p , scheme = Basic "neo4j" "testpassword" , use_tls = False } -- | Neo4j 4.4 config: reads NEO4J_HOST/NEO4J_44_PORT, defaults to 127.0.0.1:7688 neo4j44Config :: IO Config neo4j44Config = do h <- maybe "127.0.0.1" T.pack <$> lookupEnv "NEO4J_HOST" p <- maybe 7688 read <$> lookupEnv "NEO4J_44_PORT" pure def { host = h , port = p , scheme = Basic "neo4j" "testpassword" , use_tls = False } -- | Config for the no-auth Neo4j instance (port 7689) noAuthConfig :: IO Config noAuthConfig = do h <- maybe "127.0.0.1" T.pack <$> lookupEnv "NEO4J_HOST" p <- maybe 7689 read <$> lookupEnv "NEO4J_NOAUTH_PORT" pure def { host = h , port = p , scheme = None , use_tls = False } -- | Validate a config or fail mkGetConfig :: IO Config -> IO ValidatedConfig mkGetConfig mkConfig = do cfg <- mkConfig case validateConfig cfg of Val.Failure errs -> fail $ "Config invalid: " <> show errs Val.Success vc -> pure vc -- | Connect, run action, close mkWithConn :: IO Config -> (Connection -> IO a) -> IO a mkWithConn mkConfig f = do cfg <- mkGetConfig mkConfig conn <- connect cfg result <- f conn close conn pure result -- ================================================================ -- Core test suite — runs against any Neo4j version -- ================================================================ coreTestSuite :: String -> IO Config -> TopSpec coreTestSuite label mkConfig = do let getConf = mkGetConfig mkConfig let wp :: (Connection -> IO a) -> IO a wp = mkWithConn mkConfig describe (label ++ " - Basic queries") $ do it "connects and disconnects" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg liftIO $ close pipe it "runs RETURN 1 and gets result" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN 1 AS n" V.length result `shouldBe` 1 let record = V.head result V.length record `shouldBe` 1 case asInt (V.head record) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure $ "Expected integer, got: " <> show (V.head record) it "runs RETURN with string" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN 'hello' AS greeting" V.length result `shouldBe` 1 case asText (V.head $ V.head result) of Just t -> t `shouldBe` "hello" Nothing -> expectationFailure $ "Expected text, got: " <> show (V.head $ V.head result) it "runs UNWIND to get multiple rows" $ do result <- liftIO $ wp $ \p -> queryIO p "UNWIND range(1, 5) AS n RETURN n" V.length result `shouldBe` 5 it "runs query with full metadata" $ do result <- liftIO $ wp $ \p -> queryIO' p "RETURN 42 AS answer" V.length (Resp.records result) `shouldBe` 1 describe (label ++ " - Parameterized queries") $ do it "passes integer parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $x AS n" (H.singleton "x" (PsInteger 42)) case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure $ "Expected 42, got: " <> show (V.head $ V.head result) it "passes string parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $name AS name" (H.singleton "name" (PsString "Neo")) case asText (V.head $ V.head result) of Just t -> t `shouldBe` "Neo" Nothing -> expectationFailure $ "Expected 'Neo'" it "passes boolean parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $b AS b" (H.singleton "b" (PsBoolean True)) case asBool (V.head $ V.head result) of Just b -> b `shouldBe` True Nothing -> expectationFailure "Expected True" it "passes null parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $x AS x" (H.singleton "x" PsNull) case asNull (V.head $ V.head result) of Just _ -> pure () Nothing -> expectationFailure "Expected null" it "passes float parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $x AS x" (H.singleton "x" (PsFloat 3.14)) case asFloat (V.head $ V.head result) of Just d -> d `shouldBe` 3.14 Nothing -> expectationFailure "Expected 3.14" it "passes multiple parameters" $ do result <- liftIO $ wp $ \p -> queryPIO p "RETURN $a + $b AS sum" (H.fromList [("a", PsInteger 10), ("b", PsInteger 32)]) case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" it "passes list parameter" $ do result <- liftIO $ wp $ \p -> queryPIO p "UNWIND $xs AS x RETURN x" (H.singleton "xs" (PsList $ V.fromList [PsInteger 1, PsInteger 2, PsInteger 3])) V.length result `shouldBe` 3 describe (label ++ " - Transactions") $ do it "withTransaction commits on success" $ do liftIO $ wp $ \p -> withTransaction p $ \conn -> do _ <- queryIO conn "CREATE (n:TestCommit {value: 1})" pure () result <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestCommit) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ wp $ \p -> queryIO p "MATCH (n:TestCommit) DELETE n" >> pure () it "withTransaction rolls back on error" $ do liftIO $ wp $ \p -> queryIO p "CREATE (n:TestRollback {value: 1})" >> pure () result :: Either SomeException () <- liftIO $ try $ wp $ \p -> withTransaction p $ \conn -> do _ <- queryIO conn "MATCH (n:TestRollback) DELETE n" _ <- queryIO conn "THIS IS NOT VALID CYPHER" pure () case result of Left _ -> pure () Right _ -> expectationFailure "Expected transaction to fail" count <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestRollback) RETURN count(n) AS c" case asInt (V.head $ V.head count) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected count 1" liftIO $ wp $ \p -> queryIO p "MATCH (n:TestRollback) DELETE n" >> pure () it "multiple queries in a transaction" $ do liftIO $ wp $ \p -> withTransaction p $ \conn -> do _ <- queryIO conn "CREATE (n:TestMulti {value: 1})" _ <- queryIO conn "CREATE (n:TestMulti {value: 2})" _ <- queryIO conn "CREATE (n:TestMulti {value: 3})" pure () result <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestMulti) RETURN n.value AS v ORDER BY v" V.length result `shouldBe` 3 liftIO $ wp $ \p -> queryIO p "MATCH (n:TestMulti) DELETE n" >> pure () it "query with parameters inside transaction" $ do liftIO $ wp $ \p -> withTransaction p $ \conn -> queryPIO conn "CREATE (n:TestTxParam {name: $name})" (H.singleton "name" (PsString "alice")) result <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestTxParam) RETURN n.name AS name" case asText (V.head $ V.head result) of Just t -> t `shouldBe` "alice" Nothing -> expectationFailure "Expected 'alice'" liftIO $ wp $ \p -> queryIO p "MATCH (n:TestTxParam) DELETE n" >> pure () describe (label ++ " - Graph type queries") $ do it "returns a Node" $ do result <- liftIO $ wp $ \p -> queryIO p "CREATE (n:TestGraphNode {name: 'Alice', age: 30}) RETURN n" V.length result `shouldBe` 1 case asNode (V.head $ V.head result) of Just Node{labels, properties} -> do V.toList labels `shouldContain` ["TestGraphNode"] case H.lookup "name" properties of Just (PsString n) -> n `shouldBe` "Alice" _ -> expectationFailure "Expected name property" Nothing -> expectationFailure $ "Expected BoltNode, got: " <> show (V.head $ V.head result) liftIO $ wp $ \p -> queryIO p "MATCH (n:TestGraphNode) DELETE n" >> pure () it "returns a Relationship" $ do liftIO $ wp $ \p -> queryIO p "CREATE (a:TestGraphRelA)-[r:KNOWS {since: 2020}]->(b:TestGraphRelB) RETURN r" result <- liftIO $ wp $ \p -> queryIO p "MATCH (a:TestGraphRelA)-[r:KNOWS]->(b:TestGraphRelB) RETURN r" V.length result `shouldBe` 1 case asRelationship (V.head $ V.head result) of Just Relationship{type_, properties} -> do type_ `shouldBe` "KNOWS" case H.lookup "since" properties of Just (PsInteger n) -> n `shouldBe` 2020 _ -> expectationFailure "Expected since property" Nothing -> expectationFailure $ "Expected BoltRelationship, got: " <> show (V.head $ V.head result) liftIO $ wp $ \p -> queryIO p "MATCH (a:TestGraphRelA)-[r]->(b:TestGraphRelB) DELETE r, a, b" >> pure () it "returns a Path" $ do liftIO $ wp $ \p -> queryIO p "CREATE (a:TestPath {name: 'start'})-[:STEP]->(b:TestPath {name: 'end'})" result <- liftIO $ wp $ \p -> queryIO p "MATCH p=(a:TestPath)-[*]->(b:TestPath) RETURN p" V.length result `shouldBe` 1 case asPath (V.head $ V.head result) of Just path -> do V.length (nodes path) `shouldBe` 2 V.length (rels path) `shouldBe` 1 Nothing -> expectationFailure $ "Expected BoltPath, got: " <> show (V.head $ V.head result) liftIO $ wp $ \p -> queryIO p "MATCH (n:TestPath) DETACH DELETE n" >> pure () it "returns a Date" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN date('2024-01-15') AS d" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDate (Date dys) -> dys `shouldBe` 19737 other -> expectationFailure $ "Expected BoltDate, got: " <> show other it "returns a Point2D" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN point({x: 1.0, y: 2.0, srid: 7203}) AS p" V.length result `shouldBe` 1 case V.head $ V.head result of BoltPoint2D Point2D{srid, x, y} -> do srid `shouldBe` 7203 x `shouldBe` 1.0 y `shouldBe` 2.0 other -> expectationFailure $ "Expected BoltPoint2D, got: " <> show other describe (label ++ " - Temporal and spatial types") $ do it "returns a DateTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN datetime('2024-01-15T12:30:00Z') AS dt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDateTime _ -> pure () other -> expectationFailure $ "Expected BoltDateTime, got: " <> show other it "returns a LocalDateTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN localdatetime('2024-01-15T12:30:00') AS ldt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltLocalDateTime _ -> pure () other -> expectationFailure $ "Expected BoltLocalDateTime, got: " <> show other it "returns a Time" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN time('12:30:00+01:00') AS t" V.length result `shouldBe` 1 case V.head $ V.head result of BoltTime _ -> pure () other -> expectationFailure $ "Expected BoltTime, got: " <> show other it "returns a LocalTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN localtime('12:30:00') AS lt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltLocalTime _ -> pure () other -> expectationFailure $ "Expected BoltLocalTime, got: " <> show other it "returns a Date (temporal)" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN date('2024-01-15') AS d" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDate _ -> pure () other -> expectationFailure $ "Expected BoltDate, got: " <> show other it "returns a Duration" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN duration('P1Y2M3DT4H') AS dur" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDuration _ -> pure () other -> expectationFailure $ "Expected BoltDuration, got: " <> show other it "returns a Point2D (spatial)" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN point({x: 1.0, y: 2.0}) AS p" V.length result `shouldBe` 1 case V.head $ V.head result of BoltPoint2D _ -> pure () other -> expectationFailure $ "Expected BoltPoint2D, got: " <> show other it "returns a Point3D" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN point({x: 1.0, y: 2.0, z: 3.0}) AS p" V.length result `shouldBe` 1 case V.head $ V.head result of BoltPoint3D _ -> pure () other -> expectationFailure $ "Expected BoltPoint3D, got: " <> show other describe (label ++ " - Retry transactions") $ do it "withRetryTransaction commits on success" $ do liftIO $ wp $ \p -> withRetryTransaction defaultRetryConfig p $ \conn -> do _ <- queryIO conn "CREATE (n:TestRetryCommit {value: 1})" pure () result <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestRetryCommit) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ wp $ \p -> queryIO p "MATCH (n:TestRetryCommit) DELETE n" >> pure () it "withRetryTransaction propagates non-transient errors" $ do result :: Either SomeException () <- liftIO $ try $ wp $ \p -> withRetryTransaction defaultRetryConfig p $ \conn -> do _ <- queryIO conn "THIS IS NOT VALID CYPHER" pure () case result of Left _ -> pure () Right _ -> expectationFailure "Expected non-transient error to propagate" it "withTransaction' works through pool" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig liftIO $ withTransaction' pool $ \conn -> do _ <- queryIO conn "CREATE (n:TestPoolTx {value: 42})" pure () result <- liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestPoolTx) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestPoolTx) DELETE n" >> pure () liftIO $ destroyPool pool describe (label ++ " - Connection health checks") $ do it "ping returns True on healthy connection" $ do result <- liftIO $ wp $ \p -> do healthy <- ping p pure healthy result `shouldBe` True it "ping resets state to Ready" $ do liftIO $ wp $ \p -> do _ <- queryIO p "RETURN 1 AS n" healthy <- ping p healthy `shouldBe` True result <- queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 2 Nothing -> fail "Expected 2" it "withConnection validates connections" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig result <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 42 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" liftIO $ destroyPool pool it "pool survives multiple checkout cycles" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig r1 <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 1 AS n" r2 <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" liftIO $ destroyPool pool describe (label ++ " - Pipelining error recovery") $ do it "invalid query fails and connection recovers" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg result :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryIO pipe "INVALID CYPHER" case result of Left _ -> pure () Right _ -> expectationFailure "Expected failure for invalid Cypher" -- Connection should still work after error recovery result2 <- liftIO $ queryIO pipe "RETURN 1 AS n" V.length result2 `shouldBe` 1 case asInt (V.head $ V.head result2) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure $ "Expected integer, got: " <> show (V.head $ V.head result2) liftIO $ close pipe it "multiple consecutive failures recover" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Run 3 invalid queries in sequence, catching each _ :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryIO pipe "BAD QUERY 1" _ :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryIO pipe "BAD QUERY 2" _ :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryIO pipe "BAD QUERY 3" -- Connection should still work result <- liftIO $ queryIO pipe "RETURN 42 AS n" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure $ "Expected integer, got: " <> show (V.head $ V.head result) liftIO $ close pipe it "valid query works after error in auto-commit" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- First run a valid query to confirm connection works r1 <- liftIO $ queryIO pipe "RETURN 'before' AS s" V.length r1 `shouldBe` 1 -- Trigger an error _ :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryIO pipe "NOT VALID CYPHER" -- Run another valid query r2 <- liftIO $ queryIO pipe "RETURN 'after' AS s" V.length r2 `shouldBe` 1 case asText (V.head $ V.head r2) of Just t -> t `shouldBe` "after" Nothing -> expectationFailure $ "Expected text, got: " <> show (V.head $ V.head r2) liftIO $ close pipe it "connection recovers after transaction error" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Transaction with invalid query should fail _ :: Either SomeException () <- liftIO $ try $ withTransaction pipe $ \conn -> do _ <- queryIO conn "INVALID CYPHER IN TX" pure () -- Same connection should still work for a new transaction liftIO $ withTransaction pipe $ \conn -> do r <- queryIO conn "RETURN 'recovered' AS s" case asText (V.head $ V.head r) of Just t -> t `shouldBe` "recovered" Nothing -> expectationFailure $ "Expected text, got: " <> show (V.head $ V.head r) liftIO $ close pipe it "parameterized query error recovers" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Invalid Cypher with parameters _ :: Either SomeException (V.Vector Record) <- liftIO $ try $ queryPIO pipe "NOT VALID $param" (H.singleton "param" (PsInteger 1)) -- Connection should still work result <- liftIO $ queryPIO pipe "RETURN $x AS n" (H.singleton "x" (PsInteger 99)) V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 99 Nothing -> expectationFailure $ "Expected integer, got: " <> show (V.head $ V.head result) liftIO $ close pipe -- ================================================================ -- Connection validation test suite -- ================================================================ validationTestSuite :: String -> IO Config -> TopSpec validationTestSuite label mkConfig = do let getConf = mkGetConfig mkConfig describe (label ++ " - Connection validation") $ do it "AlwaysPing pool works" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = AlwaysPing } pool <- liftIO $ createPool cfg pc result <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 1 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ destroyPool pool it "NeverPing pool works" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = NeverPing } pool <- liftIO $ createPool cfg pc result <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" liftIO $ destroyPool pool it "PingIfIdle 0 always pings (acts like AlwaysPing)" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = PingIfIdle 0 } pool <- liftIO $ createPool cfg pc result <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 3 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 3 Nothing -> expectationFailure "Expected 3" liftIO $ destroyPool pool it "PingIfIdle 3600 skips ping for recent connections" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = PingIfIdle 3600 } pool <- liftIO $ createPool cfg pc -- First use - will ping since connection is fresh from pool r1 <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 10 AS n" -- Second use - should skip ping (connection was just active) r2 <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 20 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 10 Nothing -> expectationFailure "Expected 10" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 20 Nothing -> expectationFailure "Expected 20" liftIO $ destroyPool pool it "pool survives multiple checkouts with AlwaysPing" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = AlwaysPing } pool <- liftIO $ createPool cfg pc results <- liftIO $ sequence [ withConnection pool $ \p -> queryIO p "RETURN 1 AS n" , withConnection pool $ \p -> queryIO p "RETURN 2 AS n" , withConnection pool $ \p -> queryIO p "RETURN 3 AS n" ] length results `shouldBe` 3 liftIO $ destroyPool pool it "withTransaction' works with NeverPing pool" $ do cfg <- liftIO getConf let pc = defaultPoolConfig { validationStrategy = NeverPing } pool <- liftIO $ createPool cfg pc liftIO $ withTransaction' pool $ \conn -> do _ <- queryIO conn "CREATE (n:TestNeverPingTx {value: 1})" pure () result <- liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestNeverPingTx) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestNeverPingTx) DELETE n" >> pure () liftIO $ destroyPool pool -- ================================================================ -- Routing test suite — runs against any Neo4j version -- ================================================================ routingTestSuite :: String -> IO Config -> TopSpec routingTestSuite label mkBaseConfig = do let mkRoutingConfig = do cfg <- mkBaseConfig pure cfg { routing = Routing } let getRoutingConf = mkGetConfig mkRoutingConfig let wrp f = do cfg <- getRoutingConf pipe <- connect cfg result <- f pipe close pipe pure result describe (label ++ " - Routing") $ do it "fetches routing table" $ do result <- liftIO $ wrp $ \p -> getRoutingTable p Nothing (ttl result > 0) `shouldBe` True (V.length (writers result) > 0) `shouldBe` True (V.length (readers result) > 0) `shouldBe` True (V.length (routers result) > 0) `shouldBe` True it "routing table addresses contain host:port format" $ do result <- liftIO $ wrp $ \p -> getRoutingTable p Nothing let allAddrs = writers result <> readers result <> routers result V.forM_ allAddrs $ \addr -> T.isInfixOf ":" addr `shouldBe` True it "withRoutingConnection ReadAccess executes a read query" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 1 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected integer 1" liftIO $ destroyRoutingPool rp it "withRoutingConnection WriteAccess executes a write query" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "CREATE (n:TestRoutingWrite {value: 1})" >> pure () result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "MATCH (n:TestRoutingWrite) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestRoutingWrite) DELETE n" >> pure () liftIO $ destroyRoutingPool rp it "withRoutingTransaction creates and reads back data" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig liftIO $ withRoutingTransaction rp WriteAccess $ \conn -> do _ <- queryIO conn "CREATE (n:TestRoutingTx {value: 42})" pure () result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "MATCH (n:TestRoutingTx) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestRoutingTx) DELETE n" >> pure () liftIO $ destroyRoutingPool rp it "routing works after forced table refresh" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig _ <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 1 AS n" result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" liftIO $ destroyRoutingPool rp -- ================================================================ -- Auth scheme tests (Neo4j 5 only — uses no-auth server on port 7689) -- ================================================================ -- | Check if an Error is AuthentificationFailed isAuthFailed :: SomeException -> Bool isAuthFailed e = case fromException e of Just AuthentificationFailed -> True _ -> False authSchemeTests :: TopSpec authSchemeTests = do let wpNoAuth = mkWithConn noAuthConfig describe "Auth schemes" $ do it "connects with None scheme to no-auth server" $ do result <- liftIO $ wpNoAuth $ \p -> queryIO p "RETURN 1 AS n" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure $ "Expected integer, got: " <> show (V.head $ V.head result) it "None scheme fails on auth-required server" $ do cfg <- liftIO neo4j5Config let noneConfig = cfg { scheme = None } case validateConfig noneConfig of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do result :: Either SomeException () <- liftIO $ try $ do pipe <- connect vc _ <- queryIO pipe "RETURN 1" close pipe case result of Left e | isAuthFailed e -> pure () Left e -> expectationFailure $ "Expected AuthentificationFailed, got: " <> show e Right _ -> expectationFailure "Expected authentication to fail" it "Basic auth with wrong password fails" $ do cfg <- liftIO neo4j5Config let badConfig = cfg { scheme = Basic "neo4j" "wrongpassword" } case validateConfig badConfig of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do result :: Either SomeException () <- liftIO $ try $ do pipe <- connect vc _ <- queryIO pipe "RETURN 1" close pipe case result of Left e | isAuthFailed e -> pure () Left e -> expectationFailure $ "Expected AuthentificationFailed, got: " <> show e Right _ -> expectationFailure "Expected authentication to fail" -- ================================================================ -- Session test suite — managed transactions with bookmarks -- ================================================================ sessionTestSuite :: String -> IO Config -> TopSpec sessionTestSuite label mkConfig = do let getConf = mkGetConfig mkConfig describe (label ++ " - Sessions") $ do it "writeTransaction commits and returns result" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig result <- liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestSessionWrite {value: 1}) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" -- cleanup liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionWrite) DELETE n" >> pure () liftIO $ destroyPool pool it "readTransaction executes read query" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig result <- liftIO $ readTransaction session $ \conn -> queryIO conn "RETURN 42 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" liftIO $ destroyPool pool it "writeTransaction rolls back on error" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig -- First create a node to verify rollback later liftIO $ withConnection pool $ \p -> queryIO p "CREATE (n:TestSessionRB {value: 1})" >> pure () -- Transaction that should fail and rollback result :: Either SomeException () <- liftIO $ try $ writeTransaction session $ \conn -> do _ <- queryIO conn "MATCH (n:TestSessionRB) DELETE n" _ <- queryIO conn "THIS IS NOT VALID CYPHER" pure () case result of Left _ -> pure () Right _ -> expectationFailure "Expected transaction to fail" -- Node should still exist (rollback) count <- liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionRB) RETURN count(n) AS c" case asInt (V.head $ V.head count) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected count 1" liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionRB) DELETE n" >> pure () liftIO $ destroyPool pool it "writeTransaction produces a bookmark" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig -- Before any transaction, bookmarks should be empty bms0 <- liftIO $ getLastBookmarks session bms0 `shouldBe` [] -- After a write transaction, there should be a bookmark liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestSessionBM {value: 1})" >> pure () bms1 <- liftIO $ getLastBookmarks session length bms1 `shouldBe` 1 -- Cleanup liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionBM) DELETE n" >> pure () liftIO $ destroyPool pool it "bookmarks update after each transaction" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig -- First transaction liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestSessionBM2 {value: 1})" >> pure () bms1 <- liftIO $ getLastBookmarks session length bms1 `shouldBe` 1 -- Second transaction liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestSessionBM2 {value: 2})" >> pure () bms2 <- liftIO $ getLastBookmarks session length bms2 `shouldBe` 1 -- Bookmark should have changed (bms1 /= bms2) `shouldBe` True -- Cleanup liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionBM2) DELETE n" >> pure () liftIO $ destroyPool pool it "initial bookmarks are passed to first transaction" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig -- First session: create data and get bookmark session1 <- liftIO $ createSession pool defaultSessionConfig liftIO $ writeTransaction session1 $ \conn -> queryIO conn "CREATE (n:TestSessionCross {value: 99})" >> pure () bms <- liftIO $ getLastBookmarks session1 -- Second session: use bookmark from first session let cfg2 = defaultSessionConfig { sessionBookmarks = bms } session2 <- liftIO $ createSession pool cfg2 result <- liftIO $ readTransaction session2 $ \conn -> queryIO conn "MATCH (n:TestSessionCross) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 99 Nothing -> expectationFailure "Expected 99" -- Cleanup liftIO $ withConnection pool $ \p -> queryIO p "MATCH (n:TestSessionCross) DELETE n" >> pure () liftIO $ destroyPool pool -- ================================================================ -- Routing session tests -- ================================================================ routingSessionTests :: TopSpec routingSessionTests = do let getRoutingConf = mkGetConfig (do cfg <- neo4j5Config; pure cfg { routing = Routing }) describe "Routing sessions" $ do it "createRoutingSession + writeTransaction works" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig session <- liftIO $ createRoutingSession rp defaultSessionConfig liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestRoutingSession {value: 1})" >> pure () result <- liftIO $ readTransaction session $ \conn -> queryIO conn "MATCH (n:TestRoutingSession) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" -- Cleanup liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestRoutingSession) DELETE n" >> pure () liftIO $ destroyRoutingPool rp it "routing session produces bookmarks" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig session <- liftIO $ createRoutingSession rp defaultSessionConfig bms0 <- liftIO $ getLastBookmarks session bms0 `shouldBe` [] liftIO $ writeTransaction session $ \conn -> queryIO conn "CREATE (n:TestRoutingSessBM {value: 1})" >> pure () bms1 <- liftIO $ getLastBookmarks session length bms1 `shouldBe` 1 -- Cleanup liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestRoutingSessBM) DELETE n" >> pure () liftIO $ destroyRoutingPool rp -- ================================================================ -- Failover tests -- ================================================================ failoverTests :: TopSpec failoverTests = do let getRoutingConf = mkGetConfig (do cfg <- neo4j5Config; pure cfg { routing = Routing }) describe "Failover" $ do it "withRoutingConnection works after cache invalidation" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig -- Normal operation r1 <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 1 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" -- Invalidate cache and verify it still works (triggers refresh) liftIO $ invalidateRoutingTable rp r2 <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" liftIO $ destroyRoutingPool rp it "withRoutingTransaction works after cache invalidation" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig liftIO $ invalidateRoutingTable rp liftIO $ withRoutingTransaction rp WriteAccess $ \conn -> do _ <- queryIO conn "CREATE (n:TestFailoverTx {value: 1})" pure () result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "MATCH (n:TestFailoverTx) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestFailoverTx) DELETE n" >> pure () liftIO $ destroyRoutingPool rp it "session writeTransaction works after cache invalidation" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig session <- liftIO $ createRoutingSession rp defaultSessionConfig liftIO $ invalidateRoutingTable rp liftIO $ writeTransaction session $ \conn -> do _ <- queryIO conn "CREATE (n:TestFailoverSess {value: 1})" pure () result <- liftIO $ readTransaction session $ \conn -> queryIO conn "MATCH (n:TestFailoverSess) RETURN n.value AS v" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ withRoutingConnection rp WriteAccess $ \p -> queryIO p "MATCH (n:TestFailoverSess) DELETE n" >> pure () liftIO $ destroyRoutingPool rp it "repeated cache invalidation does not break operations" $ do cfg <- liftIO getRoutingConf rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig -- Invalidate multiple times liftIO $ invalidateRoutingTable rp liftIO $ invalidateRoutingTable rp liftIO $ invalidateRoutingTable rp result <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 42 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" liftIO $ destroyRoutingPool rp -- ================================================================ -- Routing edge case tests (shared, uses Neo4j 5 config) -- ================================================================ routingEdgeCaseTests :: TopSpec routingEdgeCaseTests = do let wp5 = mkWithConn neo4j5Config let getRoutingConf5 = mkGetConfig (do cfg <- neo4j5Config; pure cfg { routing = Routing }) describe "Routing edge cases" $ do it "getRoutingTable works even without routing flag in HELLO" $ do -- CE accepts ROUTE even without routing in HELLO result <- liftIO $ wp5 $ \p -> getRoutingTable p Nothing (ttl result > 0) `shouldBe` True it "createRoutingPool to unreachable host fails" $ do cfg <- liftIO neo4j5Config let badCfg = cfg { host = "192.0.2.1", port = 19999, scheme = None } case validateConfig badCfg of Val.Failure _ -> expectationFailure "Config should validate" Val.Success vc -> do result :: Either SomeException RoutingPool <- liftIO $ try $ createRoutingPool vc defaultRoutingPoolConfig case result of Left _ -> pure () Right rp -> do liftIO $ destroyRoutingPool rp expectationFailure "Expected failure connecting to unreachable host" it "routing pool reuses cached table on consecutive calls" $ do cfg <- liftIO getRoutingConf5 rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig r1 <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 1 AS n" r2 <- liftIO $ withRoutingConnection rp ReadAccess $ \p -> queryIO p "RETURN 2 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" liftIO $ destroyRoutingPool rp it "destroyRoutingPool is idempotent" $ do cfg <- liftIO getRoutingConf5 rp <- liftIO $ createRoutingPool cfg defaultRoutingPoolConfig liftIO $ destroyRoutingPool rp liftIO $ destroyRoutingPool rp -- ================================================================ -- LOGON/LOGOFF tests (Bolt 5.1+) -- ================================================================ logonLogoffTestSuite :: TopSpec logonLogoffTestSuite = do let getConf = mkGetConfig neo4j5Config let wp :: (Connection -> IO a) -> IO a wp = mkWithConn neo4j5Config describe "LOGON/LOGOFF (Bolt 5.1+)" $ do it "connects with 5.1+ handshake (HELLO + LOGON)" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Verify negotiated version is 5.1+ let ver = connectionVersion pipe versionMajor ver `shouldBe` 5 (versionMinor ver >= 1) `shouldBe` True liftIO $ close pipe it "queries work after 5.1+ handshake" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN 42 AS n" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" it "transactions work after 5.1+ handshake" $ do liftIO $ wp $ \p -> withTransaction p $ \conn -> do _ <- queryIO conn "CREATE (n:TestLogonTx {value: 1})" pure () result <- liftIO $ wp $ \p -> queryIO p "MATCH (n:TestLogonTx) RETURN n.value AS v" V.length result `shouldBe` 1 case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ wp $ \p -> queryIO p "MATCH (n:TestLogonTx) DELETE n" >> pure () it "logoff + logon re-authenticates on same connection" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Verify version supports logon/logoff let ver = connectionVersion pipe if supportsLogonLogoff ver then do -- Query before logoff r1 <- liftIO $ queryIO pipe "RETURN 1 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" -- Logoff and logon again liftIO $ logoff pipe liftIO $ logon pipe (Basic "neo4j" "testpassword") -- Query after re-auth r2 <- liftIO $ queryIO pipe "RETURN 2 AS n" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" else pure () -- skip on 5.0 liftIO $ close pipe it "logoff + logon with None scheme on no-auth server" $ do naCfg <- liftIO $ mkGetConfig noAuthConfig pipe <- liftIO $ connect naCfg let ver = connectionVersion pipe if supportsLogonLogoff ver then do r1 <- liftIO $ queryIO pipe "RETURN 1 AS n" case asInt (V.head $ V.head r1) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ logoff pipe liftIO $ logon pipe None r2 <- liftIO $ queryIO pipe "RETURN 2 AS n" case asInt (V.head $ V.head r2) of Just n -> n `shouldBe` 2 Nothing -> expectationFailure "Expected 2" else pure () liftIO $ close pipe -- ================================================================ -- Telemetry tests -- ================================================================ telemetryTestSuite :: TopSpec telemetryTestSuite = do let getConf = mkGetConfig neo4j5Config describe "Telemetry" $ do it "sendTelemetry does not break operations" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg -- Send telemetry (no-op if server doesn't support it) liftIO $ sendTelemetry pipe ManagedTransactions -- Verify connection still works result <- liftIO $ queryIO pipe "RETURN 1 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ close pipe it "session telemetry does not break managed transactions" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig session <- liftIO $ createSession pool defaultSessionConfig result <- liftIO $ writeTransaction session $ \conn -> queryIO conn "RETURN 42 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 42 Nothing -> expectationFailure "Expected 42" -- Second transaction should also work (telemetry already sent) result2 <- liftIO $ readTransaction session $ \conn -> queryIO conn "RETURN 99 AS n" case asInt (V.head $ V.head result2) of Just n -> n `shouldBe` 99 Nothing -> expectationFailure "Expected 99" liftIO $ destroyPool pool -- ================================================================ -- Query logging integration tests -- ================================================================ setQueryLogger :: Maybe (QueryLog -> QueryMeta -> IO ()) -> Config -> Config setQueryLogger ql Config{..} = Config { queryLogger = ql, .. } loggingTestSuite :: TopSpec loggingTestSuite = do describe "Query logging" $ do it "logger callback fires and receives correct cypher" $ do logRef <- liftIO $ newIORef (Nothing :: Maybe QueryLog) cfg <- liftIO neo4j5Config let cfgWithLogger = setQueryLogger (Just (\ql _meta -> writeIORef logRef (Just ql))) cfg case validateConfig cfgWithLogger of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryIO pipe "RETURN 1 AS n" liftIO $ close pipe logged <- liftIO $ readIORef logRef case logged of Nothing -> expectationFailure "Logger should have been called" Just ql -> qlCypher ql `shouldBe` "RETURN 1 AS n" it "logger reports correct row count" $ do logRef <- liftIO $ newIORef (Nothing :: Maybe QueryLog) cfg <- liftIO neo4j5Config let cfgWithLogger = setQueryLogger (Just (\ql _meta -> writeIORef logRef (Just ql))) cfg case validateConfig cfgWithLogger of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryIO pipe "UNWIND range(1, 5) AS n RETURN n" liftIO $ close pipe logged <- liftIO $ readIORef logRef case logged of Nothing -> expectationFailure "Logger should have been called" Just ql -> qlRowCount ql `shouldBe` 5 it "client timing is positive" $ do logRef <- liftIO $ newIORef (Nothing :: Maybe QueryLog) cfg <- liftIO neo4j5Config let cfgWithLogger = setQueryLogger (Just (\ql _meta -> writeIORef logRef (Just ql))) cfg case validateConfig cfgWithLogger of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryIO pipe "RETURN 1 AS n" liftIO $ close pipe logged <- liftIO $ readIORef logRef case logged of Nothing -> expectationFailure "Logger should have been called" Just ql -> (qlClientTime ql > 0) `shouldBe` True it "server timing values are non-negative" $ do logRef <- liftIO $ newIORef (Nothing :: Maybe QueryLog) cfg <- liftIO neo4j5Config let cfgWithLogger = setQueryLogger (Just (\ql _meta -> writeIORef logRef (Just ql))) cfg case validateConfig cfgWithLogger of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryIO pipe "RETURN 1 AS n" liftIO $ close pipe logged <- liftIO $ readIORef logRef case logged of Nothing -> expectationFailure "Logger should have been called" Just ql -> do (qlServerFirst ql >= 0) `shouldBe` True (qlServerLast ql >= 0) `shouldBe` True it "logger fires for parameterized queries" $ do logRef <- liftIO $ newIORef (Nothing :: Maybe QueryLog) cfg <- liftIO neo4j5Config let cfgWithLogger = setQueryLogger (Just (\ql _meta -> writeIORef logRef (Just ql))) cfg case validateConfig cfgWithLogger of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryPIO pipe "RETURN $x AS n" (H.singleton "x" (PsInteger 42)) liftIO $ close pipe logged <- liftIO $ readIORef logRef case logged of Nothing -> expectationFailure "Logger should have been called" Just ql -> do qlCypher ql `shouldBe` "RETURN $x AS n" H.member "x" (qlParameters ql) `shouldBe` True -- ================================================================ -- Notification integration tests -- ================================================================ notificationTestSuite :: TopSpec notificationTestSuite = do describe "Notifications" $ do it "notification handler fires for cartesian product warning" $ do notifsRef <- liftIO $ newIORef ([] :: [Notification]) cfg <- liftIO neo4j5Config let cfgWithHandler = cfg { notificationHandler = Just (\n -> do ns <- readIORef notifsRef writeIORef notifsRef (n : ns)) } case validateConfig cfgWithHandler of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc -- A cartesian product query should produce a notification _ <- liftIO $ queryIO pipe "MATCH (a), (b) RETURN count(*)" liftIO $ close pipe notifs <- liftIO $ readIORef notifsRef (length notifs > 0) `shouldBe` True -- At least one should be about cartesian product let codes = map nCode notifs any (T.isInfixOf "CartesianProduct") codes `shouldBe` True it "parsedNotifications available via queryMeta" $ do cfg <- liftIO $ mkGetConfig neo4j5Config pipe <- liftIO $ connect cfg result <- liftIO $ queryIO' pipe "MATCH (a), (b) RETURN count(*)" liftIO $ close pipe let notifs = parsedNotifications (infos result) (V.length notifs > 0) `shouldBe` True it "no notifications for simple query" $ do notifsRef <- liftIO $ newIORef ([] :: [Notification]) cfg <- liftIO neo4j5Config let cfgWithHandler = cfg { notificationHandler = Just (\n -> do ns <- readIORef notifsRef writeIORef notifsRef (n : ns)) } case validateConfig cfgWithHandler of Val.Failure errs -> liftIO $ fail $ "Config invalid: " <> show errs Val.Success vc -> do pipe <- liftIO $ connect vc _ <- liftIO $ queryIO pipe "RETURN 1 AS n" liftIO $ close pipe notifs <- liftIO $ readIORef notifsRef length notifs `shouldBe` 0 -- ================================================================ -- Query stats integration tests -- ================================================================ statsTestSuite :: TopSpec statsTestSuite = do let wp :: (Connection -> IO a) -> IO a wp = mkWithConn neo4j5Config describe "Query statistics" $ do it "CREATE query has nodesCreated == 1" $ do result <- liftIO $ wp $ \p -> do r <- queryIO' p "CREATE (n:TestStats {value: 1}) RETURN n" _ <- queryIO p "MATCH (n:TestStats) DELETE n" pure r case parsedStats (infos result) of Nothing -> expectationFailure "Expected stats for CREATE query" Just qs -> nodesCreated qs `shouldBe` 1 it "read-only query has no stats" $ do result <- liftIO $ wp $ \p -> queryIO' p "RETURN 1 AS n" parsedStats (infos result) `shouldBe` Nothing it "CREATE + SET has propertiesSet > 0" $ do result <- liftIO $ wp $ \p -> do r <- queryIO' p "CREATE (n:TestStats2 {a: 1, b: 2}) RETURN n" _ <- queryIO p "MATCH (n:TestStats2) DELETE n" pure r case parsedStats (infos result) of Nothing -> expectationFailure "Expected stats" Just qs -> (propertiesSet qs > 0) `shouldBe` True -- ================================================================ -- Server hints integration tests -- ================================================================ serverHintTestSuite :: TopSpec serverHintTestSuite = do let getConf = mkGetConfig neo4j5Config describe "Server hints" $ do it "Neo4j 5 reports server idle timeout" $ do cfg <- liftIO getConf pipe <- liftIO $ connect cfg let sto = connectionServerIdleTimeout pipe liftIO $ close pipe -- Neo4j 5 should report a timeout (typically 120 seconds) case sto of Just secs -> (secs > 0) `shouldBe` True Nothing -> pure () -- Some Neo4j configs may not send this it "pool works with server idle timeout hint" $ do cfg <- liftIO getConf pool <- liftIO $ createPool cfg defaultPoolConfig result <- liftIO $ withConnection pool $ \p -> queryIO p "RETURN 1 AS n" case asInt (V.head $ V.head result) of Just n -> n `shouldBe` 1 Nothing -> expectationFailure "Expected 1" liftIO $ destroyPool pool -- ================================================================ -- Decode integration tests -- ================================================================ decodeTestSuite :: TopSpec decodeTestSuite = do let wp :: (Connection -> IO a) -> IO a wp = mkWithConn neo4j5Config describe "Record decoding (integration)" $ do it "queryWith decodes RETURN 1 with column 0 int64" $ do result <- liftIO $ wp $ \p -> runBolt p $ queryWith (column 0 int64) "RETURN 1 AS n" mempty case result of Right v -> do V.length v `shouldBe` 1 V.head v `shouldBe` (1 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e it "queryWith with parameters decodes correctly" $ do result <- liftIO $ wp $ \p -> runBolt p $ queryWith (column 0 int64) "RETURN $x AS n" (H.singleton "x" (PsInteger 42)) case result of Right v -> V.head v `shouldBe` (42 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e it "queryWith with field 'name' text" $ do result <- liftIO $ wp $ \p -> runBolt p $ queryWith (field "name" Database.Bolty.text) "RETURN 'hello' AS name" mempty case result of Right v -> V.head v `shouldBe` "hello" Left e -> expectationFailure $ "Expected Right, got: " <> show e it "queryWith returns Left on type mismatch" $ do result <- liftIO $ wp $ \p -> runBolt p $ queryWith (field "n" Database.Bolty.text) "RETURN 1 AS n" mempty case result of Left (TypeMismatch _ _) -> pure () other -> expectationFailure $ "Expected Left TypeMismatch, got: " <> show other it "queryWith with multi-column Applicative decoder" $ do result <- liftIO $ wp $ \p -> runBolt p $ queryWith ((,) <$> field "name" Database.Bolty.text <*> field "age" int64) "RETURN 'Alice' AS name, 30 AS age" mempty case result of Right v -> V.head v `shouldBe` ("Alice" :: T.Text, 30 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e it "query with FromBolt instance" $ do result <- liftIO $ wp $ \p -> runBolt p $ query "RETURN 'Alice' AS name, 30 AS age" mempty case result of Right v -> do let PersonResult n a = V.head v n `shouldBe` "Alice" a `shouldBe` (30 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e it "queryResult + decodeResultSet for multi-pass decoding" $ do rs <- liftIO $ wp $ \p -> runBolt p $ queryResult "RETURN 'Alice' AS name, 30 AS age" mempty case decodeResultSet (field "name" Database.Bolty.text) rs of Right names -> V.head names `shouldBe` "Alice" Left e -> expectationFailure $ "Expected Right, got: " <> show e case decodeResultSet (field "age" int64) rs of Right ages -> V.head ages `shouldBe` (30 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e it "queryMeta returns metadata" $ do (rs, meta) <- liftIO $ wp $ \p -> runBolt p $ queryResultMeta "RETURN 1 AS n" mempty case decodeResultSet (field "n" int64) rs of Right v -> V.head v `shouldBe` (1 :: Int64) Left e -> expectationFailure $ "Expected Right, got: " <> show e -- QueryMeta should have a bookmark case bookmark meta of Just _ -> pure () Nothing -> pure () -- not all servers return bookmarks for auto-commit it "BoltM monadic pattern" $ do liftIO $ wp $ \conn -> runBolt conn $ do result <- query "RETURN 'hello' AS name, 42 AS age" mempty case result of Right v -> liftIO $ V.head v `shouldBe` PersonResult "hello" 42 Left e -> liftIO $ expectationFailure $ show e -- ================================================================ -- Temporal type tests — runs against any Neo4j version -- ================================================================ temporalTestSuite :: String -> IO Config -> TopSpec temporalTestSuite label mkConfig = do let wp :: (Connection -> IO a) -> IO a wp = mkWithConn mkConfig describe (label ++ " - Temporal types") $ do it "returns a DateTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN datetime('2024-01-15T12:30:00Z') AS dt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDateTime DateTime{seconds, nanoseconds} -> do (seconds > 0) `shouldBe` True nanoseconds `shouldBe` 0 other -> expectationFailure $ "Expected BoltDateTime, got: " <> show other it "returns a LocalDateTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN localdatetime('2024-01-15T12:30:00') AS ldt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltLocalDateTime LocalDateTime{seconds, nanoseconds} -> do (seconds > 0) `shouldBe` True nanoseconds `shouldBe` 0 other -> expectationFailure $ "Expected BoltLocalDateTime, got: " <> show other it "returns a Time" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN time('12:30:00+01:00') AS t" V.length result `shouldBe` 1 case V.head $ V.head result of BoltTime Time{nanoseconds, tz_offset_seconds} -> do (nanoseconds > 0) `shouldBe` True tz_offset_seconds `shouldBe` 3600 other -> expectationFailure $ "Expected BoltTime, got: " <> show other it "returns a LocalTime" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN localtime('12:30:00') AS lt" V.length result `shouldBe` 1 case V.head $ V.head result of BoltLocalTime LocalTime{nanoseconds} -> (nanoseconds > 0) `shouldBe` True other -> expectationFailure $ "Expected BoltLocalTime, got: " <> show other it "returns a Duration" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN duration('P1Y2M3DT4H') AS d" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDuration _ -> pure () other -> expectationFailure $ "Expected BoltDuration, got: " <> show other it "returns a DateTimeZoneId" $ do result <- liftIO $ wp $ \p -> queryIO p "RETURN datetime({year: 2024, month: 1, day: 15, hour: 12, timezone: 'Europe/Paris'}) AS dtz" V.length result `shouldBe` 1 case V.head $ V.head result of BoltDateTimeZoneId DateTimeZoneId{tz_id} -> tz_id `shouldBe` "Europe/Paris" other -> expectationFailure $ "Expected BoltDateTimeZoneId, got: " <> show other -- ================================================================ -- Main -- ================================================================ main :: IO () main = runSandwichWithCommandLineArgs defaultOptions $ do -- Core test suite against Neo4j 5 coreTestSuite "Neo4j 5" neo4j5Config -- Core test suite against Neo4j 4.4 coreTestSuite "Neo4j 4.4" neo4j44Config -- Connection validation tests validationTestSuite "Neo4j 5" neo4j5Config validationTestSuite "Neo4j 4.4" neo4j44Config -- Routing tests against Neo4j 5 routingTestSuite "Neo4j 5" neo4j5Config -- Routing tests against Neo4j 4.4 routingTestSuite "Neo4j 4.4" neo4j44Config -- Session tests against Neo4j 5 sessionTestSuite "Neo4j 5" neo4j5Config -- Session tests against Neo4j 4.4 sessionTestSuite "Neo4j 4.4" neo4j44Config -- Routing session tests (Neo4j 5 only - requires routing pool) routingSessionTests -- Failover tests (Neo4j 5 only) failoverTests -- Auth scheme tests (Neo4j 5 + no-auth server) authSchemeTests -- Routing edge cases routingEdgeCaseTests -- LOGON/LOGOFF tests (Bolt 5.1+) logonLogoffTestSuite -- Telemetry tests telemetryTestSuite -- Query logging tests (Neo4j 5) loggingTestSuite -- Notification tests (Neo4j 5) notificationTestSuite -- Query stats tests (Neo4j 5) statsTestSuite -- Server hints tests (Neo4j 5) serverHintTestSuite -- Decode tests (Neo4j 5) decodeTestSuite -- Temporal type tests temporalTestSuite "Neo4j 5" neo4j5Config temporalTestSuite "Neo4j 4.4" neo4j44Config