packages feed

bloodhound-1.0.0.0: tests/Test/SynonymsSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.SynonymsSpec (spec) where

import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch8.Requests qualified as RequestsES8
import Database.Bloodhound.ElasticSearch8.Types qualified as Types
import TestsUtils.Common
import TestsUtils.Import

hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
  Just (Object obj) -> k `KM.member` obj
  _ -> False

spec :: Spec
spec = describe "Synonyms API (PUT/GET/DELETE /_synonyms/{synonyms_set_id})" $ do
  describe "SynonymsSetId JSON" $ do
    it "round-trips a SynonymsSetId" $ do
      let sid = Types.SynonymsSetId "my-synonyms-set"
      decode (encode sid) `shouldBe` Just sid

    it "unSynonymsSetId extracts the underlying Text" $
      Types.unSynonymsSetId (Types.SynonymsSetId "abc") `shouldBe` ("abc" :: Text)

  describe "SynonymRule JSON wire shape" $ do
    it "encodes a rule with an id as {id, synonyms}" $ do
      let rule = Types.SynonymRule (Just "test-1") "hello, hi"
      encode rule
        `shouldBe` "{\"id\":\"test-1\",\"synonyms\":\"hello, hi\"}"
      decode (encode rule) `shouldBe` Just rule

    it "omits id when Nothing (server mints one)" $ do
      let rule = Types.SynonymRule Nothing "bye, goodbye"
      hasKey "id" (encode rule) `shouldBe` False
      encode rule `shouldBe` "{\"synonyms\":\"bye, goodbye\"}"
      decode (encode rule) `shouldBe` Just rule

    it "decodes a rule that omits id" $ do
      let raw = LBS.pack "{\"synonyms\":\"universe, cosmos\"}"
      decode raw `shouldBe` Just (Types.SynonymRule Nothing "universe, cosmos")

    it "decodes a full GET-style rule with id" $ do
      let raw = LBS.pack "{\"id\":\"test-2\",\"synonyms\":\"test => check\"}"
      decode raw `shouldBe` Just (Types.SynonymRule (Just "test-2") "test => check")

  describe "SynonymsSet (PUT body) JSON" $ do
    it "encodes as {synonyms_set: [...]}" $ do
      let body =
            Types.SynonymsSet
              [ Types.SynonymRule (Just "test-1") "hello, hi",
                Types.SynonymRule Nothing "bye, goodbye"
              ]
      encode body
        `shouldBe` "{\"synonyms_set\":[{\"id\":\"test-1\",\"synonyms\":\"hello, hi\"},{\"synonyms\":\"bye, goodbye\"}]}"
      decode (encode body) `shouldBe` Just body

    it "encodes an empty set as {synonyms_set: []}" $
      encode (Types.SynonymsSet [])
        `shouldBe` "{\"synonyms_set\":[]}"

    it "decodes the documented PUT example verbatim" $ do
      let raw =
            LBS.pack
              "{\"synonyms_set\":[{\"id\":\"test-1\",\"synonyms\":\"hello, hi\"},{\"synonyms\":\"bye, goodbye\"},{\"id\":\"test-2\",\"synonyms\":\"test => check\"}]}"
      case decode raw :: Maybe Types.SynonymsSet of
        Just (Types.SynonymsSet rules) -> length rules `shouldBe` 3
        Nothing -> expectationFailure "failed to decode SynonymsSet"

  describe "SynonymsSetInfo (GET response) JSON" $ do
    it "decodes the documented GET response verbatim" $ do
      let raw =
            LBS.pack
              "{\"count\":3,\"synonyms_set\":[{\"id\":\"test-1\",\"synonyms\":\"hello, hi\"},{\"id\":\"test-2\",\"synonyms\":\"bye, goodbye\"},{\"id\":\"test-3\",\"synonyms\":\"test => check\"}]}"
      case decode raw :: Maybe Types.SynonymsSetInfo of
        Just info -> do
          Types.ssiCount info `shouldBe` 3
          length (Types.ssiRules info) `shouldBe` 3
        Nothing ->
          expectationFailure "failed to decode SynonymsSetInfo"

    it "defaults missing count/synonyms_set to 0/[]" $ do
      let raw = LBS.pack "{}"
      case decode raw :: Maybe Types.SynonymsSetInfo of
        Just info -> do
          Types.ssiCount info `shouldBe` 0
          Types.ssiRules info `shouldBe` []
        Nothing ->
          expectationFailure "failed to decode empty SynonymsSetInfo"

    it "round-trips a populated response" $ do
      let info =
            Types.SynonymsSetInfo
              { Types.ssiCount = 1,
                Types.ssiRules = [Types.SynonymRule (Just "a") "x, y"]
              }
      decode (encode info) `shouldBe` Just info

  describe "SynonymsSetPutResponse JSON" $ do
    it "decodes {acknowledged: true}" $ do
      let raw = LBS.pack "{\"acknowledged\":true}"
      decode raw `shouldBe` Just (Types.SynonymsSetPutResponse (Just True) Nothing)

    it "decodes {result: updated} (reload path)" $ do
      let raw = LBS.pack "{\"result\":\"updated\"}"
      decode raw `shouldBe` Just (Types.SynonymsSetPutResponse Nothing (Just "updated"))

    it "decodes both fields when present" $ do
      let raw = LBS.pack "{\"acknowledged\":true,\"result\":\"created\"}"
      decode raw `shouldBe` Just (Types.SynonymsSetPutResponse (Just True) (Just "created"))

  describe "synonymsGetOptionsParams URI rendering" $ do
    it "defaultSynonymsGetOptions emits no params" $
      Types.synonymsGetOptionsParams Types.defaultSynonymsGetOptions
        `shouldBe` []

    it "renders from and size" $ do
      let opts =
            Types.defaultSynonymsGetOptions
              { Types.sgoFrom = Just 5,
                Types.sgoSize = Just 20
              }
      Types.synonymsGetOptionsParams opts
        `shouldBe` [("from", Just "5"), ("size", Just "20")]

    it "renders only the set fields" $ do
      let opts =
            Types.defaultSynonymsGetOptions
              { Types.sgoSize = Just 50
              }
      Types.synonymsGetOptionsParams opts
        `shouldBe` [("size", Just "50")]

  describe "endpoint shape" $ do
    it "PUTs to /_synonyms/<id> with a body (ES8)" $ do
      let req =
            RequestsES8.putSynonymsSet
              "my-synonyms-set"
              (Types.SynonymsSet [Types.SynonymRule Nothing "a, b"])
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_synonyms", "my-synonyms-set"]
      bhRequestBody req `shouldSatisfy` isJust

    it "GETs /_synonyms/<id> with no body (ES8)" $ do
      let req = RequestsES8.getSynonymsSet "my-synonyms-set"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_synonyms", "my-synonyms-set"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "getSynonymsSetWith appends from/size query params" $ do
      let opts =
            Types.defaultSynonymsGetOptions
              { Types.sgoFrom = Just 1,
                Types.sgoSize = Just 2
              }
          req = RequestsES8.getSynonymsSetWith "my-synonyms-set" opts
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_synonyms", "my-synonyms-set"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("from", Just "1"), ("size", Just "2")]
      bhRequestBody req `shouldSatisfy` isNothing

    it "DELETEs /_synonyms/<id> with no body (ES8)" $ do
      let req = RequestsES8.deleteSynonymsSet "my-synonyms-set"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_synonyms", "my-synonyms-set"]
      bhRequestBody req `shouldSatisfy` isNothing

  describe "live integration (requires ES8+ backend)" $
    backendSpecific [ElasticSearch8] $ do
      -- Search synonyms are available under the free basic license in
      -- ES 8.x, so the round-trip below should run on the docker-compose
      -- ES8 container. It still tolerates a privilege failure
      -- (manage_search_synonyms cluster privilege) by pending.
      it "round-trips a synonyms set via PUT then GET then DELETE" $
        withTestEnv $ do
          let sid = Types.SynonymsSetId "bloodhound-test-synonyms"
              body =
                Types.SynonymsSet
                  [ Types.SynonymRule (Just "test-1") "hello, hi",
                    Types.SynonymRule Nothing "bye, goodbye"
                  ]
          _ <-
            tryPerformBHRequest $
              RequestsES8.deleteSynonymsSet sid
          putResult <- tryEsError (ClientES8.putSynonymsSet sid body)
          case putResult of
            Left e
              | errorStatus e == Just 403
                  || "privilege" `T.isInfixOf` T.toLower (errorMessage e) ->
                  liftIO $
                    pendingWith
                      "synonyms API requires manage_search_synonyms \
                      \privilege; skip on locked-down clusters"
            Left e ->
              liftIO $
                expectationFailure $
                  "unexpected PUT error: " <> show e
            Right _putResp -> do
              info <- ClientES8.getSynonymsSet sid
              liftIO $ do
                Types.ssiCount info `shouldBe` 2
                length (Types.ssiRules info) `shouldBe` 2
              _ <- ClientES8.deleteSynonymsSet sid
              pure ()

      it "DELETE of a non-existent synonyms set surfaces a 4xx EsError" $
        withTestEnv $ do
          result <-
            tryPerformBHRequest $
              RequestsES8.deleteSynonymsSet
                (Types.SynonymsSetId "bloodhound-definitely-missing-synonyms")
          case result of
            Left e ->
              case errorStatus e of
                Just s -> liftIO $ (s >= 400 && s < 500) `shouldBe` True
                Nothing ->
                  liftIO $
                    expectationFailure $
                      "expected a 4xx status, got none. Message: "
                        <> show (errorMessage e)
            Right _ ->
              liftIO $
                expectationFailure
                  "expected DELETE of a missing synonyms set to fail, but it succeeded"