packages feed

bloodhound-1.0.0.0: tests/Test/NotificationsSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Test.NotificationsSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Monad.Catch (bracket_)
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List qualified as L
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Time.Clock.POSIX (getPOSIXTime)
import Database.Bloodhound.OpenSearch2.Client qualified as OS2Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2Types
import Database.Bloodhound.OpenSearch3.Client qualified as OS3Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- ---------------------------------------------------------------------------
-- Sample bodies, drawn verbatim from the OS Notifications plugin docs
-- (<https://docs.opensearch.org/latest/observing-your-data/notifications/api/>).
-- ---------------------------------------------------------------------------

-- | The canonical POST body for a Slack channel — exactly the shape
-- documented under "Create notification configuration".
sampleSlackConfigJson :: LBS.ByteString
sampleSlackConfigJson =
  "{\
  \  \"name\": \"Sample Slack Channel\",\
  \  \"description\": \"This is a Slack channel\",\
  \  \"config_type\": \"slack\",\
  \  \"is_enabled\": true,\
  \  \"slack\": {\"url\": \"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\"}\
  \}"

-- | An SNS config with the optional @role_arn@ omitted — exercises the
-- 'Maybe' field on 'SnsTransport'.
sampleSnsConfigNoRoleJson :: LBS.ByteString
sampleSnsConfigNoRoleJson =
  "{\
  \  \"name\": \"SNS no role\",\
  \  \"config_type\": \"sns\",\
  \  \"sns\": {\"topic_arn\": \"arn:aws:sns:us-east-1:123:topic\"}\
  \}"

-- | The POST request body wrapping a Slack config with a caller-supplied
-- @config_id@.
sampleCreateRequestJson :: LBS.ByteString
sampleCreateRequestJson =
  "{\
  \  \"config_id\": \"sample-id\",\
  \  \"config\": {\
  \    \"name\": \"Sample Slack Channel\",\
  \    \"description\": \"This is a Slack channel\",\
  \    \"config_type\": \"slack\",\
  \    \"is_enabled\": true,\
  \    \"slack\": {\"url\": \"https://sample-slack-webhook\"}\
  \  }\
  \}"

-- | The create response body — a single-field object echoing @config_id@.
sampleCreateResponseJson :: LBS.ByteString
sampleCreateResponseJson = "{\"config_id\":\"abc-123\"}"

spec :: Spec
spec = describe "Notifications plugin" $ do
  -- =======================================================================
  -- NotificationConfigType
  -- =======================================================================
  describe "NotificationConfigType JSON" $ do
    let cases =
          [ (NotificationConfigTypeSlack, "slack"),
            (NotificationConfigTypeChime, "chime"),
            (NotificationConfigTypeWebhook, "webhook"),
            (NotificationConfigTypeMicrosoftTeams, "microsoft_teams"),
            (NotificationConfigTypeSns, "sns"),
            (NotificationConfigTypeSesAccount, "ses_account"),
            (NotificationConfigTypeSmtpAccount, "smtp_account"),
            (NotificationConfigTypeEmailGroup, "email_group"),
            (NotificationConfigTypeEmail, "email")
          ]
    forM_ cases $ \(t, wire) -> do
      it ("encodes " <> show wire <> " to the documented wire string") $ do
        encode t `shouldBe` "\"" <> LBS.fromStrict wire <> "\""
      it ("decodes the documented wire string " <> show wire) $ do
        decode ("\"" <> LBS.fromStrict wire <> "\"" :: LBS.ByteString)
          `shouldBe` Just t

    it "rejects an unknown config_type" $ do
      decode "\"slack_v2\"" `shouldBe` (Nothing :: Maybe NotificationConfigType)

    it "rejects a non-string config_type" $ do
      decode "42" `shouldBe` (Nothing :: Maybe NotificationConfigType)

    it "round-trips through ToJSON/FromJSON" $
      property $ \(t :: NotificationConfigType) ->
        (decode . encode) t === Just t

  -- =======================================================================
  -- SlackTransport / ChimeTransport / WebhookTransport / MicrosoftTeams
  -- =======================================================================
  describe "SlackTransport JSON" $ do
    it "round-trips a url" $ do
      let t = SlackTransport "https://hooks.slack.com/abc"
      (decode . encode) t `shouldBe` Just t

    it "encodes to a single-field url object" $
      encode (SlackTransport "https://x")
        `shouldBe` "{\"url\":\"https://x\"}"

    it "requires the url field" $
      decode "{}" `shouldBe` (Nothing :: Maybe SlackTransport)

  describe "ChimeTransport JSON" $ do
    it "round-trips a url" $ do
      let t = ChimeTransport "https://chime.webhook"
      (decode . encode) t `shouldBe` Just t
    it "encodes to a single-field url object" $
      encode (ChimeTransport "https://y")
        `shouldBe` "{\"url\":\"https://y\"}"

  describe "WebhookTransport JSON" $ do
    it "round-trips a url" $ do
      let t = WebhookTransport "https://custom.example/hook"
      (decode . encode) t `shouldBe` Just t

  describe "MicrosoftTeamsTransport JSON" $ do
    it "round-trips a url" $ do
      let t = MicrosoftTeamsTransport "https://teams.webhook"
      (decode . encode) t `shouldBe` Just t
    it "encodes under the microsoft_teams wire key when wrapped" $ do
      let wrapped = NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "https://teams.webhook")
      encode wrapped `shouldBe` "{\"microsoft_teams\":{\"url\":\"https://teams.webhook\"}}"

  -- =======================================================================
  -- SnsTransport (exercises Maybe field)
  -- =======================================================================
  describe "SnsTransport JSON" $ do
    it "round-trips with role_arn populated" $ do
      let t = SnsTransport "arn:aws:sns:..." (Just "arn:aws:iam::role")
      (decode . encode) t `shouldBe` Just t

    it "round-trips with role_arn omitted" $ do
      let t = SnsTransport "arn:aws:sns:..." Nothing
      (decode . encode) t `shouldBe` Just t

    it "omits role_arn when Nothing (wire shape)" $
      encode (SnsTransport "arn:aws:sns:..." Nothing)
        `shouldBe` "{\"topic_arn\":\"arn:aws:sns:...\"}"

    it "requires topic_arn" $
      decode "{\"role_arn\":\"x\"}" `shouldBe` (Nothing :: Maybe SnsTransport)

  -- =======================================================================
  -- SesAccountTransport / SmtpAccountTransport
  -- =======================================================================
  describe "SesAccountTransport JSON" $ do
    it "round-trips the documented shape" $ do
      let t =
            SesAccountTransport
              { sesAccountTransportRegion = "us-east-1",
                sesAccountTransportRoleArn = "arn:aws:iam::012345678912:role/x",
                sesAccountTransportFromAddress = "test@email.com"
              }
      (decode . encode) t `shouldBe` Just t
    it "requires region, role_arn, from_address" $
      decode "{\"region\":\"us-east-1\"}" `shouldBe` (Nothing :: Maybe SesAccountTransport)

  describe "SmtpAccountTransport JSON" $ do
    it "round-trips the documented shape" $ do
      let t =
            SmtpAccountTransport
              { smtpAccountTransportHost = "smtp.example.com",
                smtpAccountTransportPort = 587,
                smtpAccountTransportMethod = SmtpMethodStartTls,
                smtpAccountTransportFromAddress = "test@email.com"
              }
      (decode . encode) t `shouldBe` Just t
    it "round-trips each SmtpMethod variant" $
      mapM_
        ( \m -> do
            let t = SmtpAccountTransport "h" 25 m "f@e.com"
            (decode . encode) t `shouldBe` Just t
        )
        [SmtpMethodNone, SmtpMethodSsl, SmtpMethodStartTls]
    it "rejects an unknown method" $
      ( decode "{\"host\":\"h\",\"port\":25,\"method\":\"bogus\",\"from_address\":\"f@e.com\"}" ::
          Maybe SmtpAccountTransport
      )
        `shouldBe` Nothing

  -- =======================================================================
  -- EmailRecipient / EmailGroupTransport / EmailTransport
  -- =======================================================================
  describe "EmailRecipient JSON" $ do
    it "round-trips an object {recipient: ...}" $ do
      let r = EmailRecipient "alice@example.com"
      (decode . encode) r `shouldBe` Just r
    it "encodes to {\"recipient\":\"...\"}" $
      encode (EmailRecipient "x@y.com") `shouldBe` "{\"recipient\":\"x@y.com\"}"
    it "accepts the bare-string variant shown in docs (decode-only)" $
      decode "\"alice@example.com\"" `shouldBe` Just (EmailRecipient "alice@example.com")
    it "OS2: accepts the bare-string variant shown in docs (decode-only)" $
      decode "\"alice@example.com\"" `shouldBe` Just (OS2Types.EmailRecipient "alice@example.com")

  describe "EmailGroupTransport JSON" $ do
    it "round-trips a non-empty recipient list" $ do
      let t =
            EmailGroupTransport
              { emailGroupTransportRecipientList =
                  EmailRecipient "a@b.com" :| [EmailRecipient "c@d.com"]
              }
      (decode . encode) t `shouldBe` Just t
    it "requires a non-empty recipient_list" $
      decode "{\"recipient_list\":[]}" `shouldBe` (Nothing :: Maybe EmailGroupTransport)

  describe "EmailTransport JSON" $ do
    it "round-trips with email_group_id_list populated" $ do
      let t =
            EmailTransport
              { emailTransportEmailAccountId = "smtp-1",
                emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
                emailTransportEmailGroupIdList = Just ["g1", "g2"]
              }
      (decode . encode) t `shouldBe` Just t
    it "round-trips with email_group_id_list omitted" $ do
      let t =
            EmailTransport
              { emailTransportEmailAccountId = "smtp-1",
                emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
                emailTransportEmailGroupIdList = Nothing
              }
      (decode . encode) t `shouldBe` Just t
    it "omits email_group_id_list when Nothing (wire shape)" $ do
      let bs =
            LBS.toStrict . encode $
              EmailTransport
                { emailTransportEmailAccountId = "smtp-1",
                  emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
                  emailTransportEmailGroupIdList = Nothing
                }
      bs `shouldSatisfy` BS.isInfixOf "\"email_account_id\""
      -- Assert absence: a buggy encoder that emitted `email_group_id_list`
      -- for `Nothing` would silently pass without this negative check.
      bs `shouldSatisfy` not . BS.isInfixOf "email_group_id_list"

  -- =======================================================================
  -- NotificationTransport sum
  -- =======================================================================
  describe "NotificationTransport JSON" $ do
    it "encodes Slack variant under the slack wire key" $ do
      let t = NotificationTransportSlack (SlackTransport "https://x")
      encode t `shouldBe` "{\"slack\":{\"url\":\"https://x\"}}"

    it "encodes Sns variant under the sns wire key" $ do
      let t = NotificationTransportSns (SnsTransport "arn:sns" Nothing)
      encode t `shouldBe` "{\"sns\":{\"topic_arn\":\"arn:sns\"}}"

    it "encodes SmtpAccount variant under the smtp_account wire key" $ do
      let t =
            NotificationTransportSmtpAccount
              ( SmtpAccountTransport
                  { smtpAccountTransportHost = "h",
                    smtpAccountTransportPort = 25,
                    smtpAccountTransportMethod = SmtpMethodStartTls,
                    smtpAccountTransportFromAddress = "f@e.com"
                  }
              )
          bs = LBS.toStrict (encode t)
      -- Aeson 2.x sorts object keys alphabetically on encode; assert each
      -- substring is present rather than the exact byte ordering.
      bs `shouldSatisfy` BS.isInfixOf "\"smtp_account\":"
      bs `shouldSatisfy` BS.isInfixOf "\"host\":\"h\""
      bs `shouldSatisfy` BS.isInfixOf "\"port\":25"
      bs `shouldSatisfy` BS.isInfixOf "\"method\":\"start_tls\""
      bs `shouldSatisfy` BS.isInfixOf "\"from_address\":\"f@e.com\""

    it "encodes EmailGroup variant under the email_group wire key" $ do
      let t =
            NotificationTransportEmailGroup
              ( EmailGroupTransport
                  { emailGroupTransportRecipientList = EmailRecipient "a@b" :| []
                  }
              )
      encode t
        `shouldBe` "{\"email_group\":{\"recipient_list\":[{\"recipient\":\"a@b\"}]}}"

    it "decodes a single-variant transport body" $
      decode "{\"chime\":{\"url\":\"https://c\"}}"
        `shouldBe` Just (NotificationTransportChime (ChimeTransport "https://c"))

    it "rejects an empty transport object" $
      decode "{}" `shouldBe` (Nothing :: Maybe NotificationTransport)

    it "rejects an unknown transport object" $
      decode "{\"unknown_transport\":{\"x\":1}}"
        `shouldBe` (Nothing :: Maybe NotificationTransport)

    it "rejects a multi-transport object (server would reject anyway)" $
      -- Both 'slack' and 'chime' keys are present. The decoder detects
      -- multiple known transport keys and parse-fails rather than
      -- silently picking the first.
      decode "{\"slack\":{\"url\":\"a\"},\"chime\":{\"url\":\"b\"}}"
        `shouldBe` (Nothing :: Maybe NotificationTransport)

  -- =======================================================================
  -- transportConfigType invariant
  -- =======================================================================
  describe "transportConfigType" $ do
    it "recovers NotificationConfigTypeSlack from a Slack transport" $
      transportConfigType (NotificationTransportSlack (SlackTransport "x"))
        `shouldBe` NotificationConfigTypeSlack
    it "recovers NotificationConfigTypeMicrosoftTeams from a Teams transport" $
      transportConfigType
        (NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "x"))
        `shouldBe` NotificationConfigTypeMicrosoftTeams
    it "recovers NotificationConfigTypeEmailGroup from an EmailGroup transport" $
      transportConfigType
        ( NotificationTransportEmailGroup
            (EmailGroupTransport (EmailRecipient "x" :| []))
        )
        `shouldBe` NotificationConfigTypeEmailGroup

    -- Parametric coverage across all 9 variants: prevents a future
    -- copy-paste error in transportConfigType from going undetected.
    let allTransports :: [(NotificationConfigType, NotificationTransport)]
        allTransports =
          [ (NotificationConfigTypeSlack, NotificationTransportSlack (SlackTransport "u")),
            (NotificationConfigTypeChime, NotificationTransportChime (ChimeTransport "u")),
            ( NotificationConfigTypeWebhook,
              NotificationTransportWebhook (WebhookTransport "u")
            ),
            ( NotificationConfigTypeMicrosoftTeams,
              NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "u")
            ),
            ( NotificationConfigTypeSns,
              NotificationTransportSns (SnsTransport "arn" Nothing)
            ),
            ( NotificationConfigTypeSesAccount,
              NotificationTransportSesAccount
                (SesAccountTransport "us-east-1" "arn:role" "from@x.com")
            ),
            ( NotificationConfigTypeSmtpAccount,
              NotificationTransportSmtpAccount
                (SmtpAccountTransport "smtp.x" 587 SmtpMethodStartTls "from@x.com")
            ),
            ( NotificationConfigTypeEmailGroup,
              NotificationTransportEmailGroup
                (EmailGroupTransport (EmailRecipient "a@b" :| []))
            ),
            ( NotificationConfigTypeEmail,
              NotificationTransportEmail
                ( EmailTransport
                    { emailTransportEmailAccountId = "smtp-1",
                      emailTransportRecipientList = EmailRecipient "x@y" :| [],
                      emailTransportEmailGroupIdList = Nothing
                    }
                )
            )
          ]

    forM_ allTransports $ \(expectedType, transport) ->
      it ("transportConfigType returns " <> show expectedType <> " for its variant") $
        transportConfigType transport `shouldBe` expectedType

  -- =======================================================================
  -- NotificationConfig
  -- =======================================================================
  describe "NotificationConfig JSON" $ do
    let slackConfig =
          NotificationConfig
            { notificationConfigName = "Sample Slack Channel",
              notificationConfigDescription = Just "This is a Slack channel",
              notificationConfigIsEnabled = Just True,
              notificationConfigTransport =
                NotificationTransportSlack (SlackTransport "https://sample-slack-webhook")
            }

    it "decodes the documented Slack sample body" $ do
      let Just decoded = decode sampleSlackConfigJson :: Maybe NotificationConfig
      notificationConfigName decoded `shouldBe` "Sample Slack Channel"
      notificationConfigDescription decoded `shouldBe` Just "This is a Slack channel"
      notificationConfigIsEnabled decoded `shouldBe` Just True

    it "round-trips the documented Slack sample body" $ do
      let Just decoded = decode sampleSlackConfigJson :: Maybe NotificationConfig
      (decode . encode) decoded `shouldBe` Just decoded

    it "encodes config_type derived from the transport variant" $ do
      let bs = encode slackConfig
      LBS.toStrict bs `shouldSatisfy` BS.isInfixOf "\"config_type\":\"slack\""

    it "encodes exactly one transport key matching config_type" $ do
      let bs = LBS.toStrict (encode slackConfig)
      BS.isInfixOf "\"slack\":" bs `shouldBe` True
      BS.isInfixOf "\"sns\":" bs `shouldBe` False
      BS.isInfixOf "\"email\":" bs `shouldBe` False

    it "decodes an SNS body without role_arn (optional field)" $ do
      let Just (decoded :: NotificationConfig) = decode sampleSnsConfigNoRoleJson
      case notificationConfigTransport decoded of
        NotificationTransportSns sns -> snsTransportRoleArn sns `shouldBe` Nothing
        _ -> expectationFailure "expected SNS transport"

    it "round-trips with all optional fields as Nothing" $ do
      let cfg =
            NotificationConfig
              { notificationConfigName = "n",
                notificationConfigDescription = Nothing,
                notificationConfigIsEnabled = Nothing,
                notificationConfigTransport =
                  NotificationTransportSlack (SlackTransport "u")
              }
      (decode . encode) cfg `shouldBe` Just cfg

    it "fails when config_type disagrees with the transport key" $ do
      -- Body claims slack but only provides a chime block. The decoder
      -- looks up the slack key on a slack config_type; finding nothing
      -- parse-fails.
      let bad =
            "{\
            \  \"name\": \"x\",\
            \  \"config_type\": \"slack\",\
            \  \"chime\": {\"url\": \"https://c\"}\
            \}"
      decode bad `shouldBe` (Nothing :: Maybe NotificationConfig)

    it "fails when the transport key is absent entirely" $ do
      let bad =
            "{\
            \  \"name\": \"x\",\
            \  \"config_type\": \"slack\"\
            \}"
      decode bad `shouldBe` (Nothing :: Maybe NotificationConfig)

    -- Parametric coverage: round-trip a NotificationConfig built from
    -- each of the 9 transport variants. This exercises both ToJSON
    -- (transport key + derived config_type) and FromJSON (config_type
    -- dispatch + transport-key lookup) for every variant — catches
    -- typos in either direction.
    let mkConfig t =
          NotificationConfig
            { notificationConfigName = "n",
              notificationConfigDescription = Nothing,
              notificationConfigIsEnabled = Nothing,
              notificationConfigTransport = t
            }
        variants =
          [ ( "slack",
              NotificationTransportSlack (SlackTransport "u"),
              "\"config_type\":\"slack\"",
              "\"slack\":{\"url\":\"u\"}"
            ),
            ( "chime",
              NotificationTransportChime (ChimeTransport "u"),
              "\"config_type\":\"chime\"",
              "\"chime\":{\"url\":\"u\"}"
            ),
            ( "webhook",
              NotificationTransportWebhook (WebhookTransport "u"),
              "\"config_type\":\"webhook\"",
              "\"webhook\":{\"url\":\"u\"}"
            ),
            ( "microsoft_teams",
              NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "u"),
              "\"config_type\":\"microsoft_teams\"",
              "\"microsoft_teams\":{\"url\":\"u\"}"
            ),
            ( "sns",
              NotificationTransportSns (SnsTransport "arn" Nothing),
              "\"config_type\":\"sns\"",
              "\"sns\":{\"topic_arn\":\"arn\"}"
            ),
            ( "ses_account",
              NotificationTransportSesAccount
                (SesAccountTransport "us-east-1" "arn:role" "from@x.com"),
              "\"config_type\":\"ses_account\"",
              "\"ses_account\":"
            ),
            ( "smtp_account",
              NotificationTransportSmtpAccount
                (SmtpAccountTransport "smtp.x" 587 SmtpMethodStartTls "from@x.com"),
              "\"config_type\":\"smtp_account\"",
              "\"smtp_account\":"
            ),
            ( "email_group",
              NotificationTransportEmailGroup
                (EmailGroupTransport (EmailRecipient "a@b" :| [])),
              "\"config_type\":\"email_group\"",
              "\"email_group\":"
            ),
            ( "email",
              NotificationTransportEmail
                ( EmailTransport
                    { emailTransportEmailAccountId = "smtp-1",
                      emailTransportRecipientList = EmailRecipient "x@y" :| [],
                      emailTransportEmailGroupIdList = Nothing
                    }
                ),
              "\"config_type\":\"email\"",
              "\"email\":"
            )
          ]

    forM_ variants $ \(label, transport, expectedTypeSubstr, expectedTransportSubstr) -> do
      it ("round-trips a NotificationConfig with " <> label <> " transport") $ do
        let cfg = mkConfig transport
        (decode . encode) cfg `shouldBe` Just cfg
      it ("encodes " <> label <> " config_type derived from the transport") $ do
        let bs = LBS.toStrict (encode (mkConfig transport))
        bs `shouldSatisfy` BS.isInfixOf expectedTypeSubstr
      it ("encodes " <> label <> " under the matching transport key") $ do
        let bs = LBS.toStrict (encode (mkConfig transport))
        bs `shouldSatisfy` BS.isInfixOf expectedTransportSubstr

  -- =======================================================================
  -- CreateNotificationConfigRequest
  -- =======================================================================
  describe "CreateNotificationConfigRequest JSON" $ do
    let slackConfig =
          NotificationConfig
            { notificationConfigName = "Sample Slack Channel",
              notificationConfigDescription = Just "desc",
              notificationConfigIsEnabled = Just True,
              notificationConfigTransport =
                NotificationTransportSlack (SlackTransport "https://x")
            }

    it "round-trips with a caller-supplied config_id" $ do
      let req = CreateNotificationConfigRequest (Just "sample-id") slackConfig
      (decode . encode) req `shouldBe` Just req

    it "round-trips without a config_id (server will assign)" $ do
      let req = CreateNotificationConfigRequest Nothing slackConfig
      (decode . encode) req `shouldBe` Just req

    it "encodes both config_id and config when both are present" $ do
      let req = CreateNotificationConfigRequest (Just "abc") slackConfig
          bs = LBS.toStrict (encode req)
      BS.isInfixOf "\"config_id\":\"abc\"" bs `shouldBe` True
      BS.isInfixOf "\"config\":" bs `shouldBe` True

    it "omits config_id when Nothing" $ do
      let req = CreateNotificationConfigRequest Nothing slackConfig
          bs = LBS.toStrict (encode req)
      BS.isInfixOf "config_id" bs `shouldBe` False

    it "decodes the documented sample body" $ do
      let Just (decoded :: CreateNotificationConfigRequest) = decode sampleCreateRequestJson
      createNotificationConfigRequestId decoded `shouldBe` Just "sample-id"

    it "ignores the docs' alternative 'id' field name (config_id is the documented one)" $ do
      -- The plugin docs inconsistently show "id" in one example instead of
      -- the documented "config_id". We read only "config_id" and ignore
      -- the alternative, so the body parses to a request with
      -- config_id=Nothing rather than a wire-shape error. This is the
      -- silent-ignore path; an explicit parse-fail would require extra
      -- work for no real safety gain since the server is the ultimate
      -- authority on what it accepts.
      let body =
            "{\
            \  \"id\": \"sample-id\",\
            \  \"config\": {\
            \    \"name\": \"x\",\
            \    \"config_type\": \"slack\",\
            \    \"slack\": {\"url\": \"https://x\"}\
            \  }\
            \}"
          Just (decoded :: CreateNotificationConfigRequest) = decode body
      createNotificationConfigRequestId decoded `shouldBe` Nothing

  -- =======================================================================
  -- CreateNotificationConfigResponse
  -- =======================================================================
  describe "CreateNotificationConfigResponse JSON" $ do
    it "decodes the documented {config_id:...} body" $ do
      let Just (decoded :: CreateNotificationConfigResponse) = decode sampleCreateResponseJson
      createNotificationConfigResponseConfigId decoded `shouldBe` "abc-123"

    it "round-trips" $ do
      let r = CreateNotificationConfigResponse "xyz"
      (decode . encode) r `shouldBe` Just r

    it "fails when config_id is missing" $
      decode "{}" `shouldBe` (Nothing :: Maybe CreateNotificationConfigResponse)

  -- =======================================================================
  -- UpdateNotificationConfigRequest (PUT /_plugins/_notifications/configs/{id})
  -- =======================================================================
  describe "UpdateNotificationConfigRequest JSON" $ do
    let slackConfig =
          NotificationConfig
            { notificationConfigName = "Sample Slack Channel",
              notificationConfigDescription = Just "desc",
              notificationConfigIsEnabled = Just True,
              notificationConfigTransport =
                NotificationTransportSlack (SlackTransport "https://x")
            }

    it "round-trips a wrapped config" $ do
      let req = UpdateNotificationConfigRequest slackConfig
      (decode . encode) req `shouldBe` Just req

    it "encodes only the config key (no top-level config_id)" $ do
      let req = UpdateNotificationConfigRequest slackConfig
          bs = LBS.toStrict (encode req)
      -- The update body identity is the URL path, not a body field;
      -- a buggy encoder that emitted config_id would mislead callers
      -- into thinking the update is idempotent on the body alone.
      BS.isInfixOf "\"config\":" bs `shouldBe` True
      BS.isInfixOf "\"config_id\"" bs `shouldBe` False

    it "decodes the documented PUT body verbatim" $ do
      let body =
            "{\
            \  \"config\": {\
            \    \"name\": \"Slack Channel\",\
            \    \"description\": \"This is an updated channel configuration\",\
            \    \"config_type\": \"slack\",\
            \    \"is_enabled\": true,\
            \    \"slack\": {\"url\": \"https://hooks.slack.com/sample-url\"}\
            \  }\
            \}"
          Just (decoded :: UpdateNotificationConfigRequest) = decode body
      notificationConfigName (updateNotificationConfigRequestConfig decoded)
        `shouldBe` "Slack Channel"

    it "fails when the config key is absent" $
      decode "{}" `shouldBe` (Nothing :: Maybe UpdateNotificationConfigRequest)

  -- =======================================================================
  -- Endpoint shape
  -- =======================================================================
  describe "createNotificationConfig endpoint shape (OpenSearch 3)" $ do
    let cfg =
          NotificationConfig
            { notificationConfigName = "n",
              notificationConfigDescription = Nothing,
              notificationConfigIsEnabled = Nothing,
              notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
            }

    it "POSTs to /_plugins/_notifications/configs" $ do
      let req = OS3Requests.createNotificationConfig cfg
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the POST method" $ do
      let req = OS3Requests.createNotificationConfig cfg
      bhRequestMethod req `shouldBe` "POST"

    it "attaches a non-empty body" $ do
      let req = OS3Requests.createNotificationConfig cfg
      bhRequestBody req `shouldSatisfy` isJust

    it "forwards the config_id when supplied via With" $ do
      let req = OS3Requests.createNotificationConfigWith (Just "my-id") cfg
          Just body = bhRequestBody req
      LBS.toStrict body `shouldSatisfy` BS.isInfixOf "\"config_id\":\"my-id\""

    it "omits config_id from the body when the plain variant is used" $ do
      let req = OS3Requests.createNotificationConfig cfg
          Just body = bhRequestBody req
      LBS.toStrict body `shouldSatisfy` not . BS.isInfixOf "config_id"

  -- =======================================================================
  -- Cross-backend parity: OS2 and OS3 must agree on path/method/body.
  -- (Notifications plugin does not exist on OS1.)
  -- =======================================================================
  describe "cross-backend parity (OS2 vs OS3)" $ do
    let mkOS2 :: OS2Types.NotificationConfig
        mkOS2 =
          OS2Types.NotificationConfig
            { OS2Types.notificationConfigName = "n",
              OS2Types.notificationConfigDescription = Nothing,
              OS2Types.notificationConfigIsEnabled = Nothing,
              OS2Types.notificationConfigTransport =
                OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
            }
        mkOS3 :: NotificationConfig
        mkOS3 =
          NotificationConfig
            { notificationConfigName = "n",
              notificationConfigDescription = Nothing,
              notificationConfigIsEnabled = Nothing,
              notificationConfigTransport =
                NotificationTransportSlack (SlackTransport "u")
            }

    it "createNotificationConfig emits identical paths across OS2/OS3" $ do
      let p2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.createNotificationConfig mkOS2))
          p3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.createNotificationConfig mkOS3))
      p2 `shouldBe` ["_plugins", "_notifications", "configs"]
      p3 `shouldBe` p2

    it "createNotificationConfig emits identical methods across OS2/OS3" $ do
      let methods =
            [ bhRequestMethod (OS2Requests.createNotificationConfig mkOS2),
              bhRequestMethod (OS3Requests.createNotificationConfig mkOS3)
            ]
      length (L.nub methods) `shouldBe` 1
      head methods `shouldBe` "POST"

    it "createNotificationConfig emits identical bodies across OS2/OS3" $ do
      let bodies =
            catMaybes
              [ bhRequestBody (OS2Requests.createNotificationConfig mkOS2),
                bhRequestBody (OS3Requests.createNotificationConfig mkOS3)
              ]
      length bodies `shouldBe` 2
      length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1

    it "createNotificationConfigWith emits identical bodies across OS2/OS3" $ do
      let bodies =
            catMaybes
              [ bhRequestBody (OS2Requests.createNotificationConfigWith (Just "id") mkOS2),
                bhRequestBody (OS3Requests.createNotificationConfigWith (Just "id") mkOS3)
              ]
      length bodies `shouldBe` 2
      length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1

  -- =======================================================================
  -- DeleteNotificationConfigResponse (DELETE /_plugins/_notifications/configs/{id})
  -- =======================================================================
  describe "DeleteNotificationConfigResponse JSON" $ do
    it "decodes the documented single-id OK response" $ do
      let decoded =
            decode
              "{\"delete_response_list\":{\"sample-id\":\"OK\"}}" ::
              Maybe DeleteNotificationConfigResponse
      deleteResponseList <$> decoded `shouldBe` Just (Map.singleton "sample-id" "OK")

    it "encodes back to the documented wire shape" $ do
      let resp =
            DeleteNotificationConfigResponse
              { deleteResponseList = Map.singleton "sample-id" "OK"
              }
      encode resp
        `shouldBe` "{\"delete_response_list\":{\"sample-id\":\"OK\"}}"

    it "round-trips through ToJSON/FromJSON" $
      property $ \(resp :: DeleteNotificationConfigResponse) ->
        (decode . encode) resp === Just resp

    it "decodes an empty delete_response_list" $ do
      let decoded = decode "{\"delete_response_list\":{}}" :: Maybe DeleteNotificationConfigResponse
      deleteResponseList <$> decoded `shouldBe` Just Map.empty

  describe "deleteResponseStatus" $ do
    it "returns the status for a present config_id" $ do
      let resp =
            DeleteNotificationConfigResponse
              { deleteResponseList = Map.fromList [("a", "OK"), ("b", "FAIL")]
              }
      deleteResponseStatus "a" resp `shouldBe` Just "OK"
      deleteResponseStatus "b" resp `shouldBe` Just "FAIL"

    it "returns Nothing for an absent config_id" $ do
      let resp = DeleteNotificationConfigResponse {deleteResponseList = Map.singleton "a" "OK"}
      deleteResponseStatus "nope" resp `shouldBe` Nothing

  describe "deleteResponseAllOK" $ do
    it "is True when every entry is OK" $ do
      let resp =
            DeleteNotificationConfigResponse
              { deleteResponseList = Map.fromList [("a", "OK"), ("b", "OK")]
              }
      deleteResponseAllOK resp `shouldBe` True

    it "is False when any entry is not OK" $ do
      let resp =
            DeleteNotificationConfigResponse
              { deleteResponseList = Map.fromList [("a", "OK"), ("b", "FAIL")]
              }
      deleteResponseAllOK resp `shouldBe` False

    it "is True for the empty response" $
      deleteResponseAllOK (DeleteNotificationConfigResponse Map.empty) `shouldBe` True

  -- =======================================================================
  -- deleteNotificationConfig endpoint shape (pure, no live backend).
  -- Mirrors the create-endpoint shape tests above; asserts the DELETE
  -- method, the config_id-bearing path, no body, and OS2/OS3 parity.
  -- =======================================================================
  describe "deleteNotificationConfig endpoint shape (OpenSearch 3)" $ do
    it "DELETEs /_plugins/_notifications/configs/{config_id}" $ do
      let req = OS3Requests.deleteNotificationConfig "abc-123"
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the config_id verbatim as the final path segment" $ do
      let req = OS3Requests.deleteNotificationConfig "weird/id with spaces"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "weird/id with spaces"]

    it "carries no body" $
      bhRequestBody (OS3Requests.deleteNotificationConfig "x") `shouldBe` Nothing

  describe "deleteNotificationConfig endpoint shape (OpenSearch 2)" $ do
    it "DELETEs /_plugins/_notifications/configs/{config_id}" $ do
      let req = OS2Requests.deleteNotificationConfig "abc-123"
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "carries no body" $
      bhRequestBody (OS2Requests.deleteNotificationConfig "x") `shouldBe` Nothing

  describe "deleteNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
    let cfgId = "shared-id"

    it "emits identical paths across OS2/OS3" $ do
      let p2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteNotificationConfig cfgId))
          p3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteNotificationConfig cfgId))
      p2 `shouldBe` ["_plugins", "_notifications", "configs", cfgId]
      p3 `shouldBe` p2

    it "emits identical methods across OS2/OS3" $ do
      let m2 = bhRequestMethod (OS2Requests.deleteNotificationConfig cfgId)
          m3 = bhRequestMethod (OS3Requests.deleteNotificationConfig cfgId)
      m2 `shouldBe` "DELETE"
      m3 `shouldBe` m2

    it "emits identical (absent) bodies across OS2/OS3" $ do
      let b2 = bhRequestBody (OS2Requests.deleteNotificationConfig cfgId)
          b3 = bhRequestBody (OS3Requests.deleteNotificationConfig cfgId)
      b2 `shouldBe` Nothing
      b3 `shouldBe` b2

  -- =======================================================================
  -- getNotificationConfigs / NotificationConfigEntry / GetNotificationConfigsResponse
  -- (GET /_plugins/_notifications/configs)
  -- =======================================================================
  describe "NotificationConfigEntry JSON" $ do
    it "decodes the documented sample entry verbatim" $ do
      let entryJson =
            LBS.pack
              "{\
              \  \"config_id\": \"sample-id\",\
              \  \"last_updated_time_ms\": 1652760532774,\
              \  \"created_time_ms\": 1652760532774,\
              \  \"config\": {\
              \    \"name\": \"Sample Slack Channel\",\
              \    \"description\": \"This is a Slack channel\",\
              \    \"config_type\": \"slack\",\
              \    \"is_enabled\": true,\
              \    \"slack\": {\"url\": \"https://sample-slack-webhook\"}\
              \  }\
              \}"
      let Just decoded = decode entryJson :: Maybe NotificationConfigEntry
      notificationConfigEntryConfigId decoded `shouldBe` "sample-id"
      notificationConfigEntryCreatedTimeMs decoded `shouldBe` 1652760532774
      notificationConfigEntryLastUpdatedTimeMs decoded `shouldBe` 1652760532774
      notificationConfigName (notificationConfigEntryConfig decoded)
        `shouldBe` "Sample Slack Channel"

    it "round-trips through ToJSON/FromJSON" $ do
      let entry =
            NotificationConfigEntry
              { notificationConfigEntryConfigId = "abc",
                notificationConfigEntryCreatedTimeMs = 1700000000000,
                notificationConfigEntryLastUpdatedTimeMs = 1700000000001,
                notificationConfigEntryConfig =
                  NotificationConfig
                    { notificationConfigName = "n",
                      notificationConfigDescription = Nothing,
                      notificationConfigIsEnabled = Nothing,
                      notificationConfigTransport =
                        NotificationTransportSlack (SlackTransport "u")
                    }
              }
      (decode . encode) entry `shouldBe` Just (entry :: NotificationConfigEntry)

  describe "GetNotificationConfigsResponse JSON" $ do
    it "decodes the documented empty-list response" $ do
      let Just decoded = decode "{\"start_index\":0,\"total_hits\":0,\"total_hit_relation\":\"eq\",\"config_list\":[]}" :: Maybe GetNotificationConfigsResponse
      getNotificationConfigsResponseStartIndex decoded `shouldBe` 0
      getNotificationConfigsResponseTotalHits decoded `shouldBe` 0
      getNotificationConfigsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationEq
      getNotificationConfigsResponseConfigList decoded `shouldBe` []

    it "decodes gte relation" $ do
      let Just decoded = decode "{\"start_index\":0,\"total_hits\":5,\"total_hit_relation\":\"gte\",\"config_list\":[]}" :: Maybe GetNotificationConfigsResponse
      getNotificationConfigsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationGte

    it "round-trips an unknown relation value via Other" $ do
      let resp =
            GetNotificationConfigsResponse
              { getNotificationConfigsResponseStartIndex = 0,
                getNotificationConfigsResponseTotalHits = 0,
                getNotificationConfigsResponseTotalHitRelation = NotificationTotalRelationOther "future",
                getNotificationConfigsResponseConfigList = []
              }
      (decode . encode) resp `shouldBe` Just (resp :: GetNotificationConfigsResponse)

    it "round-trips through ToJSON/FromJSON" $ do
      let resp =
            GetNotificationConfigsResponse
              { getNotificationConfigsResponseStartIndex = 0,
                getNotificationConfigsResponseTotalHits = 1,
                getNotificationConfigsResponseTotalHitRelation = NotificationTotalRelationEq,
                getNotificationConfigsResponseConfigList = []
              }
      (decode . encode) resp `shouldBe` Just (resp :: GetNotificationConfigsResponse)

  describe "notificationListOptionsParams" $ do
    it "defaultNotificationListOptions renders no params" $
      notificationListOptionsParams defaultNotificationListOptions `shouldBe` []

    it "renders from_index and max_items as decimal strings" $ do
      let opts =
            defaultNotificationListOptions
              { notificationListOptionsFromIndex = Just 10,
                notificationListOptionsMaxItems = Just 25
              }
      notificationListOptionsParams opts
        `shouldBe` [("from_index", Just "10"), ("max_items", Just "25")]

    it "renders sort_order via notificationSortOrderText" $ do
      let opts =
            defaultNotificationListOptions
              { notificationListOptionsSortOrder = Just NotificationSortOrderDesc
              }
      notificationListOptionsParams opts
        `shouldBe` [("sort_order", Just "desc")]

    it "renders config_type via notificationConfigTypeText" $ do
      let opts =
            defaultNotificationListOptions
              { notificationListOptionsConfigType = Just NotificationConfigTypeChime
              }
      notificationListOptionsParams opts
        `shouldBe` [("config_type", Just "chime")]

    it "renders config_id verbatim" $ do
      let opts = defaultNotificationListOptions {notificationListOptionsConfigId = Just "abc"}
      notificationListOptionsParams opts `shouldBe` [("config_id", Just "abc")]

    it "renders config_id_list as a comma-separated string" $ do
      let opts =
            defaultNotificationListOptions
              { notificationListOptionsConfigIdList = Just ["a", "b", "c"]
              }
      notificationListOptionsParams opts
        `shouldBe` [("config_id_list", Just "a,b,c")]

    it "renders is_enabled as lowercase true/false" $ do
      let trueOpts = defaultNotificationListOptions {notificationListOptionsIsEnabled = Just True}
          falseOpts = defaultNotificationListOptions {notificationListOptionsIsEnabled = Just False}
      notificationListOptionsParams trueOpts `shouldBe` [("is_enabled", Just "true")]
      notificationListOptionsParams falseOpts `shouldBe` [("is_enabled", Just "false")]

    it "omits Nothing fields rather than rendering empty values" $
      notificationListOptionsParams defaultNotificationListOptions {notificationListOptionsSortField = Just "name"}
        `shouldBe` [("sort_field", Just "name")]

  describe "getNotificationConfigs endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_notifications/configs with no query by default" $ do
      let req = OS3Requests.getNotificationConfigs
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards options as query parameters via the With variant" $ do
      let opts = defaultNotificationListOptions {notificationListOptionsMaxItems = Just 5}
          req = OS3Requests.getNotificationConfigsWith opts
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("max_items", Just "5")]

    it "carries no body" $
      bhRequestBody OS3Requests.getNotificationConfigs `shouldBe` Nothing

  describe "getNotificationConfigs endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_notifications/configs with no query by default" $ do
      let req = OS2Requests.getNotificationConfigs
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards options as query parameters via the With variant" $ do
      let opts =
            OS2Types.defaultNotificationListOptions
              { OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeSns
              }
          req = OS2Requests.getNotificationConfigsWith opts
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("config_type", Just "sns")]

  describe "getNotificationConfigs cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths and queries for the default options" $ do
      let r2 = OS2Requests.getNotificationConfigs
          r3 = OS3Requests.getNotificationConfigs
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3

    it "emits identical queries for a populated options record" $ do
      let opts2 =
            OS2Types.defaultNotificationListOptions
              { OS2Types.notificationListOptionsFromIndex = Just 1,
                OS2Types.notificationListOptionsMaxItems = Just 50,
                OS2Types.notificationListOptionsSortOrder = Just OS2Types.NotificationSortOrderAsc,
                OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeEmail
              }
          opts3 =
            defaultNotificationListOptions
              { notificationListOptionsFromIndex = Just 1,
                notificationListOptionsMaxItems = Just 50,
                notificationListOptionsSortOrder = Just NotificationSortOrderAsc,
                notificationListOptionsConfigType = Just NotificationConfigTypeEmail
              }
          r2 = OS2Requests.getNotificationConfigsWith opts2
          r3 = OS3Requests.getNotificationConfigsWith opts3
      getRawEndpointQueries (bhRequestEndpoint r2)
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- getNotificationChannels / Channel / GetNotificationChannelsResponse
  -- (GET /_plugins/_notifications/channels)
  -- =======================================================================
  describe "Channel JSON" $ do
    it "decodes the documented sample channel verbatim" $ do
      let Just decoded =
            decode
              "{\
              \  \"config_id\": \"sample-id\",\
              \  \"name\": \"Sample Slack Channel\",\
              \  \"description\": \"This is a Slack channel\",\
              \  \"config_type\": \"slack\",\
              \  \"is_enabled\": true\
              \}" ::
              Maybe Channel
      channelConfigId decoded `shouldBe` "sample-id"
      channelName decoded `shouldBe` "Sample Slack Channel"
      channelDescription decoded `shouldBe` Just "This is a Slack channel"
      channelConfigType decoded `shouldBe` NotificationConfigTypeSlack
      channelIsEnabled decoded `shouldBe` True

    it "decodes a channel with an omitted description" $ do
      let Just decoded =
            decode
              "{\
              \  \"config_id\": \"x\",\
              \  \"name\": \"n\",\
              \  \"config_type\": \"chime\",\
              \  \"is_enabled\": false\
              \}" ::
              Maybe Channel
      channelDescription decoded `shouldBe` Nothing
      channelConfigType decoded `shouldBe` NotificationConfigTypeChime
      channelIsEnabled decoded `shouldBe` False

    it "round-trips through ToJSON/FromJSON" $ do
      let ch =
            Channel
              { channelConfigId = "abc",
                channelName = "n",
                channelDescription = Just "d",
                channelConfigType = NotificationConfigTypeWebhook,
                channelIsEnabled = True
              }
      (decode . encode) ch `shouldBe` Just (ch :: Channel)

    it "omits the description field on encode when Nothing" $ do
      let ch =
            Channel
              { channelConfigId = "abc",
                channelName = "n",
                channelDescription = Nothing,
                channelConfigType = NotificationConfigTypeWebhook,
                channelIsEnabled = True
              }
      encode ch `shouldSatisfy` not . BS.isInfixOf "\"description\"" . LBS.toStrict

  describe "GetNotificationChannelsResponse JSON" $ do
    it "decodes the documented empty-list response" $ do
      let Just decoded =
            decode
              "{\"start_index\":0,\"total_hits\":0,\"total_hit_relation\":\"eq\",\"channel_list\":[]}" ::
              Maybe GetNotificationChannelsResponse
      getNotificationChannelsResponseStartIndex decoded `shouldBe` 0
      getNotificationChannelsResponseTotalHits decoded `shouldBe` 0
      getNotificationChannelsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationEq
      getNotificationChannelsResponseChannelList decoded `shouldBe` []

    it "decodes the documented two-channel response" $ do
      let Just decoded =
            decode
              "{\
              \  \"start_index\": 0,\
              \  \"total_hits\": 2,\
              \  \"total_hit_relation\": \"eq\",\
              \  \"channel_list\": [\
              \    {\
              \      \"config_id\": \"sample-id\",\
              \      \"name\": \"Sample Slack Channel\",\
              \      \"description\": \"This is a Slack channel\",\
              \      \"config_type\": \"slack\",\
              \      \"is_enabled\": true\
              \    },\
              \    {\
              \      \"config_id\": \"sample-id2\",\
              \      \"name\": \"Test chime channel\",\
              \      \"description\": \"A test chime channel\",\
              \      \"config_type\": \"chime\",\
              \      \"is_enabled\": true\
              \    }\
              \  ]\
              \}" ::
              Maybe GetNotificationChannelsResponse
      let channels = getNotificationChannelsResponseChannelList decoded
      length channels `shouldBe` 2
      getNotificationChannelsResponseTotalHits decoded `shouldBe` 2
      -- Per-field assertions on each channel so a regression in
      -- individual field parsing surfaces here, not just a count
      -- mismatch.
      let [c1, c2] = channels
      channelConfigId c1 `shouldBe` "sample-id"
      channelName c1 `shouldBe` "Sample Slack Channel"
      channelDescription c1 `shouldBe` Just "This is a Slack channel"
      channelConfigType c1 `shouldBe` NotificationConfigTypeSlack
      channelIsEnabled c1 `shouldBe` True
      channelConfigId c2 `shouldBe` "sample-id2"
      channelName c2 `shouldBe` "Test chime channel"
      channelConfigType c2 `shouldBe` NotificationConfigTypeChime
      channelIsEnabled c2 `shouldBe` True

  describe "getNotificationChannels endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_notifications/channels with no query by default" $ do
      let req = OS3Requests.getNotificationChannels
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "channels"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards options as query parameters via the With variant" $ do
      let opts = defaultNotificationListOptions {notificationListOptionsMaxItems = Just 3}
          req = OS3Requests.getNotificationChannelsWith opts
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("max_items", Just "3")]

    it "carries no body" $
      bhRequestBody OS3Requests.getNotificationChannels `shouldBe` Nothing

  describe "getNotificationChannels endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_notifications/channels with no query by default" $ do
      let req = OS2Requests.getNotificationChannels
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "channels"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards options as query parameters via the With variant" $ do
      let opts =
            OS2Types.defaultNotificationListOptions
              { OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeSesAccount
              }
          req = OS2Requests.getNotificationChannelsWith opts
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("config_type", Just "ses_account")]

  describe "getNotificationChannels cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths and queries for the default options" $ do
      let r2 = OS2Requests.getNotificationChannels
          r3 = OS3Requests.getNotificationChannels
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3

    it "emits identical queries for a populated options record" $ do
      let opts2 =
            OS2Types.defaultNotificationListOptions
              { OS2Types.notificationListOptionsSortOrder = Just OS2Types.NotificationSortOrderDesc,
                OS2Types.notificationListOptionsSortField = Just "name"
              }
          opts3 =
            defaultNotificationListOptions
              { notificationListOptionsSortOrder = Just NotificationSortOrderDesc,
                notificationListOptionsSortField = Just "name"
              }
          r2 = OS2Requests.getNotificationChannelsWith opts2
          r3 = OS3Requests.getNotificationChannelsWith opts3
      getRawEndpointQueries (bhRequestEndpoint r2)
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- NotificationFeaturesResponse (GET /_plugins/_notifications/features)
  -- =======================================================================
  describe "NotificationFeaturesResponse JSON" $ do
    it "decodes the documented features body verbatim" $ do
      let body =
            "{\
            \  \"allowed_config_type_list\": [\
            \    \"slack\",\
            \    \"chime\",\
            \    \"webhook\",\
            \    \"email\",\
            \    \"sns\",\
            \    \"ses_account\",\
            \    \"smtp_account\",\
            \    \"email_group\"\
            \  ],\
            \  \"plugin_features\": {\
            \    \"tooltip_support\": \"true\"\
            \  }\
            \}"
          Just (decoded :: NotificationFeaturesResponse) = decode body
      notificationFeaturesResponseAllowedConfigTypeList decoded
        `shouldBe` [ NotificationConfigTypeSlack,
                     NotificationConfigTypeChime,
                     NotificationConfigTypeWebhook,
                     NotificationConfigTypeEmail,
                     NotificationConfigTypeSns,
                     NotificationConfigTypeSesAccount,
                     NotificationConfigTypeSmtpAccount,
                     NotificationConfigTypeEmailGroup
                   ]
      notificationFeaturesResponsePluginFeatures decoded
        `shouldBe` Map.singleton "tooltip_support" "true"

    it "decodes when plugin_features is absent (defensive .!=)" $ do
      -- The docs always show plugin_features, but the server may omit
      -- it on a stripped-down build; we decode such a body to the empty
      -- map rather than parse-failing.
      let body =
            "{\"allowed_config_type_list\": [\"slack\"]}"
          Just (decoded :: NotificationFeaturesResponse) = decode body
      notificationFeaturesResponsePluginFeatures decoded `shouldBe` Map.empty

    it "fails when allowed_config_type_list is absent" $
      decode "{}" `shouldBe` (Nothing :: Maybe NotificationFeaturesResponse)

    it "fails on an unknown config_type inside the list" $ do
      -- Reuses the NotificationConfigType fail-loud policy: a future
      -- plugin release adding a tenth config_type surfaces as a
      -- deliberate decode error rather than a silent drop.
      let body =
            "{\"allowed_config_type_list\": [\"slack\", \"future_transport\"]}"
      decode body `shouldBe` (Nothing :: Maybe NotificationFeaturesResponse)

    it "round-trips through ToJSON/FromJSON" $
      property $ \(resp :: NotificationFeaturesResponse) ->
        (decode . encode) resp === Just resp

    it "round-trips an empty allowed_config_type_list (no omitNulls stripping)" $ do
      -- Pins the choice of `object` (not `omitNulls`) for the
      -- ToJSON instance: a response type must round-trip every value
      -- it can construct, including the empty list. omitNulls would
      -- strip the empty Array, breaking the round-trip; the
      -- QuickCheck property above does not catch this because the
      -- Arbitrary instance uses listOf1 (non-empty), so this explicit
      -- case closes the gap.
      let resp =
            NotificationFeaturesResponse
              { notificationFeaturesResponseAllowedConfigTypeList = [],
                notificationFeaturesResponsePluginFeatures = Map.empty
              }
      (decode . encode) resp `shouldBe` Just resp

  -- =======================================================================
  -- TestNotificationResponse + sub-types
  -- (GET /_plugins/_notifications/feature/test/{config_id})
  -- =======================================================================
  describe "TestNotificationDeliveryStatus JSON" $ do
    it "round-trips the documented shape" $ do
      let s = TestNotificationDeliveryStatus "200" "<html>ok</html>"
      (decode . encode) s `shouldBe` Just s
    it "requires status_code and status_text" $
      decode "{\"status_code\":\"200\"}"
        `shouldBe` (Nothing :: Maybe TestNotificationDeliveryStatus)

  describe "TestNotificationStatus JSON" $ do
    it "decodes a slack status entry verbatim" $ do
      let body =
            "{\
            \  \"config_id\": \"abc\",\
            \  \"config_type\": \"slack\",\
            \  \"config_name\": \"sample-id\",\
            \  \"email_recipient_status\": [],\
            \  \"delivery_status\": {\
            \    \"status_code\": \"200\",\
            \    \"status_text\": \"delivered\"\
            \  }\
            \}"
          Just (decoded :: TestNotificationStatus) = decode body
      testNotificationStatusConfigId decoded `shouldBe` "abc"
      testNotificationStatusConfigType decoded `shouldBe` NotificationConfigTypeSlack
      testNotificationStatusConfigName decoded `shouldBe` "sample-id"
      testNotificationStatusEmailRecipientStatus decoded `shouldBe` []
      testNotificationDeliveryStatusCode (testNotificationStatusDeliveryStatus decoded)
        `shouldBe` "200"

    it "decodes when email_recipient_status is absent (defensive .!=)" $ do
      -- The docs only show email_recipient_status as the empty list;
      -- a non-email channel (slack/chime/...) has no recipients and
      -- the server may omit the field entirely.
      let body =
            "{\
            \  \"config_id\": \"x\",\
            \  \"config_type\": \"chime\",\
            \  \"config_name\": \"n\",\
            \  \"delivery_status\": {\"status_code\": \"200\", \"status_text\": \"\"}\
            \}"
          Just (decoded :: TestNotificationStatus) = decode body
      testNotificationStatusEmailRecipientStatus decoded `shouldBe` []

    it "fails when config_type is unknown" $ do
      let body =
            "{\
            \  \"config_id\": \"x\",\
            \  \"config_type\": \"future\",\
            \  \"config_name\": \"n\",\
            \  \"delivery_status\": {\"status_code\": \"200\", \"status_text\": \"\"}\
            \}"
      decode body `shouldBe` (Nothing :: Maybe TestNotificationStatus)

    it "round-trips" $
      property $ \(s :: TestNotificationStatus) ->
        (decode . encode) s === Just s

  describe "TestNotificationEventSource JSON" $ do
    it "decodes the documented event_source verbatim" $ do
      let body =
            "{\
            \  \"title\": \"Test Message Title-0Jnlh4ABa4TCWn5C5H2G\",\
            \  \"reference_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
            \  \"severity\": \"info\",\
            \  \"tags\": []\
            \}"
          Just (decoded :: TestNotificationEventSource) = decode body
      testNotificationEventSourceTitle decoded `shouldBe` "Test Message Title-0Jnlh4ABa4TCWn5C5H2G"
      testNotificationEventSourceSeverity decoded `shouldBe` "info"
      testNotificationEventSourceTags decoded `shouldBe` []

    it "decodes when tags is absent (defensive .!= [])" $ do
      let body =
            "{\"title\":\"t\",\"reference_id\":\"r\",\"severity\":\"info\"}"
          Just (decoded :: TestNotificationEventSource) = decode body
      testNotificationEventSourceTags decoded `shouldBe` []

    it "round-trips" $
      property $ \(e :: TestNotificationEventSource) ->
        (decode . encode) e === Just e

  describe "TestNotificationResponse JSON" $ do
    it "decodes the documented send-test body verbatim" $ do
      let body =
            "{\
            \  \"event_source\": {\
            \    \"title\": \"Test Message Title-0Jnlh4ABa4TCWn5C5H2G\",\
            \    \"reference_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
            \    \"severity\": \"info\",\
            \    \"tags\": []\
            \  },\
            \  \"status_list\": [\
            \    {\
            \      \"config_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
            \      \"config_type\": \"slack\",\
            \      \"config_name\": \"sample-id\",\
            \      \"email_recipient_status\": [],\
            \      \"delivery_status\": {\
            \        \"status_code\": \"200\",\
            \        \"status_text\": \"delivered\"\
            \      }\
            \    }\
            \  ]\
            \}"
          Just (decoded :: TestNotificationResponse) = decode body
      testNotificationEventSourceSeverity (testNotificationResponseEventSource decoded)
        `shouldBe` "info"
      length (testNotificationResponseStatusList decoded) `shouldBe` 1

    it "round-trips" $
      property $ \(r :: TestNotificationResponse) ->
        (decode . encode) r === Just r

  -- =======================================================================
  -- getNotificationConfig endpoint shape (GET configs/{config_id})
  -- =======================================================================
  describe "getNotificationConfig endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_notifications/configs/{config_id}" $ do
      let req = OS3Requests.getNotificationConfig "abc-123"
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "carries no body" $
      bhRequestBody (OS3Requests.getNotificationConfig "x") `shouldBe` Nothing

  describe "getNotificationConfig endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_notifications/configs/{config_id}" $ do
      let req = OS2Requests.getNotificationConfig "abc-123"
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]

  describe "getNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths/methods/queries for the same id" $ do
      let r2 = OS2Requests.getNotificationConfig "shared-id"
          r3 = OS3Requests.getNotificationConfig "shared-id"
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- updateNotificationConfig endpoint shape (PUT configs/{config_id})
  -- =======================================================================
  describe "updateNotificationConfig endpoint shape (OpenSearch 3)" $ do
    let cfg =
          NotificationConfig
            { notificationConfigName = "n",
              notificationConfigDescription = Nothing,
              notificationConfigIsEnabled = Nothing,
              notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
            }
    it "PUTs to /_plugins/_notifications/configs/{config_id}" $ do
      let req = OS3Requests.updateNotificationConfig "abc-123" cfg
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "wraps the config under the \"config\" key (not flat like create)" $ do
      let req = OS3Requests.updateNotificationConfig "id" cfg
          body = maybe (error "updateNotificationConfig must carry a body") id (bhRequestBody req)
          bs = LBS.toStrict body
      BS.isInfixOf "\"config\":" bs `shouldBe` True
      -- A create-shaped body would carry a top-level config_id; the
      -- update endpoint must NOT (identity is the URL path segment).
      BS.isInfixOf "\"config_id\"" bs `shouldBe` False
    it "attaches a non-empty body" $
      bhRequestBody (OS3Requests.updateNotificationConfig "x" cfg) `shouldSatisfy` isJust

  describe "updateNotificationConfig endpoint shape (OpenSearch 2)" $ do
    it "PUTs to /_plugins/_notifications/configs/{config_id}" $ do
      let cfg =
            OS2Types.NotificationConfig
              { OS2Types.notificationConfigName = "n",
                OS2Types.notificationConfigDescription = Nothing,
                OS2Types.notificationConfigIsEnabled = Nothing,
                OS2Types.notificationConfigTransport =
                  OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
              }
          req = OS2Requests.updateNotificationConfig "abc-123" cfg
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]

  describe "updateNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths/methods/bodies for the same id+config" $ do
      let mkOS2 =
            OS2Types.NotificationConfig
              { OS2Types.notificationConfigName = "n",
                OS2Types.notificationConfigDescription = Nothing,
                OS2Types.notificationConfigIsEnabled = Nothing,
                OS2Types.notificationConfigTransport =
                  OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
              }
          mkOS3 =
            NotificationConfig
              { notificationConfigName = "n",
                notificationConfigDescription = Nothing,
                notificationConfigIsEnabled = Nothing,
                notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
              }
          r2 = OS2Requests.updateNotificationConfig "id" mkOS2
          r3 = OS3Requests.updateNotificationConfig "id" mkOS3
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3
      let bodies =
            catMaybes
              [ bhRequestBody r2,
                bhRequestBody r3
              ]
      length bodies `shouldBe` 2
      length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1

  -- =======================================================================
  -- deleteNotificationConfigs endpoint shape
  -- (DELETE configs/?config_id_list=id1,id2,...)
  -- =======================================================================
  describe "deleteNotificationConfigs endpoint shape (OpenSearch 3)" $ do
    it "DELETEs /_plugins/_notifications/configs with a config_id_list query" $ do
      let req = OS3Requests.deleteNotificationConfigs ("a" :| ["b", "c"])
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("config_id_list", Just "a,b,c")]
    it "serializes a single-id NonEmpty without trailing comma" $ do
      let req = OS3Requests.deleteNotificationConfigs ("only" :| [])
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("config_id_list", Just "only")]
    it "carries no body" $
      bhRequestBody (OS3Requests.deleteNotificationConfigs ("a" :| [])) `shouldBe` Nothing

  describe "deleteNotificationConfigs endpoint shape (OpenSearch 2)" $ do
    it "DELETEs /_plugins/_notifications/configs with a config_id_list query" $ do
      let req = OS2Requests.deleteNotificationConfigs ("a" :| ["b"])
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "configs"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("config_id_list", Just "a,b")]

  describe "deleteNotificationConfigs cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths/methods/queries for the same id list" $ do
      let ids = "x" :| ["y", "z"]
          r2 = OS2Requests.deleteNotificationConfigs ids
          r3 = OS3Requests.deleteNotificationConfigs ids
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- getNotificationFeatures endpoint shape
  -- (GET /_plugins/_notifications/features)
  -- =======================================================================
  describe "getNotificationFeatures endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_notifications/features with no query" $ do
      let req = OS3Requests.getNotificationFeatures
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "features"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "carries no body" $
      bhRequestBody OS3Requests.getNotificationFeatures `shouldBe` Nothing

  describe "getNotificationFeatures endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_notifications/features with no query" $ do
      let req = OS2Requests.getNotificationFeatures
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "features"]

  describe "getNotificationFeatures cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths/methods/queries" $ do
      let r2 = OS2Requests.getNotificationFeatures
          r3 = OS3Requests.getNotificationFeatures
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- sendTestNotification endpoint shape
  -- (GET /_plugins/_notifications/feature/test/{config_id})
  -- =======================================================================
  describe "sendTestNotification endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_notifications/feature/test/{config_id}" $ do
      let req = OS3Requests.sendTestNotification "abc-123"
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "feature", "test", "abc-123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "carries no body" $
      bhRequestBody (OS3Requests.sendTestNotification "x") `shouldBe` Nothing

  describe "sendTestNotification endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_notifications/feature/test/{config_id}" $ do
      let req = OS2Requests.sendTestNotification "abc-123"
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_notifications", "feature", "test", "abc-123"]

  describe "sendTestNotification cross-backend parity (OS2 vs OS3)" $ do
    it "emits identical paths/methods/queries for the same id" $ do
      let r2 = OS2Requests.sendTestNotification "shared-id"
          r3 = OS3Requests.sendTestNotification "shared-id"
      getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
      bhRequestMethod r2 `shouldBe` bhRequestMethod r3
      getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)

  -- =======================================================================
  -- Live integration: create -> list configs -> list channels -> delete
  -- round-trip, gated to OpenSearch 2 and 3 (the Notifications plugin
  -- shipped in OS 2.0; no OS1 twin). Each backend has its own Client
  -- module (twin types), so we write one test per backend, gated via
  -- os2OnlyIT / os3OnlyIT. A timestamp-suffixed config_id avoids
  -- collisions across concurrent or failed runs; bracket_ guarantees
  -- the delete runs even on assertion failure.
  --
  -- The Notifications plugin persists configs to a system index
  -- (@.opendistro-notifications-config@) whose default refresh interval
  -- (~1s on OS2, shorter on OS3) means a freshly-created config is not
  -- visible to GET immediately. 'waitFor' polls the listing until the
  -- new config appears or a bounded timeout (5s) elapses.
  -- =======================================================================
  describe "Notifications plugin live round-trip" $ do
    let webhookConfigOS3 =
          NotificationConfig
            { notificationConfigName = "bloodhound test webhook",
              notificationConfigDescription = Just "live round-trip fixture",
              notificationConfigIsEnabled = Just True,
              notificationConfigTransport =
                NotificationTransportWebhook (WebhookTransport "https://example.invalid/hook")
            }
        webhookConfigOS2 =
          OS2Types.NotificationConfig
            { OS2Types.notificationConfigName = "bloodhound test webhook",
              OS2Types.notificationConfigDescription = Just "live round-trip fixture",
              OS2Types.notificationConfigIsEnabled = Just True,
              OS2Types.notificationConfigTransport =
                OS2Types.NotificationTransportWebhook (OS2Types.WebhookTransport "https://example.invalid/hook")
            }

    os3It <- runIO os3OnlyIT
    os3It "create -> list configs -> list channels -> delete (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let configId = T.pack ("bloodhound_test_notif_os3_" <> suffix)
        bracket_
          ( do
              createResp <- OS3Client.createNotificationConfigWith (Just configId) webhookConfigOS3
              liftIO $
                case createResp of
                  Left e -> expectationFailure ("Setup createNotificationConfigWith failed: " <> show e)
                  Right _ -> pure ()
          )
          (void $ OS3Client.deleteNotificationConfig configId)
          $ do
            configsResult <-
              waitFor $
                do
                  configsResp <- OS3Client.getNotificationConfigs
                  pure $ case configsResp of
                    Right configs ->
                      Right (configId `elem` map notificationConfigEntryConfigId configs)
                    Left e -> Left e
            liftIO $
              case configsResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationConfigs after polling")
                Left e ->
                  expectationFailure ("getNotificationConfigs failed during polling: " <> show e)
            channelsResult <-
              waitFor $
                do
                  channelsResp <- OS3Client.getNotificationChannels
                  pure $ case channelsResp of
                    Right channels ->
                      Right (configId `elem` map channelConfigId channels)
                    Left e -> Left e
            liftIO $
              case channelsResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationChannels after polling")
                Left e ->
                  expectationFailure ("getNotificationChannels failed during polling: " <> show e)
            -- getNotificationConfig (single): the per-id GET must
            -- surface the same entry the list-all just found.
            getConfigResult <-
              waitFor $
                do
                  resp <- OS3Client.getNotificationConfig configId
                  pure $ case resp of
                    Right (Just entry)
                      | notificationConfigEntryConfigId entry == configId -> Right True
                      | otherwise -> Right False
                    Right Nothing -> Right False
                    Left e -> Left e
            liftIO $
              case getConfigResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("getNotificationConfig did not return the config_id " <> T.unpack configId)
                Left e ->
                  expectationFailure ("getNotificationConfig failed: " <> show e)
            -- updateNotificationConfig: rename, then verify the new
            -- name round-trips through getNotificationConfig.
            let updatedConfig =
                  webhookConfigOS3
                    { notificationConfigName = "bloodhound test webhook (renamed)"
                    }
            updateResp <- OS3Client.updateNotificationConfig configId updatedConfig
            liftIO $
              case updateResp of
                Left e ->
                  expectationFailure ("updateNotificationConfig failed: " <> show e)
                Right _ -> pure ()
            updatedNameResult <-
              waitFor $
                do
                  resp <- OS3Client.getNotificationConfig configId
                  pure $ case resp of
                    Right (Just entry) ->
                      Right (notificationConfigName (notificationConfigEntryConfig entry) == "bloodhound test webhook (renamed)")
                    Right Nothing -> Right False
                    Left e -> Left e
            liftIO $
              case updatedNameResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("updateNotificationConfig rename did not round-trip through getNotificationConfig for " <> T.unpack configId)
                Left e ->
                  expectationFailure ("getNotificationConfig after update failed: " <> show e)
            -- getNotificationFeatures: capability discovery. The test
            -- cluster accepts webhook configs (we just created one), so
            -- NotificationConfigTypeWebhook must be in the allowed list.
            featuresResp <- OS3Client.getNotificationFeatures
            liftIO $
              case featuresResp of
                Left e ->
                  expectationFailure ("getNotificationFeatures failed: " <> show e)
                Right feats ->
                  NotificationConfigTypeWebhook
                    `elem` notificationFeaturesResponseAllowedConfigTypeList feats
                      `shouldBe` True
    -- sendTestNotification is deliberately NOT exercised here:
    -- it triggers a real delivery attempt to the config's
    -- transport (a POST to https://example.invalid/hook for
    -- our fixture), which would fail with a DNS error and
    -- pollute CI logs without proving anything the pure
    -- endpoint-shape tests don't already cover.

    os2It <- runIO os2OnlyIT
    os2It "create -> list configs -> list channels -> delete (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let configId = T.pack ("bloodhound_test_notif_os2_" <> suffix)
        bracket_
          ( do
              createResp <- OS2Client.createNotificationConfigWith (Just configId) webhookConfigOS2
              liftIO $
                case createResp of
                  Left e -> expectationFailure ("Setup createNotificationConfigWith failed: " <> show e)
                  Right _ -> pure ()
          )
          (void $ OS2Client.deleteNotificationConfig configId)
          $ do
            configsResult <-
              waitFor $
                do
                  configsResp <- OS2Client.getNotificationConfigs
                  pure $ case configsResp of
                    Right configs ->
                      Right (configId `elem` map OS2Types.notificationConfigEntryConfigId configs)
                    Left e -> Left e
            liftIO $
              case configsResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationConfigs after polling")
                Left e ->
                  expectationFailure ("getNotificationConfigs failed during polling: " <> show e)
            channelsResult <-
              waitFor $
                do
                  channelsResp <- OS2Client.getNotificationChannels
                  pure $ case channelsResp of
                    Right channels ->
                      Right (configId `elem` map OS2Types.channelConfigId channels)
                    Left e -> Left e
            liftIO $
              case channelsResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationChannels after polling")
                Left e ->
                  expectationFailure ("getNotificationChannels failed during polling: " <> show e)
            -- getNotificationConfig (single): the per-id GET must
            -- surface the same entry the list-all just found.
            getConfigResult <-
              waitFor $
                do
                  resp <- OS2Client.getNotificationConfig configId
                  pure $ case resp of
                    Right (Just entry)
                      | OS2Types.notificationConfigEntryConfigId entry == configId -> Right True
                      | otherwise -> Right False
                    Right Nothing -> Right False
                    Left e -> Left e
            liftIO $
              case getConfigResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("getNotificationConfig did not return the config_id " <> T.unpack configId)
                Left e ->
                  expectationFailure ("getNotificationConfig failed: " <> show e)
            -- updateNotificationConfig: rename, then verify the new
            -- name round-trips through getNotificationConfig.
            let updatedConfig =
                  webhookConfigOS2
                    { OS2Types.notificationConfigName = "bloodhound test webhook (renamed)"
                    }
            updateResp <- OS2Client.updateNotificationConfig configId updatedConfig
            liftIO $
              case updateResp of
                Left e ->
                  expectationFailure ("updateNotificationConfig failed: " <> show e)
                Right _ -> pure ()
            updatedNameResult <-
              waitFor $
                do
                  resp <- OS2Client.getNotificationConfig configId
                  pure $ case resp of
                    Right (Just entry) ->
                      Right
                        ( OS2Types.notificationConfigName
                            (OS2Types.notificationConfigEntryConfig entry)
                            == "bloodhound test webhook (renamed)"
                        )
                    Right Nothing -> Right False
                    Left e -> Left e
            liftIO $
              case updatedNameResult of
                Right True -> pure ()
                Right False ->
                  expectationFailure ("updateNotificationConfig rename did not round-trip through getNotificationConfig for " <> T.unpack configId)
                Left e ->
                  expectationFailure ("getNotificationConfig after update failed: " <> show e)
            -- getNotificationFeatures: capability discovery. The test
            -- cluster accepts webhook configs (we just created one), so
            -- NotificationConfigTypeWebhook must be in the allowed list.
            featuresResp <- OS2Client.getNotificationFeatures
            liftIO $
              case featuresResp of
                Left e ->
                  expectationFailure ("getNotificationFeatures failed: " <> show e)
                Right feats ->
                  OS2Types.NotificationConfigTypeWebhook
                    `elem` OS2Types.notificationFeaturesResponseAllowedConfigTypeList feats
                      `shouldBe` True

-- | Poll a query at 0.5s intervals until it returns @'Right' 'True'@ or
-- 10 attempts (5s total) elapse. Returns the last result so the caller
-- can fail with the actual server error rather than a misleading
-- \"not found\": a @'Left' e@ surfaces the 'EsError', a @'Right'
-- 'False'@ means the polling exhausted without the predicate ever
-- holding. Used to absorb the Notifications plugin's ~1-2s system-index
-- refresh delay on OS2.
waitFor :: (MonadIO m) => m (Either EsError Bool) -> m (Either EsError Bool)
waitFor check = go (0 :: Int)
  where
    go n = do
      result <- check
      case result of
        Right True -> pure result
        _
          | n >= 9 -> pure result
          | otherwise -> do
              liftIO $ threadDelay 500000
              go (n + 1)

-- QuickCheck instances for NotificationConfigType roundtrip property.
-- We don't derive Arbitrary generically because the enum is closed; we
-- pick from the explicit list.
instance Arbitrary NotificationConfigType where
  arbitrary =
    elements
      [ NotificationConfigTypeSlack,
        NotificationConfigTypeChime,
        NotificationConfigTypeWebhook,
        NotificationConfigTypeMicrosoftTeams,
        NotificationConfigTypeSns,
        NotificationConfigTypeSesAccount,
        NotificationConfigTypeSmtpAccount,
        NotificationConfigTypeEmailGroup,
        NotificationConfigTypeEmail
      ]
  shrink _ = []

-- Drives the DeleteNotificationConfigResponse round-trip property above.
-- Keys are short ASCII strings to keep the JSON readable on failure; the
-- plugin only documents @"OK"@ as a status value but the wire shape
-- permits any string, so the generator emits arbitrary status text to
-- exercise the parser's openness.
instance Arbitrary DeleteNotificationConfigResponse where
  arbitrary =
    DeleteNotificationConfigResponse . Map.fromList
      <$> listOf
        ( (,)
            <$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
            <*> (T.pack <$> listOf1 (elements ("OKFAILabc" :: String)))
        )
  shrink (DeleteNotificationConfigResponse m) =
    [ DeleteNotificationConfigResponse (Map.fromList smaller)
    | smaller <- shrinkList (const []) (Map.toList m),
      not (null smaller)
    ]

-- Drives the NotificationFeaturesResponse round-trip property. The
-- allowed_config_type_list is a non-empty subset of the 9-variant enum
-- (the docs example lists 8; real clusters vary), so we pick a random
-- non-empty sublist rather than a fixed one. The plugin_features map
-- reuses the DeleteNotificationConfigResponse hand-rolled Map pattern
-- (short ASCII keys/values keep failure output readable).
instance Arbitrary NotificationFeaturesResponse where
  arbitrary = do
    types <- listOf1 arbitrary
    feats <-
      Map.fromList
        <$> listOf
          ( (,)
              <$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
              <*> (T.pack <$> listOf1 (elements ("truefalse01" :: String)))
          )
    pure
      NotificationFeaturesResponse
        { notificationFeaturesResponseAllowedConfigTypeList = types,
          notificationFeaturesResponsePluginFeatures = feats
        }
  shrink (NotificationFeaturesResponse types feats) =
    [ NotificationFeaturesResponse types' feats
    | types' <- shrinkList (const []) types,
      not (null types')
    ]
      ++ [ NotificationFeaturesResponse types (Map.fromList smaller)
         | smaller <- shrinkList (const []) (Map.toList feats)
         ]

-- Drives the TestNotificationDeliveryStatus round-trip property.
-- status_code is typed as Text (the plugin emits "200" not 200); we
-- emit short numeric-looking strings to mirror the wire shape.
instance Arbitrary TestNotificationDeliveryStatus where
  arbitrary =
    TestNotificationDeliveryStatus
      <$> (T.pack <$> listOf1 (elements ("0123456789" :: String)))
      <*> (T.pack <$> listOf (elements ('a' : ['A' .. 'Z'] ++ "<>/= ")))

-- Drives the TestNotificationStatus round-trip property. The
-- email_recipient_status field is opaque [Value]; the docs only ever
-- show it empty and the per-recipient object shape is under-documented,
-- so we generate the empty list rather than hand-roll a Value generator
-- (which would pull in quickcheck-instances for an Arbitrary Value).
-- No @shrink@ override: the property's job is to flag a round-trip
-- failure, and the inline Text generators (@listOf1 ...@) are simple
-- enough that a failure surfaces clearly without counterexample
-- minimisation. The non-empty @[Value]@ case is therefore unverified
-- by the property — the field is typed @[Value]@ for forward
-- compatibility but the parser's behaviour on a populated list is not
-- exercised here.
instance Arbitrary TestNotificationStatus where
  arbitrary =
    TestNotificationStatus
      <$> (T.pack <$> listOf1 (elements ('a' : ['0' .. '9'])))
      <*> arbitrary
      <*> (T.pack <$> listOf1 (elements ['a' .. 'z']))
      <*> pure []
      <*> arbitrary

-- Drives the TestNotificationEventSource round-trip property. severity
-- is typed as Text (the plugin emits "info"/"warning"/"critical" but
-- the enum is not fully documented); we emit short alphabetic strings.
instance Arbitrary TestNotificationEventSource where
  arbitrary =
    TestNotificationEventSource
      <$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
      <*> (T.pack <$> listOf1 (elements ('a' : ['0' .. '9'])))
      <*> (T.pack <$> listOf1 (elements ['a' .. 'z']))
      <*> listOf (T.pack <$> listOf1 (elements ['a' .. 'z']))

-- Drives the TestNotificationResponse round-trip property. Composes
-- the event_source and a short status_list (capped at 2 entries to
-- keep the property fast under 100 samples + shrinks).
instance Arbitrary TestNotificationResponse where
  arbitrary = do
    src <- arbitrary
    n <- choose (0, 2)
    statuses <- replicateM n arbitrary
    pure
      TestNotificationResponse
        { testNotificationResponseEventSource = src,
          testNotificationResponseStatusList = statuses
        }
  shrink (TestNotificationResponse src statuses) =
    [ TestNotificationResponse src smaller
    | smaller <- shrinkList (const []) statuses
    ]