pgmq-hasql-0.1.1.0: test/TopicSpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Tests for topic routing operations (pgmq 1.11.0+):
-- - bind/unbind topic
-- - send_topic / send_topic with headers
-- - batch send_topic (immediate, delayed, with headers)
-- - validate_routing_key / validate_topic_pattern
-- - test_routing
-- - list_topic_bindings
-- - list_notify_insert_throttles / update_notify_insert
module TopicSpec (tests) where
import Data.Aeson (object, (.=))
import Data.Text qualified as T
import Data.Time.Clock (addUTCTime, getCurrentTime)
import EphemeralDb (TestFixture (..), withTestFixture)
import Hasql.Pool qualified as Pool
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Hasql.Statements.Types
( BatchSendTopic (..),
BatchSendTopicForLater (..),
BatchSendTopicWithHeaders (..),
BatchSendTopicWithHeadersForLater (..),
BindTopic (..),
EnableNotifyInsert (..),
SendTopic (..),
SendTopicWithHeaders (..),
UnbindTopic (..),
UpdateNotifyInsert (..),
)
import Pgmq.Types
( MessageBody (..),
MessageHeaders (..),
QueueName,
RoutingKey,
TopicPattern,
parseRoutingKey,
parseTopicPattern,
)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
import TestUtils (assertSession, cleanupQueue)
-- | All topic operation tests
tests :: Pool.Pool -> TestTree
tests p =
testGroup
"Topic Operations (pgmq 1.11.0+)"
[ testBindUnbindTopic p,
testSendTopic p,
testSendTopicWithHeaders p,
testBatchSendTopic p,
testBatchSendTopicForLater p,
testBatchSendTopicWithHeaders p,
testBatchSendTopicWithHeadersForLater p,
testTestRouting p,
testValidateRoutingKey p,
testValidateTopicPattern p,
testListTopicBindings p,
testListTopicBindingsForQueue p,
testNotifyInsertThrottle p
]
mkRoutingKey :: T.Text -> RoutingKey
mkRoutingKey t = case parseRoutingKey t of
Left err -> error $ "Invalid routing key: " <> show err
Right rk -> rk
mkTopicPattern :: T.Text -> TopicPattern
mkTopicPattern t = case parseTopicPattern t of
Left err -> error $ "Invalid topic pattern: " <> show err
Right tp -> tp
-- | Helper to unbind a pattern and then cleanup the queue
unbindAndCleanup :: Pool.Pool -> TopicPattern -> QueueName -> IO ()
unbindAndCleanup pool pat qn = do
_ <-
assertSession pool $
Sessions.unbindTopic
UnbindTopic {topicPattern = pat, queueName = qn}
cleanupQueue pool qn
-- | Test bind and unbind topic
testBindUnbindTopic :: Pool.Pool -> TestTree
testBindUnbindTopic p = testCase "bindTopic and unbindTopic" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
-- Bind a pattern
let pat = mkTopicPattern "bind-test.#"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
-- Verify binding exists for this queue
bindings <- assertSession pool $ Sessions.listTopicBindingsForQueue queueName
assertEqual "Should have 1 binding for queue" 1 (length bindings)
-- Unbind
removed <-
assertSession pool $
Sessions.unbindTopic
UnbindTopic {topicPattern = pat, queueName = queueName}
assertBool "Unbind should return True" removed
-- Unbind again should return False
removedAgain <-
assertSession pool $
Sessions.unbindTopic
UnbindTopic {topicPattern = pat, queueName = queueName}
assertBool "Second unbind should return False" (not removedAgain)
cleanupQueue pool queueName
-- | Test sendTopic routes to matching queues
testSendTopic :: Pool.Pool -> TestTree
testSendTopic p = testCase "sendTopic routes message to matching queues" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
-- Use a unique pattern prefix to avoid matching stale bindings
let pat = mkTopicPattern "sendtest.#"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
-- Send via topic with routing key matching only our pattern
matchedCount <-
assertSession pool $
Sessions.sendTopic
SendTopic
{ routingKey = mkRoutingKey "sendtest.error",
messageBody = MessageBody (object ["level" .= ("error" :: String)]),
delay = Nothing
}
assertEqual "Should match 1 queue" 1 matchedCount
unbindAndCleanup pool pat queueName
-- | Test sendTopicWithHeaders
testSendTopicWithHeaders :: Pool.Pool -> TestTree
testSendTopicWithHeaders p = testCase "sendTopicWithHeaders routes with headers" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "hdrstest.#"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
matchedCount <-
assertSession pool $
Sessions.sendTopicWithHeaders
SendTopicWithHeaders
{ routingKey = mkRoutingKey "hdrstest.user.login",
messageBody = MessageBody (object ["event" .= ("login" :: String)]),
messageHeaders = MessageHeaders (object ["source" .= ("auth" :: String)]),
delay = Nothing
}
assertEqual "Should match 1 queue" 1 matchedCount
unbindAndCleanup pool pat queueName
-- | Test batchSendTopic
testBatchSendTopic :: Pool.Pool -> TestTree
testBatchSendTopic p = testCase "batchSendTopic routes batch to matching queues" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "batchtest.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
results <-
assertSession pool $
Sessions.batchSendTopic
BatchSendTopic
{ routingKey = mkRoutingKey "batchtest.error",
messageBodies =
[ MessageBody (object ["msg" .= (1 :: Int)]),
MessageBody (object ["msg" .= (2 :: Int)])
],
delay = Nothing
}
assertEqual "Should return 2 results (2 msgs to 1 queue)" 2 (length results)
unbindAndCleanup pool pat queueName
-- | Test batchSendTopicForLater with a scheduled time
testBatchSendTopicForLater :: Pool.Pool -> TestTree
testBatchSendTopicForLater p = testCase "batchSendTopicForLater schedules batch via topic" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "batchlater.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
futureTime <- addUTCTime 60 <$> getCurrentTime
results <-
assertSession pool $
Sessions.batchSendTopicForLater
BatchSendTopicForLater
{ routingKey = mkRoutingKey "batchlater.info",
messageBodies =
[ MessageBody (object ["msg" .= (1 :: Int)]),
MessageBody (object ["msg" .= (2 :: Int)])
],
scheduledAt = futureTime
}
assertEqual "Should return 2 results (2 msgs to 1 queue)" 2 (length results)
unbindAndCleanup pool pat queueName
-- | Test batchSendTopicWithHeaders
testBatchSendTopicWithHeaders :: Pool.Pool -> TestTree
testBatchSendTopicWithHeaders p = testCase "batchSendTopicWithHeaders routes batch with headers" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "batchhdrs.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
results <-
assertSession pool $
Sessions.batchSendTopicWithHeaders
BatchSendTopicWithHeaders
{ routingKey = mkRoutingKey "batchhdrs.warn",
messageBodies =
[ MessageBody (object ["msg" .= (1 :: Int)]),
MessageBody (object ["msg" .= (2 :: Int)])
],
messageHeaders =
[ MessageHeaders (object ["priority" .= ("high" :: String)]),
MessageHeaders (object ["priority" .= ("low" :: String)])
],
delay = Nothing
}
assertEqual "Should return 2 results (2 msgs to 1 queue)" 2 (length results)
unbindAndCleanup pool pat queueName
-- | Test batchSendTopicWithHeadersForLater
testBatchSendTopicWithHeadersForLater :: Pool.Pool -> TestTree
testBatchSendTopicWithHeadersForLater p = testCase "batchSendTopicWithHeadersForLater schedules batch with headers" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "batchhdrslater.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
futureTime <- addUTCTime 60 <$> getCurrentTime
results <-
assertSession pool $
Sessions.batchSendTopicWithHeadersForLater
BatchSendTopicWithHeadersForLater
{ routingKey = mkRoutingKey "batchhdrslater.error",
messageBodies =
[ MessageBody (object ["msg" .= (1 :: Int)]),
MessageBody (object ["msg" .= (2 :: Int)])
],
messageHeaders =
[ MessageHeaders (object ["source" .= ("app" :: String)]),
MessageHeaders (object ["source" .= ("sys" :: String)])
],
scheduledAt = futureTime
}
assertEqual "Should return 2 results (2 msgs to 1 queue)" 2 (length results)
unbindAndCleanup pool pat queueName
-- | Test validateRoutingKey
testValidateRoutingKey :: Pool.Pool -> TestTree
testValidateRoutingKey p = testCase "validateRoutingKey validates on server" $ do
withTestFixture p $ \TestFixture {pool} -> do
result <- assertSession pool $ Sessions.validateRoutingKey (mkRoutingKey "orders.created")
assertBool "Valid routing key should return True" result
-- | Test validateTopicPattern
testValidateTopicPattern :: Pool.Pool -> TestTree
testValidateTopicPattern p = testCase "validateTopicPattern validates on server" $ do
withTestFixture p $ \TestFixture {pool} -> do
result <- assertSession pool $ Sessions.validateTopicPattern (mkTopicPattern "orders.#")
assertBool "Valid topic pattern should return True" result
-- | Test testRouting dry-run
testTestRouting :: Pool.Pool -> TestTree
testTestRouting p = testCase "testRouting shows matching patterns" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
-- Bind two patterns that both match "routetest.error"
let pat1 = mkTopicPattern "routetest.#"
pat2 = mkTopicPattern "routetest.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat1, queueName = queueName}
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat2, queueName = queueName}
-- Test routing - both patterns should match
matches <- assertSession pool $ Sessions.testRouting (mkRoutingKey "routetest.error")
assertEqual "Should match 2 patterns" 2 (length matches)
-- Cleanup
_ <-
assertSession pool $
Sessions.unbindTopic
UnbindTopic {topicPattern = pat1, queueName = queueName}
unbindAndCleanup pool pat2 queueName
-- | Test listTopicBindings
testListTopicBindings :: Pool.Pool -> TestTree
testListTopicBindings p = testCase "listTopicBindings returns all bindings" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat = mkTopicPattern "listtest.#"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat, queueName = queueName}
bindings <- assertSession pool Sessions.listTopicBindings
assertBool "Should have at least 1 binding" (not $ null bindings)
unbindAndCleanup pool pat queueName
-- | Test listTopicBindingsForQueue
testListTopicBindingsForQueue :: Pool.Pool -> TestTree
testListTopicBindingsForQueue p = testCase "listTopicBindingsForQueue returns queue-specific bindings" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
let pat1 = mkTopicPattern "listqtest.events.#"
pat2 = mkTopicPattern "listqtest.logs.*"
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat1, queueName = queueName}
assertSession pool $
Sessions.bindTopic
BindTopic {topicPattern = pat2, queueName = queueName}
bindings <- assertSession pool $ Sessions.listTopicBindingsForQueue queueName
assertEqual "Should have 2 bindings for queue" 2 (length bindings)
-- Cleanup
_ <-
assertSession pool $
Sessions.unbindTopic
UnbindTopic {topicPattern = pat1, queueName = queueName}
unbindAndCleanup pool pat2 queueName
-- | Test listNotifyInsertThrottles and updateNotifyInsert
testNotifyInsertThrottle :: Pool.Pool -> TestTree
testNotifyInsertThrottle p = testCase "listNotifyInsertThrottles and updateNotifyInsert" $ do
withTestFixture p $ \TestFixture {pool, queueName} -> do
assertSession pool (Sessions.createQueue queueName)
-- Enable notifications first
assertSession pool $
Sessions.enableNotifyInsert
EnableNotifyInsert {queueName = queueName, throttleIntervalMs = Just 100}
-- List throttles
throttles <- assertSession pool Sessions.listNotifyInsertThrottles
assertBool "Should have at least 1 throttle entry" (not $ null throttles)
-- Update throttle
assertSession pool $
Sessions.updateNotifyInsert
UpdateNotifyInsert {queueName = queueName, throttleIntervalMs = 500}
-- Verify update
throttlesAfter <- assertSession pool Sessions.listNotifyInsertThrottles
assertBool "Should still have throttle entries" (not $ null throttlesAfter)
cleanupQueue pool queueName