packages feed

bloodhound-1.0.0.0: tests/Test/MultiSearchTemplateSpec.hs

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

module Test.MultiSearchTemplateSpec where

import Data.Aeson qualified as Aeson
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 2-field form of
-- 'MultiSearchTemplateItem'. Keeps the integration-test fixtures readable
-- while the record itself carries the full per-header field set.
mkItem :: Maybe IndexName -> SearchTemplate -> MultiSearchTemplateItem
mkItem idx t =
  MultiSearchTemplateItem
    { multiSearchTemplateItemIndex = idx,
      multiSearchTemplateItemRouting = Nothing,
      multiSearchTemplateItemSearchType = Nothing,
      multiSearchTemplateItemPreference = Nothing,
      multiSearchTemplateItemAllowPartialSearchResults = Nothing,
      multiSearchTemplateItemSearchTemplate = t
    }

-- | 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 Aeson.decode bs :: Maybe Aeson.Object of
  Just o -> sort (map K.toText (KM.keys o))
  Nothing -> error "headerKeys: decode failed"

-- | Split the NDJSON body into its non-empty lines so tests can assert on
-- line count and per-line shape without depending on trailing-newline
-- placement.
bodyLines :: LC.ByteString -> [LC.ByteString]
bodyLines = filter (not . LC.null) . LC.lines

-- | A canned inline-source template that matches the seeded @Tweet@ in
-- @insertData@ (user = "bitemyapp") when its params are filled in.
matchingInlineTemplate :: SearchTemplate
matchingInlineTemplate =
  mkSearchTemplate
    (Right (SearchTemplateSource "{\"query\": { \"match\" : { \"{{my_field}}\" : \"{{my_value}}\" } } }"))
    templateParams

-- | Params for 'matchingInlineTemplate', factored out so the same values
-- can be reused for a stored-id variant in an integration test.
templateParams :: TemplateQueryKeyValuePairs
templateParams =
  TemplateQueryKeyValuePairs $
    KM.fromList
      [ ("my_field", "user"),
        ("my_value", "bitemyapp")
      ]

spec :: Spec
spec = do
  describe "MultiSearchTemplateItem ToJSON header rendering" $ do
    let bareTemplate = matchingInlineTemplate
        logs = [qqIndexName|logs|]

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

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

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

    it "renders search_type using the SearchType Text encoding" $ do
      let item = (mkItem (Just logs) bareTemplate) {multiSearchTemplateItemSearchType = Just SearchTypeDfsQueryThenFetch}
      Aeson.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) bareTemplate)
              { multiSearchTemplateItemPreference = Just "_local",
                multiSearchTemplateItemAllowPartialSearchResults = Just False
              }
      headerKeys (Aeson.encode item)
        `shouldBe` ["allow_partial_search_results", "index", "preference"]

    it "renders every per-header field simultaneously" $ do
      let item =
            (mkItem (Just logs) bareTemplate)
              { multiSearchTemplateItemRouting = Just "r1",
                multiSearchTemplateItemSearchType = Just SearchTypeDfsQueryThenFetch,
                multiSearchTemplateItemPreference = Just "_local",
                multiSearchTemplateItemAllowPartialSearchResults = Just True
              }
      Aeson.encode item
        `shouldBe` "{\"allow_partial_search_results\":true,\"index\":\"logs\",\"preference\":\"_local\",\"routing\":\"r1\",\"search_type\":\"dfs_query_then_fetch\"}"

  describe "encodeMultiSearchTemplateItems NDJSON shape" $ do
    let logs = [qqIndexName|logs|]
        t1 = matchingInlineTemplate
        t2 =
          mkSearchTemplate
            (Right (SearchTemplateSource "{\"query\":{\"match_all\":{}}}"))
            (TemplateQueryKeyValuePairs KM.empty)

    it "produces one header + one body line per item, plus a trailing newline" $ do
      let items = mkItem (Just logs) t1 :| [mkItem Nothing t2]
          encoded = encodeMultiSearchTemplateItems items
      -- 2 items * 2 lines = 4 content newlines, plus the outer
      -- wrapper's trailing newline (mirrors 'encodeMultiSearchItems').
      LC.count '\n' encoded `shouldBe` 5
      length (bodyLines encoded) `shouldBe` 4

    it "every body line decodes to the JSON of one of the input templates" $ do
      -- Order-agnostic: the encoder builds the NDJSON stream with a
      -- 'foldr', which reverses iteration order, but each (header, body)
      -- pair still corresponds to one input item. Asserting on the *set*
      -- of body JSONs keeps this test honest without pinning an order
      -- that the existing @_msearch@ encoder does not guarantee either.
      let items = mkItem (Just logs) t1 :| [mkItem Nothing t2]
          encoded = encodeMultiSearchTemplateItems items
          lines' = bodyLines encoded
          -- Lines 0 and 2 are headers (objects with @index@ at most);
          -- lines 1 and 3 are bodies (objects with @source@ or @id@).
          body1 = Aeson.decode (lines' !! 1) :: Maybe Aeson.Value
          body2 = Aeson.decode (lines' !! 3) :: Maybe Aeson.Value
          decodedSet = sort [show body1, show body2]
          expectedSet =
            sort
              [ show (Aeson.decode (Aeson.encode t1) :: Maybe Aeson.Value),
                show (Aeson.decode (Aeson.encode t2) :: Maybe Aeson.Value)
              ]
      decodedSet `shouldBe` expectedSet

    it "the header line matches encode <item>" $ do
      -- The header line is the ToJSON of the MultiSearchTemplateItem
      -- (header-only, body dropped). Lock the wire shape so a regression
      -- in the encoder (e.g. accidentally serialising the SearchTemplate
      -- body in the header line) surfaces here.
      let item = mkItem (Just logs) t1
          items = item :| []
          encoded = encodeMultiSearchTemplateItems items
          lines' = bodyLines encoded
      Aeson.decode (head lines') `shouldBe` (Aeson.decode (Aeson.encode item) :: Maybe Aeson.Value)

  describe "multi-search template API" $ do
    it "executes a single inline-source template by index" $
      withTestEnv $ do
        _ <- insertData
        result <-
          tryPerformBHRequest $
            multiSearchTemplateByIndex @Tweet testIndex (matchingInlineTemplate :| [])
        case result of
          Left _ -> liftIO $ expectationFailure "multi-search template by index failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 1
            let firstResult = head (rights responses)
            let myTweet = grabFirst firstResult
            liftIO $ myTweet `shouldBe` Right exampleTweet

    it "executes multiple templates in a single request" $
      withTestEnv $ do
        _ <- insertData
        _ <- insertOther
        let otherTemplate =
              mkSearchTemplate
                (Right (SearchTemplateSource "{\"query\":{\"match_all\":{}}}"))
                (TemplateQueryKeyValuePairs KM.empty)
            items = mkItem (Just testIndex) matchingInlineTemplate :| [mkItem (Just testIndex) otherTemplate]
        result <- tryPerformBHRequest $ multiSearchTemplate @Tweet items
        case result of
          Left _ -> liftIO $ expectationFailure "multi-search template failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2
            let myTweet = grabFirst (head (rights responses))
            liftIO $ myTweet `shouldBe` Right exampleTweet

    it "resolves a stored template id alongside an inline source" $
      withTestEnv $ do
        _ <- insertData
        let tid = SearchTemplateId "bh-msearch-template-spec"
        _ <- performBHRequest $ storeSearchTemplate tid (SearchTemplateSource "{\"query\": { \"match\" : { \"{{my_field}}\" : \"{{my_value}}\" } } }")
        let storedTemplate = mkSearchTemplate (Left tid) templateParams
            items = mkItem (Just testIndex) matchingInlineTemplate :| [mkItem (Just testIndex) storedTemplate]
        result <- tryPerformBHRequest $ multiSearchTemplate @Tweet items
        -- cleanup before assertions so a later re-run doesn't see a stale script
        _ <- performBHRequest $ deleteSearchTemplate tid
        case result of
          Left _ -> liftIO $ expectationFailure "multi-search template with stored id failed"
          Right resp -> do
            let responses = multiSearchResponses resp
            liftIO $ responses `shouldSatisfy` all (either (const False) (const True))
            liftIO $ length responses `shouldBe` 2
            let myTweet = grabFirst (head (rights responses))
            liftIO $ myTweet `shouldBe` Right exampleTweet

    it "multiSearchTemplateWith forwards URI params (max_concurrent_searches, typed_keys)" $
      withTestEnv $ do
        _ <- insertData
        _ <- insertOther
        let otherTemplate =
              mkSearchTemplate
                (Right (SearchTemplateSource "{\"query\":{\"match_all\":{}}}"))
                (TemplateQueryKeyValuePairs KM.empty)
            -- Index-pinned items: the cluster has system indices (e.g.
            -- @.tasks@) whose documents do not decode as 'Tweet', so a
            -- cluster-wide @match_all@ template would trip a parse
            -- exception in the response decoder. Pinning to 'testIndex'
            -- keeps the @match_all@ template scoped to twitter docs.
            items = mkItem (Just testIndex) matchingInlineTemplate :| [mkItem (Just testIndex) otherTemplate]
            opts =
              defaultSearchOptions
                { soMaxConcurrentSearches = Just 1,
                  soTypedKeys = Just True
                }
        result <- tryPerformBHRequest $ multiSearchTemplateWith @Tweet opts items
        case result of
          Left _ -> liftIO $ expectationFailure "multi-search template 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 item =
              (mkItem (Just testIndex) matchingInlineTemplate)
                { multiSearchTemplateItemPreference = Just "_local",
                  multiSearchTemplateItemSearchType = Just SearchTypeQueryThenFetch,
                  multiSearchTemplateItemAllowPartialSearchResults = Just True
                }
        result <- tryPerformBHRequest $ multiSearchTemplate @Tweet (item :| [])
        case result of
          Left _ -> liftIO $ expectationFailure "multi-search template 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