packages feed

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"