packages feed

bloodhound-1.0.0.0: tests/Test/SuggestSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.SuggestSpec (spec) where

import Data.Aeson (decode, encode)
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Map.Strict qualified as Map
import TestsUtils.Common
import TestsUtils.Import

spec :: Spec
spec = do
  describe "Suggest" $
    it "returns a search suggestion using the phrase suggester" $
      withTestEnv $ do
        _ <- insertData
        let query = QueryMatchNoneQuery
            phraseSuggester = mkPhraseSuggester (FieldName "message")
            namedSuggester = Suggest "Use haskel" "suggest_name" (SuggestTypePhraseSuggester phraseSuggester)
            search' = mkSearch (Just query) Nothing
            search = search' {suggestBody = Just namedSuggester}
            expectedText = Just "use haskell"
        sr <- performBHRequest $ searchByIndex @Tweet testIndex search
        liftIO $ (suggestOptionsText . head . suggestResponseOptions . head . nsrResponses <$> suggest sr) `shouldBe` expectedText

  -- ----------------------------------------------------------------------
  -- Unit tests for the term / completion / context suggester additions
  -- (bloodhound-04f.10). These pin the wire shape of each new
  -- 'SuggestType' constructor and verify the documented round-trip
  -- behaviour, including the 'SuggestTypeCustom' escape hatch and the
  -- intent-only nature of 'SuggestTypeContextSuggester'.
  describe "SuggestType term/completion/context wire shape" $ do
    it "renders a term suggester under the \"term\" tag" $ do
      let body = encode (SuggestTypeTermSuggester (mkTermSuggester (FieldName "message")))
      -- @suggest_mode@ is non-'Maybe' (defaults to @missing@) so it is
      -- always emitted, mirroring 'DirectGenerators' on encode.
      body `shouldBe` "{\"term\":{\"field\":\"message\",\"suggest_mode\":\"missing\"}}"

    it "renders a completion suggester under the \"completion\" tag" $ do
      let body = encode (SuggestTypeCompletionSuggester (mkCompletionSuggester (FieldName "suggest")))
      body `shouldBe` "{\"completion\":{\"field\":\"suggest\"}}"

    it "renders a context suggester under the \"completion\" tag (wire-identical to completion)" $ do
      -- The context suggester is not a separate wire endpoint: it
      -- serialises as @completion@ so the server accepts the request.
      -- The constructor exists only to signal caller intent.
      let cs = mkContextSuggester (mkCompletionSuggester (FieldName "suggest"))
      encode (SuggestTypeContextSuggester cs)
        `shouldBe` "{\"completion\":{\"field\":\"suggest\"}}"

    it "renders the custom escape hatch under its caller-supplied tag" $ do
      let payload = object ["prefix" .= String "ni"]
      encode (SuggestTypeCustom "mySuggester" payload)
        `shouldBe` "{\"mySuggester\":{\"prefix\":\"ni\"}}"

    it "wraps a typed suggester in a named Suggest envelope" $ do
      let named = Suggest "tring" "my-suggest" (SuggestTypeTermSuggester (mkTermSuggester (FieldName "message")))
      encode named
        `shouldBe` "{\"my-suggest\":{\"term\":{\"field\":\"message\",\"suggest_mode\":\"missing\"}},\"text\":\"tring\"}"

  describe "SuggestType term/completion/context decoding" $ do
    it "decodes a {\"term\": ...} body as SuggestTypeTermSuggester" $ do
      let Just (st :: SuggestType) = decode "{\"term\":{\"field\":\"message\"}}"
      case st of
        SuggestTypeTermSuggester ts ->
          unFieldName (termSuggesterField ts) `shouldBe` "message"
        other ->
          expectationFailure $ "expected SuggestTypeTermSuggester, got " <> show other

    it "decodes a {\"completion\": ...} body as SuggestTypeCompletionSuggester" $ do
      let Just (st :: SuggestType) = decode "{\"completion\":{\"field\":\"suggest\"}}"
      case st of
        SuggestTypeCompletionSuggester cs ->
          unFieldName (completionSuggesterField cs) `shouldBe` "suggest"
        other ->
          expectationFailure $ "expected SuggestTypeCompletionSuggester, got " <> show other

    it "decodes an unknown tag as SuggestTypeCustom (lossless round-trip)" $ do
      let raw = "{\"futureSuggester\":{\"foo\":1}}" :: LBS.ByteString
          Just (st :: SuggestType) = decode raw
      case st of
        SuggestTypeCustom tag payload -> do
          tag `shouldBe` "futureSuggester"
          -- The Value payload is preserved verbatim, so encode . decode
          -- is the identity for unknown tags.
          encode st `shouldBe` raw
        other ->
          expectationFailure $ "expected SuggestTypeCustom, got " <> show other

    it "treats a context suggester's wire form as a completion suggester on decode" $ do
      -- Encoding then decoding a 'SuggestTypeContextSuggester' yields a
      -- 'SuggestTypeCompletionSuggester': this is by design (the two
      -- are wire-identical; see 'SuggestType' docs) and is why the
      -- 'Arbitrary SuggestType' generator omits the context variant.
      let cs = mkContextSuggester (mkCompletionSuggester (FieldName "suggest"))
          encoded = encode (SuggestTypeContextSuggester cs)
          Just (decoded :: SuggestType) = decode encoded
      case decoded of
        SuggestTypeCompletionSuggester _ -> pure ()
        other ->
          expectationFailure $
            "expected the context suggester to decode as completion, got "
              <> show other

    it "rejects a multi-key or non-object body" $ do
      decode "{\"term\":{\"field\":\"x\"},\"phrase\":{}}" `shouldBe` (Nothing :: Maybe SuggestType)
      decode "[1,2,3]" `shouldBe` (Nothing :: Maybe SuggestType)

  describe "TermSuggester tuning knobs" $ do
    it "serialises sort and string_distance as their documented string forms" $ do
      let ts =
            (mkTermSuggester (FieldName "message"))
              { termSuggesterSort = Just TermSuggesterSortFrequency,
                termSuggesterStringDistance = Just TermSuggesterStringDistanceJaroWinkler
              }
      let Just (decodedTs :: TermSuggester) = decode (encode ts)
      termSuggesterSort decodedTs `shouldBe` Just TermSuggesterSortFrequency
      termSuggesterStringDistance decodedTs `shouldBe` Just TermSuggesterStringDistanceJaroWinkler

    it "defaults suggest_mode to missing when omitted on decode" $ do
      let Just (decodedTs :: TermSuggester) = decode "{\"field\":\"message\"}"
      termSuggesterSuggestMode decodedTs `shouldBe` DirectGeneratorSuggestModeMissing

  describe "CompletionSuggester contexts" $ do
    it "serialises a non-empty contexts map under \"contexts\"" $ do
      let cs =
            (mkCompletionSuggester (FieldName "suggest"))
              { completionSuggesterContexts =
                  Just $
                    Map.fromList
                      [ ("color", ContextQueryValueText "red" :| []),
                        ("place", ContextQueryValueBoosted "nyc" 2 :| [])
                      ]
              }
      let Just (decodedCs :: CompletionSuggester) = decode (encode cs)
      completionSuggesterContexts decodedCs
        `shouldBe` Just
          ( Map.fromList
              [ ("color", ContextQueryValueText "red" :| []),
                ("place", ContextQueryValueBoosted "nyc" 2 :| [])
              ]
          )

    it "renders Just Map.empty as \"contexts\": {} and round-trips back to Just Map.empty" $ do
      -- @Nothing@ and @Just mempty@ must be distinguishable on the wire
      -- (otherwise the Arbitrary-driven round-trip in JSONSpec would
      -- fail); @Just mempty@ therefore renders as an empty object.
      let cs = (mkCompletionSuggester (FieldName "suggest")) {completionSuggesterContexts = Just Map.empty}
      encode cs `shouldBe` "{\"contexts\":{},\"field\":\"suggest\"}"
      let Just (decodedCs :: CompletionSuggester) = decode (encode cs)
      completionSuggesterContexts decodedCs `shouldBe` Just Map.empty

    it "omits the contexts field entirely when it is Nothing" $ do
      let cs = mkCompletionSuggester (FieldName "suggest")
      encode cs `shouldBe` "{\"field\":\"suggest\"}"
      let Just (decodedCs :: CompletionSuggester) = decode (encode cs)
      completionSuggesterContexts decodedCs `shouldBe` Nothing

  describe "SuggestType round-trip" $ do
    -- The QuickCheck @propJSON (Proxy :: Proxy Suggest)@ property in
    -- JSONSpec covers the generated variants; these cases pin
    -- specific interesting shapes that are unlikely to be hit by the
    -- generator.
    it "round-trips a minimal term suggester" $ do
      let st = SuggestTypeTermSuggester (mkTermSuggester (FieldName "message"))
      (decode . encode) st `shouldBe` Just st

    it "round-trips a minimal completion suggester" $ do
      let st = SuggestTypeCompletionSuggester (mkCompletionSuggester (FieldName "suggest"))
      (decode . encode) st `shouldBe` Just st