packages feed

bloodhound-1.0.0.0: tests/Test/BehavioralAnalyticsSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.BehavioralAnalyticsSpec (spec) where

import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (sort)
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

normalize :: [(Text, Maybe Text)] -> [(Text, Maybe Text)]
normalize = sort

spec :: Spec
spec =
  describe "Behavioral Analytics API (/_application/analytics*)" $ do
    describe "AnalyticsCollectionName / AnalyticsEventType JSON" $ do
      it "round-trips an AnalyticsCollectionName as a bare string" $ do
        let n = Types.AnalyticsCollectionName "my-collection"
        decode (encode n) `shouldBe` Just n
      it "exposes the known event types" $ do
        Types.unAnalyticsEventType Types.analyticsEventTypeSearch
          `shouldBe` ("search" :: Text)
        Types.unAnalyticsEventType Types.analyticsEventTypeSearchClick
          `shouldBe` ("search_click" :: Text)
        Types.unAnalyticsEventType Types.analyticsEventTypePageView
          `shouldBe` ("page_view" :: Text)
      it "round-trips an AnalyticsEventType via IsString" $
        decode (encode (Types.AnalyticsEventType "custom_event" :: Types.AnalyticsEventType))
          `shouldBe` Just (Types.AnalyticsEventType "custom_event")

    describe "AnalyticsCollectionsResponse (GET list) decoding" $ do
      it "flattens a name-keyed map into a list of AnalyticsCollection" $ do
        let raw =
              LBS.pack
                "{\"clicks\":{\"name\":\"clicks\"},\"searches\":{}}"
        case decode raw :: Maybe Types.AnalyticsCollectionsResponse of
          Just resp -> do
            length (Types.unAnalyticsCollectionsResponse resp) `shouldBe` 2
            let names = Types.acName <$> Types.unAnalyticsCollectionsResponse resp
            sort names `shouldBe` ["clicks", "searches"]
          Nothing ->
            expectationFailure "failed to decode AnalyticsCollectionsResponse"
      it "decodes an empty map as []" $
        decode "{}" `shouldBe` Just (Types.AnalyticsCollectionsResponse [])

    describe "AnalyticsCollectionPutResponse JSON" $ do
      it "decodes {acknowledged, name}" $
        decode "{\"acknowledged\":true,\"name\":\"clicks\"}"
          `shouldBe` Just
            ( Types.AnalyticsCollectionPutResponse
                { Types.acprAcknowledged = True,
                  Types.acprName = "clicks"
                }
            )
      it "defaults missing fields" $
        decode "{}"
          `shouldBe` Just
            ( Types.AnalyticsCollectionPutResponse
                { Types.acprAcknowledged = False,
                  Types.acprName = ""
                }
            )

    describe "AnalyticsEventResponse JSON" $ do
      it "decodes {accepted, event}" $ do
        let raw = LBS.pack "{\"accepted\":true,\"event\":{\"q\":\"shirt\"}}"
        case decode raw :: Maybe Types.AnalyticsEventResponse of
          Just resp -> do
            Types.aerAccepted resp `shouldBe` True
          Nothing -> expectationFailure "failed to decode AnalyticsEventResponse"
      it "defaults missing event to Null" $
        decode "{\"accepted\":false}"
          `shouldBe` Just
            ( Types.AnalyticsEventResponse
                { Types.aerAccepted = False,
                  Types.aerEvent = Null
                }
            )

    describe "analyticsEventOptionsParams URI rendering" $ do
      it "defaultAnalyticsEventOptions emits no params" $
        Types.analyticsEventOptionsParams Types.defaultAnalyticsEventOptions
          `shouldBe` []
      it "renders debug=true" $
        Types.analyticsEventOptionsParams
          Types.defaultAnalyticsEventOptions {Types.aeoDebug = Just True}
          `shouldBe` [("debug", Just "true")]

    describe "endpoint shape" $ do
      it "GETs /_application/analytics with no body (list all)" $ do
        let req = RequestsES8.getAnalyticsCollections Nothing
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics"]
        bhRequestBody req `shouldSatisfy` isNothing
      it "GETs /_application/analytics/<name> (single)" $ do
        let req = RequestsES8.getAnalyticsCollections (Just "clicks")
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics", "clicks"]
        bhRequestBody req `shouldSatisfy` isNothing
      it "PUTs /_application/analytics/<name> with an empty body" $ do
        let req = RequestsES8.putAnalyticsCollection "clicks"
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics", "clicks"]
        bhRequestBody req `shouldSatisfy` isJust
      it "DELETEs /_application/analytics/<name>" $ do
        let req = RequestsES8.deleteAnalyticsCollection "clicks"
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics", "clicks"]
        bhRequestBody req `shouldSatisfy` isNothing
      it "POSTs .../event/<type> with a JSON body" $ do
        let payload = object ["q" .= ("shirt" :: Text)]
            req =
              RequestsES8.postAnalyticsEvent
                "clicks"
                Types.analyticsEventTypeSearchClick
                payload
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics", "clicks", "event", "search_click"]
        bhRequestBody req `shouldSatisfy` isJust
        getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      it "postAnalyticsEventWith appends debug" $ do
        let payload = object ["q" .= ("shirt" :: Text)]
            req =
              RequestsES8.postAnalyticsEventWith
                Types.defaultAnalyticsEventOptions {Types.aeoDebug = Just True}
                "clicks"
                Types.analyticsEventTypePageView
                payload
        getRawEndpoint (bhRequestEndpoint req)
          `shouldBe` ["_application", "analytics", "clicks", "event", "page_view"]
        getRawEndpointQueries (bhRequestEndpoint req)
          `shouldBe` [("debug", Just "true")]

    describe "live integration (requires ES8+ backend)" $
      backendSpecific [ElasticSearch8] $ do
        it "round-trips a behavioral analytics collection via PUT then GET then DELETE" $
          withTestEnv $ do
            let name = Types.AnalyticsCollectionName "bloodhound-test-ba"
            _ <- tryPerformBHRequest $ RequestsES8.deleteAnalyticsCollection name
            putResp <- tryEsError (ClientES8.putAnalyticsCollection name)
            case putResp of
              Left e
                | errorStatus e == Just 403
                    || "privilege" `T.isInfixOf` T.toLower (errorMessage e) ->
                    liftIO $
                      pendingWith
                        "behavioral analytics requires manage_behavioral_analytics \
                        \privilege; skip on locked-down clusters"
              Left e ->
                liftIO $
                  expectationFailure $
                    "unexpected PUT error: " <> show e
              Right _ -> do
                cols <- ClientES8.getAnalyticsCollections (Just name)
                liftIO $ length cols `shouldBe` 1
                _ <- ClientES8.deleteAnalyticsCollection name
                pure ()