packages feed

rdkafka-hs-1.1.0.0: test-integration/Kafka/Integration/HeadersTest.hs

-- | Record headers survive the real broker path in wire order (IR-3): the
-- list a consumer decodes is exactly the list the producer sent, repeated
-- names and identical pairs included, through both polling APIs in both
-- callback modes.
module Kafka.Integration.HeadersTest
  ( tests,
  )
where

import Control.Exception (bracket, throwIO)
import Control.Monad (forM_)
import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import GHC.Clock (getMonotonicTime)
import Kafka.Consumer
import Kafka.Producer (ProducePartition (..), ProducerRecord (..))
import Kafka.TestEnv
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertFailure, testCase, (@?=))

-- | Which public polling function a case reads through.
data PollApi = PollMessage | PollMessageAttributed

data Fixture = Fixture
  { fxSlug :: Text,
    fxName :: String,
    fxHeaders :: [(ByteString, ByteString)],
    -- | header names and the value last-occurrence selection must return
    fxLast :: [(ByteString, Maybe ByteString)]
  }

tests :: TestEnv -> TestTree
tests env =
  testGroup
    "Headers wire order"
    [ testGroup
        (apiName <> " " <> modeName)
        [ testCase (fxName fx) (scenario env api mode (apiSlug <> "-" <> modeSlug) fx)
        | fx <- fixtures
        ]
    | (api, apiName, apiSlug) <- [(PollMessage, "pollMessage", "plain"), (PollMessageAttributed, "pollMessageAttributed", "attributed")],
      (mode, modeName, modeSlug) <- [(CallbackPollModeSync, "sync", "sync"), (CallbackPollModeAsync, "async", "async")]
    ]

fixtures :: [Fixture]
fixtures =
  [ Fixture
      "interleaved"
      "interleaved duplicate names keep wire order and last values"
      [("event-type", "Other"), ("account", "two"), ("account", "one"), ("event-type", "Happened")]
      [("event-type", Just "Happened"), ("account", Just "one"), ("absent", Nothing)],
    Fixture
      "distinct"
      "distinct names keep wire order"
      [("a-header", "1"), ("b-header", "2"), ("c-header", "3")]
      [("b-header", Just "2"), ("absent", Nothing)],
    Fixture
      "identical"
      "identical repeated pairs are not deduplicated"
      [("repeat", "same"), ("repeat", "same"), ("tail", "end")]
      [("repeat", Just "same"), ("tail", Just "end")],
    Fixture
      "single"
      "a single header round-trips"
      [("only", "value")]
      [("only", Just "value"), ("absent", Nothing)],
    Fixture
      "none"
      "no headers decode as an empty list"
      []
      [("absent", Nothing)]
  ]

-- | Produce one record carrying the fixture's headers to a fresh topic, read
-- it back through a fresh consumer group, and compare the complete list.
scenario :: TestEnv -> PollApi -> CallbackPollMode -> Text -> Fixture -> Assertion
scenario env api mode label fx =
  withTopic env suffix 1 $ \topic producer -> do
    let payload = encodeUtf8 suffix
    sendMessagesWithHeaders [ProducerRecord topic (SpecifiedPartition 0) Nothing (Just payload) mempty] (headersFromList (fxHeaders fx)) producer
      >>= either throwIO pure
    let props = consumerProps env <> groupId (makeGroupId env suffix) <> callbackPollMode mode
    bracket (mkConsumerFor topic props) releaseConsumer $ \consumer -> do
      record <- awaitRecord api consumer
      crTopic record @?= topic
      crPartition record @?= PartitionId 0
      crValue record @?= Just payload
      let received = headersToList (crHeaders record)
      received @?= fxHeaders fx
      forM_ (fxLast fx) $ \(name, expected) ->
        lastValue name received @?= expected
  where
    suffix = "headers-" <> label <> "-" <> fxSlug fx

-- | The value of the last header with the given name.
lastValue :: ByteString -> [(ByteString, ByteString)] -> Maybe ByteString
lastValue name = foldl' (\acc (n, v) -> if n == name then Just v else acc) Nothing

-- | The next record, retrying poll timeouts for at most ten seconds. Any other
-- error, or no record by the deadline, fails the case.
awaitRecord :: PollApi -> KafkaConsumer -> IO (ConsumerRecord (Maybe ByteString) (Maybe ByteString))
awaitRecord api consumer = do
  deadline <- (+ 10) <$> getMonotonicTime
  let go = do
        now <- getMonotonicTime
        if now > deadline
          then assertFailure "no record arrived within 10 seconds"
          else
            poll (Timeout 200) >>= \case
              Right record -> pure record
              Left (KafkaResponseError RdKafkaRespErrTimedOut) -> go
              Left err -> assertFailure ("poll failed: " <> show err)
  go
  where
    poll = case api of
      PollMessage -> pollMessage consumer
      PollMessageAttributed -> fmap (first cmeError) . pollMessageAttributed consumer