packages feed

bloodhound-1.0.0.0: tests/Test/TermsEnumSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns -Wno-x-partial #-}

module Test.TermsEnumSpec (spec) where

import Data.Aeson.Key qualified as AK
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as BL8
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch7.Client qualified as ClientES7
import Database.Bloodhound.ElasticSearch7.Requests qualified as Requests
import Database.Bloodhound.ElasticSearch7.Types qualified as ES7Types
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import TestsUtils.Common
import TestsUtils.Import

spec :: Spec
spec = do
  describe "TermsEnumRequest JSON" $ do
    it "encodes a field-only request as {\"field\":\"user\"}" $
      let req = ES7Types.defaultTermsEnumRequest (FieldName "user")
          Object o = toJSON req
       in KM.toList o `shouldBe` [(AK.fromString "field", String "user")]

    it "includes optional fields when provided and always carries field" $
      let req =
            (ES7Types.defaultTermsEnumRequest (FieldName "user"))
              { ES7Types.termsEnumRequestString = Just "bit",
                ES7Types.termsEnumRequestSize = Just 5,
                ES7Types.termsEnumRequestTimeout = Just "1s",
                ES7Types.termsEnumRequestCaseInsensitive = Just True,
                ES7Types.termsEnumRequestIndexFilter = Just (MatchAllQuery Nothing),
                ES7Types.termsEnumRequestSearchAfter = Just "last-term"
              }
          Object o = toJSON req
       in do
            KM.lookup (AK.fromString "field") o `shouldBe` Just (String "user")
            KM.lookup (AK.fromString "string") o `shouldBe` Just (String "bit")
            KM.lookup (AK.fromString "size") o `shouldBe` Just (toJSON (5 :: Int))
            KM.lookup (AK.fromString "timeout") o `shouldBe` Just (String "1s")
            KM.lookup (AK.fromString "case_insensitive") o `shouldBe` Just (Bool True)
            KM.member (AK.fromString "index_filter") o `shouldBe` True
            KM.lookup (AK.fromString "index_filter") o `shouldBe` Just (toJSON (MatchAllQuery Nothing))
            KM.lookup (AK.fromString "search_after") o `shouldBe` Just (String "last-term")
            -- Removed fields copied by mistake from _field_caps: the
            -- server silently ignores them. Their absence is the fix.
            KM.member (AK.fromString "searchable") o `shouldBe` False
            KM.member (AK.fromString "aggregatable") o `shouldBe` False

  describe "TermsEnumOptions params" $ do
    it "renders nothing for defaultTermsEnumOptions" $
      ES7Types.termsEnumOptionsParams ES7Types.defaultTermsEnumOptions `shouldBe` []

    it "renders allow_no_indices as a boolean string" $
      let opts = ES7Types.defaultTermsEnumOptions {ES7Types.teoAllowNoIndices = Just False}
       in lookup "allow_no_indices" (ES7Types.termsEnumOptionsParams opts) `shouldBe` Just (Just "false")

    it "renders ignore_unavailable as a boolean string" $
      let opts = ES7Types.defaultTermsEnumOptions {ES7Types.teoIgnoreUnavailable = Just True}
       in lookup "ignore_unavailable" (ES7Types.termsEnumOptionsParams opts) `shouldBe` Just (Just "true")

    it "renders expand_wildcards comma-joined" $
      let opts =
            ES7Types.defaultTermsEnumOptions
              { ES7Types.teoExpandWildcards = Just [ExpandWildcardsOpen, ExpandWildcardsClosed]
              }
       in lookup "expand_wildcards" (ES7Types.termsEnumOptionsParams opts)
            `shouldBe` Just (Just "open,closed")

  describe "TermsEnumResponse JSON" $ do
    it "decodes the canonical ES doc example" $
      let bytes =
            BL8.pack
              "{\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"terms\":[\"bitemyapp\",\"notmyapp\"],\"complete\":true}"
          Just resp = decode bytes :: Maybe ES7Types.TermsEnumResponse
       in do
            ES7Types.termsEnumResponseComplete resp `shouldBe` True
            ES7Types.termsEnumResponseTerms resp
              `shouldBe` [ES7Types.TermValue "bitemyapp", ES7Types.TermValue "notmyapp"]
            ES7Types.termsEnumResponseShards resp `shouldSatisfy` isJust

    it "defaults missing terms to []" $
      let bytes = BL8.pack "{\"complete\":true}"
          Just resp = decode bytes :: Maybe ES7Types.TermsEnumResponse
       in ES7Types.termsEnumResponseTerms resp `shouldBe` []

    it "defaults missing complete to False (conservative)" $
      let bytes = BL8.pack "{\"terms\":[\"foo\"]}"
          Just resp = decode bytes :: Maybe ES7Types.TermsEnumResponse
       in ES7Types.termsEnumResponseComplete resp `shouldBe` False

    it "treats missing _shards as Nothing" $
      let bytes = BL8.pack "{\"terms\":[\"foo\"],\"complete\":true}"
          Just resp = decode bytes :: Maybe ES7Types.TermsEnumResponse
       in ES7Types.termsEnumResponseShards resp `shouldBe` Nothing

    it "decodes an empty terms array" $
      let bytes = BL8.pack "{\"terms\":[],\"complete\":false}"
          Just resp = decode bytes :: Maybe ES7Types.TermsEnumResponse
       in ES7Types.termsEnumResponseTerms resp `shouldBe` []

  describe "Terms Enum endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "GETs /{index}/_terms_enum with a body" $ do
      let req = Requests.getTermsEnum (Just [[qqIndexName|twitter|]]) (FieldName "user") Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["twitter", "_terms_enum"]
      bhRequestBody req `shouldSatisfy` isJust

    it "encodes the field (and optional string) into the request body" $ do
      let req = Requests.getTermsEnum (Just [[qqIndexName|twitter|]]) (FieldName "user") (Just "bit")
          Just body = bhRequestBody req
          Just (Object o) = decode body :: Maybe Value
       in KM.lookup (AK.fromString "field") o `shouldBe` Just (String "user")

    it "comma-joins a non-empty index list into the leading segment" $ do
      let req = Requests.getTermsEnum (Just [[qqIndexName|twitter-1|], [qqIndexName|twitter-2|]]) (FieldName "user") Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["twitter-1,twitter-2", "_terms_enum"]

    it "treats Just [] the same as Nothing (bare _terms_enum path)" $ do
      let reqNo = Requests.getTermsEnum Nothing (FieldName "user") Nothing
          reqEmpty = Requests.getTermsEnum (Just []) (FieldName "user") Nothing
      getRawEndpoint (bhRequestEndpoint reqNo)
        `shouldBe` getRawEndpoint (bhRequestEndpoint reqEmpty)

    it "honours TermsEnumOptions in the query string" $ do
      let req =
            Requests.getTermsEnumWith
              (Just [[qqIndexName|twitter|]])
              ( ES7Types.defaultTermsEnumOptions
                  { ES7Types.teoAllowNoIndices = Just True
                  }
              )
              (ES7Types.defaultTermsEnumRequest (FieldName "user"))
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("allow_no_indices", Just "true")]

  describe "Terms Enum endpoint" $
    backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
      -- _terms_enum only enumerates keyword / numeric / ip / boolean
      -- fields; the test mapping's "user" is an analyzed text field and
      -- returns no terms, so the live assertions target the "extra"
      -- keyword field populated by 'insertExtra'.
      it "returns the indexed term for a keyword field" $
        withTestEnv $ do
          _ <- insertData
          _ <- insertExtra
          resp <- getTermsEnumForBackend (Just [testIndex]) (FieldName "extra") Nothing
          liftIO $
            ES7Types.termsEnumResponseTerms resp
              `shouldSatisfy` elem (ES7Types.TermValue "blah blah")

      it "narrows results with the string prefix" $
        withTestEnv $ do
          _ <- insertData
          _ <- insertExtra
          resp <- getTermsEnumForBackend (Just [testIndex]) (FieldName "extra") (Just "blah")
          liftIO $ do
            let terms = ES7Types.termsEnumResponseTerms resp
            terms `shouldSatisfy` all (\(ES7Types.TermValue t) -> "blah" `T.isPrefixOf` t)
            terms `shouldSatisfy` elem (ES7Types.TermValue "blah blah")

      it "returns no terms for a non-matching prefix" $
        withTestEnv $ do
          _ <- insertData
          _ <- insertExtra
          resp <- getTermsEnumForBackend (Just [testIndex]) (FieldName "extra") (Just "zzzzz")
          liftIO $
            ES7Types.termsEnumResponseTerms resp `shouldBe` []

-- | Dispatch to the right getTermsEnum client based on the detected
-- backend. The wire format is identical across ES 7.10+, 8.x and 9.x;
-- the dispatch exists only because each version has its own
-- 'WithBackend' constraint.
getTermsEnumForBackend ::
  Maybe [IndexName] ->
  FieldName ->
  Maybe Text ->
  BH IO ES7Types.TermsEnumResponse
getTermsEnumForBackend mIndices field mString = do
  backend <- liftIO detectBackendType
  case backend of
    Just ElasticSearch7 -> ClientES7.getTermsEnum mIndices field mString
    Just ElasticSearch8 -> ClientES8.getTermsEnum mIndices field mString
    Just ElasticSearch9 -> ClientES9.getTermsEnum mIndices field mString
    _ -> ClientES8.getTermsEnum mIndices field mString