packages feed

bloodhound-1.0.0.0: tests/Test/ResolveClusterSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.ResolveClusterSpec (spec) where

import Data.Aeson
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as M
import Data.Text (Text)
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import Database.Bloodhound.ElasticSearch9.Types qualified as ES9
import TestsUtils.Common
import TestsUtils.Import
import Prelude

------------------------------------------------------------------------------
-- Sample payloads (trimmed to the wire fields decoded by the types).
------------------------------------------------------------------------------

-- | Per-cluster entry with only the two required fields, as returned by
-- the bare @GET /_resolve/cluster@ form (no index matching).
sampleInfoMinimalBytes :: LBS.ByteString
sampleInfoMinimalBytes =
  "{\
  \  \"connected\": true,\
  \  \"skip_unavailable\": false\
  \}"

-- | Per-cluster entry with every optional field populated, as returned
-- when an index expression is supplied and the remote is recent enough
-- to report a version.
sampleInfoFullBytes :: LBS.ByteString
sampleInfoFullBytes =
  "{\
  \  \"connected\": true,\
  \  \"skip_unavailable\": false,\
  \  \"matching_indices\": true,\
  \  \"error\": \"authorization error\",\n\
  \  \"version\": {\
  \    \"build_flavor\": \"default\",\
  \    \"minimum_index_compatibility_version\": \"8.0.0\",\
  \    \"minimum_wire_compatibility_version\": \"8.0.0\",\
  \    \"number\": \"9.0.0\"\
  \  }\
  \}"

-- | Full @GET /_resolve/cluster@ response: a map keyed by cluster alias,
-- with the querying cluster under @"(local)"@ and one remote cluster.
sampleResponseBytes :: LBS.ByteString
sampleResponseBytes =
  "{\
  \  \"(local)\": {\
  \    \"connected\": true,\
  \    \"skip_unavailable\": false\
  \  },\
  \  \"remote_cluster\": {\
  \    \"connected\": true,\
  \    \"skip_unavailable\": true,\
  \    \"matching_indices\": false,\
  \    \"version\": {\
  \      \"build_flavor\": \"default\",\
  \      \"minimum_index_compatibility_version\": \"8.0.0\",\
  \      \"minimum_wire_compatibility_version\": \"8.0.0\",\
  \      \"number\": \"9.0.0\"\
  \    }\
  \  }\
  \}"

spec :: Spec
spec = do
  --------------------------------------------------------------------------
  -- JSON (de)serialisation
  --------------------------------------------------------------------------
  describe "ResolveClusterInfo JSON" $ do
    it "decodes the minimal two-field form" $ do
      let decoded = eitherDecode sampleInfoMinimalBytes :: Either String ES9.ResolveClusterInfo
      decoded `shouldSatisfy` isRight
      let Right info = decoded
      ES9.rciConnected info `shouldBe` True
      ES9.rciSkipUnavailable info `shouldBe` False
      ES9.rciMatchingIndices info `shouldBe` Nothing
      ES9.rciVersion info `shouldBe` Nothing

    it "decodes the full form with version and matching_indices" $ do
      let decoded = eitherDecode sampleInfoFullBytes :: Either String ES9.ResolveClusterInfo
      decoded `shouldSatisfy` isRight
      let Right info = decoded
      ES9.rciMatchingIndices info `shouldBe` Just True
      ES9.rciError info `shouldBe` Just "authorization error"
      ES9.rcvNumber <$> ES9.rciVersion info `shouldBe` Just "9.0.0"

    it "round-trips through encode/decode" $ do
      let decoded = eitherDecode sampleInfoFullBytes :: Either String ES9.ResolveClusterInfo
      decoded `shouldSatisfy` isRight
      let Right info = decoded
      eitherDecode (encode info) `shouldBe` Right info

  describe "ResolveClusterResponse JSON" $ do
    it "decodes a map keyed by cluster alias" $ do
      let decoded = eitherDecode sampleResponseBytes :: Either String ES9.ResolveClusterResponse
      decoded `shouldSatisfy` isRight
      let Right resp = decoded
      let entries = ES9.resolveClusterResponseEntries resp
      M.size entries `shouldBe` 2
      M.member "(local)" entries `shouldBe` True
      M.member "remote_cluster" entries `shouldBe` True

  --------------------------------------------------------------------------
  -- Endpoint shape
  --------------------------------------------------------------------------
  describe "resolveCluster endpoint shape" $ do
    it "GETs /_resolve/cluster when given Nothing" $ do
      let req = RequestsES9.resolveCluster Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_resolve", "cluster"]

    it "GETs /_resolve/cluster/{name} when given patterns" $ do
      let req = RequestsES9.resolveCluster (Just ["logs-*"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_resolve", "cluster", "logs-*"]

    it "joins multiple patterns with a comma" $ do
      let req = RequestsES9.resolveCluster (Just ["a", "b"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_resolve", "cluster", "a,b"]

    it "uses the GET method" $ do
      let req = RequestsES9.resolveCluster Nothing
      bhRequestMethod req `shouldBe` "GET"

    it "carries no request body" $ do
      let req = RequestsES9.resolveCluster Nothing
      bhRequestBody req `shouldBe` Nothing

    it "carries no query string by default" $ do
      let req = RequestsES9.resolveCluster Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "renders expand_wildcards via resolveClusterWith" $ do
      let opts =
            ES9.defaultResolveClusterOptions
              { ES9.rcoExpandWildcards =
                  Just (ExpandWildcardsOpen :| [ExpandWildcardsHidden])
              }
          req = RequestsES9.resolveClusterWith opts (Just ["logs-*"])
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("expand_wildcards", Just "open,hidden")]

    it "renders ignore_throttled via resolveClusterWith" $ do
      let opts =
            ES9.defaultResolveClusterOptions
              { ES9.rcoIgnoreThrottled = Just True
              }
          req = RequestsES9.resolveClusterWith opts (Just ["logs-*"])
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("ignore_throttled", Just "true")]

    it "renders ignore_throttled=false when set to False" $ do
      let opts =
            ES9.defaultResolveClusterOptions
              { ES9.rcoIgnoreThrottled = Just False
              }
          req = RequestsES9.resolveClusterWith opts (Just ["logs-*"])
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("ignore_throttled", Just "false")]

    it "omits ignore_throttled when Nothing (default)" $ do
      let req = RequestsES9.resolveClusterWith ES9.defaultResolveClusterOptions (Just ["logs-*"])
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

  --------------------------------------------------------------------------
  -- Live integration (ES9 only).
  --------------------------------------------------------------------------
  backendSpecific
    [ElasticSearch9]
    $ describe "Resolve cluster API (live integration)"
    $ do
      it "resolveCluster with an index pattern returns the local cluster" $
        withTestEnv $ do
          -- 'GET /_resolve/cluster' with no index expression returns {} on a
          -- single-node cluster with no remotes; the (local) entry only
          -- appears once an index expression is supplied.
          result <- tryEsError $ ClientES9.resolveCluster (Just ["bloodhound-tests-*"])
          liftIO $
            case result of
              Left e
                | endpointMissing e ->
                    pendingWith "resolve cluster requires Elasticsearch 9+"
              _ -> pure ()
          case result of
            Left e -> liftIO $ expectationFailure $ "resolveCluster failed: " <> show e
            Right resp ->
              liftIO $
                M.member "(local)" (ES9.resolveClusterResponseEntries resp)
                  `shouldBe` True

-- | Detect the @no handler found for uri@shape returned by Elasticsearch
-- when an endpoint isn't registered on this version. Used to mark the
-- live tests as 'pendingWith' rather than failing.
endpointMissing :: EsError -> Bool
endpointMissing e =
  "no handler found" `T.isInfixOf` T.toLower (errorMessage e)