bloodhound-1.0.0.0: tests/Test/SearchApplicationsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Test.SearchApplicationsSpec (spec) where
import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Maybe (isNothing)
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch8.Requests qualified as RequestsES8
import Database.Bloodhound.ElasticSearch8.Types qualified as Types
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import TestsUtils.Common
import TestsUtils.Import
-- | Soft-test-index pair used in the integration round-trip; matches
-- the seed index name used by the rest of the test-suite so documents
-- are already present.
testAppIndices :: NonEmpty IndexName
testAppIndices = testIndex :| []
-- | Minimal 'SearchApplication' targeting 'testAppIndices' with no
-- template — accepted on every cluster (no template / no analytics
-- dependency), so the integration round-trip can run without a
-- template-rendering backend.
minimalApp :: Types.SearchApplication
minimalApp =
Types.SearchApplication
{ Types.saIndices = testAppIndices,
Types.saAnalyticsCollectionName = Nothing,
Types.saTemplate = Nothing
}
spec :: Spec
spec =
describe
"Search Applications API (PUT/GET/DELETE/POST-search/list/render on /_application/search_application)"
$ do
describe "SearchApplicationName JSON" $ do
it "round-trips a SearchApplicationName" $ do
let n = Types.SearchApplicationName "my-app"
decode (encode n) `shouldBe` Just n
it "unSearchApplicationName extracts the underlying Text" $
Types.unSearchApplicationName (Types.SearchApplicationName "abc")
`shouldBe` ("abc" :: Text)
describe "SearchApplicationScriptBody wire shape" $ do
it "encodes InlineStringSource as {\"source\": \"...\"}" $ do
let b = Types.InlineStringSource "mustache template"
encode b `shouldBe` "{\"source\":\"mustache template\"}"
decode (encode b) `shouldBe` Just b
it "encodes InlineObjectSource as {\"source\": {...}}" $ do
let v = object ["query" .= object ["match_all" .= object []]]
b = Types.InlineObjectSource v
encode b `shouldBe` "{\"source\":{\"query\":{\"match_all\":{}}}}"
decode (encode b) `shouldBe` Just b
it "encodes StoredScriptId as {\"id\": \"...\"}" $ do
let b = Types.StoredScriptId "my-stored-script"
encode b `shouldBe` "{\"id\":\"my-stored-script\"}"
decode (encode b) `shouldBe` Just b
it "rejects an object missing both source and id" $
case decode "{}" :: Maybe Types.SearchApplicationScriptBody of
Just b ->
expectationFailure $ "expected parse failure, got: " <> show b
Nothing -> pure ()
it "rejects an object carrying both source and id" $
case decode "{\"source\":\"x\",\"id\":\"y\"}" :: Maybe Types.SearchApplicationScriptBody of
Just b ->
expectationFailure $ "expected parse failure, got: " <> show b
Nothing -> pure ()
it "rejects a non-string / non-object source" $
case decode "{\"source\":5}" :: Maybe Types.SearchApplicationScriptBody of
Just b ->
expectationFailure $ "expected parse failure, got: " <> show b
Nothing -> pure ()
describe "SearchApplicationTemplateScript wire shape" $ do
it "encodes an inline-string script with all optional fields omitted" $ do
let s =
Types.SearchApplicationTemplateScript
{ Types.satsScriptBody = Types.InlineStringSource "q",
Types.satsParams = Nothing,
Types.satsLang = Nothing,
Types.satsOptions = Nothing
}
encode s `shouldBe` "{\"source\":\"q\"}"
decode (encode s) `shouldBe` Just s
it "encodes a stored-id script with params/lang/options" $ do
let s =
Types.SearchApplicationTemplateScript
{ Types.satsScriptBody = Types.StoredScriptId "sid",
Types.satsParams = Just (KM.fromList [("k", "v")]),
Types.satsLang = Just (ScriptLanguage "mustache"),
Types.satsOptions = Just (KM.fromList [("opt", "val")])
}
-- Object key order is not pinned by aeson; assert round-trip
-- and that the encoded blob carries the expected keys.
decode (encode s) `shouldBe` Just s
let hasKey k bs = case decode bs :: Maybe Object of
Just o -> k `KM.member` o
Nothing -> False
hasKey "id" (encode s) `shouldBe` True
hasKey "lang" (encode s) `shouldBe` True
it "round-trips an inline-object source" $ do
let v = object ["query" .= object ["match_all" .= object []]]
s =
Types.SearchApplicationTemplateScript
{ Types.satsScriptBody = Types.InlineObjectSource v,
Types.satsParams = Nothing,
Types.satsLang = Nothing,
Types.satsOptions = Nothing
}
decode (encode s) `shouldBe` Just s
describe "SearchApplicationTemplate wire shape" $ do
it "encodes a bare template (script only, no dictionary)" $ do
let t =
Types.SearchApplicationTemplate $
Types.SearchApplicationTemplateScript
(Types.InlineStringSource "q")
Nothing
Nothing
Nothing
encode t `shouldBe` "{\"script\":{\"source\":\"q\"}}"
decode (encode t) `shouldBe` Just t
it "never emits the removed dictionary key" $ do
let t =
Types.SearchApplicationTemplate $
Types.SearchApplicationTemplateScript
(Types.StoredScriptId "sid")
Nothing
Nothing
Nothing
json = encode t
hasKey k bs = case decode bs :: Maybe Object of
Just o -> k `KM.member` o
Nothing -> False
hasKey "dictionary" json `shouldBe` False
it "rejects a template object missing the required script key" $
decode "{}" `shouldSatisfy` (isNothing :: Maybe Types.SearchApplicationTemplate -> Bool)
it "rejects malformed input (non-object)" $
decode (LBS.pack "42") `shouldSatisfy` (isNothing :: Maybe Types.SearchApplicationTemplate -> Bool)
describe "SearchApplication wire shape" $ do
it "encodes minimal app (indices only)" $ do
let app =
Types.SearchApplication
{ Types.saIndices = [qqIndexName|logs|] :| [],
Types.saAnalyticsCollectionName = Nothing,
Types.saTemplate = Nothing
}
encode app `shouldBe` "{\"indices\":[\"logs\"]}"
decode (encode app) `shouldBe` Just app
it "encodes app with analytics collection + template" $ do
let app =
Types.SearchApplication
{ Types.saIndices = [qqIndexName|logs|] :| [[qqIndexName|logs-2|]],
Types.saAnalyticsCollectionName = Just "my-analytics",
Types.saTemplate =
Just $
Types.SearchApplicationTemplate $
Types.SearchApplicationTemplateScript
(Types.InlineStringSource "q")
Nothing
Nothing
Nothing
}
decode (encode app) `shouldBe` Just app
it "rejects an app with empty indices" $
case decode "{\"indices\":[]}" :: Maybe Types.SearchApplication of
Just a ->
expectationFailure $ "expected parse failure, got: " <> show a
Nothing -> pure ()
describe "SearchApplicationInfo wire shape" $ do
it "decodes a full GET response" $ do
let raw =
"{\"name\":\"my-app\",\"indices\":[\"logs\"],\
\\"analytics_collection_name\":\"coll\",\
\\"updated_at_millis\":1682105622204}"
case decode raw :: Maybe Types.SearchApplicationInfo of
Just info -> do
Types.saiName info `shouldBe` Types.SearchApplicationName "my-app"
Types.saiIndices info `shouldBe` [[qqIndexName|logs|]]
Types.saiAnalyticsCollectionName info `shouldBe` Just "coll"
Types.saiUpdatedAtMillis info `shouldBe` Just 1682105622204
Types.saiTemplate info `shouldBe` Nothing
Nothing ->
expectationFailure "failed to decode SearchApplicationInfo"
it "round-trips a minimal info" $ do
let info =
Types.SearchApplicationInfo
{ Types.saiName = "my-app",
Types.saiIndices = [[qqIndexName|logs|]],
Types.saiAnalyticsCollectionName = Nothing,
Types.saiTemplate = Nothing,
Types.saiUpdatedAtMillis = Nothing
}
decode (encode info) `shouldBe` Just info
describe "SearchApplicationPutResponse JSON" $ do
it "decodes {\"result\":\"created\"}" $ do
case decode "{\"result\":\"created\"}" :: Maybe Types.SearchApplicationPutResponse of
Just r -> Types.saprResult r `shouldBe` "created"
Nothing -> expectationFailure "failed to decode"
it "decodes {\"result\":\"updated\"}" $ do
case decode "{\"result\":\"updated\"}" :: Maybe Types.SearchApplicationPutResponse of
Just r -> Types.saprResult r `shouldBe` "updated"
Nothing -> expectationFailure "failed to decode"
it "round-trips" $ do
let r = Types.SearchApplicationPutResponse "created"
decode (encode r) `shouldBe` Just r
describe "SearchApplicationSearchParams JSON" $ do
it "omits params when not set" $ do
let p = Types.SearchApplicationSearchParams Nothing
encode p `shouldBe` "{}"
decode (encode p) `shouldBe` Just p
it "round-trips with params" $ do
let p =
Types.SearchApplicationSearchParams $
Just $
KM.fromList
[("q", "bitemyapp"), ("filter", "verified")]
decode (encode p) `shouldBe` Just p
describe "endpoint shape" $ do
it "PUTs to /_application/search_application/<name> with a body (ES8)" $ do
let req = RequestsES8.putSearchApplication "my-app" minimalApp
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app"]
bhRequestBody req `shouldSatisfy` isJust
it "putSearchApplication emits no query params by default (ES8)" $ do
let req = RequestsES8.putSearchApplication "my-app" minimalApp
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "putSearchApplicationWith default options emits no query params (ES8)" $ do
let req = RequestsES8.putSearchApplicationWith "my-app" Types.defaultSearchApplicationPutOptions minimalApp
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "putSearchApplicationWith default opts is byte-for-byte identical to putSearchApplication (ES8)" $ do
let legacy = RequestsES8.putSearchApplication "my-app" minimalApp
withDef = RequestsES8.putSearchApplicationWith "my-app" Types.defaultSearchApplicationPutOptions minimalApp
getRawEndpoint (bhRequestEndpoint legacy) `shouldBe` getRawEndpoint (bhRequestEndpoint withDef)
getRawEndpointQueries (bhRequestEndpoint legacy) `shouldBe` getRawEndpointQueries (bhRequestEndpoint withDef)
bhRequestBody legacy `shouldBe` bhRequestBody withDef
it "putSearchApplicationWith emits ?create=true when sapoCreate is Just True (ES8)" $ do
let opts = Types.defaultSearchApplicationPutOptions {Types.sapoCreate = Just True}
req = RequestsES8.putSearchApplicationWith "my-app" opts minimalApp
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("create", Just "true")]
it "putSearchApplicationWith emits ?create=false when sapoCreate is Just False (ES8)" $ do
let opts = Types.defaultSearchApplicationPutOptions {Types.sapoCreate = Just False}
req = RequestsES8.putSearchApplicationWith "my-app" opts minimalApp
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("create", Just "false")]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_application/search_application/<name> with no body (ES8)" $ do
let req = RequestsES8.getSearchApplication "my-app"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app"]
bhRequestBody req `shouldSatisfy` isNothing
it "DELETEs /_application/search_application/<name> with no body (ES8)" $ do
let req = RequestsES8.deleteSearchApplication "my-app"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app"]
bhRequestBody req `shouldSatisfy` isNothing
it "POSTs to /_application/search_application/<name>/_search with no body when params are omitted (ES8)" $ do
let req = RequestsES8.searchApplication @() "my-app" Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app", "_search"]
it "POSTs to /_application/search_application/<name>/_search with a body when params are supplied (ES8)" $ do
let params = Types.SearchApplicationSearchParams (Just KM.empty)
req = RequestsES8.searchApplication @() "my-app" (Just params)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app", "_search"]
bhRequestBody req `shouldSatisfy` isJust
it "ES9 request builders hit the same paths as ES8" $ do
let putReq = RequestsES9.putSearchApplication "r" minimalApp
getReq = RequestsES9.getSearchApplication "r"
delReq = RequestsES9.deleteSearchApplication "r"
searchReq = RequestsES9.searchApplication @() "r" Nothing
getRawEndpoint (bhRequestEndpoint putReq)
`shouldBe` ["_application", "search_application", "r"]
getRawEndpoint (bhRequestEndpoint getReq)
`shouldBe` ["_application", "search_application", "r"]
getRawEndpoint (bhRequestEndpoint delReq)
`shouldBe` ["_application", "search_application", "r"]
getRawEndpoint (bhRequestEndpoint searchReq)
`shouldBe` ["_application", "search_application", "r", "_search"]
bhRequestBody putReq `shouldSatisfy` isJust
bhRequestBody getReq `shouldSatisfy` isNothing
bhRequestBody delReq `shouldSatisfy` isNothing
it "ES9 putSearchApplicationWith default opts emits no query params" $ do
let req = RequestsES9.putSearchApplicationWith "r" Types.defaultSearchApplicationPutOptions minimalApp
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "ES9 putSearchApplicationWith emits ?create=true matching ES8" $ do
let opts = Types.defaultSearchApplicationPutOptions {Types.sapoCreate = Just True}
es9Req = RequestsES9.putSearchApplicationWith "r" opts minimalApp
es8Req = RequestsES8.putSearchApplicationWith "r" opts minimalApp
getRawEndpoint (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpoint (bhRequestEndpoint es8Req)
getRawEndpointQueries (bhRequestEndpoint es9Req)
`shouldBe` [("create", Just "true")]
it "searchApplicationOptionsParams default emits no params" $
Types.searchApplicationOptionsParams Types.defaultSearchApplicationOptions
`shouldBe` []
it "searchApplicationOptionsParams renders typed_keys=true" $
Types.searchApplicationOptionsParams
Types.defaultSearchApplicationOptions {Types.saoTypedKeys = Just True}
`shouldBe` [("typed_keys", Just "true")]
it "searchApplicationOptionsParams renders typed_keys=false" $
Types.searchApplicationOptionsParams
Types.defaultSearchApplicationOptions {Types.saoTypedKeys = Just False}
`shouldBe` [("typed_keys", Just "false")]
it "searchApplicationWith default opts is byte-identical to searchApplication (ES8)" $ do
let plain = RequestsES8.searchApplication @() "my-app" Nothing
withOpts = RequestsES8.searchApplicationWith @() "my-app" Nothing Types.defaultSearchApplicationOptions
getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
getRawEndpointQueries (bhRequestEndpoint withOpts) `shouldBe` []
it "searchApplicationWith attaches ?typed_keys=true (ES8)" $ do
let opts = Types.defaultSearchApplicationOptions {Types.saoTypedKeys = Just True}
req = RequestsES8.searchApplicationWith @() "my-app" Nothing opts
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app", "_search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("typed_keys", Just "true")]
it "ES9 searchApplicationWith reuses the ES8 builder" $ do
let opts = Types.defaultSearchApplicationOptions {Types.saoTypedKeys = Just True}
req = RequestsES9.searchApplicationWith @() "r" Nothing opts
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "r", "_search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("typed_keys", Just "true")]
it "searchApplicationListOptionsParams default emits no params" $
Types.searchApplicationListOptionsParams Types.defaultSearchApplicationListOptions
`shouldBe` []
it "searchApplicationListOptionsParams renders q only" $
Types.searchApplicationListOptionsParams
Types.defaultSearchApplicationListOptions {Types.saloQ = Just "app*"}
`shouldBe` [("q", Just "app*")]
it "searchApplicationListOptionsParams renders from + size" $
Types.searchApplicationListOptionsParams
Types.defaultSearchApplicationListOptions {Types.saloFrom = Just 0, Types.saloSize = Just 3}
`shouldBe` [("from", Just "0"), ("size", Just "3")]
it "searchApplicationListOptionsParams renders q + from + size" $
Types.searchApplicationListOptionsParams
Types.defaultSearchApplicationListOptions {Types.saloQ = Just "app*", Types.saloFrom = Just 0, Types.saloSize = Just 3}
`shouldBe` [("q", Just "app*"), ("from", Just "0"), ("size", Just "3")]
it "listSearchApplications GETs /_application/search_application with no body (ES8)" $ do
let req = RequestsES8.listSearchApplications
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldSatisfy` isNothing
it "listSearchApplications default opts is byte-identical to listSearchApplications (ES8)" $ do
let plain = RequestsES8.listSearchApplications
withDef = RequestsES8.listSearchApplicationsWith Types.defaultSearchApplicationListOptions
getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withDef)
getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
getRawEndpointQueries (bhRequestEndpoint withDef) `shouldBe` []
it "listSearchApplicationsWith attaches ?from&size&q (ES8)" $ do
let opts = Types.defaultSearchApplicationListOptions {Types.saloQ = Just "app*", Types.saloFrom = Just 0, Types.saloSize = Just 3}
req = RequestsES8.listSearchApplicationsWith opts
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("q", Just "app*"), ("from", Just "0"), ("size", Just "3")]
bhRequestBody req `shouldSatisfy` isNothing
it "ES9 listSearchApplications hits the same path as ES8" $ do
let es8Req = RequestsES8.listSearchApplications
es9Req = RequestsES9.listSearchApplications
getRawEndpoint (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpoint (bhRequestEndpoint es8Req)
getRawEndpointQueries (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint es8Req)
it "ES9 listSearchApplicationsWith matches ES8 with the same opts" $ do
let opts = Types.defaultSearchApplicationListOptions {Types.saloSize = Just 5}
es8Req = RequestsES8.listSearchApplicationsWith opts
es9Req = RequestsES9.listSearchApplicationsWith opts
getRawEndpoint (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpoint (bhRequestEndpoint es8Req)
getRawEndpointQueries (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint es8Req)
it "renderSearchApplicationQuery POSTs to .../<name>/_render_query when params are omitted (ES8)" $ do
let req = RequestsES8.renderSearchApplicationQuery "my-app" Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app", "_render_query"]
it "renderSearchApplicationQuery POSTs with a body when params are supplied (ES8)" $ do
let params = Types.SearchApplicationSearchParams (Just KM.empty)
req = RequestsES8.renderSearchApplicationQuery "my-app" (Just params)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_application", "search_application", "my-app", "_render_query"]
bhRequestBody req `shouldSatisfy` isJust
it "renderSearchApplicationQuery emits no query params (ES8)" $ do
let req = RequestsES8.renderSearchApplicationQuery "my-app" Nothing
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "ES9 renderSearchApplicationQuery hits the same path as ES8" $ do
let es8Req = RequestsES8.renderSearchApplicationQuery "r" Nothing
es9Req = RequestsES9.renderSearchApplicationQuery "r" Nothing
getRawEndpoint (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpoint (bhRequestEndpoint es8Req)
getRawEndpointQueries (bhRequestEndpoint es9Req)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint es8Req)
describe "SearchApplicationListResponse wire shape" $ do
it "decodes the documented list example (name + updated_at_millis per item)" $ do
let raw =
"{\"count\":2,\"results\":[\
\{\"name\":\"app-1\",\"updated_at_millis\":1690981129366},\
\{\"name\":\"app-2\",\"updated_at_millis\":1691501823939}]}"
case decode raw :: Maybe Types.SearchApplicationListResponse of
Just resp -> do
Types.salrCount resp `shouldBe` 2
length (Types.salrResults resp) `shouldBe` 2
Types.saiName (Types.salrResults resp !! 0) `shouldBe` "app-1"
Types.saiUpdatedAtMillis (Types.salrResults resp !! 0) `shouldBe` Just 1690981129366
-- Absent optional fields default cleanly.
Types.saiIndices (Types.salrResults resp !! 0) `shouldBe` []
Types.saiTemplate (Types.salrResults resp !! 1) `shouldBe` Nothing
Nothing ->
expectationFailure "failed to decode SearchApplicationListResponse"
it "round-trips a response with full info items" $ do
let item =
Types.SearchApplicationInfo
{ Types.saiName = "app-1",
Types.saiIndices = [[qqIndexName|logs|]],
Types.saiAnalyticsCollectionName = Nothing,
Types.saiTemplate = Nothing,
Types.saiUpdatedAtMillis = Just 1690981129366
}
resp = Types.SearchApplicationListResponse 1 [item]
decode (encode resp) `shouldBe` Just resp
it "decodes an empty results array" $
case decode "{\"count\":0,\"results\":[]}" :: Maybe Types.SearchApplicationListResponse of
Just resp -> Types.salrCount resp `shouldBe` 0
Nothing -> expectationFailure "failed to decode empty list response"
it "tolerates a missing count (defaults to 0)" $
case decode "{\"results\":[]}" :: Maybe Types.SearchApplicationListResponse of
Just resp -> Types.salrCount resp `shouldBe` 0
Nothing -> expectationFailure "failed to decode list response without count"
describe "live integration (requires ES8+ backend)" $
backendSpecific [ElasticSearch8, ElasticSearch9] $ do
-- Search Applications are gated behind a platinum/enterprise
-- license in production Elasticsearch. The docker-compose
-- ES8/ES9 containers ship with the free basic license, so the
-- positive round-trip below will 'pendingWith' out on most
-- local clusters; the pure-JSON and endpoint-shape tests above
-- exercise the implementation in detail.
it "round-trips a search application via PUT then GET then DELETE" $
withTestEnv $ do
let appName = Types.SearchApplicationName "bloodhound-test-search-app"
_ <-
tryPerformBHRequest $
RequestsES8.deleteSearchApplication appName
backend <- liftIO detectBackendType
let putReq =
if backend == Just ElasticSearch9
then ClientES9.putSearchApplication appName minimalApp
else ClientES8.putSearchApplication appName minimalApp
putResult <- tryEsError putReq
case putResult of
Left e
| errorStatus e == Just 403
|| "license" `T.isInfixOf` T.toLower (errorMessage e) ->
liftIO $
pendingWith
"search applications require platinum/enterprise \
\license; skip on basic-license clusters"
Left e ->
liftIO $
expectationFailure $
"unexpected PUT error: " <> show e
Right putResp -> do
liftIO $
Types.saprResult putResp
`shouldSatisfy` (\r -> r == "created" || r == "updated")
info <-
if backend == Just ElasticSearch9
then ClientES9.getSearchApplication appName
else ClientES8.getSearchApplication appName
liftIO $ do
Types.saiName info `shouldBe` appName
-- 'testIndex' may render as the literal index name
-- or as a hidden/system variant; assert on length
-- rather than the exact name to stay robust.
length (Types.saiIndices info) `shouldSatisfy` (>= 1)
_ <-
if backend == Just ElasticSearch9
then ClientES9.deleteSearchApplication appName
else ClientES8.deleteSearchApplication appName
pure ()
it "DELETE of a non-existent application surfaces a 4xx EsError" $
withTestEnv $ do
result <-
tryPerformBHRequest $
RequestsES8.deleteSearchApplication
(Types.SearchApplicationName "bloodhound-definitely-missing-app")
case result of
Left e ->
case errorStatus e of
Just s -> liftIO $ (s >= 400 && s < 500) `shouldBe` True
Nothing ->
liftIO $
expectationFailure $
"expected a 4xx status, got none. Message: "
<> show (errorMessage e)
Right _ ->
liftIO $
expectationFailure
"expected DELETE of a missing application to fail, but it succeeded"
it "searchApplication POST targets /_search and returns a 4xx for a missing app" $
withTestEnv $ do
result <-
tryPerformBHRequest $
RequestsES8.searchApplication @Value
(Types.SearchApplicationName "bloodhound-definitely-missing-app")
Nothing
-- 'searchApplication' is 'StatusDependant' and returns a
-- 'ParsedEsResponse' (an inner 'Either EsError ...'), so a
-- server-side 4xx lands in the *inner* Left while
-- 'tryPerformBHRequest' supplies the *outer* Either.
liftIO $ case result of
Left e
| Just s <- errorStatus e -> (s >= 400 && s < 600) `shouldBe` True
| otherwise ->
expectationFailure $
"expected an error status, got none. Message: "
<> show (errorMessage e)
-- The search-application feature is gated behind a
-- platinum\/enterprise license; the docker-compose ES8\/ES9
-- containers ship with the free basic license and answer
-- with a 403 security_exception. Skip there, mirroring the
-- round-trip spec above.
Right (Left e)
| errorStatus e == Just 403
|| "license" `T.isInfixOf` T.toLower (errorMessage e) ->
pendingWith
"search applications require platinum/enterprise \
\license; skip on basic-license clusters"
| Just s <- errorStatus e -> (s >= 400 && s < 600) `shouldBe` True
| otherwise ->
expectationFailure $
"expected an error status, got none. Message: "
<> show (errorMessage e)
Right (Right _) ->
expectationFailure
"expected search against a missing application to fail, but it succeeded"