tower-hs-0.1.0.0: test/Tower/Middleware/RedisDockerSpec.hs
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Integration test demonstrating tower-hs generic middleware with a real
-- Redis database via testcontainers. This proves that retry, timeout,
-- circuit breaker, logging, and validation compose for non-HTTP services
-- just as naturally as they do for HTTP or servant.
module Tower.Middleware.RedisDockerSpec (spec) where
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import Data.IORef
import Data.Text (Text, isInfixOf)
import qualified Data.Text as T
import System.Process (readProcess)
import Test.Hspec
import qualified Database.Redis as Redis
import qualified TestContainers as TC
import TestContainers.Hspec (withContainers)
import Tower.Service
import Tower.Error
import Tower.Error.Testing ()
import Tower.Middleware.CircuitBreaker
import Tower.Middleware.Logging (withLogging)
import Tower.Middleware.Retry
import Tower.Middleware.Timeout
import Tower.Middleware.Validate (withValidate)
-- ---------------------------------------------------------------------------
-- Redis container setup
-- ---------------------------------------------------------------------------
setupRedis :: TC.TestContainer Redis.Connection
setupRedis = do
container <- TC.run $ TC.containerRequest (TC.fromTag "redis:7-alpine")
TC.& TC.setExpose [6379]
TC.& TC.setWaitingFor (TC.waitUntilTimeout 30 (TC.waitUntilMappedPortReachable 6379))
let port = TC.containerPort container 6379
liftIO $ do
-- Small delay to ensure Redis is accepting commands
threadDelay 500_000
Redis.checkedConnect Redis.defaultConnectInfo
{ Redis.connectHost = "localhost"
, Redis.connectPort = Redis.PortNumber (fromIntegral port)
}
-- ---------------------------------------------------------------------------
-- Redis service wrapped as tower-hs Service
-- ---------------------------------------------------------------------------
-- | A Redis SET+GET service: takes a (key, value) pair, stores it, and
-- returns the value read back. Exercises a real database round-trip.
type RedisGetSet = Service (ByteString, ByteString) ByteString
mkRedisService :: Redis.Connection -> RedisGetSet
mkRedisService conn = Service $ \(key, value) -> do
result <- try $ Redis.runRedis conn $ do
_ <- Redis.set key value
Redis.get key
pure $ case result of
Left (err :: SomeException) -> Left (TransportError err)
Right (Right (Just v)) -> Right v
Right (Right Nothing) -> Left (CustomError "key not found after SET")
Right (Left reply) -> Left (CustomError ("Redis error: " <> T.pack (show reply)))
-- | A simple Redis PING service for connectivity checks.
type RedisPing = Service () Redis.Status
mkRedisPingService :: Redis.Connection -> RedisPing
mkRedisPingService conn = Service $ \_ -> do
result <- try $ Redis.runRedis conn Redis.ping
pure $ case result of
Left (err :: SomeException) -> Left (TransportError err)
Right (Right status) -> Right status
Right (Left reply) -> Left (CustomError ("Redis error: " <> T.pack (show reply)))
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
dockerAvailable :: IO Bool
dockerAvailable = do
result <- try (readProcess "docker" ["info"] "") :: IO (Either SomeException String)
pure $ case result of
Right _ -> True
Left _ -> False
-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------
spec :: Spec
spec = describe "Redis integration (testcontainers)" $ beforeAll dockerAvailable $ do
it "SET and GET through a tower-hs service" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \conn -> do
let svc = mkRedisService conn
result <- runService svc ("tower-key", "tower-value")
result `shouldBe` Right "tower-value"
it "retry recovers from transient errors" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \conn -> do
callCount <- newIORef (0 :: Int)
let svc = Service $ \input -> do
n <- atomicModifyIORef' callCount (\c -> (c + 1, c))
if n < 2
then pure (Left (CustomError "transient redis error"))
else runService (mkRedisService conn) input
robust = withRetry (constantBackoff 3 0) svc
result <- runService robust ("retry-key", "retry-value")
result `shouldBe` Right "retry-value"
readIORef callCount >>= (`shouldBe` 3)
it "timeout catches slow operations" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \conn -> do
-- Wrap a service that artificially delays
let slowSvc :: RedisGetSet
slowSvc = Service $ \input -> do
threadDelay 2_000_000 -- 2 seconds
runService (mkRedisService conn) input
timed = withTimeout 500 slowSvc -- 500ms timeout
result <- runService timed ("timeout-key", "timeout-value")
result `shouldBe` Left TimeoutError
it "circuit breaker trips after repeated failures" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \_ -> do
breaker <- newCircuitBreaker
let config = CircuitBreakerConfig { cbFailureThreshold = 2, cbCooldownPeriod = 10 }
failSvc :: RedisGetSet
failSvc = Service $ \_ -> pure (Left (CustomError "redis down"))
protected = withCircuitBreaker config breaker failSvc
_ <- runService protected ("k", "v")
_ <- runService protected ("k", "v")
getCircuitBreakerState breaker >>= (`shouldBe` Open)
result <- runService protected ("k", "v")
result `shouldBe` Left CircuitBreakerOpen
it "validates results" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \conn -> do
let svc = mkRedisService conn
validated = withValidate (\v ->
if v == "expected" then Nothing
else Just ("Unexpected value: " <> T.pack (show v))) svc
-- This should fail validation — the stored value won't be "expected"
result <- runService validated ("val-key", "something-else")
case result of
Left (CustomError msg) -> isInfixOf "Unexpected value" msg `shouldBe` True
other -> expectationFailure $ "Expected validation error, got: " <> show other
it "composes full middleware stack for Redis service" $ \isAvailable -> do
if not isAvailable
then pendingWith "Docker not available"
else withContainers setupRedis $ \conn -> do
logRef <- newIORef ([] :: [Text])
breaker <- newCircuitBreaker
let config = CircuitBreakerConfig { cbFailureThreshold = 5, cbCooldownPeriod = 30 }
svc = mkRedisPingService conn
robust = withRetry (constantBackoff 2 0)
. withTimeout 5000
. withCircuitBreaker config breaker
. withValidate (\status ->
if status == Redis.Pong then Nothing
else Just ("Unexpected PING response: " <> T.pack (show status)))
. withLogging
(\_ result dur -> case result of
Right _ -> "Redis OK (" <> T.pack (show (round (dur * 1000) :: Int)) <> "ms)"
Left err -> "Redis ERR: " <> displayError err)
(\msg -> modifyIORef' logRef (msg :))
$ svc
result <- runService robust ()
result `shouldBe` Right Redis.Pong
getCircuitBreakerState breaker >>= (`shouldBe` Closed)
logs <- readIORef logRef
length logs `shouldBe` 1
isInfixOf "Redis OK" (head logs) `shouldBe` True