packages feed

shibuya-core-0.9.0.0: test/Shibuya/Core/AckSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Shibuya.Core.AckSpec (spec) where

import Data.Text qualified as Text
import Data.Time (secondsToNominalDiffTime)
import Shibuya.Core.Ack
import Test.Hspec

spec :: Spec
spec = do
  describe "RetryDelay" $ do
    it "wraps NominalDiffTime" $ do
      let delay = RetryDelay (secondsToNominalDiffTime 30)
      delay.unRetryDelay `shouldBe` secondsToNominalDiffTime 30

    it "supports Eq" $ do
      let d1 = RetryDelay (secondsToNominalDiffTime 10)
          d2 = RetryDelay (secondsToNominalDiffTime 10)
          d3 = RetryDelay (secondsToNominalDiffTime 20)
      d1 `shouldBe` d2
      d1 `shouldNotBe` d3

  describe "DeadLetterReason" $ do
    it "distinguishes all constructors" $ do
      let r1 = PoisonPill "bad message"
          r2 = InvalidPayload "parse error"
          r3 = MaxRetriesExceeded
          code = validCode "example.policy.rejected"
          r4 = ApplicationFailure code "policy rejected"
      r1 `shouldNotBe` r2
      r2 `shouldNotBe` r3
      r1 `shouldNotBe` r3
      r3 `shouldNotBe` r4

    it "PoisonPill carries message" $ do
      let r = PoisonPill "corrupt data"
      case r of
        PoisonPill msg -> msg `shouldBe` "corrupt data"
        _ -> expectationFailure "wrong constructor"

    it "InvalidPayload carries message" $ do
      let r = InvalidPayload "JSON decode failed"
      case r of
        InvalidPayload msg -> msg `shouldBe` "JSON decode failed"
        _ -> expectationFailure "wrong constructor"

    describe "mkDeadLetterCode" $ do
      it "accepts namespaced lowercase application codes" $ do
        fmap deadLetterCodeText (mkDeadLetterCode "keiro.router.selection.recipient_overflow")
          `shouldBe` Right "keiro.router.selection.recipient_overflow"

      it "accepts the 128-character boundary" $ do
        let code = "a." <> Text.replicate 126 "b"
        fmap deadLetterCodeText (mkDeadLetterCode code) `shouldBe` Right code

      it "rejects each invalid grammar boundary" $ do
        let invalidCodes =
              [ "",
                "unqualified",
                "keiro.Router",
                "keiro.router-selection",
                "keiro..selection",
                "1keiro.router",
                "keiro.1router",
                "keiro.routér",
                "shibuya.router",
                "a." <> Text.replicate 127 "b"
              ]
        mapM_ (\code -> mkDeadLetterCode code `shouldSatisfy` isLeft) invalidCodes

      it "identifies the rejected code and failed rule" $ do
        mkDeadLetterCode "Keiro.router"
          `shouldBe` Left "invalid dead-letter code \"Keiro.router\": segment \"Keiro\" must match [a-z][a-z0-9_]*"

    describe "dead-letter projections and rendering" $ do
      it "preserves the built-in contracts exactly" $ do
        let cases =
              [ (PoisonPill "x", "poison_pill", Just "x", "poison_pill: x"),
                (InvalidPayload "x", "invalid_payload", Just "x", "invalid_payload: x"),
                (MaxRetriesExceeded, "max_retries_exceeded", Nothing, "max_retries_exceeded")
              ]
        mapM_
          ( \(reason, code, detail, rendered) -> do
              deadLetterCodeText (deadLetterReasonCode reason) `shouldBe` code
              deadLetterReasonDetail reason `shouldBe` detail
              renderDeadLetterReason reason `shouldBe` rendered
          )
          cases

      it "preserves an application code and detail" $ do
        let code = validCode "keiro.router.selection.recipient_overflow"
            reason = ApplicationFailure code "selected 101 recipients; configured limit is 100"
        deadLetterCodeText (deadLetterReasonCode reason)
          `shouldBe` "keiro.router.selection.recipient_overflow"
        deadLetterReasonDetail reason
          `shouldBe` Just "selected 101 recipients; configured limit is 100"
        renderDeadLetterReason reason
          `shouldBe` "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"

  describe "HaltReason" $ do
    it "HaltOrderedStream carries message" $ do
      let r = HaltOrderedStream "ordering violation"
      case r of
        HaltOrderedStream msg -> msg `shouldBe` "ordering violation"
        _ -> expectationFailure "wrong constructor"

    it "HaltFatal carries message" $ do
      let r = HaltFatal "database connection lost"
      case r of
        HaltFatal msg -> msg `shouldBe` "database connection lost"
        _ -> expectationFailure "wrong constructor"

  describe "AckDecision" $ do
    it "distinguishes all constructors" $ do
      let d1 = AckOk
          d2 = AckRetry (RetryDelay 10)
          d3 = AckDeadLetter MaxRetriesExceeded
          d4 = AckHalt (HaltFatal "error")
      d1 `shouldNotBe` d2
      d2 `shouldNotBe` d3
      d3 `shouldNotBe` d4
      d1 `shouldNotBe` d4

    it "AckOk equals AckOk" $ do
      AckOk `shouldBe` AckOk

    it "AckRetry carries delay" $ do
      let delay = RetryDelay (secondsToNominalDiffTime 60)
          decision = AckRetry delay
      case decision of
        AckRetry d -> d `shouldBe` delay
        _ -> expectationFailure "wrong constructor"

    it "AckDeadLetter carries reason" $ do
      let reason = PoisonPill "unprocessable"
          decision = AckDeadLetter reason
      case decision of
        AckDeadLetter r -> r `shouldBe` reason
        _ -> expectationFailure "wrong constructor"

    it "AckHalt carries reason" $ do
      let reason = HaltOrderedStream "must stop"
          decision = AckHalt reason
      case decision of
        AckHalt r -> r `shouldBe` reason
        _ -> expectationFailure "wrong constructor"

isLeft :: Either a b -> Bool
isLeft (Left _) = True
isLeft (Right _) = False

validCode :: Text.Text -> DeadLetterCode
validCode code =
  case mkDeadLetterCode code of
    Left err -> error $ "invalid test fixture: " <> show err
    Right valid -> valid