packages feed

bloodhound-1.0.0.0: tests/Test/AlertingSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.AlertingSpec (spec) where

import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (isInfixOf, sortOn)
import Data.Maybe (fromJust, isJust)
import Database.Bloodhound.OpenSearch1.Client qualified as OS1.Client
import Database.Bloodhound.OpenSearch1.Requests qualified as OS1Requests
import Database.Bloodhound.OpenSearch1.Types qualified as OS1A
import Database.Bloodhound.OpenSearch2.Client qualified as OS2.Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2A
import Database.Bloodhound.OpenSearch3.Client qualified as OS3.Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3A
import Numeric.Natural (Natural)
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- | Pure endpoint-shape and decoder tests for the Alerting plugin monitor
-- CRUD endpoints (@POST\/GET\/PUT\/DELETE /_plugins/_alerting/monitors[\/{id}]@)
-- across OpenSearch 1, 2, and 3. Mirrors 'Test.KnnWarmupSpec'\/'Test.IsmSpec'
-- for the shape tests and 'Test.MLModelSpec' for the decoder tests.
--
-- The monitor 'Monitor' is modelled pragmatically: the typed shell (name,
-- enabled, schedule, triggers, actions) plus opaque aeson 'Value's for the
-- per-kind payloads. These tests pin (a) the endpoint shape per backend,
-- (b) OS1\/OS2\/OS3 cross-backend parity, (c) decoder round-trips for the
-- documented wire fixtures, and (d) the wrapper-key discrimination on
-- 'Trigger' (query-level bare vs. bucket-level wrapped).
--
-- The live round-trip is @pendingWith@'d: the CI docker-compose runs
-- Elasticsearch clusters only, so the Alerting plugin (OS-specific) is not
-- available in CI. Re-enable once a live OS cluster is wired in; see the
-- precedent in 'Test.KnnWarmupSpec' \/ 'Test.IsmSpec' live blocks.
spec :: Spec
spec = describe "Alerting monitor CRUD API" $ do
  -- =========================================================== --
  -- Endpoint shape: OpenSearch 3 (detailed)                      --
  -- =========================================================== --
  describe "endpoint shape (OpenSearch 3)" $ do
    it "createMonitor POSTs to /_plugins/_alerting/monitors with the encoded body" $ do
      let req = OS3Requests.createMonitor os3SampleMonitor
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      -- Body is present and decodes back to the same monitor on the typed fields.
      let body = fromJust (bhRequestBody req)
      let decoded = (decode body :: Maybe OS3A.Monitor)
      decoded `shouldSatisfy` \m -> isJust m && OS3A.monitorName (fromJust m) == "test-monitor"

    it "getMonitor GETs /_plugins/_alerting/monitors/{id} with no body" $ do
      let req = OS3Requests.getMonitor (OS3A.MonitorId "my-monitor-id")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "getMonitor keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.getMonitor (OS3A.MonitorId "my-monitor_42")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor_42"]

    it "getMonitor uses a distinct id in the path for a different MonitorId" $ do
      let req = OS3Requests.getMonitor (OS3A.MonitorId "other-id")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "other-id"]

    it "updateMonitor PUTs /_plugins/_alerting/monitors/{id} with the encoded body" $ do
      let req = OS3Requests.updateMonitor (OS3A.MonitorId "my-monitor-id") os3SampleMonitor
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

    it "deleteMonitor DELETEs /_plugins/_alerting/monitors/{id} with no body" $ do
      let req = OS3Requests.deleteMonitor (OS3A.MonitorId "my-monitor-id")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  -- =========================================================== --
  -- Endpoint shape: OpenSearch 1 and 2 (path/method parity)      --
  -- =========================================================== --
  describe "endpoint shape (OpenSearch 1)" $ do
    it "getMonitor GETs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS1Requests.getMonitor (OS1A.MonitorId "os1-id")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os1-id"]
      bhRequestBody req `shouldBe` Nothing

    it "deleteMonitor DELETEs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS1Requests.deleteMonitor (OS1A.MonitorId "os1-id")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os1-id"]

    it "createMonitor POSTs to /_plugins/_alerting/monitors" $ do
      let req = OS1Requests.createMonitor os1SampleMonitor
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors"]

    it "updateMonitor PUTs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS1Requests.updateMonitor (OS1A.MonitorId "os1-id") os1SampleMonitor
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os1-id"]

  describe "endpoint shape (OpenSearch 2)" $ do
    it "getMonitor GETs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS2Requests.getMonitor (OS2A.MonitorId "os2-id")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os2-id"]
      bhRequestBody req `shouldBe` Nothing

    it "deleteMonitor DELETEs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS2Requests.deleteMonitor (OS2A.MonitorId "os2-id")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os2-id"]

    it "createMonitor POSTs to /_plugins/_alerting/monitors" $ do
      let req = OS2Requests.createMonitor os2SampleMonitor
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors"]

    it "updateMonitor PUTs /_plugins/_alerting/monitors/{id}" $ do
      let req = OS2Requests.updateMonitor (OS2A.MonitorId "os2-id") os2SampleMonitor
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "os2-id"]

  -- =========================================================== --
  -- Cross-backend parity (OS1, OS2, OS3 emit identical requests) --
  -- =========================================================== --
  describe "cross-backend parity" $ do
    let mid1 = OS1A.MonitorId "shared-id"
        mid2 = OS2A.MonitorId "shared-id"
        mid3 = OS3A.MonitorId "shared-id"

    it "createMonitor: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.createMonitor os1SampleMonitor))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.createMonitor os2SampleMonitor))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createMonitor os2SampleMonitor))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createMonitor os3SampleMonitor))
      bhRequestMethod (OS1Requests.createMonitor os1SampleMonitor)
        `shouldBe` bhRequestMethod (OS3Requests.createMonitor os3SampleMonitor)

    it "createMonitor: OS1, OS2, OS3 attach identical body bytes" $ do
      -- The three Monitors are structurally identical, so their encoded
      -- bodies must match byte-for-byte across the three backends.
      let b1 = bhRequestBody (OS1Requests.createMonitor os1SampleMonitor)
          b2 = bhRequestBody (OS2Requests.createMonitor os2SampleMonitor)
          b3 = bhRequestBody (OS3Requests.createMonitor os3SampleMonitor)
      b1 `shouldBe` b2
      b2 `shouldBe` b3

    it "getMonitor: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.getMonitor mid1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitor mid2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitor mid2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getMonitor mid3))
      bhRequestMethod (OS1Requests.getMonitor mid1)
        `shouldBe` bhRequestMethod (OS3Requests.getMonitor mid3)

    it "deleteMonitor: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.deleteMonitor mid1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteMonitor mid2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteMonitor mid2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteMonitor mid3))
      bhRequestMethod (OS1Requests.deleteMonitor mid1)
        `shouldBe` bhRequestMethod (OS3Requests.deleteMonitor mid3)

  -- =========================================================== --
  -- Decoder tests (OS3 types — identical instances across OS1-3) --
  -- =========================================================== --
  describe "MonitorResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"_id\":\"vd5k2GsBlQ5JUWWFxhsP\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"monitor\":{\"type\":\"monitor\",\"schema_version\":1,\"name\":\"test-monitor\",\"monitor_type\":\"query_level_monitor\",\"enabled\":true,\"enabled_time\":1551466220455,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[],\"triggers\":[],\"last_update_time\":1551466639295}}"

    it "decodes the documented create-response fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.MonitorResponse
      OS3A.monitorResponseId decoded `shouldBe` "vd5k2GsBlQ5JUWWFxhsP"
      OS3A.monitorResponseVersion decoded `shouldBe` 1
      OS3A.monitorResponseSeqNo decoded `shouldBe` 0
      OS3A.monitorResponsePrimaryTerm decoded `shouldBe` 1
      let mon = OS3A.monitorResponseMonitor decoded
      OS3A.monitorName mon `shouldBe` "test-monitor"
      OS3A.monitorEnabled mon `shouldBe` Just True
      OS3A.monitorEnabledTime mon `shouldBe` Just 1551466220455
      OS3A.monitorMonitorType mon `shouldBe` Just OS3A.MonitorTypeQueryLevel
      OS3A.monitorType mon `shouldBe` Just OS3A.AlertTypeMonitor
      OS3A.monitorSchemaVersion mon `shouldBe` Just 1

    it "monitor round-trips through encode/decode on the typed fields" $ do
      let encoded = encode os3SampleMonitor
          Just reDecoded = decode encoded :: Maybe OS3A.Monitor
      OS3A.monitorName reDecoded `shouldBe` OS3A.monitorName os3SampleMonitor
      OS3A.monitorSchedule reDecoded `shouldBe` OS3A.monitorSchedule os3SampleMonitor
      OS3A.monitorMonitorType reDecoded `shouldBe` OS3A.monitorMonitorType os3SampleMonitor

  describe "DeleteMonitorResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"_index\":\".opensearch-scheduled-jobs\",\"_id\":\"vd5k2GsBlQ5JUWWFxhsP\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":false,\"_shards\":{\"total\":2,\"successful\":1,\"skipped\":0,\"failed\":0},\"_seq_no\":1,\"_primary_term\":1}"

    it "decodes the full bare delete-document fixture (OS 1.3.x shape)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteMonitorResponse
      OS3A.deleteMonitorResponseIndex decoded `shouldBe` Just ".opensearch-scheduled-jobs"
      OS3A.deleteMonitorResponseId decoded `shouldBe` "vd5k2GsBlQ5JUWWFxhsP"
      OS3A.deleteMonitorResponseVersion decoded `shouldBe` 2
      OS3A.deleteMonitorResponseResult decoded `shouldBe` Just "deleted"
      OS3A.deleteMonitorResponseForcedRefresh decoded `shouldBe` Just False
      let Just sh = OS3A.deleteMonitorResponseShards decoded
      OS3A.alertingShardsTotal sh `shouldBe` 2
      OS3A.alertingShardsSuccessful sh `shouldBe` 1
      OS3A.alertingShardsFailed sh `shouldBe` 0
      OS3A.deleteMonitorResponseSeqNo decoded `shouldBe` Just 1
      OS3A.deleteMonitorResponsePrimaryTerm decoded `shouldBe` Just 1

    it "decodes the minimal {_id,_version} fixture (OS 2.x/3.x shape, live-verified bead bloodhound-z5j)" $ do
      let minimal = LBS.pack "{\"_id\":\"abc123\",\"_version\":2}"
          Just decoded = decode minimal :: Maybe OS3A.DeleteMonitorResponse
      OS3A.deleteMonitorResponseId decoded `shouldBe` "abc123"
      OS3A.deleteMonitorResponseVersion decoded `shouldBe` 2
      OS3A.deleteMonitorResponseIndex decoded `shouldBe` Nothing
      OS3A.deleteMonitorResponseResult decoded `shouldBe` Nothing
      OS3A.deleteMonitorResponseForcedRefresh decoded `shouldBe` Nothing
      OS3A.deleteMonitorResponseShards decoded `shouldBe` Nothing
      OS3A.deleteMonitorResponseSeqNo decoded `shouldBe` Nothing
      OS3A.deleteMonitorResponsePrimaryTerm decoded `shouldBe` Nothing

    it "rejects a payload missing _id (the sole universally-required field)" $ do
      let decoded = decode "{\"_version\":2}" :: Maybe OS3A.DeleteMonitorResponse
      decoded `shouldBe` Nothing

  describe "Schedule decoding" $ do
    it "decodes a period schedule" $ do
      let Just s = decode "{\"period\":{\"interval\":5,\"unit\":\"HOURS\"}}" :: Maybe OS3A.Schedule
          Just (OS3A.PeriodSchedule p) = Just s
      OS3A.schedulePeriodInterval p `shouldBe` 5
      OS3A.schedulePeriodUnit p `shouldBe` "HOURS"

    it "decodes a cron schedule" $ do
      let Just s = decode "{\"cron\":{\"expression\":\"10 12 1 * *\",\"timezone\":\"America/Los_Angeles\"}}" :: Maybe OS3A.Schedule
          Just (OS3A.CronSchedule c) = Just s
      OS3A.scheduleCronExpression c `shouldBe` "10 12 1 * *"
      OS3A.scheduleCronTimezone c `shouldBe` Just "America/Los_Angeles"

    it "falls through to OtherSchedule for an unknown schedule kind" $ do
      let Just s = decode "{\"custom\":{\"foo\":1}}" :: Maybe OS3A.Schedule
          Just (OS3A.OtherSchedule _) = Just s
      s `shouldBe` OS3A.OtherSchedule (object ["custom" .= object ["foo" .= (1 :: Int)]])

  describe "MonitorType discriminator" $ do
    it "decodes each documented monitor_type value" $ do
      "query_level_monitor" `decodesMonitorTypeTo` OS3A.MonitorTypeQueryLevel
      "bucket_level_monitor" `decodesMonitorTypeTo` OS3A.MonitorTypeBucketLevel
      "doc_level_monitor" `decodesMonitorTypeTo` OS3A.MonitorTypeDocLevel
      "cluster_metrics_monitor" `decodesMonitorTypeTo` OS3A.MonitorTypeClusterMetrics
      "remote_monitor" `decodesMonitorTypeTo` OS3A.MonitorTypeRemote

    it "round-trips through monitorTypeText" $
      OS3A.monitorTypeText OS3A.MonitorTypeQueryLevel `shouldBe` "query_level_monitor"

    it "falls through to MonitorTypeOther for an unknown value" $
      "future_monitor_kind" `decodesMonitorTypeTo` OS3A.MonitorTypeOther "future_monitor_kind"

  describe "Trigger wrapper-key discrimination" $ do
    it "decodes a bare query-level trigger (fields directly on the element)" $ do
      let fixture =
            LBS.pack
              "{\"name\":\"t\",\"severity\":\"1\",\"condition\":{\"script\":{\"source\":\"ctx.results[0] > 0\",\"lang\":\"painless\"}},\"actions\":[]}"
      let Just t = decode fixture :: Maybe OS3A.Trigger
      OS3A.triggerName t `shouldBe` "t"
      OS3A.triggerSeverity t `shouldBe` "1"
      OS3A.triggerActions t `shouldBe` []

    it "decodes a wrapped bucket-level trigger (fields under bucket_level_trigger)" $ do
      let fixture =
            LBS.pack
              "{\"bucket_level_trigger\":{\"name\":\"bt\",\"severity\":\"2\",\"condition\":{\"buckets_path\":{\"_count\":\"_count\"},\"parent_bucket_path\":\"agg\"},\"actions\":[]}}"
      let Just t = decode fixture :: Maybe OS3A.Trigger
      OS3A.triggerName t `shouldBe` "bt"
      OS3A.triggerSeverity t `shouldBe` "2"

    it "decodes a wrapped document-level trigger (fields under document_level_trigger)" $ do
      let fixture =
            LBS.pack
              "{\"document_level_trigger\":{\"name\":\"dt\",\"severity\":\"3\",\"condition\":{\"script\":{\"source\":\"true\",\"lang\":\"painless\"}},\"actions\":[]}}"
      let Just t = decode fixture :: Maybe OS3A.Trigger
      OS3A.triggerName t `shouldBe` "dt"
      OS3A.triggerSeverity t `shouldBe` "3"

    it "round-trips a wrapped bucket-level trigger losslessly" $ do
      let fixture =
            LBS.pack
              "{\"bucket_level_trigger\":{\"name\":\"bt\",\"severity\":\"2\",\"condition\":{\"buckets_path\":{}},\"actions\":[]}}"
      let Just t = decode fixture :: Maybe OS3A.Trigger
      -- The wrapper key must survive the encode so the discrimination round-trips.
      encode t `shouldSatisfy` \bs -> "bucket_level_trigger" `isInfixOf` LBS.unpack bs

    it "severity is decoded as a string (not a number) per the wire" $ do
      let Just t = decode "{\"name\":\"x\",\"severity\":\"1\",\"condition\":{},\"actions\":[]}" :: Maybe OS3A.Trigger
      OS3A.triggerSeverity t `shouldBe` "1"

  describe "Action decoding" $ do
    it "decodes a full action fixture" $ do
      let fixture =
            LBS.pack
              "{\"name\":\"test-action\",\"destination_id\":\"ld7912sBlQ5JUWWFThoW\",\"message_template\":{\"source\":\"Body.\",\"lang\":\"mustache\"},\"subject_template\":{\"source\":\"Subject\",\"lang\":\"mustache\"},\"throttle_enabled\":true,\"throttle\":{\"value\":27,\"unit\":\"MINUTES\"}}"
      let Just a = decode fixture :: Maybe OS3A.Action
      OS3A.actionName a `shouldBe` "test-action"
      OS3A.actionDestinationId a `shouldBe` "ld7912sBlQ5JUWWFThoW"
      OS3A.actionThrottleEnabled a `shouldBe` Just True
      let Just th = OS3A.actionThrottle a
      OS3A.throttleValue th `shouldBe` 27
      OS3A.throttleUnit th `shouldBe` "MINUTES"

  -- =========================================================== --
  -- Destinations: GET /_plugins/_alerting/destinations           --
  -- Endpoint shape, decoders, type round-trip, query params.    --
  -- =========================================================== --
  describe "getDestinations endpoint shape (OpenSearch 3)" $ do
    it "getDestinations GETs /_plugins/_alerting/destinations with no body" $ do
      let req = OS3Requests.getDestinations
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations"]
      bhRequestBody req `shouldBe` Nothing

    it "getDestinations with default options emits no query params" $ do
      let req = OS3Requests.getDestinationsWith OS3A.defaultDestinationListOptions
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "getDestinationsWith forwards options as camelCase query params" $ do
      let opts =
            OS3A.defaultDestinationListOptions
              { OS3A.destinationListOptionsSize = Just 5,
                OS3A.destinationListOptionsStartIndex = Just 10,
                OS3A.destinationListOptionsSortString = Just "destination.name.keyword",
                OS3A.destinationListOptionsSortOrder = Just OS3A.DestinationSortOrderDesc,
                OS3A.destinationListOptionsMissing = Just "_last",
                OS3A.destinationListOptionsSearchString = Just "alert",
                OS3A.destinationListOptionsDestinationType = Just OS3A.DestinationTypeSlack
              }
      let req = OS3Requests.getDestinationsWith opts
      -- Order is not significant; sort to compare deterministically.
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` sortOn
          fst
          [ ("destinationType", Just "slack"),
            ("missing", Just "_last"),
            ("searchString", Just "alert"),
            ("size", Just "5"),
            ("sortString", Just "destination.name.keyword"),
            ("sortOrder", Just "desc"),
            ("start_index", Just "10")
          ]

    it "getDestinationsWith omits fields set to Nothing (not rendered with empty values)" $ do
      let opts =
            OS3A.defaultDestinationListOptions
              { OS3A.destinationListOptionsSize = Just 5
              }
      let req = OS3Requests.getDestinationsWith opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("size", Just "5")]

  describe "getDestinations endpoint shape (OpenSearch 1 and 2 parity)" $ do
    it "OS1 getDestinations GETs /_plugins/_alerting/destinations" $ do
      let req = OS1Requests.getDestinations
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations"]
      bhRequestBody req `shouldBe` Nothing

    it "OS2 getDestinations GETs /_plugins/_alerting/destinations" $ do
      let req = OS2Requests.getDestinations
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations"]
      bhRequestBody req `shouldBe` Nothing

    it "OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint OS1Requests.getDestinations)
        `shouldBe` getRawEndpoint (bhRequestEndpoint OS2Requests.getDestinations)
      getRawEndpoint (bhRequestEndpoint OS2Requests.getDestinations)
        `shouldBe` getRawEndpoint (bhRequestEndpoint OS3Requests.getDestinations)
      bhRequestMethod OS1Requests.getDestinations
        `shouldBe` bhRequestMethod OS3Requests.getDestinations

  describe "DestinationType discriminator" $ do
    it "round-trips each documented value through destinationTypeText" $ do
      OS3A.destinationTypeText OS3A.DestinationTypeSlack `shouldBe` "slack"
      OS3A.destinationTypeText OS3A.DestinationTypeChime `shouldBe` "chime"
      OS3A.destinationTypeText OS3A.DestinationTypeCustomWebhook `shouldBe` "custom_webhook"
      OS3A.destinationTypeText OS3A.DestinationTypeEmail `shouldBe` "email"
      OS3A.destinationTypeText OS3A.DestinationTypeTestAction `shouldBe` "test_action"

    it "decodes each documented wire value" $ do
      "slack" `decodesDestinationTypeTo` OS3A.DestinationTypeSlack
      "chime" `decodesDestinationTypeTo` OS3A.DestinationTypeChime
      "custom_webhook" `decodesDestinationTypeTo` OS3A.DestinationTypeCustomWebhook
      "email" `decodesDestinationTypeTo` OS3A.DestinationTypeEmail
      "test_action" `decodesDestinationTypeTo` OS3A.DestinationTypeTestAction

    it "falls through to DestinationTypeOther for an unknown value" $
      "future_dest_kind" `decodesDestinationTypeTo` OS3A.DestinationTypeOther "future_dest_kind"

  describe "Destination decoding" $ do
    it "decodes the documented Slack fixture" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"1a2a3a4a5a6a7a\",\"type\":\"slack\",\"name\":\"sample-destination\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":6,\"last_update_time\":1603943261722,\"slack\":{\"url\":\"https://example.com\"}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationId d `shouldBe` "1a2a3a4a5a6a7a"
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeSlack
      OS3A.destinationName d `shouldBe` "sample-destination"
      OS3A.destinationSchemaVersion d `shouldBe` 3
      OS3A.destinationSeqNo d `shouldBe` 0
      OS3A.destinationPrimaryTerm d `shouldBe` 6
      OS3A.destinationLastUpdateTime d `shouldBe` Just 1603943261722
      OS3A.destinationBody d `shouldBe` object ["url" .= ("https://example.com" :: Text)]

    it "decodes a Chime destination" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"ch1\",\"type\":\"chime\",\"name\":\"chime-dest\",\"schema_version\":3,\"seq_no\":1,\"primary_term\":1,\"chime\":{\"url\":\"https://hooks.chime.aws/incomingwebhooks/x\"}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeChime
      OS3A.destinationBody d `shouldBe` object ["url" .= ("https://hooks.chime.aws/incomingwebhooks/x" :: Text)]

    it "decodes a Custom Webhook destination (full sub-object)" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"cw1\",\"type\":\"custom_webhook\",\"name\":\"cw-dest\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":1,\"custom_webhook\":{\"url\":\"https://example.com/hook\",\"scheme\":\"HTTPS\",\"host\":\"example.com\",\"port\":443,\"path\":\"hook\",\"method\":\"POST\",\"query_params\":{\"token\":\"abc\"},\"header_params\":{\"Content-Type\":\"application/json\"}}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeCustomWebhook
      OS3A.destinationBody d
        `shouldSatisfy` \body -> "\"url\"" `isInfixOf` LBS.unpack (encode body)

    it "decodes an Email destination" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"em1\",\"type\":\"email\",\"name\":\"email-dest\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":1,\"email\":{\"email_account_id\":\"YjY7mXMBx015759_IcfW\",\"recipients\":[{\"type\":\"email\",\"email\":\"example@email.com\"}]}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeEmail
      OS3A.destinationBody d
        `shouldSatisfy` \body -> "\"email_account_id\"" `isInfixOf` LBS.unpack (encode body)

    it "decodes a test_action destination (no sub-object, body=Null)" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"ta1\",\"type\":\"test_action\",\"name\":\"ta-dest\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":1}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeTestAction
      OS3A.destinationBody d `shouldBe` Null

    it "decodes the documented fixture with a user sub-object" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"1a2a3a4a5a6a7a\",\"type\":\"slack\",\"name\":\"sample-destination\",\"user\":{\"name\":\"psantos\",\"backend_roles\":[\"human-resources\"],\"roles\":[\"alerting_full_access\"]},\"schema_version\":3,\"seq_no\":0,\"primary_term\":6,\"last_update_time\":1603943261722,\"slack\":{\"url\":\"https://example.com\"}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationUser d
        `shouldSatisfy` \case
          Just _ -> True
          _ -> False

    it "tolerates missing schema_version, seq_no, primary_term (default 0)" $ do
      let fixture =
            LBS.pack
              "{\"id\":\"x\",\"type\":\"slack\",\"name\":\"x\",\"slack\":{\"url\":\"https://example.com\"}}"
      let Just d = decode fixture :: Maybe OS3A.Destination
      OS3A.destinationSchemaVersion d `shouldBe` 0
      OS3A.destinationSeqNo d `shouldBe` 0
      OS3A.destinationPrimaryTerm d `shouldBe` 0

    it "round-trips through encode/decode on the typed fields" $ do
      let encoded = encode os3SampleSlackDestination
          Just reDecoded = decode encoded :: Maybe OS3A.Destination
      OS3A.destinationId reDecoded `shouldBe` OS3A.destinationId os3SampleSlackDestination
      OS3A.destinationType reDecoded `shouldBe` OS3A.destinationType os3SampleSlackDestination
      OS3A.destinationName reDecoded `shouldBe` OS3A.destinationName os3SampleSlackDestination
      OS3A.destinationBody reDecoded `shouldBe` OS3A.destinationBody os3SampleSlackDestination

    it "re-encodes the per-type sub-object under its discriminator key" $ do
      let encoded = encode os3SampleSlackDestination
      -- The slack sub-object key must survive the round-trip.
      encoded `shouldSatisfy` \bs -> "\"slack\"" `isInfixOf` LBS.unpack bs

  describe "GetDestinationsResponse envelope decoding" $ do
    it "decodes the documented envelope" $ do
      let fixture =
            LBS.pack
              "{\"totalDestinations\":1,\"destinations\":[{\"id\":\"1a2a3a4a5a6a7a\",\"type\":\"slack\",\"name\":\"sample-destination\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":6,\"last_update_time\":1603943261722,\"slack\":{\"url\":\"https://example.com\"}}]}"
      let Just resp = decode fixture :: Maybe OS3A.GetDestinationsResponse
      OS3A.getDestinationsResponseTotalDestinations resp `shouldBe` 1
      length (OS3A.getDestinationsResponseDestinations resp) `shouldBe` 1

    it "decodes an empty destinations list" $ do
      let fixture = LBS.pack "{\"totalDestinations\":0,\"destinations\":[]}"
      let Just resp = decode fixture :: Maybe OS3A.GetDestinationsResponse
      OS3A.getDestinationsResponseTotalDestinations resp `shouldBe` 0
      OS3A.getDestinationsResponseDestinations resp `shouldBe` []

    it "tolerates a missing totalDestinations field (defaults to 0)" $ do
      let fixture = LBS.pack "{\"destinations\":[]}"
      let Just resp = decode fixture :: Maybe OS3A.GetDestinationsResponse
      OS3A.getDestinationsResponseTotalDestinations resp `shouldBe` 0

  describe "destinationListOptionsParams rendering" $ do
    it "default options render to an empty parameter list" $
      OS3A.destinationListOptionsParams OS3A.defaultDestinationListOptions `shouldBe` []

    it "renders all set fields with the documented camelCase wire names" $ do
      let opts =
            OS3A.defaultDestinationListOptions
              { OS3A.destinationListOptionsSize = Just 50,
                OS3A.destinationListOptionsStartIndex = Just 0,
                OS3A.destinationListOptionsSortString = Just "destination.type.keyword",
                OS3A.destinationListOptionsSortOrder = Just OS3A.DestinationSortOrderAsc,
                OS3A.destinationListOptionsMissing = Just "_first",
                OS3A.destinationListOptionsSearchString = Just "slack",
                OS3A.destinationListOptionsDestinationType = Just OS3A.DestinationTypeChime
              }
      -- Sorted for determinism; the actual emission order is not significant
      -- for the alerting plugin's @RestGetDestinationsAction@.
      sortOn fst (OS3A.destinationListOptionsParams opts)
        `shouldBe` sortOn
          fst
          [ ("size", Just "50"),
            ("start_index", Just "0"),
            ("sortString", Just "destination.type.keyword"),
            ("sortOrder", Just "asc"),
            ("missing", Just "_first"),
            ("searchString", Just "slack"),
            ("destinationType", Just "chime")
          ]

  -- =========================================================== --
  -- updateMonitorWith: optimistic-concurrency query params.       --
  -- =========================================================== --
  describe "updateMonitorWith endpoint shape (OpenSearch 3)" $ do
    it "emits if_seq_no and if_primary_term query params when Just" $ do
      let req = OS3Requests.updateMonitorWith (OS3A.MonitorId "mid") os3SampleMonitor (Just (3, 2))
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "mid"]
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` [("if_primary_term", Just "2"), ("if_seq_no", Just "3")]

    it "omits query params when Nothing" $ do
      let req = OS3Requests.updateMonitorWith (OS3A.MonitorId "mid") os3SampleMonitor Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "updateMonitor is the Nothing special-case (delegation regression)" $ do
      let req = OS3Requests.updateMonitor (OS3A.MonitorId "mid") os3SampleMonitor
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestMethod req `shouldBe` "PUT"

  -- =========================================================== --
  -- searchMonitors / executeMonitor endpoint shape.               --
  -- =========================================================== --
  describe "searchMonitors endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_alerting/monitors/_search with {} body when Nothing" $ do
      let req = OS3Requests.searchMonitors Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let Just body = bhRequestBody req
      body `shouldBe` "{}"

    it "forwards the encoded MonitorsSearchQuery body when Just" $ do
      let q = OS3A.defaultMonitorsSearchQuery {OS3A.monitorsSearchQuerySize = Just 5}
      let req = OS3Requests.searchMonitors (Just q)
      bhRequestMethod req `shouldBe` "GET"
      let Just body = bhRequestBody req
      body `shouldBe` encode q

  describe "executeMonitor endpoint shape (OpenSearch 3)" $ do
    it "POSTs to /_plugins/_alerting/monitors/{id}/_execute with no body and no query when Nothing" $ do
      let req = OS3Requests.executeMonitor (OS3A.MonitorId "my-monitor-id") Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor-id", "_execute"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.executeMonitor (OS3A.MonitorId "my-monitor_42") Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor_42", "_execute"]

    it "emits the ?dryrun=true query param and no body when dryrun is Just True" $ do
      let r = OS3A.defaultExecuteMonitorRequest {OS3A.executeMonitorRequestDryrun = Just True}
      let req = OS3Requests.executeMonitor (OS3A.MonitorId "mid") (Just r)
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("dryrun", Just "true")]
      bhRequestBody req `shouldBe` Nothing

    it "omits the dryrun query param when dryrun is Just False" $ do
      let r = OS3A.defaultExecuteMonitorRequest {OS3A.executeMonitorRequestDryrun = Just False}
      let req = OS3Requests.executeMonitor (OS3A.MonitorId "mid") (Just r)
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  -- =========================================================== --
  -- Destination lifecycle (create/update/delete) endpoint shape.  --
  -- =========================================================== --
  describe "destination lifecycle endpoint shape (OpenSearch 3)" $ do
    it "createDestination POSTs to /_plugins/_alerting/destinations with the encoded body" $ do
      let req = OS3Requests.createDestination os3SampleSlackDestination
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

    it "createDestination strips the server-assigned keys (id, schema_version, seq_no, primary_term)" $ do
      let req = OS3Requests.createDestination os3SampleSlackDestination
          Just body = bhRequestBody req
          Just (Object o) = decode body :: Maybe Value
      KM.lookup "id" o `shouldBe` Nothing
      KM.lookup "schema_version" o `shouldBe` Nothing
      KM.lookup "seq_no" o `shouldBe` Nothing
      KM.lookup "primary_term" o `shouldBe` Nothing
      KM.lookup "name" o `shouldNotBe` Nothing
      KM.lookup "type" o `shouldNotBe` Nothing
      KM.lookup "slack" o `shouldNotBe` Nothing

    it "updateDestination PUTs /_plugins/_alerting/destinations/{id} with the encoded body" $ do
      let req = OS3Requests.updateDestination (OS3A.DestinationId "dest-1") os3SampleSlackDestination
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "dest-1"]
      bhRequestBody req `shouldSatisfy` isJust

    it "updateDestination keeps the server-assigned keys (only create strips them)" $ do
      let req = OS3Requests.updateDestination (OS3A.DestinationId "dest-1") os3SampleSlackDestination
          Just body = bhRequestBody req
          Just (Object o) = decode body :: Maybe Value
      KM.lookup "id" o `shouldNotBe` Nothing
      KM.lookup "schema_version" o `shouldNotBe` Nothing

    it "deleteDestination DELETEs /_plugins/_alerting/destinations/{id} with no body" $ do
      let req = OS3Requests.deleteDestination (OS3A.DestinationId "dest-1")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "dest-1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  -- =========================================================== --
  -- Cross-backend parity for the new endpoints (OS1=OS2=OS3).     --
  -- =========================================================== --
  describe "cross-backend parity (new endpoints)" $ do
    let mid1 = OS1A.MonitorId "shared-id"
        mid2 = OS2A.MonitorId "shared-id"
        mid3 = OS3A.MonitorId "shared-id"
        did1 = OS1A.DestinationId "shared-dest"
        did2 = OS2A.DestinationId "shared-dest"
        did3 = OS3A.DestinationId "shared-dest"

    it "searchMonitors: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.searchMonitors Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.searchMonitors Nothing))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchMonitors Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchMonitors Nothing))
      bhRequestMethod (OS1Requests.searchMonitors Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.searchMonitors Nothing)

    it "executeMonitor: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.executeMonitor mid1 Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.executeMonitor mid2 Nothing))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.executeMonitor mid2 Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.executeMonitor mid3 Nothing))
      bhRequestMethod (OS1Requests.executeMonitor mid1 Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.executeMonitor mid3 Nothing)

    it "executeMonitor: OS1, OS2, OS3 emit the same ?dryrun=true query param" $ do
      let d1 = OS1A.defaultExecuteMonitorRequest {OS1A.executeMonitorRequestDryrun = Just True}
          d2 = OS2A.defaultExecuteMonitorRequest {OS2A.executeMonitorRequestDryrun = Just True}
          d3 = OS3A.defaultExecuteMonitorRequest {OS3A.executeMonitorRequestDryrun = Just True}
          q1 = getRawEndpointQueries (bhRequestEndpoint (OS1Requests.executeMonitor mid1 (Just d1)))
          q2 = getRawEndpointQueries (bhRequestEndpoint (OS2Requests.executeMonitor mid2 (Just d2)))
          q3 = getRawEndpointQueries (bhRequestEndpoint (OS3Requests.executeMonitor mid3 (Just d3)))
      q1 `shouldBe` [("dryrun", Just "true")]
      q2 `shouldBe` q1
      q3 `shouldBe` q1

    it "deleteDestination: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.deleteDestination did1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteDestination did2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteDestination did2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteDestination did3))
      bhRequestMethod (OS1Requests.deleteDestination did1)
        `shouldBe` bhRequestMethod (OS3Requests.deleteDestination did3)

    it "updateMonitorWith emits the same query params across backends" $ do
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint (OS1Requests.updateMonitorWith mid1 os1SampleMonitor (Just (7, 4)))))
        `shouldBe` sortOn fst (getRawEndpointQueries (bhRequestEndpoint (OS3Requests.updateMonitorWith mid3 os3SampleMonitor (Just (7, 4)))))

  -- =========================================================== --
  -- Decoder tests for the new response/request types.             --
  -- =========================================================== --
  describe "MonitorsSearchQuery rendering" $ do
    it "default query renders to {}" $
      encode OS3A.defaultMonitorsSearchQuery `shouldBe` "{}"

    it "renders size and query and round-trips through encode/decode" $ do
      let q =
            OS3A.defaultMonitorsSearchQuery
              { OS3A.monitorsSearchQuerySize = Just 10,
                OS3A.monitorsSearchQueryQuery =
                  Just
                    ( object
                        [ "match"
                            .= object
                              ["monitor.name" .= object ["query" .= ("my" :: Text)]]
                        ]
                    )
              }
      -- aeson's KeyMap is hash-ordered, so compare by decoding rather than
      -- asserting exact byte order.
      decode (encode q) `shouldBe` Just q

  describe "DestinationResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"_id\":\"d1\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"destination\":{\"id\":\"d1\",\"type\":\"slack\",\"name\":\"dest\",\"schema_version\":3,\"seq_no\":0,\"primary_term\":1,\"slack\":{\"url\":\"https://example.com\"}}}"

    it "decodes the documented create-response fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DestinationResponse
      OS3A.destinationResponseId decoded `shouldBe` "d1"
      OS3A.destinationResponseVersion decoded `shouldBe` 1
      OS3A.destinationResponseSeqNo decoded `shouldBe` 0
      OS3A.destinationResponsePrimaryTerm decoded `shouldBe` 1
      let d = OS3A.destinationResponseDestination decoded
      OS3A.destinationType d `shouldBe` OS3A.DestinationTypeSlack
      OS3A.destinationName d `shouldBe` "dest"

    it "round-trips through encode/decode on the typed fields" $ do
      let original =
            OS3A.DestinationResponse
              { OS3A.destinationResponseId = "d1",
                OS3A.destinationResponseVersion = 1,
                OS3A.destinationResponseSeqNo = 0,
                OS3A.destinationResponsePrimaryTerm = 1,
                OS3A.destinationResponseDestination = os3SampleSlackDestination
              }
          Just reDecoded = decode (encode original) :: Maybe OS3A.DestinationResponse
      OS3A.destinationResponseId reDecoded `shouldBe` "d1"
      OS3A.destinationResponseVersion reDecoded `shouldBe` 1

  describe "DeleteDestinationResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"_index\":\".opendistro-allocator-config\",\"_id\":\"d1\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":false,\"_shards\":{\"total\":2,\"successful\":1,\"skipped\":0,\"failed\":0},\"_seq_no\":1,\"_primary_term\":1}"

    it "decodes the bare delete-document fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteDestinationResponse
      OS3A.deleteDestinationResponseIndex decoded `shouldBe` ".opendistro-allocator-config"
      OS3A.deleteDestinationResponseId decoded `shouldBe` "d1"
      OS3A.deleteDestinationResponseVersion decoded `shouldBe` 2
      OS3A.deleteDestinationResponseResult decoded `shouldBe` "deleted"
      OS3A.deleteDestinationResponseForcedRefresh decoded `shouldBe` False
      let sh = OS3A.deleteDestinationResponseShards decoded
      OS3A.alertingShardsTotal sh `shouldBe` 2
      OS3A.alertingShardsSuccessful sh `shouldBe` 1
      OS3A.deleteDestinationResponseSeqNo decoded `shouldBe` 1
      OS3A.deleteDestinationResponsePrimaryTerm decoded `shouldBe` 1

  describe "SearchMonitorsResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"took\":5,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opendistro-allocator-config\",\"_id\":\"m1\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"_score\":1.0,\"_source\":{\"name\":\"my-monitor\",\"type\":\"monitor\",\"monitor_type\":\"query_level_monitor\",\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}}}}]}}"

    it "decodes the search envelope" $ do
      let Just resp = decode fixture :: Maybe OS3A.SearchMonitorsResponse
      OS3A.searchMonitorsResponseTook resp `shouldBe` 5
      OS3A.searchMonitorsResponseTimedOut resp `shouldBe` False
      OS3A.monitorsTotalValue (OS3A.searchMonitorsResponseTotal resp) `shouldBe` 1
      OS3A.monitorsTotalRelation (OS3A.searchMonitorsResponseTotal resp)
        `shouldBe` OS3A.MonitorsTotalRelationEq
      OS3A.searchMonitorsResponseMaxScore resp `shouldBe` Just 1.0
      length (OS3A.searchMonitorsResponseHits resp) `shouldBe` 1

    it "searchMonitorsResponseMonitors projects out the bare Monitor list" $ do
      let Just resp = decode fixture :: Maybe OS3A.SearchMonitorsResponse
      case OS3A.searchMonitorsResponseMonitors resp of
        (m : _) -> OS3A.monitorName m `shouldBe` "my-monitor"
        [] -> expectationFailure "expected at least one monitor hit"

    it "tolerates a missing max_score (defaults to Nothing)" $ do
      let fixture' =
            LBS.pack
              "{\"took\":1,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"hits\":[]}}"
      let Just resp = decode fixture' :: Maybe OS3A.SearchMonitorsResponse
      OS3A.searchMonitorsResponseMaxScore resp `shouldBe` Nothing
      OS3A.searchMonitorsResponseHits resp `shouldBe` []

  describe "ExecuteMonitorResponse decoding" $ do
    let fixture =
          LBS.pack
            "{\"monitor_name\":\"my-monitor\",\"period_start\":1551466220455,\"period_end\":1551466280455,\"dry_run\":false,\"trigger_results\":{\"foo\":{\"action_id\":\"a1\"}},\"input_results\":{\"results\":[]},\"script_actions\":{}}"

    it "decodes the typed shell and keeps the variable sub-objects opaque" $ do
      let Just decoded = decode fixture :: Maybe OS3A.ExecuteMonitorResponse
      OS3A.executeMonitorResponseMonitorName decoded `shouldBe` Just "my-monitor"
      OS3A.executeMonitorResponsePeriodStart decoded `shouldBe` Just 1551466220455
      OS3A.executeMonitorResponsePeriodEnd decoded `shouldBe` Just 1551466280455
      OS3A.executeMonitorResponseDryRun decoded `shouldBe` Just False
      OS3A.executeMonitorResponseTriggerResults decoded `shouldSatisfy` isJust
      OS3A.executeMonitorResponseInputResults decoded `shouldSatisfy` isJust

    it "round-trips the typed fields through encode/decode" $ do
      let original =
            OS3A.ExecuteMonitorResponse
              { OS3A.executeMonitorResponseMonitorName = Just "my-monitor",
                OS3A.executeMonitorResponsePeriodStart = Just 1,
                OS3A.executeMonitorResponsePeriodEnd = Just 2,
                OS3A.executeMonitorResponseDryRun = Just True,
                OS3A.executeMonitorResponseTriggerResults = Nothing,
                OS3A.executeMonitorResponseInputResults = Nothing,
                OS3A.executeMonitorResponseScriptActions = Nothing,
                OS3A.executeMonitorResponseOther = Null
              }
          encoded = encode original
          Just reDecoded = decode encoded :: Maybe OS3A.ExecuteMonitorResponse
      OS3A.executeMonitorResponseMonitorName reDecoded `shouldBe` Just "my-monitor"
      OS3A.executeMonitorResponseDryRun reDecoded `shouldBe` Just True
      OS3A.executeMonitorResponsePeriodStart reDecoded `shouldBe` Just 1

  describe "MonitorsTotalRelation discriminator" $ do
    it "round-trips each documented value through monitorsTotalRelationText" $ do
      OS3A.monitorsTotalRelationText OS3A.MonitorsTotalRelationEq `shouldBe` "eq"
      OS3A.monitorsTotalRelationText OS3A.MonitorsTotalRelationGe `shouldBe` "ge"

    it "falls through to MonitorsTotalRelationOther for an unknown value" $
      decode "\"future\"" `shouldBe` Just (OS3A.MonitorsTotalRelationOther "future")

  -- =========================================================== --
  -- Get alerts endpoint shape + decoders.                          --
  -- =========================================================== --
  describe "getAlerts endpoint shape (OpenSearch 3)" $ do
    it "getAlerts GETs /_plugins/_alerting/monitors/alerts with no body" $ do
      let req = OS3Requests.getAlerts
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "alerts"]
      bhRequestBody req `shouldBe` Nothing

    it "getAlertsWith with default options emits no query params" $ do
      let req = OS3Requests.getAlertsWith OS3A.defaultGetAlertsOptions
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "getAlertsWith forwards options as camelCase query params (startIndex is camelCase, unlike destinations)" $ do
      let opts =
            OS3A.defaultGetAlertsOptions
              { OS3A.getAlertsOptionsSize = Just 5,
                OS3A.getAlertsOptionsStartIndex = Just 10,
                OS3A.getAlertsOptionsSortString = Just "monitor_name.keyword",
                OS3A.getAlertsOptionsSortOrder = Just OS3A.AlertSortOrderDesc,
                OS3A.getAlertsOptionsMissing = Just "_last",
                OS3A.getAlertsOptionsSearchString = Just "alert",
                OS3A.getAlertsOptionsSeverityLevel = Just "1",
                OS3A.getAlertsOptionsAlertState = Just OS3A.AlertStateActive,
                OS3A.getAlertsOptionsMonitorId = Just "mon-1",
                OS3A.getAlertsOptionsWorkflowIds = Just "wf-1,wf-2"
              }
      let req = OS3Requests.getAlertsWith opts
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` sortOn
          fst
          [ ("alertState", Just "ACTIVE"),
            ("missing", Just "_last"),
            ("monitorId", Just "mon-1"),
            ("searchString", Just "alert"),
            ("severityLevel", Just "1"),
            ("size", Just "5"),
            ("sortOrder", Just "desc"),
            ("sortString", Just "monitor_name.keyword"),
            ("startIndex", Just "10"),
            ("workflowIds", Just "wf-1,wf-2")
          ]

    it "getAlertsWith omits fields set to Nothing" $ do
      let opts = OS3A.defaultGetAlertsOptions {OS3A.getAlertsOptionsSize = Just 5}
      let req = OS3Requests.getAlertsWith opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("size", Just "5")]

  describe "AlertState discriminator" $ do
    it "round-trips each documented value through alertStateText" $ do
      OS3A.alertStateText OS3A.AlertStateActive `shouldBe` "ACTIVE"
      OS3A.alertStateText OS3A.AlertStateCompleted `shouldBe` "COMPLETED"
      OS3A.alertStateText OS3A.AlertStateError `shouldBe` "ERROR"
      OS3A.alertStateText OS3A.AlertStateAcknowledged `shouldBe` "ACKNOWLEDGED"

    it "decodes each documented wire value" $ do
      decode "\"ACTIVE\"" `shouldBe` Just OS3A.AlertStateActive
      decode "\"COMPLETED\"" `shouldBe` Just OS3A.AlertStateCompleted
      decode "\"ERROR\"" `shouldBe` Just OS3A.AlertStateError
      decode "\"ACKNOWLEDGED\"" `shouldBe` Just OS3A.AlertStateAcknowledged

    it "falls through to AlertStateOther for an unknown value" $
      decode "\"DUMMY\"" `shouldBe` Just (OS3A.AlertStateOther "DUMMY")

  describe "Alert decoding" $ do
    let fixture =
          LBS.pack
            "{\"id\":\"eQURa3gBKo1jAh6qUo49\",\"version\":300,\"monitor_id\":\"awUMa3gBKo1jAh6qu47E\",\"schema_version\":2,\"monitor_version\":2,\"monitor_name\":\"Example_monitor_name\",\"trigger_id\":\"bQUQa3gBKo1jAh6qnY6G\",\"trigger_name\":\"Example_trigger_name\",\"state\":\"ACTIVE\",\"error_message\":null,\"severity\":\"1\",\"action_execution_results\":[],\"start_time\":1616704000492,\"last_notification_time\":1617317979908,\"end_time\":null,\"acknowledged_time\":null}"

    it "decodes the documented alert fixture" $ do
      let Just a = decode fixture :: Maybe OS3A.Alert
      OS3A.alertId a `shouldBe` "eQURa3gBKo1jAh6qUo49"
      OS3A.alertVersion a `shouldBe` 300
      OS3A.alertMonitorId a `shouldBe` "awUMa3gBKo1jAh6qu47E"
      OS3A.alertSchemaVersion a `shouldBe` Just 2
      OS3A.alertMonitorVersion a `shouldBe` Just 2
      OS3A.alertMonitorName a `shouldBe` "Example_monitor_name"
      OS3A.alertTriggerId a `shouldBe` "bQUQa3gBKo1jAh6qnY6G"
      OS3A.alertTriggerName a `shouldBe` "Example_trigger_name"
      OS3A.alertState a `shouldBe` OS3A.AlertStateActive
      OS3A.alertSeverity a `shouldBe` "1"
      OS3A.alertErrorMessage a `shouldBe` Nothing
      OS3A.alertStartTime a `shouldBe` Just 1616704000492
      OS3A.alertLastNotificationTime a `shouldBe` Just 1617317979908
      OS3A.alertEndTime a `shouldBe` Nothing
      OS3A.alertAcknowledgedTime a `shouldBe` Nothing

    it "keeps the variable sub-objects captured (action_execution_results round-trips)" $ do
      let Just a = decode fixture :: Maybe OS3A.Alert
      encode a `shouldSatisfy` \bs -> "action_execution_results" `isInfixOf` LBS.unpack bs

  describe "GetAlertsResponse envelope decoding" $ do
    let fixture =
          LBS.pack
            "{\"alerts\":[{\"id\":\"a1\",\"version\":1,\"monitor_id\":\"m1\",\"monitor_name\":\"mn\",\"trigger_id\":\"t1\",\"trigger_name\":\"tn\",\"state\":\"ACTIVE\",\"severity\":\"1\"}],\"totalAlerts\":1}"

    it "decodes the documented envelope" $ do
      let Just resp = decode fixture :: Maybe OS3A.GetAlertsResponse
      OS3A.getAlertsResponseTotalAlerts resp `shouldBe` 1
      length (OS3A.getAlertsResponseAlerts resp) `shouldBe` 1

    it "decodes an empty alerts list" $ do
      let Just resp = decode "{\"alerts\":[],\"totalAlerts\":0}" :: Maybe OS3A.GetAlertsResponse
      OS3A.getAlertsResponseTotalAlerts resp `shouldBe` 0
      OS3A.getAlertsResponseAlerts resp `shouldBe` []

    it "tolerates a missing totalAlerts field (defaults to 0)" $ do
      let Just resp = decode "{\"alerts\":[]}" :: Maybe OS3A.GetAlertsResponse
      OS3A.getAlertsResponseTotalAlerts resp `shouldBe` 0

  -- =========================================================== --
  -- Acknowledge alert endpoint shape + decoders.                   --
  -- =========================================================== --
  describe "acknowledgeAlert endpoint shape (OpenSearch 3)" $
    it "POSTs to /_plugins/_alerting/monitors/{id}/_acknowledge/alerts with the alerts body" $ do
      let req = OS3Requests.acknowledgeAlert (OS3A.MonitorId "my-monitor-id") ["a1", "a2"]
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "monitors", "my-monitor-id", "_acknowledge", "alerts"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let Just body = bhRequestBody req
      body `shouldBe` "{\"alerts\":[\"a1\",\"a2\"]}"

  describe "AcknowledgeAlertRequest / AcknowledgeAlertResponse" $ do
    it "default request renders to {\"alerts\":[]}" $
      encode OS3A.defaultAcknowledgeAlertRequest `shouldBe` "{\"alerts\":[]}"

    it "decodes the documented acknowledge response (success + failed)" $ do
      let Just resp = decode "{\"success\":[\"a1\"],\"failed\":[\"a2\"]}" :: Maybe OS3A.AcknowledgeAlertResponse
      OS3A.acknowledgeAlertResponseSuccess resp `shouldBe` ["a1"]
      OS3A.acknowledgeAlertResponseFailed resp `shouldBe` ["a2"]

    it "tolerates a missing failed array (defaults to [])" $ do
      let Just resp = decode "{\"success\":[\"a1\"]}" :: Maybe OS3A.AcknowledgeAlertResponse
      OS3A.acknowledgeAlertResponseFailed resp `shouldBe` []

  -- =========================================================== --
  -- Monitor stats endpoint shape + decoders.                       --
  -- =========================================================== --
  describe "getMonitorStats endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_alerting/stats with no args" $ do
      let req = OS3Requests.getMonitorStats Nothing Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "stats"]

    it "GETs /_plugins/_alerting/stats/{metric} with a metric only" $ do
      let req = OS3Requests.getMonitorStats Nothing (Just "job_scheduled_metrics")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "stats", "job_scheduled_metrics"]

    it "GETs /_plugins/_alerting/{node-id}/stats with a node only" $ do
      let req = OS3Requests.getMonitorStats (Just "node-1") Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "node-1", "stats"]

    it "GETs /_plugins/_alerting/{node-id}/stats/{metric} with both" $ do
      let req = OS3Requests.getMonitorStats (Just "node-1") (Just "job_scheduled_metrics")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "node-1", "stats", "job_scheduled_metrics"]

  describe "MonitorStats decoding" $ do
    let fixture =
          LBS.pack
            "{\"_nodes\":{\"total\":9,\"successful\":9,\"failed\":0},\"cluster_name\":\"alerting\",\"plugins.scheduled_jobs.enabled\":true,\"scheduled_job_index_exists\":true,\"scheduled_job_index_status\":\"green\",\"nodes_on_schedule\":9,\"nodes_not_on_schedule\":0,\"nodes\":{\"id1\":{\"name\":\"n\"}}}"

    it "decodes the typed shell and keeps nodes opaque" $ do
      let Just s = decode fixture :: Maybe OS3A.MonitorStats
      let Just nodes = OS3A.monitorStatsNodes s
      OS3A.monitorStatsNodesCountTotal nodes `shouldBe` 9
      OS3A.monitorStatsNodesCountSuccessful nodes `shouldBe` 9
      OS3A.monitorStatsNodesCountFailed nodes `shouldBe` 0
      OS3A.monitorStatsClusterName s `shouldBe` Just "alerting"
      OS3A.monitorStatsScheduledJobsEnabled s `shouldBe` Just True
      OS3A.monitorStatsScheduledJobIndexExists s `shouldBe` Just True
      OS3A.monitorStatsScheduledJobIndexStatus s `shouldBe` Just "green"
      OS3A.monitorStatsNodesOnSchedule s `shouldBe` Just 9
      OS3A.monitorStatsNodesNotOnSchedule s `shouldBe` Just 0
      encode s `shouldSatisfy` \bs -> "\"nodes\"" `isInfixOf` LBS.unpack bs

    it "tolerates a stats body with only the nodes map" $ do
      let Just s = decode "{\"nodes\":{}}" :: Maybe OS3A.MonitorStats
      OS3A.monitorStatsNodes s `shouldBe` Nothing
      OS3A.monitorStatsClusterName s `shouldBe` Nothing

  -- =========================================================== --
  -- Email account lifecycle endpoint shape + decoders.             --
  -- =========================================================== --
  describe "email account lifecycle endpoint shape (OpenSearch 3)" $ do
    it "createEmailAccount POSTs to .../email_accounts with the encoded body" $ do
      let req = OS3Requests.createEmailAccount os3SampleEmailAccount
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_accounts"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

    it "getEmailAccount GETs .../email_accounts/{id}" $ do
      let req = OS3Requests.getEmailAccount (OS3A.EmailAccountId "ea-1")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_accounts", "ea-1"]
      bhRequestBody req `shouldBe` Nothing

    it "updateEmailAccount PUTs .../email_accounts/{id}" $ do
      let req = OS3Requests.updateEmailAccount (OS3A.EmailAccountId "ea-1") os3SampleEmailAccount
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_accounts", "ea-1"]

    it "updateEmailAccountWith emits if_seq_no and if_primary_term when Just" $ do
      let req = OS3Requests.updateEmailAccountWith (OS3A.EmailAccountId "ea-1") os3SampleEmailAccount (Just (3, 2))
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` [("if_primary_term", Just "2"), ("if_seq_no", Just "3")]

    it "updateEmailAccountWith omits query params when Nothing" $ do
      let req = OS3Requests.updateEmailAccountWith (OS3A.EmailAccountId "ea-1") os3SampleEmailAccount Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "deleteEmailAccount DELETEs .../email_accounts/{id} with no body" $ do
      let req = OS3Requests.deleteEmailAccount (OS3A.EmailAccountId "ea-1")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_accounts", "ea-1"]
      bhRequestBody req `shouldBe` Nothing

  describe "EmailAccount decoding" $ do
    it "decodes the documented email account fixture" $ do
      let fixture =
            LBS.pack
              "{\"name\":\"example_account\",\"email\":\"example@email.com\",\"host\":\"smtp.email.com\",\"port\":465,\"method\":\"ssl\",\"schema_version\":2}"
      let Just a = decode fixture :: Maybe OS3A.EmailAccount
      OS3A.emailAccountName a `shouldBe` "example_account"
      OS3A.emailAccountEmail a `shouldBe` "example@email.com"
      OS3A.emailAccountHost a `shouldBe` "smtp.email.com"
      OS3A.emailAccountPort a `shouldBe` 465
      OS3A.emailAccountMethod a `shouldBe` "ssl"
      OS3A.emailAccountSchemaVersion a `shouldBe` Just 2

    it "round-trips through encode/decode on the typed fields" $ do
      let encoded = encode os3SampleEmailAccount
          Just reDecoded = decode encoded :: Maybe OS3A.EmailAccount
      OS3A.emailAccountName reDecoded `shouldBe` OS3A.emailAccountName os3SampleEmailAccount
      OS3A.emailAccountPort reDecoded `shouldBe` OS3A.emailAccountPort os3SampleEmailAccount
      OS3A.emailAccountMethod reDecoded `shouldBe` OS3A.emailAccountMethod os3SampleEmailAccount

  describe "EmailAccountResponse decoding" $
    it "decodes the documented create-response fixture" $ do
      let fixture =
            LBS.pack
              "{\"_id\":\"ea-id\",\"_version\":1,\"_seq_no\":7,\"_primary_term\":2,\"email_account\":{\"schema_version\":2,\"name\":\"example_account\",\"email\":\"example@email.com\",\"host\":\"smtp.email.com\",\"port\":465,\"method\":\"ssl\"}}"
      let Just r = decode fixture :: Maybe OS3A.EmailAccountResponse
      OS3A.emailAccountResponseId r `shouldBe` "ea-id"
      OS3A.emailAccountResponseVersion r `shouldBe` 1
      OS3A.emailAccountResponseSeqNo r `shouldBe` 7
      OS3A.emailAccountResponsePrimaryTerm r `shouldBe` 2
      let ea = OS3A.emailAccountResponseEmailAccount r
      OS3A.emailAccountName ea `shouldBe` "example_account"
      OS3A.emailAccountMethod ea `shouldBe` "ssl"

  describe "DeleteEmailAccountResponse decoding" $
    it "decodes the bare delete-document fixture" $ do
      let fixture =
            LBS.pack
              "{\"_index\":\".opendistro-alerting-config\",\"_id\":\"ea-id\",\"_version\":1,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"failed\":0},\"_seq_no\":12,\"_primary_term\":2}"
      let Just r = decode fixture :: Maybe OS3A.DeleteEmailAccountResponse
      OS3A.deleteEmailAccountResponseIndex r `shouldBe` ".opendistro-alerting-config"
      OS3A.deleteEmailAccountResponseId r `shouldBe` "ea-id"
      OS3A.deleteEmailAccountResponseResult r `shouldBe` "deleted"
      OS3A.deleteEmailAccountResponseForcedRefresh r `shouldBe` True
      OS3A.deleteEmailAccountResponseSeqNo r `shouldBe` 12

  -- =========================================================== --
  -- Email group lifecycle endpoint shape + decoders.               --
  -- =========================================================== --
  describe "email group lifecycle endpoint shape (OpenSearch 3)" $ do
    it "createEmailGroup POSTs to .../email_groups with the encoded body" $ do
      let req = OS3Requests.createEmailGroup os3SampleEmailGroup
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_groups"]
      bhRequestBody req `shouldSatisfy` isJust

    it "getEmailGroup GETs .../email_groups/{id}" $ do
      let req = OS3Requests.getEmailGroup (OS3A.EmailGroupId "eg-1")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_groups", "eg-1"]

    it "updateEmailGroup PUTs .../email_groups/{id}" $ do
      let req = OS3Requests.updateEmailGroup (OS3A.EmailGroupId "eg-1") os3SampleEmailGroup
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_groups", "eg-1"]

    it "updateEmailGroupWith emits if_seq_no and if_primary_term when Just" $ do
      let req = OS3Requests.updateEmailGroupWith (OS3A.EmailGroupId "eg-1") os3SampleEmailGroup (Just (5, 3))
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` [("if_primary_term", Just "3"), ("if_seq_no", Just "5")]

    it "updateEmailGroupWith omits query params when Nothing" $ do
      let req = OS3Requests.updateEmailGroupWith (OS3A.EmailGroupId "eg-1") os3SampleEmailGroup Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "deleteEmailGroup DELETEs .../email_groups/{id} with no body" $ do
      let req = OS3Requests.deleteEmailGroup (OS3A.EmailGroupId "eg-1")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_groups", "eg-1"]
      bhRequestBody req `shouldBe` Nothing

  describe "EmailGroup decoding" $ do
    it "decodes the documented email group fixture" $ do
      let fixture =
            LBS.pack
              "{\"name\":\"example_email_group\",\"emails\":[{\"email\":\"example@email.com\"}],\"schema_version\":2}"
      let Just g = decode fixture :: Maybe OS3A.EmailGroup
      OS3A.emailGroupName g `shouldBe` "example_email_group"
      OS3A.emailGroupSchemaVersion g `shouldBe` Just 2
      case OS3A.emailGroupEmails g of
        (r : _) -> OS3A.emailGroupRecipientEmail r `shouldBe` "example@email.com"
        [] -> expectationFailure "expected at least one recipient"

    it "tolerates a missing emails array (defaults to [])" $ do
      let Just g = decode "{\"name\":\"empty_group\"}" :: Maybe OS3A.EmailGroup
      OS3A.emailGroupName g `shouldBe` "empty_group"
      OS3A.emailGroupEmails g `shouldBe` []

    it "round-trips through encode/decode on the typed fields" $ do
      let encoded = encode os3SampleEmailGroup
          Just reDecoded = decode encoded :: Maybe OS3A.EmailGroup
      OS3A.emailGroupName reDecoded `shouldBe` OS3A.emailGroupName os3SampleEmailGroup
      length (OS3A.emailGroupEmails reDecoded)
        `shouldBe` length (OS3A.emailGroupEmails os3SampleEmailGroup)

  describe "EmailGroupResponse decoding" $
    it "decodes the documented create-response fixture" $ do
      let fixture =
            LBS.pack
              "{\"_id\":\"eg-id\",\"_version\":1,\"_seq_no\":9,\"_primary_term\":2,\"email_group\":{\"schema_version\":2,\"name\":\"example_email_group\",\"emails\":[{\"email\":\"example@email.com\"}]}}"
      let Just r = decode fixture :: Maybe OS3A.EmailGroupResponse
      OS3A.emailGroupResponseId r `shouldBe` "eg-id"
      OS3A.emailGroupResponseVersion r `shouldBe` 1
      OS3A.emailGroupResponseSeqNo r `shouldBe` 9
      let eg = OS3A.emailGroupResponseEmailGroup r
      OS3A.emailGroupName eg `shouldBe` "example_email_group"
      length (OS3A.emailGroupEmails eg) `shouldBe` 1

  describe "DeleteEmailGroupResponse decoding" $
    it "decodes the bare delete-document fixture" $ do
      let fixture =
            LBS.pack
              "{\"_index\":\".opendistro-alerting-config\",\"_id\":\"eg-id\",\"_version\":1,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"failed\":0},\"_seq_no\":11,\"_primary_term\":2}"
      let Just r = decode fixture :: Maybe OS3A.DeleteEmailGroupResponse
      OS3A.deleteEmailGroupResponseId r `shouldBe` "eg-id"
      OS3A.deleteEmailGroupResponseResult r `shouldBe` "deleted"
      OS3A.deleteEmailGroupResponseForcedRefresh r `shouldBe` True

  -- =========================================================== --
  -- Cross-backend parity for the new endpoints (OS1=OS2=OS3).     --
  -- =========================================================== --
  describe "cross-backend parity (alerts/acknowledge/stats/email endpoints)" $ do
    let mid1 = OS1A.MonitorId "shared-id"
        mid2 = OS2A.MonitorId "shared-id"
        mid3 = OS3A.MonitorId "shared-id"
        ea1 = OS1A.EmailAccountId "shared-ea"
        ea2 = OS2A.EmailAccountId "shared-ea"
        ea3 = OS3A.EmailAccountId "shared-ea"
        eg1 = OS1A.EmailGroupId "shared-eg"
        eg2 = OS2A.EmailGroupId "shared-eg"
        eg3 = OS3A.EmailGroupId "shared-eg"

    it "getAlerts: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint OS1Requests.getAlerts)
        `shouldBe` getRawEndpoint (bhRequestEndpoint OS2Requests.getAlerts)
      getRawEndpoint (bhRequestEndpoint OS2Requests.getAlerts)
        `shouldBe` getRawEndpoint (bhRequestEndpoint OS3Requests.getAlerts)
      bhRequestMethod OS1Requests.getAlerts
        `shouldBe` bhRequestMethod OS3Requests.getAlerts

    it "acknowledgeAlert: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.acknowledgeAlert mid1 ["a"]))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeAlert mid2 ["a"]))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeAlert mid2 ["a"]))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.acknowledgeAlert mid3 ["a"]))
      bhRequestMethod (OS1Requests.acknowledgeAlert mid1 ["a"])
        `shouldBe` bhRequestMethod (OS3Requests.acknowledgeAlert mid3 ["a"])

    it "getMonitorStats: OS1, OS2, OS3 produce the same path" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.getMonitorStats Nothing Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitorStats Nothing Nothing))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitorStats Nothing Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getMonitorStats Nothing Nothing))

    it "getMonitorStats: OS1, OS2, OS3 produce the same path with node + metric" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.getMonitorStats (Just "n") (Just "m")))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitorStats (Just "n") (Just "m")))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getMonitorStats (Just "n") (Just "m")))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getMonitorStats (Just "n") (Just "m")))

    it "deleteEmailAccount: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.deleteEmailAccount ea1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteEmailAccount ea2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteEmailAccount ea2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteEmailAccount ea3))
      bhRequestMethod (OS1Requests.deleteEmailAccount ea1)
        `shouldBe` bhRequestMethod (OS3Requests.deleteEmailAccount ea3)

    it "deleteEmailGroup: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.deleteEmailGroup eg1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteEmailGroup eg2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteEmailGroup eg2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteEmailGroup eg3))
      bhRequestMethod (OS1Requests.deleteEmailGroup eg1)
        `shouldBe` bhRequestMethod (OS3Requests.deleteEmailGroup eg3)

    it "getAlertsWith emits identical query params across backends" $ do
      let o1 =
            OS1A.defaultGetAlertsOptions
              { OS1A.getAlertsOptionsSize = Just 5,
                OS1A.getAlertsOptionsAlertState = Just OS1A.AlertStateActive
              }
          o3 =
            OS3A.defaultGetAlertsOptions
              { OS3A.getAlertsOptionsSize = Just 5,
                OS3A.getAlertsOptionsAlertState = Just OS3A.AlertStateActive
              }
      sortOn fst (getRawEndpointQueries (bhRequestEndpoint (OS1Requests.getAlertsWith o1)))
        `shouldBe` sortOn fst (getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getAlertsWith o3)))

  -- =========================================================== --
  -- New endpoint shape: getDestination, search endpoints,        --
  -- comments CRUD (bloodhound-ee2).                              --
  -- =========================================================== --
  describe "getDestination endpoint shape (OpenSearch 3)" $ do
    it "getDestination GETs /_plugins/_alerting/destinations/{id} with no body" $ do
      let req = OS3Requests.getDestination (OS3A.DestinationId "my-dest")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "my-dest"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "getDestination keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.getDestination (OS3A.DestinationId "dest_42")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "dest_42"]

  describe "searchEmailAccounts endpoint shape (OpenSearch 3)" $ do
    it "searchEmailAccounts POSTs to .../email_accounts/_search with a body" $ do
      let req = OS3Requests.searchEmailAccounts Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_accounts", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

    it "searchEmailAccounts with Nothing emits an empty-body {} JSON" $ do
      let req = OS3Requests.searchEmailAccounts Nothing
      let body = fromJust (bhRequestBody req)
      decode body `shouldBe` Just (Object mempty)

    it "searchEmailAccounts with Just encodes the query DSL" $ do
      let q =
            OS3A.EmailAccountSearchQuery
              { OS3A.emailAccountSearchQueryFrom = Just 0,
                OS3A.emailAccountSearchQuerySize = Just 10,
                OS3A.emailAccountSearchQuerySort = Nothing,
                OS3A.emailAccountSearchQueryQuery = Nothing
              }
      let req = OS3Requests.searchEmailAccounts (Just q)
      let body = fromJust (bhRequestBody req)
      decode body `shouldBe` Just (object ["from" .= (0 :: Int), "size" .= (10 :: Int)])

  describe "searchEmailGroups endpoint shape (OpenSearch 3)" $ do
    it "searchEmailGroups POSTs to .../email_groups/_search with a body" $ do
      let req = OS3Requests.searchEmailGroups Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "destinations", "email_groups", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

  describe "searchFindings endpoint shape (OpenSearch 3)" $ do
    it "searchFindings GETs /_plugins/_alerting/findings/_search with no body" $ do
      let req = OS3Requests.searchFindings Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "findings", "_search"]
      bhRequestBody req `shouldBe` Nothing

    it "searchFindings with default options emits no query params" $ do
      let req = OS3Requests.searchFindings Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "searchFindings with options forwards them as query params" $ do
      let opts =
            OS3A.defaultFindingsSearchOptions
              { OS3A.findingsSearchOptionsFindingId = Just "finding-1",
                OS3A.findingsSearchOptionsSize = Just 20
              }
      let req = OS3Requests.searchFindings (Just opts)
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldSatisfy` any (\(k, v) -> k == "findingId" && v == Just "finding-1")
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldSatisfy` any (\(k, v) -> k == "size" && v == Just "20")

  describe "createComment endpoint shape (OpenSearch 3)" $ do
    it "createComment POSTs to /_plugins/_alerting/comments/{alertId} with a body" $ do
      let req = OS3Requests.createComment "alert-1" (OS3A.CreateCommentRequest "hello")
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "comments", "alert-1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let body = fromJust (bhRequestBody req)
      decode body `shouldBe` Just (object ["content" .= ("hello" :: Text)])

  describe "updateComment endpoint shape (OpenSearch 3)" $ do
    it "updateComment PUTs to /_plugins/_alerting/comments/{commentId} with a body" $ do
      let req = OS3Requests.updateComment (OS3A.CommentId "c-1") (OS3A.UpdateCommentRequest "updated")
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "comments", "c-1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let body = fromJust (bhRequestBody req)
      decode body `shouldBe` Just (object ["content" .= ("updated" :: Text)])

  describe "searchComments endpoint shape (OpenSearch 3)" $ do
    it "searchComments GETs /_plugins/_alerting/comments/_search with a body" $ do
      let req = OS3Requests.searchComments Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "comments", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isJust

    it "searchComments with Nothing emits an empty-body {} JSON" $ do
      let req = OS3Requests.searchComments Nothing
      let body = fromJust (bhRequestBody req)
      decode body `shouldBe` Just (Object mempty)

  describe "deleteComment endpoint shape (OpenSearch 3)" $ do
    it "deleteComment DELETEs /_plugins/_alerting/comments/{commentId} with no body" $ do
      let req = OS3Requests.deleteComment (OS3A.CommentId "c-1")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_alerting", "comments", "c-1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  -- =========================================================== --
  -- Cross-backend parity for the new endpoints (ee2).            --
  -- =========================================================== --
  describe "cross-backend parity (new endpoints)" $ do
    let did1 = OS1A.DestinationId "shared-dest"
        did2 = OS2A.DestinationId "shared-dest"
        did3 = OS3A.DestinationId "shared-dest"
        cid2 = OS2A.CommentId "shared-comment"
        cid3 = OS3A.CommentId "shared-comment"

    it "getDestination: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.getDestination did1))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.getDestination did2))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getDestination did2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getDestination did3))
      bhRequestMethod (OS1Requests.getDestination did1)
        `shouldBe` bhRequestMethod (OS3Requests.getDestination did3)

    it "searchEmailAccounts: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.searchEmailAccounts Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.searchEmailAccounts Nothing))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchEmailAccounts Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchEmailAccounts Nothing))
      bhRequestMethod (OS1Requests.searchEmailAccounts Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.searchEmailAccounts Nothing)

    it "searchEmailGroups: OS1, OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS1Requests.searchEmailGroups Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS2Requests.searchEmailGroups Nothing))
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchEmailGroups Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchEmailGroups Nothing))
      bhRequestMethod (OS1Requests.searchEmailGroups Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.searchEmailGroups Nothing)

    it "searchFindings: OS2 and OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchFindings Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchFindings Nothing))
      bhRequestMethod (OS2Requests.searchFindings Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.searchFindings Nothing)

    it "createComment: OS2 and OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createComment "x" (OS2A.CreateCommentRequest "c")))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createComment "x" (OS3A.CreateCommentRequest "c")))
      bhRequestMethod (OS2Requests.createComment "x" (OS2A.CreateCommentRequest "c"))
        `shouldBe` bhRequestMethod (OS3Requests.createComment "x" (OS3A.CreateCommentRequest "c"))

    it "updateComment: OS2 and OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.updateComment cid2 (OS2A.UpdateCommentRequest "c")))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateComment cid3 (OS3A.UpdateCommentRequest "c")))
      bhRequestMethod (OS2Requests.updateComment cid2 (OS2A.UpdateCommentRequest "c"))
        `shouldBe` bhRequestMethod (OS3Requests.updateComment cid3 (OS3A.UpdateCommentRequest "c"))

    it "searchComments: OS2 and OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchComments Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchComments Nothing))
      bhRequestMethod (OS2Requests.searchComments Nothing)
        `shouldBe` bhRequestMethod (OS3Requests.searchComments Nothing)

    it "deleteComment: OS2 and OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteComment cid2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteComment cid3))
      bhRequestMethod (OS2Requests.deleteComment cid2)
        `shouldBe` bhRequestMethod (OS3Requests.deleteComment cid3)

  -- =========================================================== --
  -- Decoder tests for the new types (ee2).                       --
  -- =========================================================== --
  describe "SearchEmailAccountsResponse decoding" $ do
    it "decodes a documented search response fixture" $ do
      let wire =
            LBS.pack
              "{\"took\":5,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opendistro-alerting-config\",\"_id\":\"email_account_1\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"_score\":1.0,\"_source\":{\"name\":\"example_account\",\"email\":\"example@email.com\",\"host\":\"smtp.email.com\",\"port\":465,\"method\":\"ssl\"}}]}}"
      let decoded = decode wire :: Maybe OS3A.SearchEmailAccountsResponse
      decoded `shouldSatisfy` isJust
      let resp = fromJust decoded
      OS3A.searchEmailAccountsResponseTook resp `shouldBe` 5
      OS3A.searchEmailAccountsResponseTotal resp
        `shouldBe` OS3A.MonitorsTotal {OS3A.monitorsTotalValue = 1, OS3A.monitorsTotalRelation = OS3A.MonitorsTotalRelationEq}
      length (OS3A.searchEmailAccountsResponseHits resp) `shouldBe` 1
      let hit = head (OS3A.searchEmailAccountsResponseHits resp)
      OS3A.emailAccountSearchHitId hit `shouldBe` "email_account_1"
      OS3A.emailAccountName (OS3A.emailAccountSearchHitSource hit) `shouldBe` "example_account"

  describe "SearchEmailGroupsResponse decoding" $ do
    it "decodes a documented search response fixture" $ do
      let wire =
            LBS.pack
              "{\"took\":3,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opendistro-alerting-config\",\"_id\":\"email_group_1\",\"_score\":1.0,\"_source\":{\"name\":\"example_email_group\",\"emails\":[{\"email\":\"example@email.com\"}]}}]}}"
      let decoded = decode wire :: Maybe OS3A.SearchEmailGroupsResponse
      decoded `shouldSatisfy` isJust
      let resp = fromJust decoded
      OS3A.searchEmailGroupsResponseTook resp `shouldBe` 3
      length (OS3A.searchEmailGroupsResponseHits resp) `shouldBe` 1
      let hit = head (OS3A.searchEmailGroupsResponseHits resp)
      OS3A.emailGroupSearchHitId hit `shouldBe` "email_group_1"
      OS3A.emailGroupName (OS3A.emailGroupSearchHitSource hit) `shouldBe` "example_email_group"

  describe "SearchFindingsResponse decoding" $ do
    it "decodes a response with total_findings and opaque findings list" $ do
      let wire =
            LBS.pack
              "{\"total_findings\":2,\"findings\":[{\"id\":\"f-1\",\"related_doc_ids\":[\"doc-1\"]},{\"id\":\"f-2\",\"related_doc_ids\":[\"doc-2\"]}]}"
      let decoded = decode wire :: Maybe OS3A.SearchFindingsResponse
      decoded `shouldSatisfy` isJust
      let resp = fromJust decoded
      OS3A.searchFindingsResponseTotalFindings resp `shouldBe` 2

    it "decodes a response with missing total_findings (defaults to 0)" $ do
      let wire = LBS.pack "{\"findings\":[]}"
      let decoded = decode wire :: Maybe OS3A.SearchFindingsResponse
      decoded `shouldSatisfy` isJust
      OS3A.searchFindingsResponseTotalFindings (fromJust decoded) `shouldBe` 0

  describe "Comment decoding" $ do
    it "decodes a full comment fixture" $ do
      let wire =
            LBS.pack
              "{\"entity_id\":\"alert-1\",\"entity_type\":\"alert\",\"content\":\"This is a comment\",\"created_time\":1603943261722,\"last_updated_time\":1603943261723,\"user\":\"admin\"}"
      let decoded = decode wire :: Maybe OS3A.Comment
      decoded `shouldSatisfy` isJust
      let c = fromJust decoded
      OS3A.commentEntityId c `shouldBe` "alert-1"
      OS3A.commentEntityType c `shouldBe` "alert"
      OS3A.commentContent c `shouldBe` "This is a comment"
      OS3A.commentCreatedTime c `shouldBe` Just 1603943261722
      OS3A.commentLastUpdatedTime c `shouldBe` Just 1603943261723
      OS3A.commentUser c `shouldBe` Just "admin"

    it "round-trips through encode/decode losslessly on typed fields" $ do
      let c =
            OS3A.Comment
              { OS3A.commentEntityId = "alert-1",
                OS3A.commentEntityType = "alert",
                OS3A.commentContent = "hello",
                OS3A.commentCreatedTime = Just 100,
                OS3A.commentLastUpdatedTime = Nothing,
                OS3A.commentUser = Nothing,
                OS3A.commentOther = Null
              }
      let encoded = encode c
      let decoded = decode encoded :: Maybe OS3A.Comment
      decoded `shouldSatisfy` isJust
      OS3A.commentContent (fromJust decoded) `shouldBe` "hello"
      OS3A.commentEntityId (fromJust decoded) `shouldBe` "alert-1"

  describe "CommentResponse decoding" $ do
    it "decodes a create-comment response fixture" $ do
      let wire =
            LBS.pack
              "{\"_id\":\"comment-1\",\"_seq_no\":0,\"_primary_term\":1,\"comment\":{\"entity_id\":\"alert-1\",\"entity_type\":\"alert\",\"content\":\"This is a comment\"}}"
      let decoded = decode wire :: Maybe OS3A.CommentResponse
      decoded `shouldSatisfy` isJust
      let resp = fromJust decoded
      OS3A.commentResponseId resp `shouldBe` "comment-1"
      OS3A.commentResponseSeqNo resp `shouldBe` 0
      OS3A.commentResponsePrimaryTerm resp `shouldBe` 1
      OS3A.commentContent (OS3A.commentResponseComment resp) `shouldBe` "This is a comment"

  describe "DeleteCommentResponse decoding" $ do
    it "decodes the bare {_id: ...} fixture" $ do
      let wire = LBS.pack "{\"_id\":\"comment-1\"}"
      let decoded = decode wire :: Maybe OS3A.DeleteCommentResponse
      decoded `shouldSatisfy` isJust
      OS3A.deleteCommentResponseId (fromJust decoded) `shouldBe` "comment-1"

  describe "SearchCommentsResponse decoding" $ do
    it "decodes a response with a comment hit" $ do
      let wire =
            LBS.pack
              "{\"took\":2,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opensearch-alerting-comments-history-2024\",\"_id\":\"comment-1\",\"_score\":1.0,\"_source\":{\"entity_id\":\"alert-1\",\"entity_type\":\"alert\",\"content\":\"A comment\",\"created_time\":1603943261722,\"last_updated_time\":1603943261723,\"user\":\"admin\"}}]}}"
      let decoded = decode wire :: Maybe OS3A.SearchCommentsResponse
      decoded `shouldSatisfy` isJust
      let resp = fromJust decoded
      OS3A.searchCommentsResponseTook resp `shouldBe` 2
      length (OS3A.searchCommentsResponseHits resp) `shouldBe` 1
      let hit = head (OS3A.searchCommentsResponseHits resp)
      OS3A.commentSearchHitId hit `shouldBe` "comment-1"
      OS3A.commentContent (OS3A.commentSearchHitSource hit) `shouldBe` "A comment"

  describe "CreateCommentRequest encoding" $
    it "encodes content as {\"content\": \"...\"}" $ do
      let req = OS3A.CreateCommentRequest "my comment"
      encode req `shouldBe` LBS.pack "{\"content\":\"my comment\"}"

  describe "UpdateCommentRequest encoding" $
    it "encodes content as {\"content\": \"...\"}" $ do
      let req = OS3A.UpdateCommentRequest "updated text"
      encode req `shouldBe` LBS.pack "{\"content\":\"updated text\"}"

  describe "FindingsSearchOptions query params" $ do
    it "defaultFindingsSearchOptions renders no params" $ do
      OS3A.findingsSearchOptionsParams OS3A.defaultFindingsSearchOptions `shouldBe` []

    it "renders findingId, size, sortOrder as query params" $ do
      let opts =
            OS3A.defaultFindingsSearchOptions
              { OS3A.findingsSearchOptionsFindingId = Just "f-1",
                OS3A.findingsSearchOptionsSize = Just 50,
                OS3A.findingsSearchOptionsSortOrder = Just OS3A.DestinationSortOrderDesc
              }
      let params = OS3A.findingsSearchOptionsParams opts
      lookup "findingId" params `shouldBe` Just (Just "f-1")
      lookup "size" params `shouldBe` Just (Just "50")
      lookup "sortOrder" params `shouldBe` Just (Just "desc")

  -- =========================================================== --
  -- Live round-trip: gated per-OS with os{n}OnlyIT (the          --
  -- 'Test.NeuralClearCacheSpec' pattern). Wire shapes            --
  -- live-verified on OS 1.3.19, 2.19.5 and 3.7.0 (bead           --
  -- bloodhound-z5j).                                            --
  --                                                              --
  -- The DELETE-monitor response shape is version-dependent: OS    --
  -- 1.3.x returns the full bare ES delete-document body (with     --
  -- @_index@, @_shards@, @_seq_no@, ...); OS 2.x/3.x return only  --
  -- @{_id,_version}@. The per-OS assertions pin that contract so  --
  -- a regression to the old over-strict decoder (which failed on  --
  -- OS 2.x/3.x) is caught immediately.                           --
  -- =========================================================== --
  describe "deleteMonitor live round-trip (version-dependent shape)" $ do
    os1It <- runIO os1OnlyIT
    os2It <- runIO os2OnlyIT
    os3It <- runIO os3OnlyIT

    os1It "OS1: create -> delete returns the full ES delete-document body" $
      withTestEnv $ do
        resp <- OS1.Client.createMonitor os1SampleMonitor
        let mid = OS1A.MonitorId (OS1A.monitorResponseId resp)
        del <- OS1.Client.deleteMonitor mid
        liftIO $ do
          OS1A.deleteMonitorResponseId del `shouldBe` OS1A.monitorResponseId resp
          OS1A.deleteMonitorResponseVersion del `shouldSatisfy` (>= 1)
          OS1A.deleteMonitorResponseIndex del `shouldSatisfy` isJust
          OS1A.deleteMonitorResponseShards del `shouldSatisfy` isJust
          OS1A.deleteMonitorResponseResult del `shouldBe` Just "deleted"

    os2It "OS2: create -> delete returns the minimal {_id,_version} body" $
      withTestEnv $ do
        resp <- OS2.Client.createMonitor os2SampleMonitor
        let mid = OS2A.MonitorId (OS2A.monitorResponseId resp)
        del <- OS2.Client.deleteMonitor mid
        liftIO $ do
          OS2A.deleteMonitorResponseId del `shouldBe` OS2A.monitorResponseId resp
          OS2A.deleteMonitorResponseVersion del `shouldSatisfy` (>= 1)
          OS2A.deleteMonitorResponseIndex del `shouldBe` Nothing
          OS2A.deleteMonitorResponseShards del `shouldBe` Nothing
          OS2A.deleteMonitorResponseResult del `shouldBe` Nothing

    os3It "OS3: create -> delete returns the minimal {_id,_version} body" $
      withTestEnv $ do
        resp <- OS3.Client.createMonitor os3SampleMonitor
        let mid = OS3A.MonitorId (OS3A.monitorResponseId resp)
        del <- OS3.Client.deleteMonitor mid
        liftIO $ do
          OS3A.deleteMonitorResponseId del `shouldBe` OS3A.monitorResponseId resp
          OS3A.deleteMonitorResponseVersion del `shouldSatisfy` (>= 1)
          OS3A.deleteMonitorResponseIndex del `shouldBe` Nothing
          OS3A.deleteMonitorResponseShards del `shouldBe` Nothing
          OS3A.deleteMonitorResponseResult del `shouldBe` Nothing

-- ---------------------------------------------------------------- --
-- Sample fixtures (structurally identical across OS1/OS2/OS3;      --
-- nominally distinct types, so one per backend).                  --
-- ---------------------------------------------------------------- --

os3SampleMonitor :: OS3A.Monitor
os3SampleMonitor =
  OS3A.Monitor
    { OS3A.monitorName = "test-monitor",
      OS3A.monitorType = Just OS3A.AlertTypeMonitor,
      OS3A.monitorMonitorType = Just OS3A.MonitorTypeQueryLevel,
      OS3A.monitorEnabled = Just True,
      OS3A.monitorEnabledTime = Nothing,
      OS3A.monitorLastUpdateTime = Nothing,
      OS3A.monitorSchemaVersion = Nothing,
      OS3A.monitorSchedule = OS3A.PeriodSchedule (OS3A.SchedulePeriod 1 "MINUTES"),
      OS3A.monitorInputs = [],
      OS3A.monitorTriggers = [],
      OS3A.monitorUser = Nothing,
      OS3A.monitorRbacRoles = Nothing,
      OS3A.monitorOther = Null
    }

os1SampleMonitor :: OS1A.Monitor
os1SampleMonitor =
  OS1A.Monitor
    { OS1A.monitorName = "test-monitor",
      OS1A.monitorType = Just OS1A.AlertTypeMonitor,
      OS1A.monitorMonitorType = Just OS1A.MonitorTypeQueryLevel,
      OS1A.monitorEnabled = Just True,
      OS1A.monitorEnabledTime = Nothing,
      OS1A.monitorLastUpdateTime = Nothing,
      OS1A.monitorSchemaVersion = Nothing,
      OS1A.monitorSchedule = OS1A.PeriodSchedule (OS1A.SchedulePeriod 1 "MINUTES"),
      OS1A.monitorInputs = [],
      OS1A.monitorTriggers = [],
      OS1A.monitorUser = Nothing,
      OS1A.monitorRbacRoles = Nothing,
      OS1A.monitorOther = Null
    }

os2SampleMonitor :: OS2A.Monitor
os2SampleMonitor =
  OS2A.Monitor
    { OS2A.monitorName = "test-monitor",
      OS2A.monitorType = Just OS2A.AlertTypeMonitor,
      OS2A.monitorMonitorType = Just OS2A.MonitorTypeQueryLevel,
      OS2A.monitorEnabled = Just True,
      OS2A.monitorEnabledTime = Nothing,
      OS2A.monitorLastUpdateTime = Nothing,
      OS2A.monitorSchemaVersion = Nothing,
      OS2A.monitorSchedule = OS2A.PeriodSchedule (OS2A.SchedulePeriod 1 "MINUTES"),
      OS2A.monitorInputs = [],
      OS2A.monitorTriggers = [],
      OS2A.monitorUser = Nothing,
      OS2A.monitorRbacRoles = Nothing,
      OS2A.monitorOther = Null
    }

-- | Helper assertion: the given wire string decodes to the expected
-- 'MonitorType'.
decodesMonitorTypeTo :: Text -> OS3A.MonitorType -> Expectation
decodesMonitorTypeTo wire expected =
  decode (encode wire) `shouldBe` Just expected

-- | Helper assertion: the given wire string decodes to the expected
-- 'DestinationType'.
decodesDestinationTypeTo :: Text -> OS3A.DestinationType -> Expectation
decodesDestinationTypeTo wire expected =
  decode (encode wire) `shouldBe` Just expected

-- | Sample 'Destination' for round-trip tests. Mirrors the structure
-- of the documented Slack fixture but is built as a Haskell value so
-- the encode direction is also exercised.
os3SampleSlackDestination :: OS3A.Destination
os3SampleSlackDestination =
  OS3A.Destination
    { OS3A.destinationId = "sample-id",
      OS3A.destinationType = OS3A.DestinationTypeSlack,
      OS3A.destinationName = "sample-destination",
      OS3A.destinationSchemaVersion = 3,
      OS3A.destinationSeqNo = 0,
      OS3A.destinationPrimaryTerm = 6,
      OS3A.destinationLastUpdateTime = Just 1603943261722,
      OS3A.destinationUser = Nothing,
      OS3A.destinationBody = object ["url" .= ("https://example.com" :: Text)],
      OS3A.destinationOther = Null
    }

-- | Sample 'EmailAccount' for round-trip / endpoint-shape tests.
os3SampleEmailAccount :: OS3A.EmailAccount
os3SampleEmailAccount =
  OS3A.EmailAccount
    { OS3A.emailAccountName = "example_account",
      OS3A.emailAccountEmail = "example@email.com",
      OS3A.emailAccountHost = "smtp.email.com",
      OS3A.emailAccountPort = 465,
      OS3A.emailAccountMethod = "ssl",
      OS3A.emailAccountSchemaVersion = Just 2,
      OS3A.emailAccountOther = Null
    }

-- | Sample 'EmailGroup' for round-trip / endpoint-shape tests.
os3SampleEmailGroup :: OS3A.EmailGroup
os3SampleEmailGroup =
  OS3A.EmailGroup
    { OS3A.emailGroupName = "example_email_group",
      OS3A.emailGroupEmails =
        [ OS3A.EmailGroupRecipient
            { OS3A.emailGroupRecipientEmail = "example@email.com",
              OS3A.emailGroupRecipientOther = Null
            }
        ],
      OS3A.emailGroupSchemaVersion = Just 2,
      OS3A.emailGroupOther = Null
    }