packages feed

bloodhound-1.0.0.0: tests/Test/MultiSearchSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.MultiSearchSpec where

import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LC
import Data.Either (rights)
import Data.List (sort)
import Database.Bloodhound.Common.Types
import TestsUtils.Common
import TestsUtils.Import

-- | Smart constructor that mirrors the pre-bloodhound-04f.7.3 2-field form
-- of 'MultiSearchItem'. Keeps the integration-test fixtures readable while
-- the record itself gains per-header fields.
mkItem :: Maybe IndexName -> Search -> MultiSearchItem
mkItem idx s =
  MultiSearchItem
    { multiSearchItemIndex = idx,
      multiSearchItemRouting = Nothing,
      multiSearchItemSearchType = Nothing,
      multiSearchItemPreference = Nothing,
      multiSearchItemAllowPartialSearchResults = Nothing,
      multiSearchItemSearch = s
    }

-- | Decode a ToJSON-encoded value back to a sorted list of key names so the
-- tests can assert on the header line as a set rather than relying on
-- aeson's key ordering.
headerKeys :: LC.ByteString -> [Text]
headerKeys bs = case decode bs :: Maybe Object of
  Just o -> sort (map K.toText (KM.keys o))
  Nothing -> error "headerKeys: decode failed"

spec :: Spec
spec = do
  describe "MultiSearchItem ToJSON header rendering" $ do
    let bareSearch = mkSearch (Just (MatchAllQuery Nothing)) Nothing
        logs = [qqIndexName|logs|]

    it "renders only index when no per-header fields are set" $ do
      let item = mkItem (Just logs) bareSearch
      encode item `shouldBe` "{\"index\":\"logs\"}"

    it "renders an empty object when no fields (including index) are set" $ do
      let item = mkItem Nothing bareSearch
      encode item `shouldBe` "{}"

    it "renders routing alongside index" $ do
      let item = (mkItem (Just logs) bareSearch) {multiSearchItemRouting = Just "r1"}
      headerKeys (encode item) `shouldBe` ["index", "routing"]

    it "renders search_type using the SearchType Text encoding" $ do
      let item = (mkItem (Just logs) bareSearch) {multiSearchItemSearchType = Just SearchTypeDfsQueryThenFetch}
      encode item `shouldBe` "{\"index\":\"logs\",\"search_type\":\"dfs_query_then_fetch\"}"

    it "renders preference and allow_partial_search_results" $ do
      let item =
            (mkItem (Just logs) bareSearch)
              { multiSearchItemPreference = Just "_local",
                multiSearchItemAllowPartialSearchResults = Just False
              }
      headerKeys (encode item)
        `shouldBe` ["allow_partial_search_results", "index", "preference"]

    it "renders every per-header field simultaneously" $ do
      let item =
            (mkItem (Just logs) bareSearch)
              { multiSearchItemRouting = Just "r1",
                multiSearchItemSearchType = Just SearchTypeDfsQueryThenFetch,
                multiSearchItemPreference = Just "_local",
                multiSearchItemAllowPartialSearchResults = Just True
              }
      -- aeson 2.x renders object keys in alphabetical order; the
      -- assertion pins the full wire shape so regressions in either
      -- the key set or the value encoding surface here.
      encode item
        `shouldBe` "{\"allow_partial_search_results\":true,\"index\":\"logs\",\"preference\":\"_local\",\"routing\":\"r1\",\"search_type\":\"dfs_query_then_fetch\"}"

  describe "SearchOptions max_concurrent_searches (msearch URI param)" $ do
    it "renders max_concurrent_searches as a decimal string" $ do
      let opts = defaultSearchOptions {soMaxConcurrentSearches = Just 8}
      searchOptionsParams opts SearchTypeQueryThenFetch
        `shouldBe` [("max_concurrent_searches", Just "8")]

  describe "MultiSearchResponse per-item error decoding" $ do
    -- No server round-trip: the decoder is exercised directly so the
    -- assertions are stable across ES/OS versions and don't depend on
    -- a live cluster. The wire shapes mirror what ES returns for a
    -- per-item 404 (missing index) and a normal success sub-search.
    it "decodes a per-item error as Left EsError" $ do
      let perItemError =
            LC.pack
              "{\"responses\":[{\"error\":\"index_not_found_exception\",\
              \\"status\":404}]}"
      let Just (resp :: MultiSearchResponse Tweet) = decode perItemError
      multiSearchResponses resp
        `shouldBe` [Left (EsError (Just 404) "index_not_found_exception")]

    it "decodes the modern ES error object form (error.reason) as Left EsError" $ do
      -- ES 7+ returns per-item errors as objects carrying
      -- {"type":..., "reason":...}. EsError's FromJSON p2 alternative
      -- extracts .reason as the message. See BHRequest.hs EsError parser.
      let perItemErrorObject =
            LC.pack
              "{\"responses\":[{\"error\":{\"type\":\"index_not_found_exception\",\
              \\"reason\":\"no such index [foo]\",\
              \\"index\":\"foo\"},\"status\":404}]}"
      let Just (resp :: MultiSearchResponse Tweet) = decode perItemErrorObject
      multiSearchResponses resp
        `shouldBe` [Left (EsError (Just 404) "no such index [foo]")]

    it "preserves order when mixing successes and errors" $ do
      let mixed =
            LC.pack
              "{\"responses\":[\
              \{\"took\":1,\"timed_out\":false,\"_shards\":{},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"max_score\":null,\"hits\":[]}},\
              \{\"error\":\"index_not_found_exception\",\"status\":404}\
              \]}"
      let Just (resp :: MultiSearchResponse Tweet) = decode mixed
      length (multiSearchResponses resp) `shouldBe` 2
      -- Order preserved: first item is a successful SearchResult, the
      -- second is a per-item EsError carrying the 404 status.
      length (rights (multiSearchResponses resp)) `shouldBe` 1
      length [() | Left _ <- multiSearchResponses resp] `shouldBe` 1
      case multiSearchResponses resp of
        [Right _, Left _] -> pure ()
        _ -> expectationFailure "expected [Right _, Left _] in order"

    it "still decodes an all-success payload as all Right" $ do
      let allOk =
            LC.pack
              "{\"responses\":[\
              \{\"took\":1,\"timed_out\":false,\"_shards\":{},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"max_score\":null,\"hits\":[]}},\
              \{\"took\":2,\"timed_out\":false,\"_shards\":{},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"max_score\":null,\"hits\":[]}}\
              \]}"
      let Just (resp :: MultiSearchResponse Tweet) = decode allOk
      multiSearchResponses resp `shouldSatisfy` all (either (const False) (const True))
      length (multiSearchResponses resp) `shouldBe` 2

  describe "multi-search API" $ do
    it "executes multiple searches in a single request" $
      withTestEnv $ do
        _ <- insertData
        _ <- insertOther
        let query1 = TermQuery (Term "user" "bitemyapp") Nothing
        let search1 = mkSearch (Just query1) Nothing
        let query2 = TermQuery (Term "user" "notmyapp") Nothing
        let search2 = mkSearch (Just query2) Nothing
        let items = mkItem Nothing search1 :| [mkItem Nothing search2]
        result <- tryPerformBHRequest $ multiSearch @Tweet items
        case result of
          Left _ -> liftIO $ expectationFailure "Multi-search failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2

    it "executes multiple searches on a specific index" $
      withTestEnv $ do
        _ <- insertData
        let query1 = TermQuery (Term "user" "bitemyapp") Nothing
        let search1 = mkSearch (Just query1) Nothing
        let query2 = MatchAllQuery Nothing
        let search2 = mkSearch (Just query2) Nothing
        let searches = search1 :| [search2]
        result <- tryPerformBHRequest $ multiSearchByIndex @Tweet testIndex searches
        case result of
          Left _ -> liftIO $ expectationFailure "Multi-search by index failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2
            let firstResult = head (rights responses)
            let hitsCount = maybe 0 value (hitsTotal (searchHits firstResult))
            liftIO $ hitsCount `shouldSatisfy` (> 0)

    it "handles searches on different indices" $
      withTestEnv $ do
        _ <- insertData
        _ <- insertOther
        let query1 = TermQuery (Term "user" "bitemyapp") Nothing
        let search1 = mkSearch (Just query1) Nothing
        let query2 = TermQuery (Term "user" "notmyapp") Nothing
        let search2 = mkSearch (Just query2) Nothing
        let items =
              mkItem (Just testIndex) search1
                :| [ mkItem (Just testIndex) search2
                   ]
        result <- tryPerformBHRequest $ multiSearch @Tweet items
        case result of
          Left _ -> liftIO $ expectationFailure "Multi-search failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2

    it "multiSearchWith forwards URI params (max_concurrent_searches, typed_keys)" $
      withTestEnv $ do
        _ <- insertData
        _ <- insertOther
        let query1 = TermQuery (Term "user" "bitemyapp") Nothing
        let search1 = mkSearch (Just query1) Nothing
        let query2 = TermQuery (Term "user" "notmyapp") Nothing
        let search2 = mkSearch (Just query2) Nothing
        let items = mkItem Nothing search1 :| [mkItem Nothing search2]
            opts =
              defaultSearchOptions
                { soMaxConcurrentSearches = Just 1,
                  soTypedKeys = Just True
                }
        result <- tryPerformBHRequest $ multiSearchWith @Tweet opts items
        case result of
          Left _ -> liftIO $ expectationFailure "Multi-search with options failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2

    it "per-header fields land on the header line and are accepted by ES" $
      withTestEnv $ do
        _ <- insertData
        let query = TermQuery (Term "user" "bitemyapp") Nothing
        let search = mkSearch (Just query) Nothing
        let item =
              (mkItem (Just testIndex) search)
                { multiSearchItemPreference = Just "_local",
                  multiSearchItemSearchType = Just SearchTypeQueryThenFetch,
                  multiSearchItemAllowPartialSearchResults = Just True
                }
        result <- tryPerformBHRequest $ multiSearch @Tweet (item :| [])
        case result of
          Left _ -> liftIO $ expectationFailure "Multi-search with header fields failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 1