packages feed

sydtest-hedis-0.0.0.0: src/Test/Syd/Redis.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-deprecations #-}

-- TODO possibly supply a variant of 'redisSpec' that uses a different database scope per test
-- so that the tests can still happen in parallel:
-- see https://hackage.haskell.org/package/hedis-0.14.2/docs/Database-Redis.html#v:select
-- and connectDatabase:
-- https://hackage.haskell.org/package/hedis-0.14.2/docs/Database-Redis.html#t:ConnectInfo
module Test.Syd.Redis
  ( redisSpec,
    redisConnectionSetupFunc,
    checkedConnectSetupFunc,
    RedisServerHandle (..),
    redisServerSpec,
    cleanRedisServerState,
    redisServerSetupFunc,
    redisServerSetupFunc',
  )
where

import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Database.Redis as Redis
import Network.Socket
import Network.Socket.Free
import qualified Network.Socket.Wait as Socket
import Path
import Path.IO
import System.Process.Typed
import Test.Syd
import Test.Syd.Path
import Test.Syd.Process.Typed

-- | A handle to a child process that is a Redis server.
data RedisServerHandle = RedisServerHandle
  { redisServerHandleProcessHandle :: !(Process () () ()),
    redisServerHandlePort :: !PortNumber
  }

-- | Run a redis server around a group of test and provide a clean connection to every test
--
-- Example usage:
--
-- >  redisSpec $ do
-- >    it "sets up and tears down a redis connection nicely" $ \conn -> do
-- >      runRedis conn $ do
-- >        errOrStatus <- Redis.set "hello" "world"
-- >        liftIO $ case errOrStatus of
-- >          Left err -> expectationFailure $ show err
-- >          Right status -> status `shouldBe` Ok
-- >        errOrReply <- Redis.get "hello"
-- >        liftIO $ case errOrReply of
-- >          Left err -> expectationFailure $ show err
-- >          Right val -> val `shouldBe` Just "world"
--
-- This function just combines 'redisServerSpec' with 'setupAroundWith' redisConnectionSetupFunc'.
redisSpec :: TestDefM (RedisServerHandle ': outers) Redis.Connection result -> TestDefM outers inner result
redisSpec = redisServerSpec . setupAroundWith' (\serverHandle _ -> redisConnectionSetupFunc serverHandle)

-- | Set up a clean redis connection given a handle to the redis server.
--
-- This function cleans the state using `flushall`.
redisConnectionSetupFunc :: RedisServerHandle -> SetupFunc Redis.Connection
redisConnectionSetupFunc RedisServerHandle {..} = do
  let connInfo = Redis.defaultConnectInfo {connectPort = PortNumber redisServerHandlePort}
  conn <- checkedConnectSetupFunc connInfo
  SetupFunc $ \func -> do
    cleanRedisServerState conn
    func conn

-- | A 'SetupFunc' that 'bracket's 'checkedConnect' and 'disconnect'.
checkedConnectSetupFunc :: Redis.ConnectInfo -> SetupFunc Redis.Connection
checkedConnectSetupFunc connInfo = bracketSetupFunc (checkedConnect connInfo) disconnect

-- | Run a redis server around a group of tests.
redisServerSpec :: TestDefM (RedisServerHandle ': outers) inner result -> TestDefM outers inner result
redisServerSpec = setupAroundAll redisServerSetupFunc . sequential -- Must run sequentially because state is shared.

-- | Clean the redis server's state.
cleanRedisServerState :: Connection -> IO ()
cleanRedisServerState conn = do
  errOrStatus <- runRedis conn Redis.flushall -- Clean state
  case errOrStatus of
    Left err -> expectationFailure $ "Something went wrong while trying to clean the state before the test starts: " <> show err
    Right s -> s `shouldBe` Ok

-- | Setup func for running a Redis server
--
-- This function uses a temporary directory (using 'tempDirSetupFunc') for any state.
redisServerSetupFunc :: SetupFunc RedisServerHandle
redisServerSetupFunc = do
  td <- tempDirSetupFunc "sydtest-hedis"
  redisServerSetupFunc' td

-- | Setup func for running a Redis server in a given directory
redisServerSetupFunc' :: Path Abs Dir -> SetupFunc RedisServerHandle
redisServerSetupFunc' td = do
  pidFile <- resolveFile td "redis.pid"
  logFile <- resolveFile td "redis.log"
  portInt <- liftIO $ do
    (portInt, _socket) <- openFreePort
    close _socket
    pure portInt
  let pn = fromIntegral portInt -- (hopefully) safe because it came from 'getFreePort'.
  let configFileContents =
        T.pack $
          unlines
            [ unwords ["port", show (fromIntegral pn :: Int)],
              unwords ["pidfile", fromAbsFile pidFile],
              unwords ["always-show-logo", "no"], -- No need to see the logo.
              unwords ["logfile", fromAbsFile logFile]
            ]
  configFile <- tempBinaryFileWithContentsSetupFunc "config-file" (TE.encodeUtf8 configFileContents)
  let pc =
        setWorkingDir (fromAbsDir td) $
          setStdout inherit $
            setStderr inherit $
              proc
                "redis-server"
                [fromAbsFile configFile]
  ph <- typedProcessSetupFunc pc
  liftIO $ Socket.wait "127.0.0.1" portInt
  pure $
    RedisServerHandle
      { redisServerHandleProcessHandle = ph,
        redisServerHandlePort = pn
      }