packages feed

shibuya-pgmq-adapter-0.5.0.0: test/Shibuya/Adapter/Pgmq/InternalSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Shibuya.Adapter.Pgmq.InternalSpec (spec) where

import Data.Aeson (Value (..), object, (.=))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Int (Int32)
import Data.Time (NominalDiffTime)
import Pgmq.Hasql.Statements.Types (ReadGrouped (..), ReadMessage (..), ReadWithPollMessage (..))
import Pgmq.Types (parseQueueName)
import Shibuya.Adapter.Pgmq.Config
  ( PgmqAdapterConfig (..),
    defaultPollingConfig,
  )
import Shibuya.Adapter.Pgmq.Internal
  ( mergeDlqHeaders,
    mkReadGrouped,
    mkReadMessage,
    mkReadWithPoll,
    nominalToSeconds,
  )
import Test.Hspec

spec :: Spec
spec = do
  nominalToSecondsSpec
  mkReadMessageSpec
  mkReadWithPollSpec
  mkReadGroupedSpec
  mergeDlqHeadersSpec

-- | Tests for nominalToSeconds
nominalToSecondsSpec :: Spec
nominalToSecondsSpec = describe "nominalToSeconds" $ do
  it "converts whole seconds" $ do
    nominalToSeconds 5 `shouldBe` 5

  it "rounds up fractional seconds" $ do
    nominalToSeconds 5.1 `shouldBe` 6

  it "rounds up small fractions" $ do
    nominalToSeconds 5.001 `shouldBe` 6

  it "handles zero" $ do
    nominalToSeconds 0 `shouldBe` 0

  it "handles negative (rounds toward positive infinity)" $ do
    nominalToSeconds (-5.1) `shouldBe` (-5)

  describe "clamping" $ do
    it "exact 30 seconds passes through" $ do
      nominalToSeconds 30 `shouldBe` 30

    it "rounds up subsecond" $ do
      nominalToSeconds 0.1 `shouldBe` 1

    it "clamps a 100-year delay to maxBound :: Int32" $ do
      let hundredYears = 100 * 365 * 24 * 60 * 60 :: NominalDiffTime
      nominalToSeconds hundredYears `shouldBe` (maxBound :: Int32)

    it "clamps a very large negative delay to minBound :: Int32" $ do
      let hugelyNegative = negate (100 * 365 * 24 * 60 * 60) :: NominalDiffTime
      nominalToSeconds hugelyNegative `shouldBe` (minBound :: Int32)

-- | Tests for mkReadMessage
mkReadMessageSpec :: Spec
mkReadMessageSpec = describe "mkReadMessage" $ do
  let queueName = case parseQueueName "test_queue" of
        Right q -> q
        Left e -> error $ "Unexpected: " <> show e
      config =
        PgmqAdapterConfig
          { queueName = queueName,
            visibilityTimeout = 60,
            batchSize = 10,
            polling = defaultPollingConfig,
            deadLetterConfig = Nothing,
            maxRetries = 3,
            fifoConfig = Nothing,
            prefetchConfig = Nothing
          }
      ReadMessage
        { queueName = queryQueueName,
          delay = queryDelay,
          batchSize = queryBatchSize,
          conditional = queryConditional
        } = mkReadMessage config

  it "sets queueName from config" $ do
    queryQueueName `shouldBe` queueName

  it "sets delay to visibilityTimeout" $ do
    queryDelay `shouldBe` 60

  it "sets batchSize from config" $ do
    queryBatchSize `shouldBe` Just 10

  it "sets conditional to Nothing" $ do
    queryConditional `shouldBe` Nothing

-- | Tests for mkReadWithPoll
mkReadWithPollSpec :: Spec
mkReadWithPollSpec = describe "mkReadWithPoll" $ do
  let queueName = case parseQueueName "test_queue" of
        Right q -> q
        Left e -> error $ "Unexpected: " <> show e
      config =
        PgmqAdapterConfig
          { queueName = queueName,
            visibilityTimeout = 60,
            batchSize = 10,
            polling = defaultPollingConfig,
            deadLetterConfig = Nothing,
            maxRetries = 3,
            fifoConfig = Nothing,
            prefetchConfig = Nothing
          }
      ReadWithPollMessage
        { queueName = queryQueueName,
          delay = queryDelay,
          batchSize = queryBatchSize,
          maxPollSeconds = queryMaxPoll,
          pollIntervalMs = queryPollInterval,
          conditional = queryConditional
        } = mkReadWithPoll config 5 100

  it "sets queueName from config" $ do
    queryQueueName `shouldBe` queueName

  it "sets delay to visibilityTimeout" $ do
    queryDelay `shouldBe` 60

  it "sets batchSize from config" $ do
    queryBatchSize `shouldBe` Just 10

  it "sets maxPollSeconds from parameter" $ do
    queryMaxPoll `shouldBe` 5

  it "sets pollIntervalMs from parameter" $ do
    queryPollInterval `shouldBe` 100

  it "sets conditional to Nothing" $ do
    queryConditional `shouldBe` Nothing

-- | Tests for mkReadGrouped
mkReadGroupedSpec :: Spec
mkReadGroupedSpec = describe "mkReadGrouped" $ do
  let queueName = case parseQueueName "test_queue" of
        Right q -> q
        Left e -> error $ "Unexpected: " <> show e
      config =
        PgmqAdapterConfig
          { queueName = queueName,
            visibilityTimeout = 60,
            batchSize = 20,
            polling = defaultPollingConfig,
            deadLetterConfig = Nothing,
            maxRetries = 3,
            fifoConfig = Nothing,
            prefetchConfig = Nothing
          }
      ReadGrouped
        { queueName = queryQueueName,
          visibilityTimeout = queryVt,
          qty = queryQty
        } = mkReadGrouped config

  it "sets queueName from config" $ do
    queryQueueName `shouldBe` queueName

  it "sets visibilityTimeout from config" $ do
    queryVt `shouldBe` 60

  it "sets qty to batchSize" $ do
    queryQty `shouldBe` 20

-- | Tests for mergeDlqHeaders, the helper that injects the failing
-- consumer's trace context onto a DLQ message while preserving the
-- original producer's trace under x-shibuya-upstream-* keys.
mergeDlqHeadersSpec :: Spec
mergeDlqHeadersSpec = describe "mergeDlqHeaders" $ do
  it "with no consumer headers, forwards original headers verbatim" $ do
    let original =
          Just $
            object
              [ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
                "tracestate" .= ("vendor=opaque" :: String),
                "custom" .= ("value" :: String)
              ]
    mergeDlqHeaders Nothing original `shouldBe` original

  it "with no consumer headers and no original, returns Nothing" $ do
    mergeDlqHeaders Nothing Nothing `shouldBe` Nothing

  it "consumer's traceparent overrides original's, original moves under x-shibuya-upstream-traceparent" $ do
    let original =
          Just $
            object
              [ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
                "tracestate" .= ("vendor=opaque" :: String),
                "custom" .= ("preserved" :: String)
              ]
        consumerHdrs =
          Just
            [ ("traceparent", "00-consumer-trace-id-cid-01"),
              ("tracestate", "consumer=ok")
            ]
    case mergeDlqHeaders consumerHdrs original of
      Just (Object obj) -> do
        KeyMap.lookup "traceparent" obj
          `shouldBe` Just (String "00-consumer-trace-id-cid-01")
        KeyMap.lookup "tracestate" obj
          `shouldBe` Just (String "consumer=ok")
        KeyMap.lookup "x-shibuya-upstream-traceparent" obj
          `shouldBe` Just (String "00-producer-trace-id-pid-01")
        KeyMap.lookup "x-shibuya-upstream-tracestate" obj
          `shouldBe` Just (String "vendor=opaque")
        KeyMap.lookup "custom" obj
          `shouldBe` Just (String "preserved")
      other -> expectationFailure $ "expected merged Object, got " <> show other

  it "consumer's headers carry through when original headers are absent" $ do
    let consumerHdrs =
          Just
            [ ("traceparent", "00-consumer-trace-id-cid-01"),
              ("tracestate", "consumer=ok")
            ]
    case mergeDlqHeaders consumerHdrs Nothing of
      Just (Object obj) -> do
        KeyMap.lookup "traceparent" obj
          `shouldBe` Just (String "00-consumer-trace-id-cid-01")
        KeyMap.lookup "tracestate" obj
          `shouldBe` Just (String "consumer=ok")
        KeyMap.lookup "x-shibuya-upstream-traceparent" obj `shouldBe` Nothing
        KeyMap.lookup "x-shibuya-upstream-tracestate" obj `shouldBe` Nothing
      other -> expectationFailure $ "expected merged Object, got " <> show other

  it "original's tracestate is stashed even when consumer has no tracestate" $ do
    let original =
          Just $
            object
              [ "traceparent" .= ("00-producer-trace-id-pid-01" :: String),
                "tracestate" .= ("vendor=opaque" :: String)
              ]
        consumerHdrs = Just [("traceparent", "00-consumer-trace-id-cid-01")]
    case mergeDlqHeaders consumerHdrs original of
      Just (Object obj) -> do
        KeyMap.lookup "traceparent" obj
          `shouldBe` Just (String "00-consumer-trace-id-cid-01")
        KeyMap.lookup "tracestate" obj `shouldBe` Nothing
        KeyMap.lookup "x-shibuya-upstream-traceparent" obj
          `shouldBe` Just (String "00-producer-trace-id-pid-01")
        KeyMap.lookup "x-shibuya-upstream-tracestate" obj
          `shouldBe` Just (String "vendor=opaque")
      other -> expectationFailure $ "expected merged Object, got " <> show other