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
}