packages feed

bloodhound-1.0.0.0: tests/Test/FeaturesSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.FeaturesSpec (spec) where

import Data.Aeson
import Data.ByteString.Lazy.Char8 qualified as LBS
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 System.Environment (lookupEnv)
import TestsUtils.Common
import TestsUtils.Import
import Prelude

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

-- | @GET /_features@ response matching the documented example.
sampleGetFeaturesBytes :: LBS.ByteString
sampleGetFeaturesBytes =
  "{\
  \  \"features\": [\
  \    {\"name\": \"tasks\", \"description\": \"Manages task results\"},\
  \    {\"name\": \"kibana\", \"description\": \"Manages Kibana configuration and reports\"}\
  \  ]\
  \}"

-- | @POST /_features/_reset@ response matching the documented example,
-- which uses @feature_name@/@status@ (not the @name@/@description@ the
-- schema declares).
sampleResetExampleBytes :: LBS.ByteString
sampleResetExampleBytes =
  "{\
  \  \"features\": [\
  \    {\"feature_name\": \"security\", \"status\": \"SUCCESS\"},\
  \    {\"feature_name\": \"tasks\", \"status\": \"SUCCESS\"}\
  \  ]\
  \}"

-- | The schema-declared @name@/@description@ shape, which the reset
-- endpoint may also return.
sampleResetSchemaBytes :: LBS.ByteString
sampleResetSchemaBytes =
  "{\
  \  \"features\": [\
  \    {\"name\": \"tasks\", \"description\": \"Manages task results\"}\
  \  ]\
  \}"

spec :: Spec
spec = do
  --------------------------------------------------------------------------
  -- JSON (de)serialisation
  --------------------------------------------------------------------------
  describe "FeaturesResponse JSON" $ do
    it "decodes the GET /_features example" $ do
      let decoded = eitherDecode sampleGetFeaturesBytes :: Either String ES9.FeaturesResponse
      decoded `shouldSatisfy` isRight
      let Right resp = decoded
      length (ES9.featuresResponseFeatures resp) `shouldBe` 2

    it "round-trips through encode/decode" $ do
      let decoded = eitherDecode sampleGetFeaturesBytes :: Either String ES9.FeaturesResponse
          Right resp = decoded
      eitherDecode (encode resp) `shouldBe` Right resp

  describe "ResetFeaturesResponse JSON" $ do
    it "decodes the documented feature_name/status example" $ do
      let decoded = eitherDecode sampleResetExampleBytes :: Either String ES9.ResetFeaturesResponse
      decoded `shouldSatisfy` isRight
      let Right resp = decoded
      length (ES9.resetFeaturesResponseFeatures resp) `shouldBe` 2

    it "decodes the schema-declared name/description shape" $ do
      let decoded = eitherDecode sampleResetSchemaBytes :: Either String ES9.ResetFeaturesResponse
      decoded `shouldSatisfy` isRight
      let Right resp = decoded
          [item] = ES9.resetFeaturesResponseFeatures resp
      ES9.rfiName item `shouldBe` Just "tasks"

  --------------------------------------------------------------------------
  -- Endpoint shape
  --------------------------------------------------------------------------
  describe "getFeatures endpoint shape" $ do
    let req = RequestsES9.getFeatures

    it "GETs /_features" $ do
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_features"]

    it "uses the GET method" $ do
      bhRequestMethod req `shouldBe` "GET"

    it "carries no request body" $ do
      bhRequestBody req `shouldBe` Nothing

    it "carries no query string by default" $ do
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "renders master_timeout via getFeaturesWith" $ do
      let opts = ES9.defaultFeaturesOptions {ES9.foMasterTimeout = Just (TimeUnitSeconds, 30)}
          reqWith = RequestsES9.getFeaturesWith opts
      getRawEndpointQueries (bhRequestEndpoint reqWith)
        `shouldBe` [("master_timeout", Just "30s")]

  describe "resetFeatures endpoint shape" $ do
    let req = RequestsES9.resetFeatures

    it "POSTs /_features/_reset" $ do
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_features", "_reset"]

    it "uses the POST method" $ do
      bhRequestMethod req `shouldBe` "POST"

    it "sends an empty body" $ do
      bhRequestBody req `shouldBe` Just ""

    it "carries no query string by default" $ do
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

  --------------------------------------------------------------------------
  -- Live integration (ES9 only; the mutating reset is gated behind
  -- ES_TEST_RUN_MUTATING so cluster state is not perturbed by default).
  --------------------------------------------------------------------------
  backendSpecific
    [ElasticSearch9]
    $ describe "Features API (live integration)"
    $ do
      it "getFeatures returns a parseable response" $
        withTestEnv $ do
          result <- tryEsError ClientES9.getFeatures
          liftIO $
            case result of
              Left e
                | endpointMissing e ->
                    pendingWith "features endpoints require Elasticsearch 9+"
              _ -> pure ()
          case result of
            Left e -> liftIO $ expectationFailure $ "getFeatures failed: " <> show e
            Right resp ->
              liftIO $
                ES9.featuresResponseFeatures resp `shouldSatisfy` (not . null)

      it "resetFeatures returns a parseable response when ES_TEST_RUN_MUTATING=true" $
        withTestEnv $ do
          enabled <- liftIO $ lookupEnv "ES_TEST_RUN_MUTATING"
          case enabled of
            Just "true" -> do
              result <- tryEsError ClientES9.resetFeatures
              liftIO $
                case result of
                  Left e
                    | endpointMissing e ->
                        pendingWith "features endpoints require Elasticsearch 9+"
                  _ -> pure ()
              case result of
                Left e -> liftIO $ expectationFailure $ "resetFeatures failed: " <> show e
                Right resp ->
                  liftIO $
                    ES9.resetFeaturesResponseFeatures resp `shouldSatisfy` (not . null)
            _ ->
              liftIO $
                pendingWith
                  "skipped: set ES_TEST_RUN_MUTATING=true to run this mutating test"

-- | 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)