packages feed

bloodhound-1.0.0.0: tests/Test/EnrichSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.EnrichSpec (spec) where

import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import TestsUtils.Import
import Prelude

-- | A canonical @match@ policy body matching the ES 7.17 docs example.
sampleMatchPolicyBytes :: LBS.ByteString
sampleMatchPolicyBytes =
  "{\
  \  \"match\": {\
  \    \"indices\": [\"users\"],\
  \    \"match_field\": \"email\",\
  \    \"enrich_fields\": [\"first_name\", \"last_name\"]\
  \  }\
  \}"

-- | A @geo_match@ policy that exercises a non-default 'EnrichPolicyType'
-- and a bare-string @indices@ (ES accepts a single string there).
sampleGeoPolicyBytes :: LBS.ByteString
sampleGeoPolicyBytes =
  "{\
  \  \"geo_match\": {\
  \    \"indices\": \"users-geo-*\",\
  \    \"match_field\": \"geo_location\",\
  \    \"enrich_fields\": \"city\"\
  \  }\
  \}"

-- | A policy carrying an optional @query@ and an unknown sibling field
-- (@description@) which the @epcExtras@ catch-all must preserve.
samplePolicyWithExtrasBytes :: LBS.ByteString
samplePolicyWithExtrasBytes =
  "{\
  \  \"range\": {\
  \    \"indices\": [\"orders\"],\
  \    \"match_field\": \"customer_id\",\
  \    \"enrich_fields\": [\"tier\"],\
  \    \"query\": {\"term\": {\"active\": true}},\
  \    \"description\": \"customer-tier lookup\"\
  \  }\
  \}"

-- | Full @GET /_enrich/policy@ response with two entries.
sampleListResponseBytes :: LBS.ByteString
sampleListResponseBytes =
  "{\
  \  \"policies\": [\
  \    {\
  \      \"name\": \"users-policy\",\
  \      \"config\": {\
  \        \"match\": {\
  \          \"indices\": [\"users\"],\
  \          \"match_field\": \"email\",\
  \          \"enrich_fields\": [\"first_name\"]\
  \        }\
  \      }\
  \    },\
  \    {\
  \      \"name\": \"geo-policy\",\
  \      \"config\": {\
  \        \"geo_match\": {\
  \          \"indices\": [\"geodata\"],\
  \          \"match_field\": \"location\",\
  \          \"enrich_fields\": [\"city\", \"state\"]\
  \        }\
  \      }\
  \    }\
  \  ]\
  \}"

-- | @GET /_enrich/_execute_stats@ response, ES 7.x shape:
-- @last_execution_time@ as epoch-millis number.
sampleExecuteStatsEpochMillisBytes :: LBS.ByteString
sampleExecuteStatsEpochMillisBytes =
  "{\
  \  \"executor_pool\": \"generic\",\
  \  \"execute_stats\": [\
  \    {\
  \      \"policy\": \"users-policy\",\
  \      \"last_execution_time\": 1719394582880,\
  \      \"running_policy_index_search\": false,\
  \      \"phase\": \"ENDED\"\
  \    }\
  \  ]\
  \}"

-- | @GET /_enrich/_execute_stats@ response, ES 8.x\/9.x shape:
-- @last_execution_time@ as an ISO-8601 string, plus an unknown sibling
-- field (@remote_indices@) that the @eseeExtras@ catch-all must keep.
sampleExecuteStatsIsoBytes :: LBS.ByteString
sampleExecuteStatsIsoBytes =
  "{\
  \  \"executor_pool\": \"generic\",\
  \  \"execute_stats\": [\
  \    {\
  \      \"policy\": \"geo-policy\",\
  \      \"last_execution_time\": \"2024-06-26T10:16:22.880Z\",\
  \      \"phase\": \"RUNNING\",\
  \      \"remote_indices\": [\"remote:geodata\"]\
  \    }\
  \  ]\
  \}"

spec :: Spec
spec = describe "Enrich policy API" $ do
  describe "EnrichPolicyId JSON" $ do
    it "round-trips as a bare JSON string" $ do
      let Just decoded = decode "\"my-policy\"" :: Maybe EnrichPolicyId
      unEnrichPolicyId decoded `shouldBe` "my-policy"
      encode (EnrichPolicyId "my-policy") `shouldBe` "\"my-policy\""

    it "rejects a non-string value" $ do
      let decoded = decode "42" :: Maybe EnrichPolicyId
      decoded `shouldBe` Nothing

  describe "EnrichPolicyType JSON" $ do
    it "'enrichPolicyTypeText' spells the documented keys" $ do
      enrichPolicyTypeText EnrichPolicyTypeMatch `shouldBe` "match"
      enrichPolicyTypeText EnrichPolicyTypeGeoMatch `shouldBe` "geo_match"
      enrichPolicyTypeText EnrichPolicyTypeRange `shouldBe` "range"

    it "absorbs an unknown policy type into the Custom branch" $ do
      let Just (EnrichPolicyTypeCustom t) =
            decode "\"mismatch\"" :: Maybe EnrichPolicyType
      t `shouldBe` "mismatch"

  describe "EnrichPolicy JSON (PUT body)" $ do
    it "decodes a canonical match policy" $ do
      let Just decoded = decode sampleMatchPolicyBytes :: Maybe EnrichPolicy
      epTypeTag decoded `shouldBe` EnrichPolicyTypeMatch
      let cfg = epConfig decoded
      NE.toList (epcIndices cfg) `shouldBe` [IndexPattern "users"]
      epcMatchField cfg `shouldBe` FieldName "email"
      NE.toList (epcEnrichFields cfg)
        `shouldBe` [FieldName "first_name", FieldName "last_name"]
      epcQuery cfg `shouldBe` Nothing
      epcExtras cfg `shouldBe` KM.empty

    it "decodes a geo_match policy with bare-string indices/enrich_fields" $ do
      let Just decoded = decode sampleGeoPolicyBytes :: Maybe EnrichPolicy
      epTypeTag decoded `shouldBe` EnrichPolicyTypeGeoMatch
      let cfg = epConfig decoded
      -- A bare string is normalised to a singleton list.
      NE.toList (epcIndices cfg) `shouldBe` [IndexPattern "users-geo-*"]
      NE.toList (epcEnrichFields cfg) `shouldBe` [FieldName "city"]

    it "preserves an optional query and unknown sibling fields in extras" $ do
      let Just decoded = decode samplePolicyWithExtrasBytes :: Maybe EnrichPolicy
          cfg = epConfig decoded
      epTypeTag decoded `shouldBe` EnrichPolicyTypeRange
      -- The @query@ body is carried as an opaque Value.
      epcQuery cfg `shouldSatisfy` isJust
      -- The unknown @description@ key survives in the extras catch-all.
      KM.lookup "description" (epcExtras cfg) `shouldSatisfy` isJust

    it "encodes the type-tagged shape with array indices/enrich_fields" $ do
      let Just decoded = decode sampleMatchPolicyBytes :: Maybe EnrichPolicy
          encoded = encode decoded
          Just roundTripped = decode encoded :: Maybe EnrichPolicy
      -- Round-trip is stable.
      epTypeTag roundTripped `shouldBe` EnrichPolicyTypeMatch
      NE.toList (epcEnrichFields (epConfig roundTripped))
        `shouldBe` [FieldName "first_name", FieldName "last_name"]

    it "round-trips a policy with extras through encode . decode" $ do
      let Just decoded = decode samplePolicyWithExtrasBytes :: Maybe EnrichPolicy
          Just roundTripped = decode (encode decoded) :: Maybe EnrichPolicy
          cfg = epConfig roundTripped
      KM.lookup "description" (epcExtras cfg) `shouldSatisfy` isJust
      -- The known @query@ key is NOT duplicated into extras.
      KM.lookup "query" (epcExtras cfg) `shouldBe` Nothing

    it "rejects an empty object body" $ do
      let decoded = decode "{}" :: Maybe EnrichPolicy
      decoded `shouldBe` Nothing

    it "rejects a multi-key body" $ do
      let decoded =
            decode "{\"match\":{},\"geo_match\":{}}" :: Maybe EnrichPolicy
      decoded `shouldBe` Nothing

    it "rejects a policy missing required 'indices'" $ do
      let decoded =
            decode
              "{\"match\":{\"match_field\":\"email\",\"enrich_fields\":[\"x\"]}}" ::
              Maybe EnrichPolicy
      decoded `shouldBe` Nothing

    it "rejects a policy with an empty 'enrich_fields' array" $ do
      let decoded =
            decode
              "{\"match\":{\"indices\":[\"u\"],\"match_field\":\"e\",\"enrich_fields\":[]}}" ::
              Maybe EnrichPolicy
      decoded `shouldBe` Nothing

    it "rejects a policy with an empty 'indices' array" $ do
      let decoded =
            decode
              "{\"match\":{\"indices\":[],\"match_field\":\"e\",\"enrich_fields\":[\"x\"]}}" ::
              Maybe EnrichPolicy
      decoded `shouldBe` Nothing

  describe "EnrichPoliciesResponse JSON (GET response)" $ do
    it "decodes the {policies: [...]} envelope" $ do
      let Just decoded =
            decode sampleListResponseBytes :: Maybe EnrichPoliciesResponse
      length (unEnrichPoliciesResponse decoded) `shouldBe` 2
      let firstInfo = head (unEnrichPoliciesResponse decoded)
      epiName firstInfo `shouldBe` EnrichPolicyId "users-policy"
      epTypeTag (epiPolicy firstInfo)
        `shouldBe` EnrichPolicyTypeMatch
      let secondInfo = unEnrichPoliciesResponse decoded !! 1
      epTypeTag (epiPolicy secondInfo)
        `shouldBe` EnrichPolicyTypeGeoMatch

    it "re-encodes to the same envelope shape" $ do
      let Just decoded =
            decode sampleListResponseBytes :: Maybe EnrichPoliciesResponse
          reEncoded = encode decoded
          Just reparsed =
            decode reEncoded :: Maybe EnrichPoliciesResponse
      length (unEnrichPoliciesResponse reparsed) `shouldBe` 2

  describe "EnrichPolicyPhase JSON" $ do
    it "decodes documented values" $ do
      decode "\"ENDED\"" `shouldBe` Just EnrichPolicyPhaseEnded
      decode "\"RUNNING\"" `shouldBe` Just EnrichPolicyPhaseRunning

    it "encodes documented values back to the wire spelling" $ do
      encode EnrichPolicyPhaseEnded `shouldBe` "\"ENDED\""
      encode EnrichPolicyPhaseRunning `shouldBe` "\"RUNNING\""

    it "absorbs an unknown phase into the Custom branch" $ do
      let Just (EnrichPolicyPhaseCustom t) =
            decode "\"WAITING_FOR_LEADER\"" :: Maybe EnrichPolicyPhase
      t `shouldBe` "WAITING_FOR_LEADER"

  describe "EnrichExecuteStatsResponse JSON" $ do
    it "decodes the ES 7.x epoch-millis shape" $ do
      let Just decoded =
            decode sampleExecuteStatsEpochMillisBytes ::
              Maybe EnrichExecuteStatsResponse
      eesrExecutorPool decoded `shouldBe` Just "generic"
      length (eesrStats decoded) `shouldBe` 1
      let entry = head (eesrStats decoded)
      eseePolicy entry `shouldBe` EnrichPolicyId "users-policy"
      eseeRunningPolicyIndexSearch entry `shouldBe` Just False
      eseePhase entry `shouldBe` Just EnrichPolicyPhaseEnded
      -- @last_execution_time@ is carried as an opaque Value (epoch millis).
      eseeLastExecutionTime entry `shouldSatisfy` isJust

    it "decodes the ES 8.x/9.x ISO-8601 shape and preserves unknown fields" $ do
      let Just decoded =
            decode sampleExecuteStatsIsoBytes ::
              Maybe EnrichExecuteStatsResponse
      let entry = head (eesrStats decoded)
      eseePolicy entry `shouldBe` EnrichPolicyId "geo-policy"
      eseePhase entry `shouldBe` Just EnrichPolicyPhaseRunning
      -- The unknown @remote_indices@ sibling is preserved in extras.
      KM.lookup "remote_indices" (eseeExtras entry) `shouldSatisfy` isJust
      -- @running_policy_index_search@ is absent on the wire, so it is Nothing.
      eseeRunningPolicyIndexSearch entry `shouldBe` Nothing

    it "round-trips the execute-stats response" $ do
      let Just decoded =
            decode sampleExecuteStatsEpochMillisBytes ::
              Maybe EnrichExecuteStatsResponse
          Just roundTripped =
            decode (encode decoded) :: Maybe EnrichExecuteStatsResponse
      length (eesrStats roundTripped) `shouldBe` 1

    it "preserves unknown execute-stats fields through encode . decode" $ do
      let Just decoded =
            decode sampleExecuteStatsIsoBytes ::
              Maybe EnrichExecuteStatsResponse
          Just roundTripped =
            decode (encode decoded) :: Maybe EnrichExecuteStatsResponse
          entry = head (eesrStats roundTripped)
      -- The @remote_indices@ extra survives the round-trip.
      KM.lookup "remote_indices" (eseeExtras entry) `shouldSatisfy` isJust

  describe "EnrichExecuteOptions params" $ do
    it "default options render no query string" $ do
      enrichExecuteOptionsParams defaultEnrichExecuteOptions
        `shouldBe` []

    it "wait_for_completion is rendered as a bool string" $ do
      let opts =
            defaultEnrichExecuteOptions
              { eeoWaitForCompletion = Just False
              }
      enrichExecuteOptionsParams opts
        `shouldBe` [("wait_for_completion", Just "false")]

    it "wait_for_completion=True renders 'true'" $ do
      let opts =
            defaultEnrichExecuteOptions
              { eeoWaitForCompletion = Just True
              }
      enrichExecuteOptionsParams opts
        `shouldBe` [("wait_for_completion", Just "true")]

  describe "defaultEnrichPolicyConfig" $ do
    it "builds a minimal config with single-element lists" $ do
      let cfg =
            defaultEnrichPolicyConfig
              (IndexPattern "users")
              (FieldName "email")
              (FieldName "first_name" :| [])
      NE.toList (epcIndices cfg) `shouldBe` [IndexPattern "users"]
      epcMatchField cfg `shouldBe` FieldName "email"
      NE.toList (epcEnrichFields cfg) `shouldBe` [FieldName "first_name"]
      epcQuery cfg `shouldBe` Nothing
      epcExtras cfg `shouldBe` KM.empty