packages feed

pgmq-inspect-0.7.0.0: test/RouterSpec.hs

module RouterSpec (tests, mockEnv, lookupKey, errorCodeOf) where

import Control.Monad (forM_)
import Data.Aeson (Value)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.Time (UTCTime (..), fromGregorian)
import Data.Vector qualified as V
import Effectful (runEff)
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (runError)
import Pgmq.Effectful (PgmqRuntimeError)
import Pgmq.Effectful.Effect (Pgmq (..))
import Pgmq.Hasql.Statements.Types qualified as H
import Pgmq.Inspect.Config (defaultInspectConfig)
import Pgmq.Inspect.Env (InspectEnv (..))
import Pgmq.Types (UnvalidatedQueue (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))
import TestServer

-- | An environment whose runner never opens a connection: the handler answers
-- the four operations the Milestone 1 routes need and errors on any other.
mockEnv :: InspectEnv
mockEnv = InspectEnv {runInspection = runMock, listenerSettings = Nothing}
  where
    runMock action =
      either (Left . snd) Right
        <$> runEff
          ( runError @PgmqRuntimeError
              ( interpret
                  ( \_ -> \case
                      ListQueuesUnvalidated -> pure [cannedQueue]
                      QueueMetricsUnvalidated name -> pure (cannedMetrics name)
                      AllQueueMetrics -> pure [cannedMetrics "orders"]
                      ListTopicBindings -> pure []
                      PeekMessages _ -> pure V.empty
                      PeekArchivedMessages _ -> pure V.empty
                      LookupMessage _ -> pure Nothing
                      LookupArchivedMessage _ -> pure Nothing
                      ListNotifyInsertThrottles -> pure []
                      TestRouting _ -> pure []
                      _ -> error "RouterSpec: operation not canned"
                  )
                  action
              )
          )
    epoch = UTCTime (fromGregorian 2026 1 1) 0
    cannedQueue = UnvalidatedQueue "orders" epoch False False
    cannedMetrics name = H.QueueMetrics name 3 (Just 1) (Just 9) 12 epoch 2 Nothing

tests :: TestTree
tests =
  testGroup
    "Router (mock interpreter, no database)"
    [ testCase "integer overflow and malformed input are rejected before database work" $
        withTestServer defaultInspectConfig mockEnv $ \port -> forM_ ["-1", "9223372036854775808", "18446744073709551617", replicate 128 '9', replicate 129 '1', "1x", "", "%2B1"] $ \raw -> do
          reply <- get port ("/queues/orders/messages?from=" <> raw)
          replyStatus reply @?= 400
          errorCodeOf reply >>= (@?= Aeson.String "invalid_cursor")
          one <- get port ("/queues/orders/messages/" <> if null raw then "abc" else raw)
          replyStatus one @?= 400,
      testCase "maximum int64 and large capped limit remain exact" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- get port "/queues/orders/messages?from=9223372036854775807&limit=999999999999999999999999999999999999999"
          replyStatus reply @?= 200,
      testCase "GET / describes the service" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- get port "/"
          replyStatus reply @?= 200
          body <- decodeReply reply
          lookupKey "service" body @?= Just (Aeson.String "pgmq-inspect")
          lookupKey "websocket_path" body @?= Just (Aeson.String "ws"),
      testCase "GET /queues lists the canned queue with pgmq column names" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- get port "/queues"
          replyStatus reply @?= 200
          rows <- decodeReply reply
          map (lookupKey "queue_name") rows @?= [Just (Aeson.String "orders")],
      testCase "GET /queues/orders/metrics distinguishes total and visible depth" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          body <- decodeReply =<< get port "/queues/orders/metrics"
          lookupKey "queue_length" body @?= Just (Aeson.Number 3)
          lookupKey "queue_visible_length" body @?= Just (Aeson.Number 2)
          lookupKey "default_partition_length" body @?= Just Aeson.Null,
      testCase "an unknown path is 404 not_found" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- get port "/nope"
          replyStatus reply @?= 404
          code <- errorCodeOf reply
          code @?= "not_found",
      testCase "POST is 405 method_not_allowed" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- getWith port "POST" [] "/queues"
          replyStatus reply @?= 405,
      testCase "limit=abc is 400 invalid_limit before any database work" $
        withTestServer defaultInspectConfig mockEnv $ \port -> do
          reply <- get port "/queues/orders/messages?limit=abc"
          replyStatus reply @?= 400
          code <- errorCodeOf reply
          code @?= "invalid_limit"
    ]

lookupKey :: Aeson.Key -> Value -> Maybe Value
lookupKey k = \case
  Aeson.Object o -> KM.lookup k o
  _ -> Nothing

errorCodeOf :: Reply -> IO Value
errorCodeOf reply = do
  body <- decodeReply reply
  maybe (fail "no error.code") pure (lookupKey "error" body >>= lookupKey "code")