packages feed

bloodhound-1.0.0.0: tests/Test/DataStreamsSpec.hs

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

module Test.DataStreamsSpec (spec) where

import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Int (Int64)
import Data.List (isInfixOf, sort)
import Data.Map.Strict qualified as M
import Database.Bloodhound.ElasticSearch7.Requests qualified as ES7
import Database.Bloodhound.ElasticSearch8.Requests qualified as ES8
import Database.Bloodhound.ElasticSearch9.Requests qualified as ES9
import TestsUtils.Import
import Prelude

-- | Fixture mimicking the @GET /_data_stream@ response (two streams).
-- Adapted from the shape documented at
-- https://www.elastic.co/guide/en/elasticsearch/reference/current/data-stream-apis.html#get-data-streams.
sampleListResponse :: LBS.ByteString
sampleListResponse =
  "{\
  \  \"data_streams\": [\
  \    {\
  \      \"name\": \"logs-foobar\",\
  \      \"timestamp_field\": { \"name\": \"@timestamp\" },\
  \      \"indices\": [\
  \        { \"index_name\": \".ds-logs-foobar-2024.01.01-000001\", \"index_uuid\": \"uuid-a\" }\
  \      ],\
  \      \"generation\": 1,\
  \      \"status\": \"GREEN\",\
  \      \"template\": \"logs-foobar-template\",\
  \      \"hidden\": false\
  \    },\
  \    {\
  \      \"name\": \"metrics-hidden\",\
  \      \"timestamp_field\": { \"name\": \"ts\" },\
  \      \"indices\": [],\
  \      \"generation\": 5,\
  \      \"status\": \"YELLOW\",\
  \      \"template\": \"metrics-template\",\
  \      \"ilm_policy\": \"logs-policy\",\
  \      \"hidden\": true\
  \    }\
  \  ]\
  \}"

-- | Fixture mimicking the @GET /_data_stream@ response when a stream is
-- missing the optional @ilm_policy@ field (the common case for streams
-- not backed by ILM).
sampleNoIlmPolicyResponse :: LBS.ByteString
sampleNoIlmPolicyResponse =
  "{\
  \  \"data_streams\": [\
  \    {\
  \      \"name\": \"plain-stream\",\
  \      \"timestamp_field\": { \"name\": \"@timestamp\" },\
  \      \"indices\": [],\
  \      \"generation\": 1,\
  \      \"status\": \"GREEN\",\
  \      \"template\": \"plain-template\",\
  \      \"hidden\": false\
  \    }\
  \  ]\
  \}"

streamName :: DataStream -> Text
streamName = (\(DataStreamName n) -> n) . dataStreamName

-- | Construct an 'IndexName' at the value level for tests. Uses
-- 'mkIndexNameSystem' (not the stricter 'mkIndexName') because backing
-- index names start with @.@ (e.g. @.ds-stream-2024.01.01-000001@),
-- which the value-level 'FromJSON' instance accepts but 'qqIndexName'
-- rejects.
mkIdx :: Text -> IndexName
mkIdx = either (error . ("bad IndexName: " <>) . show) id . mkIndexNameSystem

-- | Build a minimal 'ExplainDataStreamLifecycleIndex' for round-trip
-- tests: only the required fields (@index@ and @managed_by_lifecycle@)
-- are populated; optionals are 'Nothing'.
minimalExplainEntry :: IndexName -> ExplainDataStreamLifecycleIndex
minimalExplainEntry name =
  ExplainDataStreamLifecycleIndex
    { explainDataStreamLifecycleIndexIndex = name,
      explainDataStreamLifecycleIndexManagedByLifecycle = True,
      explainDataStreamLifecycleIndexCreationDateMillis = Nothing,
      explainDataStreamLifecycleIndexTimeSinceIndexCreation = Nothing,
      explainDataStreamLifecycleIndexRolloverDateMillis = Nothing,
      explainDataStreamLifecycleIndexTimeSinceRollover = Nothing,
      explainDataStreamLifecycleIndexLifecycle = Nothing,
      explainDataStreamLifecycleIndexGenerationTime = Nothing,
      explainDataStreamLifecycleIndexError = Nothing
    }

-- | Fixture mimicking the @GET /_data_stream/_stats@ response with a
-- single data stream. Adapted from the shape documented at
-- https://www.elastic.co/guide/en/elasticsearch/reference/current/data-stream-apis.html#get-data-stream-stats.
sampleStatsResponse :: LBS.ByteString
sampleStatsResponse =
  "{\
  \  \"_shards\": { \"total\": 2, \"successful\": 2, \"skipped\": 0, \"failed\": 0 },\
  \  \"data_stream_count\": 1,\
  \  \"backing_indices\": 2,\
  \  \"total_store_size_bytes\": 786432,\
  \  \"total_store_size\": \"786kb\",\
  \  \"data_streams\": [\
  \    {\
  \      \"data_stream\": \"logs-foobar\",\
  \      \"backing_indices\": 2,\
  \      \"store_size_bytes\": 786432,\
  \      \"store_size\": \"786kb\",\
  \      \"maximum_timestamp\": 1716854400000\
  \    }\
  \  ]\
  \}"

spec :: Spec
spec = describe "Data Streams API" $ do
  describe "DataStream JSON" $ do
    it "decodes a single stream with all fields populated" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false }" :: Maybe DataStream
      streamName <$> decoded `shouldBe` Just "ds"
      dataStreamGeneration <$> decoded `shouldBe` Just 1
      dataStreamStatus <$> decoded `shouldBe` Just Green
      dataStreamHidden <$> decoded `shouldBe` Just False
      dataStreamIlmPolicy <$> decoded `shouldBe` Just Nothing

    it "decodes ilm_policy when present" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"ilm_policy\": \"my-policy\", \"hidden\": true }" :: Maybe DataStream
      dataStreamIlmPolicy <$> decoded `shouldBe` Just (Just "my-policy")
      dataStreamHidden <$> decoded `shouldBe` Just True

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false }" :: Maybe DataStream
      (decode . encode) decoded `shouldBe` Just decoded

    it "decodes system/replicated/_meta when present" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false, \"system\": true, \"replicated\": false, \"_meta\": { \"my-meta-field\": \"foo\" } }" :: Maybe DataStream
          expectedMeta = decode "{ \"my-meta-field\": \"foo\" }" :: Maybe Object
      dataStreamSystem <$> decoded `shouldBe` Just (Just True)
      dataStreamReplicated <$> decoded `shouldBe` Just (Just False)
      dataStreamMeta <$> decoded `shouldBe` Just expectedMeta

    it "rejects a non-object _meta (object is the documented wire type)" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false, \"_meta\": \"not-an-object\" }" :: Maybe DataStream
      decoded `shouldBe` Nothing

    it "decodes absent system/replicated/_meta as Nothing" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false }" :: Maybe DataStream
      dataStreamSystem <$> decoded `shouldBe` Just Nothing
      dataStreamReplicated <$> decoded `shouldBe` Just Nothing
      dataStreamMeta <$> decoded `shouldBe` Just Nothing

    it "decodes explicit null system/replicated/_meta as Nothing" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"hidden\": false, \"system\": null, \"replicated\": null, \"_meta\": null }" :: Maybe DataStream
      dataStreamSystem <$> decoded `shouldBe` Just Nothing
      dataStreamReplicated <$> decoded `shouldBe` Just Nothing
      dataStreamMeta <$> decoded `shouldBe` Just Nothing

    it "round-trips a fully-populated stream through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"GREEN\", \"template\": \"tpl\", \"ilm_policy\": \"my-policy\", \"hidden\": false, \"system\": true, \"replicated\": false, \"_meta\": { \"my-meta-field\": \"foo\" } }" :: Maybe DataStream
      (decode . encode) decoded `shouldBe` Just decoded

    it "preserves system/replicated/_meta as Nothing on the existing two-stream fixture" $ do
      let Just decoded = decode sampleListResponse :: Maybe GetDataStreamsResponse
          [s1, s2] = dataStreams decoded
      dataStreamSystem s1 `shouldBe` Nothing
      dataStreamReplicated s1 `shouldBe` Nothing
      dataStreamMeta s1 `shouldBe` Nothing
      dataStreamSystem s2 `shouldBe` Nothing
      dataStreamReplicated s2 `shouldBe` Nothing
      dataStreamMeta s2 `shouldBe` Nothing

    it "rejects a body missing the required 'name' field" $ do
      let decoded = decode "{ \"generation\": 1 }" :: Maybe DataStream
      decoded `shouldBe` Nothing

    it "decodes a stream carrying a lowercase / degenerate status through the full record" $ do
      let decoded = decode "{ \"name\": \"ds\", \"timestamp_field\": { \"name\": \"@timestamp\" }, \"indices\": [], \"generation\": 1, \"status\": \"unknown\", \"template\": \"tpl\", \"hidden\": false }" :: Maybe DataStream
      dataStreamStatus <$> decoded `shouldBe` Just Unknown

    it "decodes DataStreamStatus variants (uppercase)" $ do
      decode "\"GREEN\"" `shouldBe` Just Green
      decode "\"YELLOW\"" `shouldBe` Just Yellow
      decode "\"RED\"" `shouldBe` Just Red

    it "decodes DataStreamStatus variants (lowercase)" $ do
      decode "\"green\"" `shouldBe` Just Green
      decode "\"yellow\"" `shouldBe` Just Yellow
      decode "\"red\"" `shouldBe` Just Red

    it "decodes unknown/unavailable health states" $ do
      decode "\"unknown\"" `shouldBe` Just Unknown
      decode "\"unavailable\"" `shouldBe` Just Unavailable

    it "round-trips Unknown and Unavailable" $ do
      (decode . encode) Unknown `shouldBe` Just Unknown
      (decode . encode) Unavailable `shouldBe` Just Unavailable

    it "still rejects truly invalid status values" $
      (decode "\"purple\"" :: Maybe DataStreamStatus) `shouldBe` Nothing

  describe "GetDataStreamsResponse JSON" $ do
    it "decodes the list-all response with two streams" $ do
      let Just decoded = decode sampleListResponse :: Maybe GetDataStreamsResponse
      length (dataStreams decoded) `shouldBe` 2
      sort (map streamName (dataStreams decoded))
        `shouldBe` ["logs-foobar", "metrics-hidden"]

    it "preserves ilm_policy on the stream that has one" $ do
      let Just decoded = decode sampleListResponse :: Maybe GetDataStreamsResponse
          hiddenStream = head [s | s <- dataStreams decoded, streamName s == "metrics-hidden"]
      dataStreamIlmPolicy hiddenStream `shouldBe` Just "logs-policy"
      dataStreamHidden hiddenStream `shouldBe` True

    it "decodes the response missing ilm_policy as Nothing" $ do
      let Just decoded = decode sampleNoIlmPolicyResponse :: Maybe GetDataStreamsResponse
          [plainStream] = dataStreams decoded
      dataStreamIlmPolicy plainStream `shouldBe` Nothing

    it "decodes an empty data_streams array as the empty list" $ do
      let decoded = decode "{ \"data_streams\": [] }" :: Maybe GetDataStreamsResponse
      (dataStreams <$> decoded) `shouldBe` Just []

    it "rejects a top-level array (the response must be a keyed object)" $ do
      let decoded = decode "[]" :: Maybe GetDataStreamsResponse
      decoded `shouldBe` Nothing

    it "rejects malformed JSON" $ do
      let decoded = decode "{ this is not json" :: Maybe GetDataStreamsResponse
      decoded `shouldBe` Nothing

  describe "getDataStreams endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "GETs /_data_stream when given Nothing" $ do
      let req = ES7.getDataStreams Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream when given Just [] (empty list collapses to all)" $ do
      let req = ES7.getDataStreams (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{name} when given a single-element list" $ do
      let req = ES7.getDataStreams (Just [DataStreamName "logs-foobar"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{a,b,c} when given a multi-element list (comma-joined)" $ do
      let req = ES7.getDataStreams (Just [DataStreamName "a", DataStreamName "b", DataStreamName "c"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b,c"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "does not attach a body (GET semantics)" $ do
      let req = ES7.getDataStreams Nothing
      bhRequestBody req `shouldBe` Nothing

  describe "createDataStream endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "PUTs /_data_stream/{name} for the given data stream" $ do
      let req = ES7.createDataStream (DataStreamName "logs-foobar")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the PUT method" $ do
      let req = ES7.createDataStream (DataStreamName "logs-foobar")
      bhRequestMethod req `shouldBe` "PUT"

    it "sends an empty body (ES derives the config from the index template)" $ do
      let req = ES7.createDataStream (DataStreamName "logs-foobar")
      bhRequestBody req `shouldBe` Just ""

  describe "deleteDataStream endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "DELETEs /_data_stream/{name} for the given data stream" $ do
      let req = ES7.deleteDataStream (DataStreamName "logs-foobar")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the DELETE method" $ do
      let req = ES7.deleteDataStream (DataStreamName "logs-foobar")
      bhRequestMethod req `shouldBe` "DELETE"

    it "does not attach a body (DELETE semantics)" $ do
      let req = ES7.deleteDataStream (DataStreamName "logs-foobar")
      bhRequestBody req `shouldBe` Nothing

  describe "migrateToDataStream endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "POSTs /_data_stream/_migrate/{alias} for the given alias" $ do
      let req = ES7.migrateToDataStream (IndexAliasName [qqIndexName|logs-alias|])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_migrate", "logs-alias"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the POST method" $ do
      let req = ES7.migrateToDataStream (IndexAliasName [qqIndexName|logs-alias|])
      bhRequestMethod req `shouldBe` "POST"

    it "sends an empty body (ES derives the config from the alias and its write index)" $ do
      let req = ES7.migrateToDataStream (IndexAliasName [qqIndexName|logs-alias|])
      bhRequestBody req `shouldBe` Just ""

  describe "promoteDataStream endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "POSTs /_data_stream/_promote/{name} for the given data stream" $ do
      let req = ES7.promoteDataStream (DataStreamName "logs-foobar")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_promote", "logs-foobar"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the POST method" $ do
      let req = ES7.promoteDataStream (DataStreamName "logs-foobar")
      bhRequestMethod req `shouldBe` "POST"

    it "sends an empty body (ES promotes the stream in place)" $ do
      let req = ES7.promoteDataStream (DataStreamName "logs-foobar")
      bhRequestBody req `shouldBe` Just ""

  describe "DataStreamStat JSON" $ do
    it "decodes a stat with all fields populated" $ do
      let decoded = decode "{ \"data_stream\": \"logs-foobar\", \"backing_indices\": 2, \"store_size_bytes\": 786432, \"store_size\": \"786kb\", \"maximum_timestamp\": 1716854400000 }" :: Maybe DataStreamStat
      (dataStreamStatName <$> decoded) `shouldBe` Just (DataStreamName "logs-foobar")
      (dataStreamStatBackingIndices <$> decoded) `shouldBe` Just 2
      (dataStreamStatStoreSizeBytes <$> decoded) `shouldBe` Just (786432 :: Int64)
      (dataStreamStatStoreSize <$> decoded) `shouldBe` Just (Just "786kb")
      (dataStreamStatMaximumTimestamp <$> decoded) `shouldBe` Just 1716854400000

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"data_stream\": \"ds\", \"backing_indices\": 1, \"store_size_bytes\": 1024, \"store_size\": \"1kb\", \"maximum_timestamp\": 1700000000 }" :: Maybe DataStreamStat
      (decode . encode) decoded `shouldBe` Just decoded

    it "rejects a body missing the required 'data_stream' field" $ do
      let decoded = decode "{ \"backing_indices\": 1 }" :: Maybe DataStreamStat
      decoded `shouldBe` Nothing

    it "rejects a per-stream stat using 'name' instead of 'data_stream'" $ do
      let decoded = decode "{ \"name\": \"logs-foobar\", \"backing_indices\": 2, \"store_size_bytes\": 786432, \"store_size\": \"786kb\", \"maximum_timestamp\": 1716854400000 }" :: Maybe DataStreamStat
      decoded `shouldBe` Nothing

    it "decodes a stat missing the human-only 'store_size' as Nothing (human=false)" $ do
      let decoded = decode "{ \"data_stream\": \"ds\", \"backing_indices\": 1, \"store_size_bytes\": 1024, \"maximum_timestamp\": 1700000000 }" :: Maybe DataStreamStat
      dataStreamStatStoreSize <$> decoded `shouldBe` Just Nothing
      (dataStreamStatStoreSizeBytes <$> decoded) `shouldBe` Just (1024 :: Int64)

    it "decodes a stat whose 'store_size' is explicitly null as Nothing" $ do
      let decoded = decode "{ \"data_stream\": \"ds\", \"backing_indices\": 1, \"store_size_bytes\": 1024, \"store_size\": null, \"maximum_timestamp\": 1700000000 }" :: Maybe DataStreamStat
      dataStreamStatStoreSize <$> decoded `shouldBe` Just Nothing

    it "round-trips a stat without store_size (encodes the field as null)" $ do
      let Just decoded = decode "{ \"data_stream\": \"ds\", \"backing_indices\": 1, \"store_size_bytes\": 1024, \"maximum_timestamp\": 1700000000 }" :: Maybe DataStreamStat
      (decode . encode) decoded `shouldBe` Just decoded
      LBS.unpack (encode decoded) `shouldSatisfy` isInfixOf "\"store_size\":null"

  describe "DataStreamStats JSON" $ do
    it "decodes the full response with one data stream" $ do
      let Just decoded = decode sampleStatsResponse :: Maybe DataStreamStats
      dataStreamStatsCount decoded `shouldBe` 1
      dataStreamStatsBackingIndices decoded `shouldBe` 2
      dataStreamStatsTotalStoreSizeBytes decoded `shouldBe` (786432 :: Int64)
      dataStreamStatsTotalStoreSize decoded `shouldBe` Just "786kb"
      length (dataStreamStatsDataStreams decoded) `shouldBe` 1

    it "decodes the _shards summary into a ShardResult" $ do
      let Just decoded = decode sampleStatsResponse :: Maybe DataStreamStats
      dataStreamStatsShards decoded `shouldBe` ShardResult 2 2 0 0

    it "preserves per-stream fields through decode" $ do
      let Just decoded = decode sampleStatsResponse :: Maybe DataStreamStats
          [stat] = dataStreamStatsDataStreams decoded
      dataStreamStatName stat `shouldBe` DataStreamName "logs-foobar"
      dataStreamStatStoreSize stat `shouldBe` Just "786kb"
      dataStreamStatMaximumTimestamp stat `shouldBe` 1716854400000

    it "decodes multiple data streams in the data_streams array" $ do
      let payload =
            "{ \"_shards\": { \"total\": 4, \"successful\": 4, \"skipped\": 0, \"failed\": 0 }"
              <> ", \"data_stream_count\": 2, \"backing_indices\": 4, \"total_store_size_bytes\": 1000, \"total_store_size\": \"1kb\""
              <> ", \"data_streams\": ["
              <> "  { \"data_stream\": \"a\", \"backing_indices\": 2, \"store_size_bytes\": 500, \"store_size\": \"500b\", \"maximum_timestamp\": 1 },"
              <> "  { \"data_stream\": \"b\", \"backing_indices\": 2, \"store_size_bytes\": 500, \"store_size\": \"500b\", \"maximum_timestamp\": 2 }"
              <> "] }"
          Just decoded = decode payload :: Maybe DataStreamStats
      length (dataStreamStatsDataStreams decoded) `shouldBe` 2
      sort (map (\s -> (\(DataStreamName n) -> n) (dataStreamStatName s)) (dataStreamStatsDataStreams decoded))
        `shouldBe` ["a", "b"]

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleStatsResponse :: Maybe DataStreamStats
      (decode . encode) decoded `shouldBe` Just decoded

    it "decodes an empty data_streams array as the empty list" $ do
      let decoded = decode "{ \"_shards\": { \"total\": 0, \"successful\": 0, \"skipped\": 0, \"failed\": 0 }, \"data_stream_count\": 0, \"backing_indices\": 0, \"total_store_size_bytes\": 0, \"total_store_size\": \"0b\", \"data_streams\": [] }" :: Maybe DataStreamStats
      (dataStreamStatsDataStreams <$> decoded) `shouldBe` Just []

    it "rejects a response missing the _shards field" $ do
      let decoded = decode "{ \"data_stream_count\": 0, \"backing_indices\": 0, \"total_store_size_bytes\": 0, \"total_store_size\": \"0b\", \"data_streams\": [] }" :: Maybe DataStreamStats
      decoded `shouldBe` Nothing

    it "decodes a response missing the human-only 'total_store_size' as Nothing (human=false)" $ do
      let payload =
            "{ \"_shards\": { \"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0 }"
              <> ", \"data_stream_count\": 0, \"backing_indices\": 0, \"total_store_size_bytes\": 0, \"data_streams\": [] }"
          Just decoded = decode payload :: Maybe DataStreamStats
      dataStreamStatsTotalStoreSize decoded `shouldBe` Nothing
      dataStreamStatsTotalStoreSizeBytes decoded `shouldBe` (0 :: Int64)

    it "round-trips a response without total_store_size (encodes the field as null)" $ do
      let payload =
            "{ \"_shards\": { \"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0 }"
              <> ", \"data_stream_count\": 0, \"backing_indices\": 0, \"total_store_size_bytes\": 0, \"data_streams\": [] }"
          Just decoded = decode payload :: Maybe DataStreamStats
      (decode . encode) decoded `shouldBe` Just decoded
      LBS.unpack (encode decoded) `shouldSatisfy` isInfixOf "\"total_store_size\":null"

    it "rejects a top-level array (the response must be a keyed object)" $ do
      let decoded = decode "[]" :: Maybe DataStreamStats
      decoded `shouldBe` Nothing

    it "rejects malformed JSON" $ do
      let decoded = decode "{ this is not json" :: Maybe DataStreamStats
      decoded `shouldBe` Nothing

  describe "getDataStreamStats endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "GETs /_data_stream/_stats when given Nothing" $ do
      let req = ES7.getDataStreamStats Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/_stats when given Just [] (empty list collapses to all)" $ do
      let req = ES7.getDataStreamStats (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{name}/_stats when given a single-element list" $ do
      let req = ES7.getDataStreamStats (Just [DataStreamName "logs-foobar"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar", "_stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{a,b,c}/_stats when given a multi-element list (comma-joined)" $ do
      let req = ES7.getDataStreamStats (Just [DataStreamName "a", DataStreamName "b", DataStreamName "c"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b,c", "_stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "does not attach a body (GET semantics)" $ do
      let req = ES7.getDataStreamStats Nothing
      bhRequestBody req `shouldBe` Nothing

  -- ============================================================
  -- Data Stream Lifecycle (ES 8.x+, back-filled to ES 7.17.x)
  -- ============================================================

  describe "DataStreamLifecycleDownsampling JSON" $ do
    it "decodes a fully-populated downsampling entry" $ do
      let decoded = decode "{ \"after\": \"10d\", \"fixed_interval\": \"1h\" }" :: Maybe DataStreamLifecycleDownsampling
      dataStreamLifecycleDownsamplingAfter <$> decoded `shouldBe` Just (Just "10d")
      dataStreamLifecycleDownsamplingFixedInterval <$> decoded `shouldBe` Just (Just "1h")

    it "decodes an empty object as both fields Nothing" $ do
      let decoded = decode "{}" :: Maybe DataStreamLifecycleDownsampling
      decoded `shouldBe` Just (DataStreamLifecycleDownsampling Nothing Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"after\": \"10d\", \"fixed_interval\": \"1h\" }" :: Maybe DataStreamLifecycleDownsampling
      (decode . encode) decoded `shouldBe` Just decoded

    it "omits Nothing fields on encode (clean wire form)" $ do
      let value = DataStreamLifecycleDownsampling (Just "10d") Nothing
      encode value `shouldBe` "{\"after\":\"10d\"}"

  describe "SamplingMethod JSON (data-stream downsampling method)" $ do
    it "decodes aggregate" $ decode "\"aggregate\"" `shouldBe` Just Aggregate
    it "decodes last_value" $ decode "\"last_value\"" `shouldBe` Just LastValue
    it "rejects an unknown method" $ (decode "\"average\"" :: Maybe SamplingMethod) `shouldBe` Nothing
    it "round-trips both variants" $ do
      (decode . encode) Aggregate `shouldBe` Just Aggregate
      (decode . encode) LastValue `shouldBe` Just LastValue

  describe "DataStreamLifecycle JSON" $ do
    it "decodes a fully-populated lifecycle" $ do
      let payload =
            "{ \"data_retention\": \"30d\""
              <> ", \"enabled\": true"
              <> ", \"downsampling\": [{ \"after\": \"10d\", \"fixed_interval\": \"1h\" }]"
              <> ", \"downsampling_method\": \"aggregate\" }"
          Just decoded = decode payload :: Maybe DataStreamLifecycle
      dataStreamLifecycleDataRetention decoded `shouldBe` Just "30d"
      dataStreamLifecycleEnabled decoded `shouldBe` Just True
      dataStreamLifecycleDownsampling decoded `shouldBe` Just [DataStreamLifecycleDownsampling (Just "10d") (Just "1h")]
      dataStreamLifecycleDownsamplingMethod decoded `shouldBe` Just Aggregate

    it "decodes an empty object as the all-Nothing lifecycle" $ do
      let decoded = decode "{}" :: Maybe DataStreamLifecycle
      decoded `shouldBe` Just (DataStreamLifecycle Nothing Nothing Nothing Nothing Nothing Nothing)

    it "decodes data_retention-only as the rest Nothing" $ do
      let decoded = decode "{ \"data_retention\": \"7d\" }" :: Maybe DataStreamLifecycle
      dataStreamLifecycleDataRetention <$> decoded `shouldBe` Just (Just "7d")
      dataStreamLifecycleEnabled <$> decoded `shouldBe` Just Nothing

    it "round-trips a fully-populated lifecycle" $ do
      let Just decoded = decode "{ \"data_retention\": \"30d\", \"enabled\": true, \"downsampling\": [{ \"after\": \"10d\", \"fixed_interval\": \"1h\" }], \"downsampling_method\": \"aggregate\" }" :: Maybe DataStreamLifecycle
      (decode . encode) decoded `shouldBe` Just decoded

    it "omits Nothing fields on encode (clean PUT body)" $ do
      let value = DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing
      encode value `shouldBe` "{\"data_retention\":\"7d\"}"

    it "encodes the all-Nothing lifecycle as an empty object" $ do
      encode (DataStreamLifecycle Nothing Nothing Nothing Nothing Nothing Nothing) `shouldBe` "{}"

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe DataStreamLifecycle
      decoded `shouldBe` Nothing

  describe "DataStreamLifecycleInfo JSON" $ do
    it "decodes a managed stream with a populated lifecycle" $ do
      let payload =
            "{ \"name\": \"logs-app\""
              <> ", \"lifecycle\": { \"data_retention\": \"7d\", \"enabled\": true } }"
          Just decoded = decode payload :: Maybe DataStreamLifecycleInfo
      dataStreamLifecycleInfoName decoded `shouldBe` DataStreamName "logs-app"
      dataStreamLifecycleInfoLifecycle decoded `shouldSatisfy` isJust

    it "decodes an unmanaged stream with lifecycle:null as Nothing" $ do
      let Just decoded = decode "{ \"name\": \"logs-app\", \"lifecycle\": null }" :: Maybe DataStreamLifecycleInfo
      dataStreamLifecycleInfoName decoded `shouldBe` DataStreamName "logs-app"
      dataStreamLifecycleInfoLifecycle decoded `shouldBe` Nothing

    it "decodes an unmanaged stream with lifecycle absent as Nothing" $ do
      let Just decoded = decode "{ \"name\": \"logs-app\" }" :: Maybe DataStreamLifecycleInfo
      dataStreamLifecycleInfoLifecycle decoded `shouldBe` Nothing

    it "round-trips a managed stream" $ do
      let Just decoded = decode "{ \"name\": \"ds\", \"lifecycle\": { \"data_retention\": \"7d\" } }" :: Maybe DataStreamLifecycleInfo
      (decode . encode) decoded `shouldBe` Just decoded

    it "rejects an info missing the data_stream field" $ do
      let decoded = decode "{ \"lifecycle\": { \"data_retention\": \"7d\" } }" :: Maybe DataStreamLifecycleInfo
      decoded `shouldBe` Nothing

  describe "GetDataStreamLifecyclesResponse JSON" $ do
    it "decodes a response with two managed streams" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } },"
              <> "  { \"name\": \"b\", \"lifecycle\": { \"data_retention\": \"2d\" } } ] }"
          Just decoded = decode payload :: Maybe GetDataStreamLifecyclesResponse
      length (dataStreamLifecycles decoded) `shouldBe` 2

    it "decodes a stream with lifecycle:null alongside a managed one" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"managed\", \"lifecycle\": { \"data_retention\": \"1d\" } },"
              <> "  { \"name\": \"unmanaged\", \"lifecycle\": null } ] }"
          Just decoded = decode payload :: Maybe GetDataStreamLifecyclesResponse
      length (dataStreamLifecycles decoded) `shouldBe` 2
      let unmanaged = head [i | i <- dataStreamLifecycles decoded, (\(DataStreamName n) -> n) (dataStreamLifecycleInfoName i) == "unmanaged"]
      dataStreamLifecycleInfoLifecycle unmanaged `shouldBe` Nothing

    it "decodes an empty data_streams array" $ do
      let decoded = decode "{ \"data_streams\": [] }" :: Maybe GetDataStreamLifecyclesResponse
      (dataStreamLifecycles <$> decoded) `shouldBe` Just []

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"data_streams\": [{ \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } }] }" :: Maybe GetDataStreamLifecyclesResponse
      (decode . encode) decoded `shouldBe` Just decoded

    it "rejects a response missing the data_streams field" $ do
      let decoded = decode "{}" :: Maybe GetDataStreamLifecyclesResponse
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe GetDataStreamLifecyclesResponse
      decoded `shouldBe` Nothing

  describe "putDataStreamLifecycle endpoint shape" $ do
    it "PUTs /_data_stream/{name}/_lifecycle for the given stream" $ do
      let req = ES8.putDataStreamLifecycle (DataStreamName "logs-foobar") (DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing)
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the PUT method" $ do
      let req = ES8.putDataStreamLifecycle (DataStreamName "logs-foobar") (DataStreamLifecycle Nothing Nothing Nothing Nothing Nothing Nothing)
      bhRequestMethod req `shouldBe` "PUT"

    it "sends the encoded DataStreamLifecycle as the body" $ do
      let req = ES8.putDataStreamLifecycle (DataStreamName "logs-foobar") (DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing)
      bhRequestBody req `shouldBe` Just "{\"data_retention\":\"7d\"}"

    it "sends an empty object body when the lifecycle is all-Nothing" $ do
      let req = ES8.putDataStreamLifecycle (DataStreamName "logs-foobar") (DataStreamLifecycle Nothing Nothing Nothing Nothing Nothing Nothing)
      bhRequestBody req `shouldBe` Just "{}"

  describe "getDataStreamLifecycle endpoint shape" $ do
    it "GETs /_data_stream/_lifecycle when given Nothing" $ do
      let req = ES8.getDataStreamLifecycle Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/_lifecycle when given Just [] (empty list collapses to all)" $ do
      let req = ES8.getDataStreamLifecycle (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{name}/_lifecycle when given a single-element list" $ do
      let req = ES8.getDataStreamLifecycle (Just [DataStreamName "logs-foobar"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{a,b,c}/_lifecycle when given a multi-element list (comma-joined)" $ do
      let req = ES8.getDataStreamLifecycle (Just [DataStreamName "a", DataStreamName "b", DataStreamName "c"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b,c", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "does not attach a body (GET semantics)" $ do
      let req = ES8.getDataStreamLifecycle Nothing
      bhRequestBody req `shouldBe` Nothing

  describe "deleteDataStreamLifecycle endpoint shape" $ do
    it "DELETEs /_data_stream/{name}/_lifecycle for the given stream" $ do
      let req = ES8.deleteDataStreamLifecycle (DataStreamName "logs-foobar")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the DELETE method" $ do
      let req = ES8.deleteDataStreamLifecycle (DataStreamName "logs-foobar")
      bhRequestMethod req `shouldBe` "DELETE"

    it "does not attach a body (DELETE semantics)" $ do
      let req = ES8.deleteDataStreamLifecycle (DataStreamName "logs-foobar")
      bhRequestBody req `shouldBe` Nothing

  describe "DataStreamLifecycle read-only fields (effective_retention, retention_determined_by)" $ do
    it "decodes effective_retention on a GET response" $ do
      let payload = "{ \"data_retention\": \"7d\", \"effective_retention\": \"7d\", \"retention_determined_by\": \"data_stream_configuration\" }"
          Just decoded = decode payload :: Maybe DataStreamLifecycle
      dataStreamLifecycleEffectiveRetention decoded `shouldBe` Just "7d"
      dataStreamLifecycleRetentionDeterminedBy decoded `shouldBe` Just "data_stream_configuration"

    it "decodes retention_determined_by=max_global_retention when global retention caps the stream" $ do
      let Just decoded = decode "{ \"data_retention\": \"365d\", \"effective_retention\": \"30d\", \"retention_determined_by\": \"max_global_retention\" }" :: Maybe DataStreamLifecycle
      dataStreamLifecycleRetentionDeterminedBy decoded `shouldBe` Just "max_global_retention"

    it "round-trips a lifecycle with read-only fields populated" $ do
      let Just decoded = decode "{ \"data_retention\": \"7d\", \"effective_retention\": \"7d\", \"retention_determined_by\": \"data_stream_configuration\" }" :: Maybe DataStreamLifecycle
      (decode . encode) decoded `shouldBe` Just decoded

    it "omits read-only fields on PUT when they are Nothing (clean wire form)" $ do
      let value = DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing
      encode value `shouldBe` "{\"data_retention\":\"7d\"}"

  describe "DataStreamGlobalRetention JSON" $ do
    it "decodes a fully-populated global_retention object" $ do
      let Just decoded = decode "{ \"max_retention\": \"365d\", \"default_retention\": \"30d\" }" :: Maybe DataStreamGlobalRetention
      dataStreamGlobalRetentionMaxRetention decoded `shouldBe` Just "365d"
      dataStreamGlobalRetentionDefaultRetention decoded `shouldBe` Just "30d"

    it "decodes an empty object as both fields Nothing" $ do
      let decoded = decode "{}" :: Maybe DataStreamGlobalRetention
      decoded `shouldBe` Just (DataStreamGlobalRetention Nothing Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode "{ \"max_retention\": \"365d\", \"default_retention\": \"30d\" }" :: Maybe DataStreamGlobalRetention
      (decode . encode) decoded `shouldBe` Just decoded

  describe "S6: omitNulls collapse of empty downsampling list" $ do
    -- Pins the documented behavior: Just [] and Nothing are indistinguishable
    -- on the wire (both omit the field), because omitNulls drops empty arrays.
    it "encodes Just [] downsampling the same as Nothing downsampling" $ do
      let withEmptyList = DataStreamLifecycle Nothing Nothing (Just []) Nothing Nothing Nothing
          withNothing = DataStreamLifecycle Nothing Nothing Nothing Nothing Nothing Nothing
      encode withEmptyList `shouldBe` encode withNothing
      encode withEmptyList `shouldBe` "{}"

  describe "GetDataStreamLifecyclesResponse with global_retention" $ do
    it "decodes a response carrying global_retention" $ do
      let payload =
            "{ \"data_streams\": [{ \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } }]"
              <> ", \"global_retention\": { \"max_retention\": \"365d\", \"default_retention\": \"30d\" } }"
          Just decoded = decode payload :: Maybe GetDataStreamLifecyclesResponse
      length (dataStreamLifecycles decoded) `shouldBe` 1
      dataStreamLifecyclesGlobalRetention decoded `shouldSatisfy` isJust
      let Just gr = dataStreamLifecyclesGlobalRetention decoded
      dataStreamGlobalRetentionMaxRetention gr `shouldBe` Just "365d"
      dataStreamGlobalRetentionDefaultRetention gr `shouldBe` Just "30d"

    it "decodes a response without global_retention as Nothing" $ do
      let Just decoded = decode "{ \"data_streams\": [] }" :: Maybe GetDataStreamLifecyclesResponse
      dataStreamLifecyclesGlobalRetention decoded `shouldBe` Nothing

    it "round-trips a response with global_retention" $ do
      let payload =
            "{ \"data_streams\": [{ \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } }]"
              <> ", \"global_retention\": { \"max_retention\": \"365d\", \"default_retention\": \"30d\" } }"
          Just decoded = decode payload :: Maybe GetDataStreamLifecyclesResponse
      (decode . encode) decoded `shouldBe` Just decoded

  describe "GET /_data_stream/{name}/_lifecycle verbatim ES docs example" $ do
    -- Anchor: payload lifted verbatim from the ES 8.x REST docs page
    -- operation-indices-get-data-lifecycle response example.
    it "decodes the two-stream example from the official docs" $ do
      let docsExample =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"my-data-stream-1\", \"lifecycle\": { \"enabled\": true, \"data_retention\": \"7d\" } },"
              <> "  { \"name\": \"my-data-stream-2\", \"lifecycle\": { \"enabled\": true, \"data_retention\": \"7d\" } } ] }"
          Just decoded = decode docsExample :: Maybe GetDataStreamLifecyclesResponse
      length (dataStreamLifecycles decoded) `shouldBe` 2
      let names = [(\(DataStreamName n) -> n) (dataStreamLifecycleInfoName i) | i <- dataStreamLifecycles decoded]
      names `shouldBe` ["my-data-stream-1", "my-data-stream-2"]
      let firstStream = head (dataStreamLifecycles decoded)
          Just firstLifecycle = dataStreamLifecycleInfoLifecycle firstStream
      dataStreamLifecycleEnabled firstLifecycle `shouldBe` Just True
      dataStreamLifecycleDataRetention firstLifecycle `shouldBe` Just "7d"

  -- ============================================================
  -- Modify data stream (POST /_data_stream/_modify, ES 7.16+)
  -- ============================================================

  describe "ModifyDataStreamAction JSON" $ do
    it "decodes an add_backing_index action" $ do
      let decoded = decode "{ \"add_backing_index\": { \"data_stream\": \"logs\", \"index\": \".ds-logs-000001\" } }" :: Maybe ModifyDataStreamAction
      case decoded of
        Just (AddBackingIndex (DataStreamName "logs") _) -> pure ()
        other -> expectationFailure ("expected AddBackingIndex, got " <> show other)

    it "decodes a remove_backing_index action" $ do
      let decoded = decode "{ \"remove_backing_index\": { \"data_stream\": \"logs\", \"index\": \".ds-logs-000001\" } }" :: Maybe ModifyDataStreamAction
      case decoded of
        Just (RemoveBackingIndex (DataStreamName "logs") _) -> pure ()
        other -> expectationFailure ("expected RemoveBackingIndex, got " <> show other)

    it "round-trips add_backing_index through ToJSON/FromJSON" $ do
      let value = AddBackingIndex (DataStreamName "s") [qqIndexName|i|]
      (decode . encode) value `shouldBe` Just value

    it "round-trips remove_backing_index through ToJSON/FromJSON" $ do
      let value = RemoveBackingIndex (DataStreamName "s") [qqIndexName|i|]
      (decode . encode) value `shouldBe` Just value

    it "encodes add_backing_index as a single-key object with nested target" $ do
      let value = AddBackingIndex (DataStreamName "s") [qqIndexName|i|]
      encode value `shouldBe` "{\"add_backing_index\":{\"data_stream\":\"s\",\"index\":\"i\"}}"

    it "encodes remove_backing_index as a single-key object with nested target" $ do
      let value = RemoveBackingIndex (DataStreamName "s") [qqIndexName|i|]
      encode value `shouldBe` "{\"remove_backing_index\":{\"data_stream\":\"s\",\"index\":\"i\"}}"

    it "decodes the verbatim ES docs add_backing_index target (dot-prefixed index name)" $ do
      -- From the official curl example at
      -- https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-modify-data-stream
      let decoded = decode "{ \"add_backing_index\": { \"data_stream\": \"my-data-stream\", \"index\": \".ds-my-data-stream-2023.07.26-000001-downsample\" } }" :: Maybe ModifyDataStreamAction
      case decoded of
        Just (AddBackingIndex (DataStreamName "my-data-stream") ix)
          | unIndexName ix == ".ds-my-data-stream-2023.07.26-000001-downsample" -> pure ()
        other -> expectationFailure ("verbatim add_backing_index did not decode, got " <> show other)

    it "rejects an action with no discriminator key" $ do
      let decoded = decode "{ \"data_stream\": \"s\", \"index\": \"i\" }" :: Maybe ModifyDataStreamAction
      decoded `shouldBe` Nothing

    it "rejects a non-object action" $ do
      let decoded = decode "[]" :: Maybe ModifyDataStreamAction
      decoded `shouldBe` Nothing

    it "rejects an action whose target is missing 'index'" $ do
      let decoded = decode "{ \"add_backing_index\": { \"data_stream\": \"s\" } }" :: Maybe ModifyDataStreamAction
      decoded `shouldBe` Nothing

    it "rejects an action carrying both discriminator keys (server-side they are mutually exclusive)" $ do
      let both =
            decode
              "{ \"add_backing_index\": { \"data_stream\": \"a\", \"index\": \"x\" }, \"remove_backing_index\": { \"data_stream\": \"r\", \"index\": \"y\" } }" ::
              Maybe ModifyDataStreamAction
      both `shouldBe` Nothing

  describe "ModifyDataStreamRequest JSON" $ do
    it "decodes a request with multiple actions" $ do
      let payload =
            "{ \"actions\": ["
              <> "  { \"remove_backing_index\": { \"data_stream\": \"my-data-stream\", \"index\": \".ds-my-data-stream-2023.07.26-000001\" } },"
              <> "  { \"add_backing_index\": { \"data_stream\": \"my-data-stream\", \"index\": \".ds-my-data-stream-2023.07.26-000001-downsample\" } } ] }"
          decoded = decode payload :: Maybe ModifyDataStreamRequest
      length . modifyDataStreamActions <$> decoded `shouldBe` Just 2

    it "decodes an empty actions array as the empty list" $ do
      let decoded = decode "{ \"actions\": [] }" :: Maybe ModifyDataStreamRequest
      (modifyDataStreamActions <$> decoded) `shouldBe` Just []

    it "round-trips through ToJSON/FromJSON" $ do
      let value = ModifyDataStreamRequest [AddBackingIndex (DataStreamName "s") [qqIndexName|i|]]
      (decode . encode) value `shouldBe` Just value

    it "encodes the two-action example verbatim from the ES docs curl" $ do
      -- The IndexName constructor is not exported, so build the request via
      -- 'decode' of the verbatim docs payload (which goes through
      -- mkIndexNameSystem and accepts the dot-prefixed backing-index names),
      -- then assert that re-encoding reproduces the exact byte-for-byte body.
      let payload =
            "{ \"actions\": ["
              <> "  { \"remove_backing_index\": { \"data_stream\": \"my-data-stream\", \"index\": \".ds-my-data-stream-2023.07.26-000001\" } },"
              <> "  { \"add_backing_index\": { \"data_stream\": \"my-data-stream\", \"index\": \".ds-my-data-stream-2023.07.26-000001-downsample\" } } ] }"
          Just value = decode payload :: Maybe ModifyDataStreamRequest
      encode value
        `shouldBe` "{\"actions\":[{\"remove_backing_index\":{\"data_stream\":\"my-data-stream\",\"index\":\".ds-my-data-stream-2023.07.26-000001\"}},{\"add_backing_index\":{\"data_stream\":\"my-data-stream\",\"index\":\".ds-my-data-stream-2023.07.26-000001-downsample\"}}]}"

    it "rejects a request missing the actions field" $ do
      let decoded = decode "{}" :: Maybe ModifyDataStreamRequest
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe ModifyDataStreamRequest
      decoded `shouldBe` Nothing

  describe "modifyDataStream endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "POSTs /_data_stream/_modify with no stream name in the path" $ do
      let req = ES7.modifyDataStream (ModifyDataStreamRequest [AddBackingIndex (DataStreamName "logs") [qqIndexName|logs-000001|]])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_modify"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the POST method" $ do
      let req = ES7.modifyDataStream (ModifyDataStreamRequest [RemoveBackingIndex (DataStreamName "logs") [qqIndexName|logs-000001|]])
      bhRequestMethod req `shouldBe` "POST"

    it "sends the encoded ModifyDataStreamRequest as the body" $ do
      let req = ES7.modifyDataStream (ModifyDataStreamRequest [AddBackingIndex (DataStreamName "logs") [qqIndexName|logs-000001|]])
      bhRequestBody req `shouldBe` Just "{\"actions\":[{\"add_backing_index\":{\"data_stream\":\"logs\",\"index\":\"logs-000001\"}}]}"

    it "sends a multi-action body when given multiple actions" $ do
      let req =
            ES7.modifyDataStream
              ( ModifyDataStreamRequest
                  [ RemoveBackingIndex (DataStreamName "a") [qqIndexName|ds-a-000001|],
                    AddBackingIndex (DataStreamName "b") [qqIndexName|ds-b-000001|]
                  ]
              )
      bhRequestBody req
        `shouldBe` Just
          "{\"actions\":[{\"remove_backing_index\":{\"data_stream\":\"a\",\"index\":\"ds-a-000001\"}},{\"add_backing_index\":{\"data_stream\":\"b\",\"index\":\"ds-b-000001\"}}]}"

  -- ============================================================
  -- dataStreamExists (HEAD /_data_stream/{name})
  -- ============================================================

  describe "dataStreamExists endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "HEADs /_data_stream/{name} for the given data stream" $ do
      let req = ES7.dataStreamExists (DataStreamName "logs-foobar")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs-foobar"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the HEAD method" $ do
      let req = ES7.dataStreamExists (DataStreamName "logs-foobar")
      bhRequestMethod req `shouldBe` "HEAD"

    it "does not attach a body (HEAD semantics)" $ do
      let req = ES7.dataStreamExists (DataStreamName "logs-foobar")
      bhRequestBody req `shouldBe` Nothing

  -- ============================================================
  -- PutDataStreamsLifecycleRequest (bulk PUT /_data_stream/_lifecycle)
  -- ============================================================

  describe "PutDataStreamsLifecycleRequest JSON" $ do
    it "decodes a request with two lifecycle entries" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } },"
              <> "  { \"name\": \"b\", \"lifecycle\": { \"data_retention\": \"2d\" } } ] }"
          Just decoded = decode payload :: Maybe PutDataStreamsLifecycleRequest
      length (putDataStreamsLifecycleRequestStreams decoded) `shouldBe` 2

    it "decodes an empty data_streams array" $ do
      let decoded = decode "{ \"data_streams\": [] }" :: Maybe PutDataStreamsLifecycleRequest
      (putDataStreamsLifecycleRequestStreams <$> decoded) `shouldBe` Just []

    it "round-trips through ToJSON/FromJSON" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"a\", \"lifecycle\": { \"data_retention\": \"1d\" } } ] }"
          Just decoded = decode payload :: Maybe PutDataStreamsLifecycleRequest
      (decode . encode) decoded `shouldBe` Just decoded

    it "round-trips an entry whose lifecycle is Nothing (disables management)" $ do
      let value =
            PutDataStreamsLifecycleRequest
              [DataStreamLifecycleInfo (DataStreamName "unmanaged") Nothing]
      (decode . encode) value `shouldBe` Just value

    it "encodes as a {\"data_streams\":[...]} object" $ do
      let value =
            PutDataStreamsLifecycleRequest
              [ DataStreamLifecycleInfo
                  (DataStreamName "ds")
                  (Just (DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing))
              ]
      encode value
        `shouldBe` "{\"data_streams\":[{\"lifecycle\":{\"data_retention\":\"7d\"},\"name\":\"ds\"}]}"

    it "encodes an empty request as {\"data_streams\":[]}" $ do
      encode (PutDataStreamsLifecycleRequest []) `shouldBe` "{\"data_streams\":[]}"

    it "rejects a request missing the data_streams field" $ do
      let decoded = decode "{}" :: Maybe PutDataStreamsLifecycleRequest
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe PutDataStreamsLifecycleRequest
      decoded `shouldBe` Nothing

  describe "putDataStreamsLifecycle endpoint shape" $ do
    it "PUTs /_data_stream/_lifecycle for the bulk lifecycle update" $ do
      let req =
            ES8.putDataStreamsLifecycle
              ( PutDataStreamsLifecycleRequest
                  [ DataStreamLifecycleInfo
                      (DataStreamName "logs-foobar")
                      (Just (DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing))
                  ]
              )
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_lifecycle"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the PUT method" $ do
      let req = ES8.putDataStreamsLifecycle (PutDataStreamsLifecycleRequest [])
      bhRequestMethod req `shouldBe` "PUT"

    it "sends the encoded PutDataStreamsLifecycleRequest as the body" $ do
      let req =
            ES8.putDataStreamsLifecycle
              ( PutDataStreamsLifecycleRequest
                  [ DataStreamLifecycleInfo
                      (DataStreamName "ds")
                      (Just (DataStreamLifecycle (Just "7d") Nothing Nothing Nothing Nothing Nothing))
                  ]
              )
      bhRequestBody req `shouldBe` Just "{\"data_streams\":[{\"lifecycle\":{\"data_retention\":\"7d\"},\"name\":\"ds\"}]}"

    it "sends an empty data_streams array body when given no entries" $ do
      let req = ES8.putDataStreamsLifecycle (PutDataStreamsLifecycleRequest [])
      bhRequestBody req `shouldBe` Just "{\"data_streams\":[]}"

  -- ============================================================
  -- Data Stream Options (GET/PUT/DELETE /_data_stream/{name}/_options, ES 8.19+)
  -- ============================================================

  describe "DataStreamFailureStoreLifecycle JSON" $ do
    it "decodes a fully-populated lifecycle" $ do
      let decoded = decode "{ \"data_retention\": \"7d\", \"enabled\": true }" :: Maybe DataStreamFailureStoreLifecycle
      decoded `shouldBe` Just (DataStreamFailureStoreLifecycle (Just "7d") (Just True))

    it "decodes an empty object as all-Nothing" $ do
      let decoded = decode "{}" :: Maybe DataStreamFailureStoreLifecycle
      decoded `shouldBe` Just (DataStreamFailureStoreLifecycle Nothing Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let value = DataStreamFailureStoreLifecycle (Just "30d") (Just False)
      (decode . encode) value `shouldBe` Just value

    it "encodes {} for the all-Nothing value (omitNulls)" $ do
      encode (DataStreamFailureStoreLifecycle Nothing Nothing) `shouldBe` "{}"

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe DataStreamFailureStoreLifecycle
      decoded `shouldBe` Nothing

  describe "DataStreamFailureStore JSON" $ do
    it "decodes the verbatim ES docs example (enabled + lifecycle)" $ do
      let decoded = decode "{ \"enabled\": true, \"lifecycle\": { \"data_retention\": \"string\", \"enabled\": true } }" :: Maybe DataStreamFailureStore
      decoded
        `shouldBe` Just
          ( DataStreamFailureStore
              (Just True)
              (Just (DataStreamFailureStoreLifecycle (Just "string") (Just True)))
          )

    it "decodes a store with only `enabled` set" $ do
      let decoded = decode "{ \"enabled\": false }" :: Maybe DataStreamFailureStore
      decoded `shouldBe` Just (DataStreamFailureStore (Just False) Nothing)

    it "decodes an empty object as all-Nothing" $ do
      let decoded = decode "{}" :: Maybe DataStreamFailureStore
      decoded `shouldBe` Just (DataStreamFailureStore Nothing Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let value =
            DataStreamFailureStore
              (Just True)
              (Just (DataStreamFailureStoreLifecycle (Just "7d") Nothing))
      (decode . encode) value `shouldBe` Just value

    it "encodes the verbatim ES docs curl --data body" $ do
      let value =
            DataStreamFailureStore
              (Just True)
              (Just (DataStreamFailureStoreLifecycle (Just "string") (Just True)))
      encode value `shouldBe` "{\"enabled\":true,\"lifecycle\":{\"data_retention\":\"string\",\"enabled\":true}}"

    it "encodes {} for the all-Nothing value (omitNulls)" $ do
      encode (DataStreamFailureStore Nothing Nothing) `shouldBe` "{}"

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe DataStreamFailureStore
      decoded `shouldBe` Nothing

  describe "UpdateDataStreamOptionsRequest JSON" $ do
    it "decodes a request with failure_store populated" $ do
      let decoded = decode "{ \"failure_store\": { \"enabled\": true } }" :: Maybe UpdateDataStreamOptionsRequest
      decoded
        `shouldBe` Just
          (UpdateDataStreamOptionsRequest (Just (DataStreamFailureStore (Just True) Nothing)))

    it "decodes a request with no failure_store key as Nothing" $ do
      let decoded = decode "{}" :: Maybe UpdateDataStreamOptionsRequest
      decoded `shouldBe` Just (UpdateDataStreamOptionsRequest Nothing)

    it "decodes a request with explicit null failure_store as Nothing" $ do
      let decoded = decode "{ \"failure_store\": null }" :: Maybe UpdateDataStreamOptionsRequest
      decoded `shouldBe` Just (UpdateDataStreamOptionsRequest Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let value =
            UpdateDataStreamOptionsRequest
              (Just (DataStreamFailureStore (Just False) Nothing))
      (decode . encode) value `shouldBe` Just value

    it "round-trips a Nothing failure_store through ToJSON/FromJSON" $ do
      let value = UpdateDataStreamOptionsRequest Nothing
      (decode . encode) value `shouldBe` Just value

    it "encodes the verbatim ES docs curl body (failure_store wrapping the docs example)" $ do
      let value =
            UpdateDataStreamOptionsRequest
              ( Just
                  ( DataStreamFailureStore
                      (Just True)
                      (Just (DataStreamFailureStoreLifecycle (Just "string") (Just True)))
                  )
              )
      encode value `shouldBe` "{\"failure_store\":{\"enabled\":true,\"lifecycle\":{\"data_retention\":\"string\",\"enabled\":true}}}"

    it "encodes a Nothing failure_store as {} (omitNulls)" $ do
      encode (UpdateDataStreamOptionsRequest Nothing) `shouldBe` "{}"

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe UpdateDataStreamOptionsRequest
      decoded `shouldBe` Nothing

  describe "DataStreamOptions JSON" $ do
    it "decodes an options object with failure_store" $ do
      let decoded = decode "{ \"failure_store\": { \"enabled\": false } }" :: Maybe DataStreamOptions
      decoded `shouldBe` Just (DataStreamOptions (Just (DataStreamFailureStore (Just False) Nothing)))

    it "decodes an empty object as all-Nothing" $ do
      let decoded = decode "{}" :: Maybe DataStreamOptions
      decoded `shouldBe` Just (DataStreamOptions Nothing)

    it "round-trips through ToJSON/FromJSON" $ do
      let value = DataStreamOptions (Just (DataStreamFailureStore (Just True) Nothing))
      (decode . encode) value `shouldBe` Just value

    it "decodes an options object with unknown future keys (forward-compat)" $ do
      -- The `options` object is open-ended on the server side; unknown keys
      -- must decode without breaking the client.
      let decoded = decode "{ \"failure_store\": { \"enabled\": true }, \"future_field\": 42 }" :: Maybe DataStreamOptions
      decoded `shouldBe` Just (DataStreamOptions (Just (DataStreamFailureStore (Just True) Nothing)))

  describe "DataStreamOptionsEntry JSON" $ do
    it "decodes an entry with options" $ do
      let decoded = decode "{ \"name\": \"logs\", \"options\": { \"failure_store\": { \"enabled\": true } } }" :: Maybe DataStreamOptionsEntry
      decoded
        `shouldBe` Just
          ( DataStreamOptionsEntry
              (DataStreamName "logs")
              (Just (DataStreamOptions (Just (DataStreamFailureStore (Just True) Nothing))))
          )

    it "decodes an entry whose options key is absent (unmanaged stream)" $ do
      let decoded = decode "{ \"name\": \"plain\" }" :: Maybe DataStreamOptionsEntry
      decoded `shouldBe` Just (DataStreamOptionsEntry (DataStreamName "plain") Nothing)

    it "decodes an entry whose options key is explicitly null as Nothing" $ do
      let decoded = decode "{ \"name\": \"plain\", \"options\": null }" :: Maybe DataStreamOptionsEntry
      decoded `shouldBe` Just (DataStreamOptionsEntry (DataStreamName "plain") Nothing)

    it "round-trips an entry with options through ToJSON/FromJSON" $ do
      let value =
            DataStreamOptionsEntry
              (DataStreamName "ds")
              (Just (DataStreamOptions (Just (DataStreamFailureStore (Just True) Nothing))))
      (decode . encode) value `shouldBe` Just value

    it "round-trips an entry with no options through ToJSON/FromJSON" $ do
      let value = DataStreamOptionsEntry (DataStreamName "ds") Nothing
      (decode . encode) value `shouldBe` Just value

    it "rejects an entry missing the name field" $ do
      let decoded = decode "{ \"options\": {} }" :: Maybe DataStreamOptionsEntry
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe DataStreamOptionsEntry
      decoded `shouldBe` Nothing

  describe "GetDataStreamOptionsResponse JSON" $ do
    it "decodes a response with multiple entries" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"a\", \"options\": { \"failure_store\": { \"enabled\": true } } },"
              <> "  { \"name\": \"b\" } ] }"
          decoded = decode payload :: Maybe GetDataStreamOptionsResponse
      length . getDataStreamOptionsResponseDataStreams <$> decoded `shouldBe` Just 2

    it "decodes an empty data_streams array" $ do
      let decoded = decode "{ \"data_streams\": [] }" :: Maybe GetDataStreamOptionsResponse
      (getDataStreamOptionsResponseDataStreams <$> decoded) `shouldBe` Just []

    it "round-trips through ToJSON/FromJSON" $ do
      let value =
            GetDataStreamOptionsResponse
              [ DataStreamOptionsEntry
                  (DataStreamName "ds")
                  (Just (DataStreamOptions (Just (DataStreamFailureStore (Just True) Nothing))))
              ]
      (decode . encode) value `shouldBe` Just value

    it "rejects a response missing the data_streams field" $ do
      let decoded = decode "{}" :: Maybe GetDataStreamOptionsResponse
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe GetDataStreamOptionsResponse
      decoded `shouldBe` Nothing

  describe "getDataStreamOptions endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "GETs /_data_stream/_options when given Nothing" $ do
      let req = ES8.getDataStreamOptions Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/_options when given Just []" $ do
      let req = ES8.getDataStreamOptions (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{a,b,c}/_options for multiple names" $ do
      let req = ES8.getDataStreamOptions (Just [DataStreamName "a", DataStreamName "b", DataStreamName "c"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b,c", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{name}/_options for a single name" $ do
      let req = ES8.getDataStreamOptions (Just [DataStreamName "logs"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the GET method" $ do
      let req = ES8.getDataStreamOptions (Just [DataStreamName "logs"])
      bhRequestMethod req `shouldBe` "GET"

    it "does not attach a body (GET semantics)" $ do
      let req = ES8.getDataStreamOptions Nothing
      bhRequestBody req `shouldBe` Nothing

  describe "updateDataStreamOptions endpoint shape" $ do
    it "PUTs /_data_stream/{name}/_options for a single name" $ do
      let req = ES8.updateDataStreamOptions (Just [DataStreamName "logs"]) (UpdateDataStreamOptionsRequest Nothing)
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "PUTs /_data_stream/{a,b}/_options for multiple names" $ do
      let req = ES8.updateDataStreamOptions (Just [DataStreamName "a", DataStreamName "b"]) (UpdateDataStreamOptionsRequest Nothing)
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "PUTs /_data_stream/*/_options when given Nothing (server resolves wildcard)" $ do
      let req = ES8.updateDataStreamOptions Nothing (UpdateDataStreamOptionsRequest Nothing)
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "PUTs /_data_stream/*/_options when given Just [] (server resolves wildcard)" $ do
      let req = ES8.updateDataStreamOptions (Just []) (UpdateDataStreamOptionsRequest Nothing)
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the PUT method" $ do
      let req = ES8.updateDataStreamOptions (Just [DataStreamName "logs"]) (UpdateDataStreamOptionsRequest Nothing)
      bhRequestMethod req `shouldBe` "PUT"

    it "sends the encoded UpdateDataStreamOptionsRequest as the body" $ do
      let req =
            ES8.updateDataStreamOptions
              (Just [DataStreamName "logs"])
              ( UpdateDataStreamOptionsRequest
                  (Just (DataStreamFailureStore (Just True) Nothing))
              )
      bhRequestBody req `shouldBe` Just "{\"failure_store\":{\"enabled\":true}}"

    it "sends {} as the body when failure_store is Nothing (omitNulls)" $ do
      let req = ES8.updateDataStreamOptions (Just [DataStreamName "logs"]) (UpdateDataStreamOptionsRequest Nothing)
      bhRequestBody req `shouldBe` Just "{}"

  describe "deleteDataStreamOptions endpoint shape" $ do
    it "DELETEs /_data_stream/{name}/_options for a single name" $ do
      let req = ES8.deleteDataStreamOptions (Just [DataStreamName "logs"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "logs", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "DELETEs /_data_stream/{a,b}/_options for multiple names" $ do
      let req = ES8.deleteDataStreamOptions (Just [DataStreamName "a", DataStreamName "b"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "DELETEs /_data_stream/*/_options when given Nothing (server resolves wildcard)" $ do
      let req = ES8.deleteDataStreamOptions Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "DELETEs /_data_stream/*/_options when given Just [] (server resolves wildcard)" $ do
      let req = ES8.deleteDataStreamOptions (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_options"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the DELETE method" $ do
      let req = ES8.deleteDataStreamOptions (Just [DataStreamName "logs"])
      bhRequestMethod req `shouldBe` "DELETE"

    it "does not attach a body (DELETE semantics)" $ do
      let req = ES8.deleteDataStreamOptions (Just [DataStreamName "logs"])
      bhRequestBody req `shouldBe` Nothing

  -- =====================================================================
  -- GET /_lifecycle/stats (indices-get-data-lifecycle-stats)
  -- =====================================================================

  describe "getDataStreamLifecycleStats JSON" $ do
    it "decodes the verbatim ES docs example with data_streams_count (plural)" $ do
      let payload =
            "{ \"last_run_duration_in_millis\": 2"
              <> ", \"last_run_duration\": \"2ms\""
              <> ", \"time_between_starts_in_millis\": 9998"
              <> ", \"time_between_starts\": \"9.99s\""
              <> ", \"data_streams_count\": 2"
              <> ", \"data_streams\": ["
              <> "    { \"name\": \"my-data-stream\", \"backing_indices_in_total\": 2, \"backing_indices_in_error\": 0 },"
              <> "    { \"name\": \"my-other-stream\", \"backing_indices_in_total\": 2, \"backing_indices_in_error\": 1 } ] }"
          Just decoded = decode payload :: Maybe DataStreamLifecycleStats
      dataStreamLifecycleStatsCount decoded `shouldBe` 2
      dataStreamLifecycleStatsLastRunDurationInMillis decoded `shouldBe` Just 2
      dataStreamLifecycleStatsLastRunDuration decoded `shouldBe` Just "2ms"
      dataStreamLifecycleStatsTimeBetweenStartsInMillis decoded `shouldBe` Just 9998
      dataStreamLifecycleStatsTimeBetweenStarts decoded `shouldBe` Just "9.99s"
      length (dataStreamLifecycleStatsDataStreams decoded) `shouldBe` 2

    it "also accepts data_stream_count (singular schema form)" $ do
      let Just decoded = decode "{ \"data_stream_count\": 0, \"data_streams\": [] }" :: Maybe DataStreamLifecycleStats
      dataStreamLifecycleStatsCount decoded `shouldBe` 0

    it "rejects a payload missing both count keys" $ do
      let decoded = decode "{ \"data_streams\": [] }" :: Maybe DataStreamLifecycleStats
      decoded `shouldBe` Nothing

    it "decodes a per-stream entry" $ do
      let Just decoded = decode "{ \"name\": \"ds\", \"backing_indices_in_total\": 5, \"backing_indices_in_error\": 2 }" :: Maybe DataStreamLifecycleStatsEntry
      dataStreamLifecycleStatsEntryName decoded `shouldBe` DataStreamName "ds"
      dataStreamLifecycleStatsEntryBackingIndicesInTotal decoded `shouldBe` 5
      dataStreamLifecycleStatsEntryBackingIndicesInError decoded `shouldBe` 2

    it "round-trips a fully-populated stats response" $ do
      let value =
            DataStreamLifecycleStats
              { dataStreamLifecycleStatsLastRunDurationInMillis = Just 2,
                dataStreamLifecycleStatsLastRunDuration = Just "2ms",
                dataStreamLifecycleStatsTimeBetweenStartsInMillis = Just 9998,
                dataStreamLifecycleStatsTimeBetweenStarts = Just "9.99s",
                dataStreamLifecycleStatsCount = 1,
                dataStreamLifecycleStatsDataStreams =
                  [ DataStreamLifecycleStatsEntry (DataStreamName "ds") 2 0
                  ]
              }
      (decode . encode) value `shouldBe` Just value

    it "encodes count as data_streams_count (plural, matching the docs example)" $ do
      let value =
            DataStreamLifecycleStats
              { dataStreamLifecycleStatsLastRunDurationInMillis = Nothing,
                dataStreamLifecycleStatsLastRunDuration = Nothing,
                dataStreamLifecycleStatsTimeBetweenStartsInMillis = Nothing,
                dataStreamLifecycleStatsTimeBetweenStarts = Nothing,
                dataStreamLifecycleStatsCount = 0,
                dataStreamLifecycleStatsDataStreams = []
              }
      -- omitNulls drops the empty data_streams array (documented
      -- behaviour — see "S6: omitNulls collapse of empty downsampling
      -- list"); the count survives because 0 is not null.
      encode value `shouldBe` "{\"data_streams_count\":0}"

    it "round-trips the empty case (omitNulls drops data_streams, FromJSON tolerates its absence)" $ do
      let value =
            DataStreamLifecycleStats
              { dataStreamLifecycleStatsLastRunDurationInMillis = Nothing,
                dataStreamLifecycleStatsLastRunDuration = Nothing,
                dataStreamLifecycleStatsTimeBetweenStartsInMillis = Nothing,
                dataStreamLifecycleStatsTimeBetweenStarts = Nothing,
                dataStreamLifecycleStatsCount = 0,
                dataStreamLifecycleStatsDataStreams = []
              }
      -- encode drops data_streams, decode tolerates its absence (.!= [])
      (decode . encode) value `shouldBe` Just value

  describe "getDataStreamLifecycleStats endpoint shape (ES8)" $ do
    it "GETs /_lifecycle/stats" $ do
      let req = ES8.getDataStreamLifecycleStats
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_lifecycle", "stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the GET method" $ do
      let req = ES8.getDataStreamLifecycleStats
      bhRequestMethod req `shouldBe` "GET"

    it "does not attach a body" $ do
      let req = ES8.getDataStreamLifecycleStats
      bhRequestBody req `shouldBe` Nothing

  describe "getDataStreamLifecycleStats endpoint shape (ES9 delegates to ES8)" $ do
    it "GETs /_lifecycle/stats" $ do
      let req = ES9.getDataStreamLifecycleStats
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_lifecycle", "stats"]

  -- =====================================================================
  -- GET /{index}/_lifecycle/explain (indices-explain-data-lifecycle)
  -- =====================================================================

  describe "explainDataStreamLifecycle JSON" $ do
    it "decodes the verbatim ES docs example (single managed backing index)" $ do
      let payload =
            "{ \"indices\": {"
              <> "  \".ds-metrics-2023.03.22-000001\": {"
              <> "    \"index\": \".ds-metrics-2023.03.22-000001\","
              <> "    \"managed_by_lifecycle\": true,"
              <> "    \"index_creation_date_millis\": 1679475563571,"
              <> "    \"time_since_index_creation\": \"843ms\","
              <> "    \"rollover_date_millis\": 1679475564293,"
              <> "    \"time_since_rollover\": \"121ms\","
              <> "    \"lifecycle\": {},"
              <> "    \"generation_time\": \"121ms\" } } }"
          Just decoded = decode payload :: Maybe ExplainDataStreamLifecycleResponse
      M.size (explainDataStreamLifecycleResponseIndices decoded) `shouldBe` 1

    it "decodes an entry with an error string" $ do
      let payload =
            "{ \"indices\": {"
              <> "  \".ds-broken-000001\": {"
              <> "    \"index\": \".ds-broken-000001\","
              <> "    \"managed_by_lifecycle\": true,"
              <> "    \"error\": \"something went wrong\" } } }"
          Just decoded = decode payload :: Maybe ExplainDataStreamLifecycleResponse
      let entry = (M.elems . explainDataStreamLifecycleResponseIndices) decoded
      length entry `shouldBe` 1
      explainDataStreamLifecycleIndexError (head entry) `shouldBe` Just "something went wrong"
      explainDataStreamLifecycleIndexManagedByLifecycle (head entry) `shouldBe` True

    it "round-trips a fully-populated entry" $ do
      let value =
            ExplainDataStreamLifecycleIndex
              { explainDataStreamLifecycleIndexIndex = mkIdx ".ds-foo-000001",
                explainDataStreamLifecycleIndexManagedByLifecycle = True,
                explainDataStreamLifecycleIndexCreationDateMillis = Just 1679475563571,
                explainDataStreamLifecycleIndexTimeSinceIndexCreation = Just "843ms",
                explainDataStreamLifecycleIndexRolloverDateMillis = Just 1679475564293,
                explainDataStreamLifecycleIndexTimeSinceRollover = Just "121ms",
                explainDataStreamLifecycleIndexLifecycle = Nothing,
                explainDataStreamLifecycleIndexGenerationTime = Just "121ms",
                explainDataStreamLifecycleIndexError = Nothing
              }
      (decode . encode) value `shouldBe` Just value

    it "round-trips the top-level response with a Map" $ do
      let key = mkIdx ".ds-foo-000001"
          value = ExplainDataStreamLifecycleResponse (M.singleton key (minimalExplainEntry key))
      (decode . encode) value `shouldBe` Just value

    it "rejects a response missing the indices field" $ do
      let decoded = decode "{}" :: Maybe ExplainDataStreamLifecycleResponse
      decoded `shouldBe` Nothing

  describe "explainDataStreamLifecycle endpoint shape (ES8)" $ do
    it "GETs /_lifecycle/explain when given Nothing" $ do
      let req = ES8.explainDataStreamLifecycle Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_lifecycle", "explain"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_lifecycle/explain when given Just []" $ do
      let req = ES8.explainDataStreamLifecycle (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_lifecycle", "explain"]

    it "GETs /{index}/_lifecycle/explain for a single index" $ do
      let req = ES8.explainDataStreamLifecycle (Just [mkIdx ".ds-foo-000001"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` [".ds-foo-000001", "_lifecycle", "explain"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /{a,b}/_lifecycle/explain for multiple indices (comma-joined)" $ do
      let req = ES8.explainDataStreamLifecycle (Just [mkIdx ".ds-a-000001", mkIdx ".ds-b-000001"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` [".ds-a-000001,.ds-b-000001", "_lifecycle", "explain"]

    it "does not attach a body" $ do
      let req = ES8.explainDataStreamLifecycle Nothing
      bhRequestBody req `shouldBe` Nothing

    it "emits include_defaults=true when set" $ do
      let opts = defaultExplainDataStreamLifecycleOptions {explainDataStreamLifecycleOptionsIncludeDefaults = Just True}
          req = ES8.explainDataStreamLifecycleWith (Just [mkIdx ".ds-foo-000001"]) opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("include_defaults", Just "true")]

    it "emits master_timeout when set" $ do
      let opts = defaultExplainDataStreamLifecycleOptions {explainDataStreamLifecycleOptionsMasterTimeout = Just "30s"}
          req = ES8.explainDataStreamLifecycleWith Nothing opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("master_timeout", Just "30s")]

    it "emits both params when both are set" $ do
      let opts =
            defaultExplainDataStreamLifecycleOptions
              { explainDataStreamLifecycleOptionsIncludeDefaults = Just False,
                explainDataStreamLifecycleOptionsMasterTimeout = Just "1m"
              }
          req = ES8.explainDataStreamLifecycleWith Nothing opts
      sort (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` sort [("include_defaults", Just "false"), ("master_timeout", Just "1m")]

  describe "explainDataStreamLifecycle endpoint shape (ES9 delegates to ES8)" $ do
    it "GETs /_lifecycle/explain when given Nothing" $ do
      let req = ES9.explainDataStreamLifecycle Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_lifecycle", "explain"]

    it "GETs /{index}/_lifecycle/explain for a single index" $ do
      let req = ES9.explainDataStreamLifecycle (Just [mkIdx ".ds-foo-000001"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` [".ds-foo-000001", "_lifecycle", "explain"]

  -- =====================================================================
  -- GET/PUT /_data_stream/{name}/_mappings
  -- (indices-get/put-data-stream-mappings) — ES9 only
  -- =====================================================================

  describe "getDataStreamMappings JSON" $ do
    it "decodes the verbatim ES docs example" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"my-data-stream\","
              <> "    \"mappings\": { \"properties\": { \"field1\": { \"type\": \"ip\" } } },"
              <> "    \"effective_mappings\": { \"properties\": { \"field1\": { \"type\": \"ip\" }, \"field2\": { \"type\": \"text\" } } } } ] }"
          Just decoded = decode payload :: Maybe GetDataStreamMappingsResponse
      length (getDataStreamMappingsResponseDataStreams decoded) `shouldBe` 1
      let entry = head (getDataStreamMappingsResponseDataStreams decoded)
      getDataStreamMappingsEntryName entry `shouldBe` DataStreamName "my-data-stream"

    it "round-trips a fully-populated response" $ do
      let value =
            GetDataStreamMappingsResponse
              [ GetDataStreamMappingsEntry
                  (DataStreamName "ds")
                  (object ["properties" .= object ["foo" .= object ["type" .= ("text" :: Text)]]])
                  (object ["properties" .= object ["foo" .= object ["type" .= ("text" :: Text)]]])
              ]
      (decode . encode) value `shouldBe` Just value

    it "rejects a response missing data_streams" $ do
      let decoded = decode "{}" :: Maybe GetDataStreamMappingsResponse
      decoded `shouldBe` Nothing

  describe "updateDataStreamMappings JSON" $ do
    it "decodes a successful PUT response (applied_to_data_stream=true)" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\","
              <> "    \"applied_to_data_stream\": true,"
              <> "    \"mappings\": { \"properties\": {} },"
              <> "    \"effective_mappings\": { \"properties\": {} } } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamMappingsResponse
      let entry = head (updateDataStreamMappingsResponseDataStreams decoded)
      updateDataStreamMappingsEntryAppliedToDataStream entry `shouldBe` True
      updateDataStreamMappingsEntryError entry `shouldBe` Nothing

    it "decodes a failed PUT response (applied_to_data_stream=false) with error" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\","
              <> "    \"applied_to_data_stream\": false,"
              <> "    \"error\": \"Cannot set setting X\" } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamMappingsResponse
      let entry = head (updateDataStreamMappingsResponseDataStreams decoded)
      updateDataStreamMappingsEntryAppliedToDataStream entry `shouldBe` False
      updateDataStreamMappingsEntryError entry `shouldBe` Just "Cannot set setting X"

    it "tolerates missing mappings/effective_mappings (decode as empty objects)" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\", \"applied_to_data_stream\": false, \"error\": \"x\" } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamMappingsResponse
      let entry = head (updateDataStreamMappingsResponseDataStreams decoded)
      updateDataStreamMappingsEntryMappings entry `shouldBe` object []

  describe "getDataStreamMappings endpoint shape (ES9)" $ do
    it "GETs /_data_stream/_mappings when given Nothing" $ do
      let req = ES9.getDataStreamMappings Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_mappings"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/_mappings when given Just []" $ do
      let req = ES9.getDataStreamMappings (Just [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_mappings"]

    it "GETs /_data_stream/{a,b}/_mappings for multiple names" $ do
      let req = ES9.getDataStreamMappings (Just [DataStreamName "a", DataStreamName "b"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "a,b", "_mappings"]

    it "emits master_timeout when set" $ do
      let opts = defaultGetDataStreamMappingsOptions {getDataStreamMappingsOptionsMasterTimeout = Just "30s"}
          req = ES9.getDataStreamMappingsWith (Just [DataStreamName "ds"]) opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("master_timeout", Just "30s")]

  describe "putDataStreamMappings endpoint shape (ES9)" $ do
    it "PUTs /_data_stream/{name}/_mappings for a single name" $ do
      let req = ES9.putDataStreamMappings (Just [DataStreamName "ds"]) (object ["properties" .= object []])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "ds", "_mappings"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "PUTs /_data_stream/*/_mappings when given Nothing (wildcard fan-out)" $ do
      let req = ES9.putDataStreamMappings Nothing (object ["properties" .= object []])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_mappings"]

    it "PUTs /_data_stream/*/_mappings when given Just [] (wildcard fan-out)" $ do
      let req = ES9.putDataStreamMappings (Just []) (object ["properties" .= object []])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_mappings"]

    it "uses the PUT method" $ do
      let req = ES9.putDataStreamMappings (Just [DataStreamName "ds"]) (object [])
      bhRequestMethod req `shouldBe` "PUT"

    it "encodes the mapping object as the body" $ do
      let req = ES9.putDataStreamMappings (Just [DataStreamName "ds"]) (object ["properties" .= object ["foo" .= object ["type" .= ("text" :: Text)]]])
      bhRequestBody req `shouldBe` Just "{\"properties\":{\"foo\":{\"type\":\"text\"}}}"

    it "emits dry_run=true when set" $ do
      let opts = defaultUpdateDataStreamMappingsOptions {updateDataStreamMappingsOptionsDryRun = Just True}
          req = ES9.putDataStreamMappingsWith (Just [DataStreamName "ds"]) (object []) opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("dry_run", Just "true")]

  -- =====================================================================
  -- GET/PUT /_data_stream/{name}/_settings
  -- (indices-get/put-data-stream-settings) — ES9 only
  -- =====================================================================

  describe "getDataStreamSettings JSON" $ do
    it "decodes the verbatim ES docs example (numeric-as-string)" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"my-data-stream\","
              <> "    \"settings\": { \"index\": { \"lifecycle\": { \"name\": \"policy\" }, \"number_of_shards\": \"11\" } },"
              <> "    \"effective_settings\": { \"index\": { \"lifecycle\": { \"name\": \"policy\" }, \"number_of_shards\": \"11\", \"number_of_replicas\": \"0\" } } } ] }"
          Just decoded = decode payload :: Maybe GetDataStreamSettingsResponse
      length (getDataStreamSettingsResponseDataStreams decoded) `shouldBe` 1

    it "round-trips a fully-populated response" $ do
      let value =
            GetDataStreamSettingsResponse
              [ GetDataStreamSettingsEntry
                  (DataStreamName "ds")
                  (object ["index" .= object ["number_of_shards" .= ("11" :: Text)]])
                  (object ["index" .= object ["number_of_shards" .= ("11" :: Text)]])
              ]
      (decode . encode) value `shouldBe` Just value

  describe "updateDataStreamSettings JSON" $ do
    it "decodes a successful PUT response with index_settings_results" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\","
              <> "    \"applied_to_data_stream\": true,"
              <> "    \"settings\": { \"index\": { \"number_of_shards\": \"11\" } },"
              <> "    \"effective_settings\": { \"index\": { \"number_of_shards\": \"11\" } },"
              <> "    \"index_settings_results\": {"
              <> "      \"applied_to_data_stream_only\": [\"index.number_of_shards\"],"
              <> "      \"applied_to_data_stream_and_backing_indices\": [\"index.lifecycle.name\"] } } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamSettingsResponse
      let entry = head (updateDataStreamSettingsResponseDataStreams decoded)
      updateDataStreamSettingsEntryAppliedToDataStream entry `shouldBe` True
      let Just results = updateDataStreamSettingsEntryIndexSettingsResults entry
      indexSettingsResultsAppliedToDataStreamOnly results `shouldBe` ["index.number_of_shards"]
      indexSettingsResultsAppliedToDataStreamAndBackingIndices results `shouldBe` ["index.lifecycle.name"]
      indexSettingsResultsErrors results `shouldBe` []

    it "decodes a partial-failure response with per-index errors" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\","
              <> "    \"applied_to_data_stream\": true,"
              <> "    \"settings\": {}, \"effective_settings\": {},"
              <> "    \"index_settings_results\": {"
              <> "      \"applied_to_data_stream_only\": [],"
              <> "      \"applied_to_data_stream_and_backing_indices\": [],"
              <> "      \"errors\": [ { \"index\": \".ds-ds-000001\", \"error\": \"blocked by FORBIDDEN/9\" } ] } } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamSettingsResponse
      let entry = head (updateDataStreamSettingsResponseDataStreams decoded)
          Just results = updateDataStreamSettingsEntryIndexSettingsResults entry
      length (indexSettingsResultsErrors results) `shouldBe` 1
      indexSettingErrorIndex (head (indexSettingsResultsErrors results)) `shouldBe` ".ds-ds-000001"

    it "decodes a total-failure response (applied_to_data_stream=false)" $ do
      let payload =
            "{ \"data_streams\": ["
              <> "  { \"name\": \"ds\","
              <> "    \"applied_to_data_stream\": false,"
              <> "    \"error\": \"Cannot set the following settings\","
              <> "    \"settings\": {}, \"effective_settings\": {},"
              <> "    \"index_settings_results\": { \"applied_to_data_stream_only\": [], \"applied_to_data_stream_and_backing_indices\": [] } } ] }"
          Just decoded = decode payload :: Maybe UpdateDataStreamSettingsResponse
      let entry = head (updateDataStreamSettingsResponseDataStreams decoded)
      updateDataStreamSettingsEntryAppliedToDataStream entry `shouldBe` False
      updateDataStreamSettingsEntryError entry `shouldBe` Just "Cannot set the following settings"

    it "round-trips an IndexSettingError" $ do
      let value = IndexSettingError ".ds-ds-000001" "blocked"
      (decode . encode) value `shouldBe` Just value

  describe "getDataStreamSettings endpoint shape (ES9)" $ do
    it "GETs /_data_stream/_settings when given Nothing" $ do
      let req = ES9.getDataStreamSettings Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "_settings"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "GETs /_data_stream/{name}/_settings for a single name" $ do
      let req = ES9.getDataStreamSettings (Just [DataStreamName "ds"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "ds", "_settings"]

    it "emits master_timeout when set" $ do
      let opts = defaultGetDataStreamSettingsOptions {getDataStreamSettingsOptionsMasterTimeout = Just "1m"}
          req = ES9.getDataStreamSettingsWith Nothing opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("master_timeout", Just "1m")]

  describe "putDataStreamSettings endpoint shape (ES9)" $ do
    it "PUTs /_data_stream/{name}/_settings for a single name" $ do
      let req = ES9.putDataStreamSettings (Just [DataStreamName "ds"]) (object ["index.lifecycle.name" .= ("policy" :: Text)])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "ds", "_settings"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "PUTs /_data_stream/*/_settings when given Nothing (wildcard fan-out)" $ do
      let req = ES9.putDataStreamSettings Nothing (object [])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_data_stream", "*", "_settings"]

    it "uses the PUT method" $ do
      let req = ES9.putDataStreamSettings (Just [DataStreamName "ds"]) (object [])
      bhRequestMethod req `shouldBe` "PUT"

    it "encodes the settings object as the body" $ do
      let req = ES9.putDataStreamSettings (Just [DataStreamName "ds"]) (object ["index.number_of_shards" .= (11 :: Int)])
      bhRequestBody req `shouldBe` Just "{\"index.number_of_shards\":11}"

    it "emits dry_run=false when set to False" $ do
      let opts = defaultUpdateDataStreamSettingsOptions {updateDataStreamSettingsOptionsDryRun = Just False}
          req = ES9.putDataStreamSettingsWith (Just [DataStreamName "ds"]) (object []) opts
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("dry_run", Just "false")]

    it "emits both dry_run and timeout when set" $ do
      let opts =
            defaultUpdateDataStreamSettingsOptions
              { updateDataStreamSettingsOptionsDryRun = Just True,
                updateDataStreamSettingsOptionsTimeout = Just "5s"
              }
          req = ES9.putDataStreamSettingsWith Nothing (object []) opts
      sort (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` sort [("dry_run", Just "true"), ("timeout", Just "5s")]