shibuya-core-0.8.0.0: test/Shibuya/RunnerSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Shibuya.RunnerSpec (spec) where
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Text qualified as Text
import Data.Time (UTCTime (..), fromGregorian)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Mock (TrackingAck (..), newTrackingAck, trackingAckHandle)
import Shibuya.App (AppConfig (..), AppError (..), QueueProcessor (..), defaultAppConfig, mkProcessor, runApp, waitApp)
import Shibuya.Core.Ack (AckDecision (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Error (ConfigError (..), PolicyError (..))
import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested)
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Handler (Handler)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
spec :: Spec
spec = do
describe "runApp" $ do
it "rejects inboxSize 0 before starting processors" $ do
result <- runEff $ runTracingNoop $ do
messages <- createTestMessages 1
let processor = mkProcessor (testAdapter messages) alwaysAckOk
runApp defaultAppConfig {inboxSize = 0} [(ProcessorId "invalid-config", processor)]
case result of
Left (AppConfigInvalid (InvalidInboxSize 0)) -> pure ()
Left err -> expectationFailure $ "Expected AppConfigInvalid, got: " ++ show err
Right _ -> expectationFailure "Expected config validation to fail"
it "rejects negative inboxSize before Natural conversion" $ do
result <- runEff $ runTracingNoop $ do
messages <- createTestMessages 1
let processor = mkProcessor (testAdapter messages) alwaysAckOk
runApp defaultAppConfig {inboxSize = -5} [(ProcessorId "invalid-config", processor)]
case result of
Left (AppConfigInvalid (InvalidInboxSize (-5))) -> pure ()
Left err -> expectationFailure $ "Expected AppConfigInvalid, got: " ++ show err
Right _ -> expectationFailure "Expected config validation to fail"
it "processes messages from mock adapter" $ do
result <- runEff $ runTracingNoop $ do
-- Track processed messages
processedRef <- liftIO $ newIORef ([] :: [String])
-- Create test messages
messages <- createTestMessages 3
-- Create adapter and handler
let adapter = testAdapter messages
handler = testHandler processedRef
processor = mkProcessor adapter handler
-- Run the app
res <-
runApp
defaultAppConfig
[ (ProcessorId "test", processor)
]
case res of
Left err -> pure $ Left err
Right appHandle -> do
waitApp appHandle
pure $ Right ()
-- Verify result
result `shouldBe` Right ()
it "calls finalize for each message" $ do
(decisions, result) <- runEff $ runTracingNoop $ do
-- Track ack decisions
tracking <- newTrackingAck
-- Create test messages with tracking acks
messages <- createTrackedMessages tracking 3
-- Create adapter and handler
let adapter = testAdapter messages
handler = alwaysAckOk
processor = mkProcessor adapter handler
-- Run the app
res <-
runApp
defaultAppConfig
[ (ProcessorId "test", processor)
]
case res of
Left err -> do
decs <- liftIO $ readIORef tracking.trackedDecisions
pure (decs, Left err)
Right appHandle -> do
waitApp appHandle
decs <- liftIO $ readIORef tracking.trackedDecisions
pure (decs, Right ())
-- Verify all messages were acked
result `shouldBe` Right ()
length decisions `shouldBe` 3
-- All should be AckOk (decisions are in reverse order)
all ((== AckOk) . snd) decisions `shouldBe` True
it "returns AppHandle for multiple processors" $ do
result <- runEff $ runTracingNoop $ do
messages1 <- createTestMessages 2
messages2 <- createTestMessages 2
let adapter1 = testAdapter messages1
adapter2 = testAdapter messages2
handler = alwaysAckOk
proc1 = mkProcessor adapter1 handler
proc2 = mkProcessor adapter2 handler
res <-
runApp
defaultAppConfig
[ (ProcessorId "proc1", proc1),
(ProcessorId "proc2", proc2)
]
case res of
Left err -> pure $ Left err
Right appHandle -> do
waitApp appHandle
pure $ Right ()
result `shouldBe` Right ()
describe "Policy validation" $ do
it "rejects StrictInOrder with Async" $ do
result <- runEff $ runTracingNoop $ do
messages <- createTestMessages 3
let adapter = testAdapter messages
handler = alwaysAckOk
-- Invalid combination: StrictInOrder requires Serial
processor = QueueProcessor adapter handler StrictInOrder (Async 5)
runApp defaultAppConfig [(ProcessorId "invalid", processor)]
case result of
Left (AppPolicyError (InvalidPolicyCombo _)) -> pure ()
Left err -> expectationFailure $ "Expected AppPolicyError, got: " ++ show err
Right _ -> expectationFailure "Expected policy validation to fail"
it "rejects StrictInOrder with Ahead" $ do
result <- runEff $ runTracingNoop $ do
messages <- createTestMessages 3
let adapter = testAdapter messages
handler = alwaysAckOk
processor = QueueProcessor adapter handler StrictInOrder (Ahead 5)
runApp defaultAppConfig [(ProcessorId "invalid", processor)]
case result of
Left (AppPolicyError (InvalidPolicyCombo _)) -> pure ()
Left err -> expectationFailure $ "Expected AppPolicyError, got: " ++ show err
Right _ -> expectationFailure "Expected policy validation to fail"
it "accepts valid policy combinations" $ do
result <- runEff $ runTracingNoop $ do
messages <- createTestMessages 2
let adapter = testAdapter messages
handler = alwaysAckOk
-- Valid combinations
proc1 = QueueProcessor adapter handler Unordered (Async 3)
proc2 = QueueProcessor adapter handler PartitionedInOrder (Ahead 3)
res <-
runApp
defaultAppConfig
[ (ProcessorId "async", proc1),
(ProcessorId "ahead", proc2)
]
case res of
Left err -> pure $ Left err
Right appHandle -> do
waitApp appHandle
pure $ Right ()
result `shouldBe` Right ()
describe "mkProcessor" $ do
it "creates processor with Unordered ordering" $ do
messages <- runEff $ createTestMessages 1
let adapter = testAdapter messages
handler = alwaysAckOk
ordering = case mkProcessor adapter handler of
QueueProcessor {ordering = o} -> o
BatchingProcessor {ordering = o} -> o
ordering `shouldBe` Unordered
it "creates processor with Serial concurrency" $ do
messages <- runEff $ createTestMessages 1
let adapter = testAdapter messages
handler = alwaysAckOk
concurrency = case mkProcessor adapter handler of
QueueProcessor {concurrency = c} -> c
BatchingProcessor {concurrency = c} -> c
concurrency `shouldBe` Serial
-- Test helpers
testTime :: UTCTime
testTime = UTCTime (fromGregorian 2024 1 1) 0
-- | Create N test messages with simple string payloads
createTestMessages :: (IOE :> es) => Int -> Eff es [Ingested es String]
createTestMessages n = mapM createMessage [1 .. n]
where
createMessage i = do
let msgId = MessageId $ "msg-" <> (if i < 10 then "0" else "") <> Text.pack (show i)
env =
(mkEnvelope msgId ("message-" <> show i))
{ cursor = Just (CursorInt i),
enqueuedAt = Just testTime
}
ackHandle = AckHandle $ \_ -> pure () -- No-op ack
pure $ mkIngested env ackHandle
-- | Create N test messages with tracking acks
createTrackedMessages :: (IOE :> es) => TrackingAck -> Int -> Eff es [Ingested es String]
createTrackedMessages tracking n = mapM createMessage [1 .. n]
where
createMessage i = do
let msgId = MessageId $ "msg-" <> (if i < 10 then "0" else "") <> Text.pack (show i)
env =
(mkEnvelope msgId ("message-" <> show i))
{ cursor = Just (CursorInt i),
enqueuedAt = Just testTime
}
ackHandle = trackingAckHandle tracking msgId
pure $ mkIngested env ackHandle
-- | Create a test adapter from a list of messages
testAdapter :: [Ingested es String] -> Adapter es String
testAdapter messages =
Adapter
{ adapterName = "test:mock",
source = Stream.fromList messages,
shutdown = pure ()
}
-- | Handler that records processed messages
testHandler :: (IOE :> es) => IORef [String] -> Handler es String
testHandler ref ingested = do
liftIO $ modifyIORef' ref (ingested.envelope.payload :)
pure AckOk
-- | Handler that always returns AckOk
alwaysAckOk :: Handler es msg
alwaysAckOk _ = pure AckOk