packages feed

bloodhound-1.0.0.0: tests/Test/TemplatesSpec.hs

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

module Test.TemplatesSpec (spec) where

import Control.Exception (SomeException)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as AKM
import Data.List qualified as L
import Data.Text qualified as T
import TestsUtils.Common
import TestsUtils.Import

spec :: Spec
spec = do
  describe "template API" $ do
    it "can create a template" $
      withTestEnv $ do
        let idxTpl = IndexTemplate [IndexPattern "tweet-*"] (Just (IndexSettings (ShardCount 1) (ReplicaCount 1) defaultIndexMappingsLimits)) (toJSON TweetMapping)
        resp <- performBHRequest $ putTemplate idxTpl (TemplateName "tweet-tpl")
        liftIO $ resp `shouldBe` Acknowledged True

    it "can detect if a template exists" $
      withTestEnv $ do
        exists <- performBHRequest $ templateExists (TemplateName "tweet-tpl")
        liftIO $ exists `shouldBe` True

    it "getTemplate (Just name) returns the template we PUT" $
      withTestEnv $ do
        let idxTpl = IndexTemplate [IndexPattern "tweet-*"] (Just (IndexSettings (ShardCount 1) (ReplicaCount 1) defaultIndexMappingsLimits)) (toJSON TweetMapping)
        _ <- performBHRequest $ putTemplate idxTpl (TemplateName "tweet-tpl")
        infos <- performBHRequest $ getTemplate (Just (TemplateNamePattern "tweet-tpl"))
        liftIO $ map tiName infos `shouldBe` [TemplateName "tweet-tpl"]
        liftIO $ case infos of
          [info] ->
            -- The server may synthesise default @settings@ on GET (e.g.
            -- @refresh_interval@), and @mappings@ echoes what we PUT but
            -- possibly with extra ES-internal keys. Assert only on the
            -- invariant part of the body: the patterns we registered.
            tiIndexPatterns info `shouldBe` [IndexPattern "tweet-*"]
          _ -> expectationFailure ("expected a singleton list but got " <> show infos)

    it "getTemplate Nothing lists templates including ours" $
      withTestEnv $ do
        let idxTpl = IndexTemplate [IndexPattern "tweet-*"] (Just (IndexSettings (ShardCount 1) (ReplicaCount 1) defaultIndexMappingsLimits)) (toJSON TweetMapping)
        _ <- performBHRequest $ putTemplate idxTpl (TemplateName "tweet-tpl")
        infos <- performBHRequest $ getTemplate Nothing
        -- Membership rather than equality: a shared cluster will almost
        -- always have other legacy templates (e.g. built-ins on ES 7).
        liftIO $ TemplateName "tweet-tpl" `elem` map tiName infos `shouldBe` True

    it "getTemplate accepts a wildcard pattern" $
      withTestEnv $ do
        let idxTpl = IndexTemplate [IndexPattern "tweet-*"] (Just (IndexSettings (ShardCount 1) (ReplicaCount 1) defaultIndexMappingsLimits)) (toJSON TweetMapping)
        _ <- performBHRequest $ putTemplate idxTpl (TemplateName "tweet-tpl")
        infos <- performBHRequest $ getTemplate (Just (TemplateNamePattern "tweet-*"))
        liftIO $ TemplateName "tweet-tpl" `elem` map tiName infos `shouldBe` True

    it "getTemplate surfaces a non-matching pattern as an error" $
      withTestEnv $ do
        -- On ES 8+ the server returns a parseable error body and the
        -- request surfaces as 'Left EsError' via 'tryPerformBHRequest'.
        -- On ES 7 the legacy @GET /_template/<missing>@ endpoint
        -- returns a 404 with an empty @{}@ body, which the client
        -- cannot decode as an 'EsError' and so throws
        -- 'EsProtocolException'. Both are valid failure modes; we just
        -- assert the request does not succeed.
        result <-
          try $
            performBHRequest $
              getTemplate (Just (TemplateNamePattern "definitely-not-a-real-template-xyz"))
        liftIO $ case result of
          Left (_ :: SomeException) -> return ()
          Right infos ->
            expectationFailure ("expected a failure but got " <> show infos)

    it "can delete a template" $
      withTestEnv $ do
        resp <- performBHRequest $ deleteTemplate (TemplateName "tweet-tpl")
        liftIO $ resp `shouldBe` Acknowledged True

    it "can detect if a template doesn't exist" $
      withTestEnv $ do
        exists <- performBHRequest $ templateExists (TemplateName "tweet-tpl")
        liftIO $ exists `shouldBe` False

  -- ------------------------------------------------------------------ --
  -- Composable index templates (PUT /_index_template/{name}).           --
  -- ------------------------------------------------------------------ --
  describe "composable index template API" $ do
    let content =
          ComposableTemplateContent
            { -- The composable template's @settings@ field is flat
              -- (@number_of_shards@ lives directly under @settings@),
              -- /not/ the wrapped @{"settings":{"index":{...}}}@ shape
              -- produced by 'IndexSettings'\'s 'ToJSON'. Pass a flat
              -- object here.
              ctcSettings =
                Just
                  ( Aeson.object
                      [ "number_of_shards" Aeson..= (1 :: Int),
                        "number_of_replicas" Aeson..= (1 :: Int)
                      ]
                  ),
              ctcMappings = Just (toJSON TweetMapping),
              ctcAliases = Nothing
            }
        tpl =
          ComposableTemplate
            { ctIndexPatterns = [IndexPattern "composable-tweet-*"],
              ctTemplate = Just content,
              ctPriority = Just 100,
              ctVersion = Nothing,
              ctComposedOf = Nothing,
              ctAllowAutoCreate = Nothing
            }
        name = TemplateName "bh-composable-tpl"

    it "putIndexTemplate returns Acknowledged True" $
      withTestEnv $ do
        resp <- performBHRequest $ putIndexTemplate name tpl
        liftIO $ resp `shouldBe` Acknowledged True

    it "putIndexTemplate is idempotent (re-PUT succeeds)" $
      withTestEnv $ do
        resp <- performBHRequest $ putIndexTemplate name tpl
        liftIO $ resp `shouldBe` Acknowledged True

    it "putIndexTemplateWith create=true fails when the template already exists" $
      withTestEnv $ do
        -- Ensure the template exists (idempotent upsert).
        _ <- performBHRequest $ putIndexTemplate name tpl
        -- create=true must refuse to overwrite an existing template.
        result <-
          tryPerformBHRequest $
            putIndexTemplateWith name tpl defaultComposableTemplateOptions {ctoCreate = Just True}
        liftIO $ case result of
          Left _ -> return ()
          Right _ -> expectationFailure "expected create=true to fail but the PUT succeeded"

    it "getIndexTemplate (Just name) returns the template we PUT" $
      withTestEnv $ do
        _ <- performBHRequest $ putIndexTemplate name tpl
        infos <- performBHRequest $ getIndexTemplate (Just (TemplateNamePattern "bh-composable-tpl"))
        liftIO $ map itiName infos `shouldBe` [name]
        liftIO $ case infos of
          [info] -> do
            -- The server normalizes the body on GET (wraps settings
            -- under "index", stringifies numbers, echoes
            -- "composed_of": []), so we only assert on the fields ES
            -- preserves verbatim: the index patterns and priority.
            ctIndexPatterns (itiIndexTemplate info) `shouldBe` ctIndexPatterns tpl
            ctPriority (itiIndexTemplate info) `shouldBe` ctPriority tpl
          _ -> expectationFailure ("expected a singleton list but got " <> show infos)

    it "getIndexTemplate Nothing lists templates including ours" $
      withTestEnv $ do
        _ <- performBHRequest $ putIndexTemplate name tpl
        infos <- performBHRequest $ getIndexTemplate Nothing
        liftIO $ name `elem` map itiName infos `shouldBe` True

    it "getIndexTemplate accepts a wildcard pattern" $
      withTestEnv $ do
        _ <- performBHRequest $ putIndexTemplate name tpl
        infos <- performBHRequest $ getIndexTemplate (Just (TemplateNamePattern "bh-composable-*"))
        -- Membership rather than singleton equality: a previous,
        -- independently-created template with the same prefix would
        -- make a strict @[name]@ assertion flaky on a shared cluster.
        liftIO $ name `elem` map itiName infos `shouldBe` True

    it "getIndexTemplate surfaces a non-matching pattern as an EsError (404)" $
      withTestEnv $ do
        result <-
          tryPerformBHRequest $
            getIndexTemplate (Just (TemplateNamePattern "definitely-not-a-real-template-xyz"))
        liftIO $ case result of
          Left _ -> return ()
          Right infos ->
            expectationFailure ("expected a 404 EsError but got " <> show infos)

    it "deleteIndexTemplate returns Acknowledged True after PUT" $
      withTestEnv $ do
        _ <- performBHRequest $ putIndexTemplate name tpl
        resp <- performBHRequest $ deleteIndexTemplate name
        liftIO $ resp `shouldBe` Acknowledged True

    it "deleteIndexTemplate makes a subsequent GET 404" $
      withTestEnv $ do
        _ <- performBHRequest $ putIndexTemplate name tpl
        _ <- performBHRequest $ deleteIndexTemplate name
        result <-
          tryPerformBHRequest $
            getIndexTemplate (Just (TemplateNamePattern "bh-composable-tpl"))
        liftIO $ case result of
          Left _ -> return ()
          Right infos ->
            expectationFailure
              ("expected a 404 EsError after deleteIndexTemplate but got " <> show infos)

    it "deleteIndexTemplate surfaces a missing template as an EsError (404)" $
      withTestEnv $ do
        result <-
          tryPerformBHRequest $
            deleteIndexTemplate (TemplateName "bh-composable-not-actually-present")
        liftIO $ case result of
          Left _ -> return ()
          Right ack ->
            expectationFailure
              ("expected a 404 EsError but got " <> show ack)

    -- ----------------------------------------------------------------
    -- POST /_index_template/_simulate (bloodhound-04f.2.17.5).
    -- Tests 1 and 3 use patterns disjoint from @composable-tweet-*@
    -- (the patterns of @bh-composable-tpl@ above) so execution order
    -- does not matter — the simulate validation rejects requests
    -- whose priority conflicts with an existing template that matches
    -- the same index name.
    -- ----------------------------------------------------------------
    it "simulateIndexTemplate returns the resolved template body" $
      withTestEnv $ do
        let isoTpl =
              ComposableTemplate
                { ctIndexPatterns = [IndexPattern "bh-simulate-iso-*"],
                  ctTemplate =
                    Just
                      ( ComposableTemplateContent
                          { ctcSettings =
                              Just
                                ( Aeson.object
                                    ["number_of_shards" Aeson..= (1 :: Int)]
                                ),
                            ctcMappings = Nothing,
                            ctcAliases = Nothing
                          }
                      ),
                  ctPriority = Just 200,
                  ctVersion = Nothing,
                  ctComposedOf = Nothing,
                  ctAllowAutoCreate = Nothing
                }
        simulated <-
          performBHRequest $
            simulateIndexTemplate isoTpl
        -- The simulated template merges settings/mappings/aliases from
        -- the supplied body plus any component templates referenced.
        -- We assert on the settings we supplied (number_of_shards=1)
        -- being present in the resolved body, rather than on the full
        -- merged shape (which can include server-added defaults).
        liftIO $
          ctcSettings (stTemplate simulated)
            `shouldSatisfy` isJust

    it "simulateIndexTemplate includes an existing overlapping template" $
      withTestEnv $ do
        -- Persist @tpl@ under @name@, then simulate a sibling template
        -- whose index_patterns overlap @name@'s. The persisted @name@
        -- must appear in the simulated overlapping list. The cleanup
        -- runs before the assertion so an HUnit failure does not leak
        -- @bh-composable-tpl@ onto a shared cluster.
        _ <- performBHRequest $ putIndexTemplate name tpl
        let overlappingTpl =
              ComposableTemplate
                { ctIndexPatterns = [IndexPattern "composable-tweet-extra-*"],
                  ctTemplate = Just content,
                  -- Priority lower than @name@'s 100 so the simulate
                  -- validation does not flag a priority conflict.
                  ctPriority = Just 50,
                  ctVersion = Nothing,
                  ctComposedOf = Nothing,
                  ctAllowAutoCreate = Nothing
                }
        simulated <-
          performBHRequest $
            simulateIndexTemplate overlappingTpl
        -- Clean up before asserting so an HUnit failure does not leave
        -- @name@ behind on a shared cluster. The PUT is idempotent so
        -- a leftover from a previous run is fine, but cleanup is still
        -- polite.
        _ <- tryPerformBHRequest $ deleteIndexTemplate name
        -- @composable-tweet-*@ (from @name@) overlaps our simulated
        -- @composable-tweet-extra-*@ patterns, so @name@ should be in
        -- the overlapping list. Membership-only because a shared
        -- cluster may carry unrelated overlapping templates too.
        liftIO $
          name
            `elem` mapMaybe stoName (stOverlapping simulated)
              `shouldBe` True

    it "simulateIndexTemplateWith with default options behaves like simulateIndexTemplate" $
      withTestEnv $ do
        let isoTpl =
              ComposableTemplate
                { ctIndexPatterns = [IndexPattern "bh-simulate-with-*"],
                  ctTemplate =
                    Just
                      ( ComposableTemplateContent
                          { ctcSettings =
                              Just
                                ( Aeson.object
                                    ["number_of_shards" Aeson..= (1 :: Int)]
                                ),
                            ctcMappings = Nothing,
                            ctcAliases = Nothing
                          }
                      ),
                  ctPriority = Just 200,
                  ctVersion = Nothing,
                  ctComposedOf = Nothing,
                  ctAllowAutoCreate = Nothing
                }
        -- defaultComposableTemplateOptions emits no query string, so
        -- the wire request is byte-for-byte identical to the parameter-
        -- less variant. We assert both calls succeed and return a
        -- resolved template with the supplied settings present.
        r1 <- performBHRequest $ simulateIndexTemplate isoTpl
        r2 <-
          performBHRequest $
            simulateIndexTemplateWith isoTpl defaultComposableTemplateOptions
        liftIO $
          ctcSettings (stTemplate r1) `shouldSatisfy` isJust
        liftIO $
          ctcSettings (stTemplate r2) `shouldSatisfy` isJust

    it "simulateIndex resolves a hypothetical name against a PUT template" $
      withTestEnv $ do
        -- Persist @tpl@ under @name@ (patterns @composable-tweet-*@),
        -- then simulate the index name @composable-tweet-simulated@ which
        -- the template should match. The resolved body must carry the
        -- settings we PUT. Cleanup before the assertion so an HUnit
        -- failure does not leak the template onto a shared cluster.
        _ <- performBHRequest $ putIndexTemplate name tpl
        simulated <-
          performBHRequest $
            simulateIndex [qqIndexName|composable-tweet-simulated|]
        _ <- tryPerformBHRequest $ deleteIndexTemplate name
        liftIO $
          ctcSettings (stTemplate simulated)
            `shouldSatisfy` isJust

    it "simulateIndex resolves a name with no matching template to an empty-ish body" $
      withTestEnv $ do
        -- A name matching no registered template returns a
        -- 'SimulatedTemplate' whose @template@ is present (carrying only
        -- server defaults) and whose @overlapping@ is empty. This pins
        -- the read-only contract: the endpoint always succeeds for a
        -- syntactically valid name, regardless of template coverage.
        simulated <-
          performBHRequest $
            simulateIndex [qqIndexName|bh-simulate-index-no-match-2026|]
        liftIO $
          stOverlapping simulated `shouldBe` []

  -- ------------------------------------------------------------------ --
  -- composableTemplateOptionsParams: pure URI rendering (no ES needed)  --
  -- ------------------------------------------------------------------ --
  describe "composableTemplateOptionsParams URI rendering" $ do
    let normalize :: [(T.Text, Maybe T.Text)] -> [(T.Text, Maybe T.Text)]
        normalize = L.sort

    it "defaultComposableTemplateOptions emits no params" $
      composableTemplateOptionsParams defaultComposableTemplateOptions
        `shouldBe` []

    it "renders create as lowercase true/false" $ do
      let opts = defaultComposableTemplateOptions {ctoCreate = Just True}
      composableTemplateOptionsParams opts `shouldBe` [("create", Just "true")]
      let opts' = defaultComposableTemplateOptions {ctoCreate = Just False}
      composableTemplateOptionsParams opts' `shouldBe` [("create", Just "false")]

    it "renders master_timeout as <n><suffix>" $ do
      let opts = defaultComposableTemplateOptions {ctoMasterTimeout = Just (TimeUnitSeconds, 30)}
      composableTemplateOptionsParams opts `shouldBe` [("master_timeout", Just "30s")]

    it "renders cause as the raw text" $ do
      let opts = defaultComposableTemplateOptions {ctoCause = Just "reload"}
      composableTemplateOptionsParams opts `shouldBe` [("cause", Just "reload")]

    it "emits every param together when all are set" $ do
      let opts =
            defaultComposableTemplateOptions
              { ctoCreate = Just True,
                ctoMasterTimeout = Just (TimeUnitMilliseconds, 500),
                ctoCause = Just "reload"
              }
      normalize (composableTemplateOptionsParams opts)
        `shouldBe` [ ("cause", Just "reload"),
                     ("create", Just "true"),
                     ("master_timeout", Just "500ms")
                   ]

  -- ------------------------------------------------------------------ --
  -- ComposableTemplate JSON: pure, no ES required.                      --
  -- Locks in conditional emission of optional fields (absent when       --
  -- Nothing) and required index_patterns.                               --
  -- ------------------------------------------------------------------ --
  describe "ComposableTemplate JSON" $ do
    let decodeAsObject :: Aeson.Value -> Maybe Aeson.Object
        decodeAsObject (Aeson.Object o) = Just o
        decodeAsObject _ = Nothing
        minimalCt =
          ComposableTemplate
            { ctIndexPatterns = [IndexPattern "log-*"],
              ctTemplate = Nothing,
              ctPriority = Nothing,
              ctVersion = Nothing,
              ctComposedOf = Nothing,
              ctAllowAutoCreate = Nothing
            }

    it "emits only index_patterns when everything else is Nothing" $ do
      let encoded = Aeson.encode minimalCt
          wireObject = Aeson.decode' encoded >>= decodeAsObject
      wireObject `shouldSatisfy` maybe False ((== 1) . length . AKM.keys)
      wireObject `shouldSatisfy` maybe False (AKM.member "index_patterns")

    it "roundtrips a fully-populated template" $ do
      let fullContent =
            ComposableTemplateContent
              { ctcSettings = Just (Aeson.object ["index.refresh_interval" Aeson..= ("1s" :: T.Text)]),
                ctcMappings = Just (toJSON TweetMapping),
                ctcAliases = Just (AKM.singleton "live" (Aeson.object []))
              }
          ct =
            ComposableTemplate
              { ctIndexPatterns = [IndexPattern "tweet-*"],
                ctTemplate = Just fullContent,
                ctPriority = Just 100,
                ctVersion = Just 3,
                ctComposedOf = Just [TemplateName "ct-base"],
                ctAllowAutoCreate = Just True
              }
          encoded = Aeson.encode ct
          decoded = Aeson.decode' encoded :: Maybe ComposableTemplate
      decoded `shouldBe` Just ct

    it "roundtrips the minimal template (optional fields absent on the wire)" $ do
      let encoded = Aeson.encode minimalCt
          decoded = Aeson.decode' encoded :: Maybe ComposableTemplate
      decoded `shouldBe` Just minimalCt
      let wireObject = Aeson.decode' encoded >>= decodeAsObject
      wireObject `shouldSatisfy` maybe False (not . AKM.member "template")
      wireObject `shouldSatisfy` maybe False (not . AKM.member "priority")

  -- ------------------------------------------------------------------ --
  -- IndexTemplateInfo / GetIndexTemplatesResponse JSON:                 --
  -- pure, no ES required. Locks in the GET /_index_template envelope    --
  -- shape: {"index_templates": [{"name": ..., "index_template": {...}}]} --
  -- ------------------------------------------------------------------ --
  describe "IndexTemplateInfo JSON" $ do
    let -- A representative composable-template body matching the wire
        -- format ES returns from GET /_index_template.
        sampleBody :: ComposableTemplate
        sampleBody =
          ComposableTemplate
            { ctIndexPatterns = [IndexPattern "tweet-*"],
              ctTemplate = Nothing,
              ctPriority = Just 50,
              ctVersion = Nothing,
              ctComposedOf = Nothing,
              ctAllowAutoCreate = Nothing
            }

    it "decodes a single {name, index_template} entry" $ do
      let payload =
            Aeson.object
              [ "name" Aeson..= ("tweet-tpl" :: T.Text),
                "index_template" Aeson..= sampleBody
              ]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe IndexTemplateInfo
      decoded
        `shouldBe` Just
          ( IndexTemplateInfo
              { itiName = TemplateName "tweet-tpl",
                itiIndexTemplate = sampleBody
              }
          )

    it "roundtrips an IndexTemplateInfo" $ do
      let info =
            IndexTemplateInfo
              { itiName = TemplateName "logs-tpl",
                itiIndexTemplate = sampleBody
              }
          encoded = Aeson.encode info
          decoded = Aeson.decode' encoded :: Maybe IndexTemplateInfo
      decoded `shouldBe` Just info

    it "decodes the full {\"index_templates\": [...]} envelope" $ do
      let entries =
            [ IndexTemplateInfo
                { itiName = TemplateName "a",
                  itiIndexTemplate = sampleBody
                },
              IndexTemplateInfo
                { itiName = TemplateName "b",
                  itiIndexTemplate = sampleBody
                }
            ]
          envelope = GetIndexTemplatesResponse entries
          encoded = Aeson.encode envelope
          decoded = Aeson.decode' encoded :: Maybe GetIndexTemplatesResponse
      decoded `shouldBe` Just envelope
      getIndexTemplatesResponseTemplates <$> decoded `shouldBe` Just entries

    it "decodes an empty envelope to an empty list" $ do
      let encoded = Aeson.encode (Aeson.object ["index_templates" Aeson..= ([] :: [Aeson.Value])])
          decoded = Aeson.decode' encoded :: Maybe GetIndexTemplatesResponse
      decoded `shouldBe` Just (GetIndexTemplatesResponse [])

  -- ------------------------------------------------------------------ --
  -- SimulatedTemplate JSON: pure, no ES required. Locks in the          --
  -- @POST /_index_template/_simulate@ response shape:                   --
  -- {"template": {settings,mappings,aliases}, "overlapping": [{name,..}]} --
  -- and the bare-request-body invariant (no @index_template@ wrapper).  --
  -- ------------------------------------------------------------------ --
  describe "SimulatedTemplate JSON" $ do
    let -- A representative @POST /_index_template/_simulate@ response
        -- payload mirroring the wire format ES returns.
        sampleResponse :: Aeson.Value
        sampleResponse =
          Aeson.object
            [ "template"
                Aeson..= Aeson.object
                  [ "settings"
                      Aeson..= Aeson.object
                        ["number_of_shards" Aeson..= (1 :: Int)],
                    "mappings" Aeson..= Aeson.object ["properties" Aeson..= Aeson.object []]
                  ],
              "overlapping"
                Aeson..= [ Aeson.object
                             [ "name" Aeson..= ("logs" :: T.Text),
                               "index_patterns" Aeson..= ["logs-*" :: T.Text]
                             ]
                         ]
            ]

    it "decodes template settings + overlapping name and patterns" $ do
      let decoded = Aeson.decode' (Aeson.encode sampleResponse) :: Maybe SimulatedTemplate
      decoded `shouldSatisfy` isJust
      let Just st = decoded
      -- The resolved template body reuses ComposableTemplateContent;
      -- the server-emitted settings survive in the *Other blob.
      ctcSettings (stTemplate st) `shouldSatisfy` isJust
      length (stOverlapping st) `shouldBe` 1
      case stOverlapping st of
        [ov] -> do
          stoName ov `shouldBe` Just (TemplateName "logs")
          stoIndexPatterns ov `shouldBe` [IndexPattern "logs-*"]
        _ -> expectationFailure "expected a singleton overlapping list"

    it "tolerates a payload missing the overlapping key (defaults to [])" $ do
      let payload =
            Aeson.object
              [ "template"
                  Aeson..= Aeson.object
                    ["settings" Aeson..= Aeson.object ["number_of_shards" Aeson..= (1 :: Int)]]
              ]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe SimulatedTemplate
      stOverlapping <$> decoded `shouldBe` Just []

    it "tolerates a payload missing the template key (defaults to empty content)" $ do
      -- The server always emits @template@ today, but the decoder is
      -- lenient so a forward-incompatible response still decodes.
      let payload = Aeson.object ["overlapping" Aeson..= ([] :: [Aeson.Value])]
          Just decoded = Aeson.decode' (Aeson.encode payload) :: Maybe SimulatedTemplate
      stTemplate decoded
        `shouldBe` ComposableTemplateContent
          { ctcSettings = Nothing,
            ctcMappings = Nothing,
            ctcAliases = Nothing
          }

    it "SimulatedTemplateOverlap tolerates a missing name (defaults to Nothing)" $ do
      let payload =
            Aeson.object
              [ "index_patterns" Aeson..= ["metrics-*" :: T.Text]
              ]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe SimulatedTemplateOverlap
      stoName <$> decoded `shouldBe` Just Nothing
      stoIndexPatterns <$> decoded `shouldBe` Just [IndexPattern "metrics-*"]

    it "SimulatedTemplateOverlap tolerates a missing index_patterns (defaults to [])" $ do
      let payload = Aeson.object ["name" Aeson..= ("only-name" :: T.Text)]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe SimulatedTemplateOverlap
      stoName <$> decoded `shouldBe` Just (Just (TemplateName "only-name"))
      stoIndexPatterns <$> decoded `shouldBe` Just []

    it "round-trips a fully-populated SimulatedTemplate (typed fields + stOther preserved)" $ do
      let st =
            SimulatedTemplate
              { stTemplate =
                  ComposableTemplateContent
                    { ctcSettings = Just (Aeson.object ["number_of_shards" Aeson..= (1 :: Int)]),
                      ctcMappings = Nothing,
                      ctcAliases = Nothing
                    },
                stOverlapping =
                  [ SimulatedTemplateOverlap
                      { stoName = Just (TemplateName "logs"),
                        stoIndexPatterns = [IndexPattern "logs-*"],
                        stoOther =
                          Aeson.object
                            ["name" Aeson..= ("logs" :: T.Text), "index_patterns" Aeson..= ["logs-*" :: T.Text]]
                      }
                  ],
                stOther = Aeson.object ["template" Aeson..= Aeson.object []]
              }
          encoded = Aeson.encode st
          decoded = Aeson.decode' encoded :: Maybe SimulatedTemplate
      -- The typed projections survive round-trip unchanged; the *Other
      -- blob also survives because the typed keys take precedence on
      -- merge and the verbatim ones are kept.
      decoded `shouldSatisfy` isJust
      let Just again = decoded
      stOverlapping again `shouldBe` stOverlapping st
      ctcSettings (stTemplate again) `shouldBe` ctcSettings (stTemplate st)

    it "simulateIndexTemplate request body is the bare ComposableTemplate (no index_template wrapper)" $ do
      -- The wire body matches the @PUT /_index_template/{name}@ shape
      -- verbatim (validated end-to-end by the live tests below); this
      -- pure test pins the @index_template@-wrapper-must-not-appear
      -- invariant so a future change to 'simulateIndexTemplate' that
      -- reintroduces it fails here rather than at the cluster edge.
      let ct =
            ComposableTemplate
              { ctIndexPatterns = [IndexPattern "tweet-*"],
                ctTemplate = Nothing,
                ctPriority = Just 50,
                ctVersion = Nothing,
                ctComposedOf = Nothing,
                ctAllowAutoCreate = Nothing
              }
          wireObject =
            Aeson.decode' (Aeson.encode ct) >>= \case
              (Aeson.Object o) -> Just o
              _ -> Nothing
      wireObject `shouldSatisfy` maybe False (not . AKM.member "index_template")
      wireObject `shouldSatisfy` maybe False (AKM.member "index_patterns")

  -- ------------------------------------------------------------------ --
  -- simulateIndex endpoint shape (POST /_index_template/_simulate_index/{name}).  --
  -- Pure wire-shape pins; the SimulatedTemplate response decoder is     --
  -- covered by the "SimulatedTemplate JSON" section above (shared with  --
  -- the simulateIndexTemplate sibling).                                 --
  -- ------------------------------------------------------------------ --
  describe "simulateIndex endpoint shape" $ do
    let idxName = [qqIndexName|logs-2026-06-21|]

    it "POSTs to /_index_template/_simulate_index/{name}" $ do
      let req = simulateIndex idxName
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_index_template", "_simulate_index", "logs-2026-06-21"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $ do
      let req = simulateIndex idxName
      bhRequestMethod req `shouldBe` "POST"
    it "attaches an empty body (the index name carries all the input)" $ do
      let req = simulateIndex idxName
      -- emptyBody is the empty string; the server treats the absence of
      -- a body as "resolve against the already-registered templates".
      -- A {} body would be rejected (the server then requires an
      -- index_patterns field), so we pin the empty-string invariant.
      bhRequestBody req `shouldBe` Just ""
    it "is distinct from simulateIndexTemplate (different path, no body arg)" $ do
      -- Guard against a copy-paste regression where simulateIndex might
      -- accidentally hit the sibling _simulate endpoint (which takes a
      -- ComposableTemplate body) instead of _simulate_index/{name}.
      let ct =
            ComposableTemplate
              { ctIndexPatterns = [IndexPattern "logs-*"],
                ctTemplate = Nothing,
                ctPriority = Nothing,
                ctVersion = Nothing,
                ctComposedOf = Nothing,
                ctAllowAutoCreate = Nothing
              }
          indexReq = simulateIndex idxName
          templateReq = simulateIndexTemplate ct
      getRawEndpoint (bhRequestEndpoint indexReq)
        `shouldNotBe` getRawEndpoint (bhRequestEndpoint templateReq)
    it "simulateIndexWith renders master_timeout as a query parameter" $ do
      let opts = defaultComposableTemplateOptions {ctoMasterTimeout = Just (TimeUnitSeconds, 30)}
          req = simulateIndexWith idxName opts
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_index_template", "_simulate_index", "logs-2026-06-21"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("master_timeout", Just "30s")]
    it "simulateIndexWith defaultComposableTemplateOptions emits no query" $ do
      let req = simulateIndexWith idxName defaultComposableTemplateOptions
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

  -- ------------------------------------------------------------------ --
  -- Component templates (PUT /_component_template/{name}).              --
  -- ------------------------------------------------------------------ --
  describe "component template API" $ do
    let content =
          ComposableTemplateContent
            { ctcSettings =
                Just
                  ( Aeson.object
                      [ "number_of_shards" Aeson..= (1 :: Int),
                        "number_of_replicas" Aeson..= (1 :: Int)
                      ]
                  ),
              ctcMappings = Just (toJSON TweetMapping),
              ctcAliases = Nothing
            }
        tpl =
          ComponentTemplate
            { cpTemplate = Just content,
              cpVersion = Just 1,
              cpMeta = Nothing,
              cpDeprecated = Nothing
            }
        name = TemplateName "bh-component-tpl"

    it "putComponentTemplate returns Acknowledged True" $
      withTestEnv $ do
        resp <- performBHRequest $ putComponentTemplate name tpl
        liftIO $ resp `shouldBe` Acknowledged True

    it "putComponentTemplate is idempotent (re-PUT succeeds)" $
      withTestEnv $ do
        resp <- performBHRequest $ putComponentTemplate name tpl
        liftIO $ resp `shouldBe` Acknowledged True

    it "deleteComponentTemplate returns Acknowledged True for an existing template" $
      withTestEnv $ do
        _ <- performBHRequest $ putComponentTemplate name tpl
        resp <- performBHRequest $ deleteComponentTemplate name
        liftIO $ resp `shouldBe` Acknowledged True

    it "deleteComponentTemplate on a missing template yields EsError" $ do
      result <-
        withTestEnv $
          tryEsError (performBHRequest $ deleteComponentTemplate (TemplateName "bh-component-tpl-missing-04f-2-17-8"))
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "expected EsError for missing component template, got success"

    it "getComponentTemplate (Just name) returns the template we PUT" $
      withTestEnv $ do
        _ <- performBHRequest $ putComponentTemplate name tpl
        infos <- performBHRequest $ getComponentTemplate (Just (TemplateNamePattern "bh-component-tpl"))
        liftIO $ map ctiName infos `shouldBe` [name]
        liftIO $ case infos of
          [info] -> do
            -- The server may normalise the inner @template@ body
            -- (re-serialise settings, drop empty objects), so we only
            -- assert on the typed metadata that round-trips verbatim:
            -- @version@.
            cpVersion (ctiComponentTemplate info) `shouldBe` cpVersion tpl
          _ -> expectationFailure ("expected a singleton list but got " <> show infos)

    it "getComponentTemplate Nothing lists templates including ours" $
      withTestEnv $ do
        _ <- performBHRequest $ putComponentTemplate name tpl
        infos <- performBHRequest $ getComponentTemplate Nothing
        liftIO $ name `elem` map ctiName infos `shouldBe` True

    it "getComponentTemplate accepts a wildcard pattern" $
      withTestEnv $ do
        _ <- performBHRequest $ putComponentTemplate name tpl
        infos <- performBHRequest $ getComponentTemplate (Just (TemplateNamePattern "bh-component-*"))
        -- Membership rather than singleton equality: a previous,
        -- independently-created template with the same prefix would
        -- make a strict @[name]@ assertion flaky on a shared cluster.
        liftIO $ name `elem` map ctiName infos `shouldBe` True

    it "getComponentTemplate surfaces a non-matching pattern as an EsError (404)" $
      withTestEnv $ do
        result <-
          tryPerformBHRequest $
            getComponentTemplate (Just (TemplateNamePattern "definitely-not-a-real-component-template-xyz"))
        liftIO $ case result of
          Left _ -> return ()
          Right infos ->
            expectationFailure ("expected a 404 EsError but got " <> show infos)

  -- ------------------------------------------------------------------ --
  -- ComponentTemplate JSON: pure, no ES required.                       --
  -- Locks in conditional emission of optional fields (absent when       --
  -- Nothing) and the @_meta@ underscore key.                            --
  -- ------------------------------------------------------------------ --
  describe "ComponentTemplate JSON" $ do
    let decodeAsObject :: Aeson.Value -> Maybe Aeson.Object
        decodeAsObject (Aeson.Object o) = Just o
        decodeAsObject _ = Nothing
        minimalCp =
          ComponentTemplate
            { cpTemplate = Nothing,
              cpVersion = Nothing,
              cpMeta = Nothing,
              cpDeprecated = Nothing
            }

    it "emits no fields when everything is Nothing" $ do
      let encoded = Aeson.encode minimalCp
          wireObject = Aeson.decode' encoded >>= decodeAsObject
      wireObject `shouldSatisfy` isJust
      wireObject `shouldSatisfy` maybe True AKM.null
      wireObject `shouldSatisfy` maybe False (not . AKM.member "template")
      wireObject `shouldSatisfy` maybe False (not . AKM.member "_meta")

    it "encodes cpMeta under the _meta (underscore) key" $ do
      let cp =
            minimalCp
              { cpMeta = Just (AKM.singleton "owner" (Aeson.String "dev"))
              }
          wireObject = Aeson.decode' (Aeson.encode cp) >>= decodeAsObject
      wireObject `shouldSatisfy` maybe False (AKM.member "_meta")
      wireObject `shouldSatisfy` maybe False (not . AKM.member "meta")

    it "roundtrips a fully-populated component template" $ do
      let fullContent =
            ComposableTemplateContent
              { ctcSettings = Just (Aeson.object ["index.refresh_interval" Aeson..= ("1s" :: T.Text)]),
                ctcMappings = Just (toJSON TweetMapping),
                ctcAliases = Just (AKM.singleton "live" (Aeson.object []))
              }
          cp =
            ComponentTemplate
              { cpTemplate = Just fullContent,
                cpVersion = Just 3,
                cpMeta = Just (AKM.singleton "owner" (Aeson.String "dev")),
                cpDeprecated = Just True
              }
          encoded = Aeson.encode cp
          decoded = Aeson.decode' encoded :: Maybe ComponentTemplate
      decoded `shouldBe` Just cp

    it "roundtrips the minimal component template (optional fields absent on the wire)" $ do
      let encoded = Aeson.encode minimalCp
          decoded = Aeson.decode' encoded :: Maybe ComponentTemplate
      decoded `shouldBe` Just minimalCp

  -- ------------------------------------------------------------------ --
  -- TemplateInfo / GetTemplatesResponse JSON: pure, no ES required.      --
  -- Locks in the legacy @GET /_template@ envelope shape:                 --
  -- {name1: body1, name2: body2, ...} with the outer key lifted into    --
  -- each entry's tiName.                                                 --
  -- ------------------------------------------------------------------ --
  describe "TemplateInfo JSON" $ do
    let sampleBody :: Aeson.Value
        sampleBody =
          Aeson.object
            [ "order" Aeson..= (0 :: Int),
              "index_patterns" Aeson..= ["tweet-*" :: T.Text],
              "mappings" Aeson..= toJSON TweetMapping
            ]
        -- Build a TemplateInfo by decoding the body and stamping on the
        -- name (mirrors what GetTemplatesResponse's decoder does).
        withBody :: TemplateName -> Aeson.Value -> TemplateInfo
        withBody name body =
          ( case Aeson.decode' (Aeson.encode body) :: Maybe TemplateInfo of
              Just info -> info
              Nothing -> error "TemplateInfo JSON fixture did not decode"
          )
            { tiName = name
            }

    it "decodes the legacy {name: body} envelope and lifts the key into tiName" $ do
      let payload = Aeson.object ["tweet-tpl" Aeson..= sampleBody]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe GetTemplatesResponse
      decoded `shouldSatisfy` isJust
      let infos = getTemplatesResponseTemplates <$> decoded
      infos `shouldBe` Just [TemplateName "tweet-tpl" `withBody` sampleBody]

    it "decodes a multi-key envelope to a list in document order" $ do
      let payload =
            Aeson.object
              [ "a-tpl" Aeson..= sampleBody,
                "b-tpl" Aeson..= sampleBody
              ]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe GetTemplatesResponse
      -- We do not assert the *order* of the decoded list (KeyMap
      -- iteration order is unspecified); membership of both names is
      -- sufficient.
      let names = (map tiName . getTemplatesResponseTemplates) <$> decoded
          -- TemplateName has no Ord instance; sort by the underlying
          -- text instead.
          toTexts = map (\(TemplateName n) -> n)
      fmap (L.sort . toTexts) names
        `shouldBe` Just ["a-tpl", "b-tpl"]

    it "decodes an empty object envelope to an empty list" $ do
      let encoded = Aeson.encode (Aeson.object [])
          decoded = Aeson.decode' encoded :: Maybe GetTemplatesResponse
      decoded `shouldBe` Just (GetTemplatesResponse [])

    it "treats absent optional body fields as Nothing/[]" $ do
      -- Only the name comes from the key; the body is @{}@.
      let payload = Aeson.object ["bare-tpl" Aeson..= Aeson.object []]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe GetTemplatesResponse
          expected =
            TemplateInfo
              { tiName = TemplateName "bare-tpl",
                tiOrder = Nothing,
                tiIndexPatterns = [],
                tiSettings = Nothing,
                tiMappings = Nothing,
                tiAliases = Nothing,
                tiVersion = Nothing
              }
      decoded `shouldBe` Just (GetTemplatesResponse [expected])

    it "roundtrips a TemplateInfo via the standalone-body shape" $ do
      let info =
            TemplateInfo
              { tiName = TemplateName "roundtrip-tpl",
                tiOrder = Just 5,
                tiIndexPatterns = [IndexPattern "logs-*"],
                tiSettings = Nothing,
                tiMappings = Just (toJSON TweetMapping),
                tiAliases = Just (AKM.singleton "live" (Aeson.object [])),
                tiVersion = Just 2
              }
          -- TemplateInfo's ToJSON emits the *body* (no name field);
          -- decoding back as a standalone TemplateInfo leaves tiName
          -- empty, so we re-inject it.
          body = Aeson.encode info
          decodedBody = Aeson.decode' body :: Maybe TemplateInfo
      decodedBody `shouldBe` Just info {tiName = TemplateName ""}

    it "GetTemplatesResponse roundtrips (key lifted in, then back out)" $ do
      let info =
            TemplateInfo
              { tiName = TemplateName "rt-tpl",
                tiOrder = Just 1,
                tiIndexPatterns = [IndexPattern "tweet-*"],
                tiSettings = Nothing,
                tiMappings = Nothing,
                tiAliases = Nothing,
                tiVersion = Nothing
              }
          envelope = GetTemplatesResponse [info]
          encoded = Aeson.encode envelope
          decoded = Aeson.decode' encoded :: Maybe GetTemplatesResponse
      decoded `shouldBe` Just envelope

  -- ------------------------------------------------------------------ --
  -- ComponentTemplateInfo / GetComponentTemplatesResponse JSON:         --
  -- pure, no ES required. Locks in the GET /_component_template         --
  -- envelope shape:                                                     --
  -- {"component_templates": [{"name": ..., "component_template": {...}}]} --
  -- ------------------------------------------------------------------ --
  describe "ComponentTemplateInfo JSON" $ do
    let -- A representative component-template body matching the wire
        -- format ES returns from GET /_component_template.
        sampleBody :: ComponentTemplate
        sampleBody =
          ComponentTemplate
            { cpTemplate = Nothing,
              cpVersion = Just 7,
              cpMeta = Nothing,
              cpDeprecated = Nothing
            }

    it "decodes a single {name, component_template} entry" $ do
      let payload =
            Aeson.object
              [ "name" Aeson..= ("component-tpl" :: T.Text),
                "component_template" Aeson..= sampleBody
              ]
          decoded = Aeson.decode' (Aeson.encode payload) :: Maybe ComponentTemplateInfo
      decoded
        `shouldBe` Just
          ( ComponentTemplateInfo
              { ctiName = TemplateName "component-tpl",
                ctiComponentTemplate = sampleBody
              }
          )

    it "roundtrips a ComponentTemplateInfo" $ do
      let info =
            ComponentTemplateInfo
              { ctiName = TemplateName "logs-component",
                ctiComponentTemplate = sampleBody
              }
          encoded = Aeson.encode info
          decoded = Aeson.decode' encoded :: Maybe ComponentTemplateInfo
      decoded `shouldBe` Just info

    it "decodes the full {\"component_templates\": [...]} envelope" $ do
      let entries =
            [ ComponentTemplateInfo
                { ctiName = TemplateName "a",
                  ctiComponentTemplate = sampleBody
                },
              ComponentTemplateInfo
                { ctiName = TemplateName "b",
                  ctiComponentTemplate = sampleBody
                }
            ]
          envelope = GetComponentTemplatesResponse entries
          encoded = Aeson.encode envelope
          decoded = Aeson.decode' encoded :: Maybe GetComponentTemplatesResponse
      decoded `shouldBe` Just envelope
      getComponentTemplatesResponseTemplates <$> decoded `shouldBe` Just entries

    it "decodes an empty envelope to an empty list" $ do
      let encoded = Aeson.encode (Aeson.object ["component_templates" Aeson..= ([] :: [Aeson.Value])])
          decoded = Aeson.decode' encoded :: Maybe GetComponentTemplatesResponse
      decoded `shouldBe` Just (GetComponentTemplatesResponse [])
  where
    -- Build a TemplateInfo by decoding the body and stamping on the
    -- name (mirrors what GetTemplatesResponse's decoder does).
    withBody :: TemplateName -> Aeson.Value -> TemplateInfo
    withBody name body =
      ( case Aeson.decode' (Aeson.encode body) :: Maybe TemplateInfo of
          Just info -> info
          Nothing -> error "TemplateInfo JSON fixture did not decode"
      )
        { tiName = name
        }