packages feed

shibuya-core-0.8.0.0: test/Shibuya/Batch/TestHarness.hs

module Shibuya.Batch.TestHarness
  ( -- * Scenario model
    BatchScenario (..),
    genScenario,

    -- * Envelope builders
    mkEnvelope,
    scenarioEnvelopes,
    scenarioBatchKey,
    scenarioMsgIds,
    scenarioIntended,

    -- * Exactly-once checking
    finalizedExactlyOnce,
  )
where

import Data.List (sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Shibuya.Batch (BatchKey (..))
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..))
import Shibuya.Core.Types qualified as Core
import Test.QuickCheck

-- | A randomized batch schedule. Each message index @i@ in @[1 .. msgCount]@ has a
-- distinct 'MessageId' @"msg-i"@, an assigned 'BatchKey' (@keyOf ! i@), and an
-- intended finalized decision (@outcomeOf ! i@, always 'AckOk' or an
-- 'AckDeadLetter'). @batchSize@ and @batchTimeoutMs@ configure the batcher.
data BatchScenario = BatchScenario
  { msgCount :: !Int,
    batchSize :: !Int,
    batchTimeoutMs :: !Int,
    keyOf :: !(Map Int BatchKey),
    outcomeOf :: !(Map Int AckDecision)
  }
  deriving stock (Show)

tshow :: (Show a) => a -> Text
tshow = Text.pack . show

-- | Generate a randomized scenario: 1..40 messages, a batch size that may be
-- smaller than, equal to, or larger than the message count (so some runs never
-- flush by size), a short timeout, 1..4 batch keys, and a per-message outcome that
-- is mostly 'AckOk' with an occasional dead-letter.
genScenario :: Gen BatchScenario
genScenario = do
  n <- choose (1, 40)
  bs <- choose (1, n + 5)
  toMs <- choose (20, 200)
  numKeys <- choose (1, min 4 n)
  keys <- vectorOf n (chooseKey numKeys)
  outs <- vectorOf n genOutcome
  pure
    BatchScenario
      { msgCount = n,
        batchSize = bs,
        batchTimeoutMs = toMs,
        keyOf = Map.fromList (zip [1 ..] keys),
        outcomeOf = Map.fromList (zip [1 ..] outs)
      }
  where
    chooseKey k = do
      j <- choose (1, k)
      pure (BatchKey ("k" <> tshow (j :: Int)))
    genOutcome =
      frequency
        [ (3, pure AckOk),
          (1, AckDeadLetter <$> elements [MaxRetriesExceeded, PoisonPill "qc", InvalidPayload "qc"])
        ]

instance Arbitrary BatchScenario where
  arbitrary = genScenario

-- | Build one envelope carrying a payload, with the given id number and batch key
-- stamped into the 'partition' field (so a pure @batchKey@ config function can
-- recover it).
mkEnvelope :: Int -> BatchKey -> Int -> Envelope Int
mkEnvelope i key payload =
  (Core.mkEnvelope (MessageId ("msg-" <> tshow i)) payload)
    { cursor = Just (CursorInt i),
      partition = Just key.unBatchKey
    }

-- | The envelopes for a scenario, in index order, each carrying its assigned key.
scenarioEnvelopes :: BatchScenario -> [Envelope Int]
scenarioEnvelopes s =
  [ mkEnvelope i (keyFor i) i
  | i <- [1 .. s.msgCount]
  ]
  where
    keyFor i = fromMaybe (BatchKey "default") (Map.lookup i s.keyOf)

-- | The pure @batchKey@ function to hand to 'BatchConfig': recover the key from the
-- envelope's partition, defaulting to @"default"@.
scenarioBatchKey :: Envelope Int -> BatchKey
scenarioBatchKey env = BatchKey (fromMaybe "default" env.partition)

-- | The set of message ids a scenario produces.
scenarioMsgIds :: BatchScenario -> [MessageId]
scenarioMsgIds s = [MessageId ("msg-" <> tshow i) | i <- [1 .. s.msgCount]]

-- | The intended finalized decision per message id.
scenarioIntended :: BatchScenario -> Map MessageId AckDecision
scenarioIntended s =
  Map.fromList
    [ (MessageId ("msg-" <> tshow i), fromMaybe AckOk (Map.lookup i s.outcomeOf))
    | i <- [1 .. s.msgCount]
    ]

-- | The successful-finalization checker. Given the raw tracked list of
-- @(messageId, decision)@ pairs (as recorded by a 'Shibuya.Adapter.Mock.TrackingAck',
-- which appends one entry per @finalize@ call) and the expected finalized decision
-- per id, return @Right ()@ iff every expected id appears exactly once and carries
-- its expected decision, and no unexpected id appears. Otherwise return a
-- human-readable explanation for use as a QuickCheck counterexample or an HSpec
-- failure message.
finalizedExactlyOnce ::
  [(MessageId, AckDecision)] ->
  Map MessageId AckDecision ->
  Either String ()
finalizedExactlyOnce tracked expected
  | not (null dupes) =
      Left ("finalized more than once: " <> show dupes)
  | trackedIds /= expectedIds =
      Left
        ( "id set mismatch: missing="
            <> show (expectedIds `minus` trackedIds)
            <> " extra="
            <> show (trackedIds `minus` expectedIds)
        )
  | not (null wrong) =
      Left ("wrong decision: " <> show wrong)
  | otherwise = Right ()
  where
    counts :: Map MessageId Int
    counts = Map.fromListWith (+) [(mid, 1 :: Int) | (mid, _) <- tracked]
    dupes = [mid | (mid, c) <- Map.toList counts, c /= 1]
    trackedIds = sort (Map.keys counts)
    expectedIds = sort (Map.keys expected)
    minus xs ys = filter (`notElem` ys) xs
    -- Since each id appears exactly once (checked above via dupes), a simple
    -- lookup of the single decision is well-defined here.
    got = Map.fromList tracked
    wrong =
      [ (mid, want, Map.lookup mid got)
      | (mid, want) <- Map.toList expected,
        Map.lookup mid got /= Just want
      ]