bloodhound-1.0.0.0: tests/Test/TransformSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.TransformSpec (spec) where
import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List.NonEmpty qualified as NE
import TestsUtils.Import
import Prelude
-- | Construct an 'IndexName' from a literal known to be valid (all the
-- samples here are lowercase and within the documented rules).
idxName :: Text -> IndexName
idxName t = case mkIndexName t of
Right n -> n
Left e -> error ("invalid index name " <> show t <> ": " <> show e)
-- | A canonical pivot transform PUT body matching the ES 7.17 docs example.
-- The @pivot@ body is opaque on the Haskell side (group_by\/aggregations
-- DSL), so it round-trips verbatim.
samplePivotConfigBytes :: LBS.ByteString
samplePivotConfigBytes =
"{\
\ \"source\": {\"index\": \"kibana_sample_data_ecommerce\"},\
\ \"dest\": {\"index\": \"kibana_sample_data_ecommerce_transform\"},\
\ \"pivot\": {\
\ \"group_by\": {\"customer_id\": {\"terms\": {\"field\": \"customer_id\"}}},\
\ \"aggregations\": {\"order_count\": {\"value_count\": {\"field\": \"order_id\"}}}\
\ },\
\ \"description\": \"ecommerce transform\",\
\ \"frequency\": \"1m\"\
\}"
-- | A latest transform PUT body exercising the typed 'TransformLatest' path.
sampleLatestConfigBytes :: LBS.ByteString
sampleLatestConfigBytes =
"{\
\ \"source\": {\"index\": [\"logs-*\"]},\
\ \"dest\": {\"index\": \"latest-logs\", \"pipeline\": \"logs-pipeline\"},\
\ \"latest\": {\
\ \"unique_key\": [\"host\", \"service\"],\
\ \"sort\": \"@timestamp\"\
\ }\
\}"
-- | A continuous transform config carrying a typed @sync@ and @settings@,
-- plus an unknown sibling field (@_meta@-like) preserved in 'tcExtras'.
sampleContinuousConfigBytes :: LBS.ByteString
sampleContinuousConfigBytes =
"{\
\ \"source\": {\
\ \"index\": \"events-*\",\
\ \"query\": {\"match_all\": {}}\
\ },\
\ \"dest\": {\"index\": \"events-summary\"},\
\ \"pivot\": {\"group_by\": {}, \"aggregations\": {}},\
\ \"sync\": {\
\ \"time\": {\
\ \"field\": \"@timestamp\",\
\ \"delay\": \"60s\"\
\ }\
\ },\
\ \"settings\": {\
\ \"max_page_search_size\": 500,\
\ \"docs_per_second\": 200,\
\ \"align_checkpoints\": true,\
\ \"dates_as_epoch_millis\": false\
\ },\
\ \"created_by\": \"system\"\
\}"
-- | A full @GET /_transform@ response with two entries (pivot + latest),
-- including the GET-only @id@ and @version@ fields.
sampleListResponseBytes :: LBS.ByteString
sampleListResponseBytes =
"{\
\ \"count\": 2,\
\ \"transforms\": [\
\ {\
\ \"id\": \"ecommerce-transform\",\
\ \"source\": {\"index\": \"ecommerce\"},\
\ \"dest\": {\"index\": \"ecommerce-summary\"},\
\ \"pivot\": {\"group_by\": {}, \"aggregations\": {}},\
\ \"version\": \"7.17.0\"\
\ },\
\ {\
\ \"id\": \"latest-transform\",\
\ \"source\": {\"index\": \"logs\"},\
\ \"dest\": {\"index\": \"latest-logs\"},\
\ \"latest\": {\"unique_key\": [\"host\"], \"sort\": \"@timestamp\"}\
\ }\
\ ]\
\}"
-- | A @GET /_transform/_stats@ response carrying the state enum and document
-- counters.
sampleStatsResponseBytes :: LBS.ByteString
sampleStatsResponseBytes =
"{\
\ \"count\": 1,\
\ \"transforms\": [\
\ {\
\ \"id\": \"ecommerce-transform\",\
\ \"state\": \"started\",\
\ \"health\": \"green\",\
\ \"checkpointing\": {\"last\": {\"checkpoint\": 1}},\
\ \"documents_indexed\": 1000,\
\ \"documents_processed\": 1200,\
\ \"documents_deleted\": 0,\
\ \"documents_failed\": 0\
\ }\
\ ]\
\}"
-- | A @GET /_transform/{id}/_explain@ response.
sampleExplainResponseBytes :: LBS.ByteString
sampleExplainResponseBytes =
"{\
\ \"transforms\": [\
\ {\
\ \"id\": \"ecommerce-transform\",\
\ \"state\": {\"task_state\": \"started\"},\
\ \"checkpointing\": {\"last\": {\"checkpoint\": 1}}\
\ }\
\ ]\
\}"
-- | A @POST /_transform/_preview@ response.
samplePreviewResponseBytes :: LBS.ByteString
samplePreviewResponseBytes =
"{\
\ \"preview\": [{\"order_count\": 42}],\
\ \"generated_dest_index\": {\
\ \"mappings\": {\"properties\": {\"order_count\": {\"type\": \"long\"}}}\
\ }\
\}"
spec :: Spec
spec = do
describe "TransformState JSON" $ do
it "decodes documented states" $ do
decode "\"started\"" `shouldBe` (Just TransformStateStarted :: Maybe TransformState)
decode "\"stopped\"" `shouldBe` (Just TransformStateStopped :: Maybe TransformState)
decode "\"failed\"" `shouldBe` (Just TransformStateFailed :: Maybe TransformState)
decode "\"indexing\"" `shouldBe` (Just TransformStateIndexing :: Maybe TransformState)
it "round-trips a custom state through the escape hatch" $ do
let Just (TransformStateCustom t) =
decode "\"restarting\"" :: Maybe TransformState
t `shouldBe` "restarting"
encode (TransformStateCustom "restarting") `shouldBe` "\"restarting\""
describe "TransformHealth JSON" $ do
it "decodes documented health values" $ do
decode "\"green\"" `shouldBe` (Just TransformHealthGreen :: Maybe TransformHealth)
decode "\"yellow\"" `shouldBe` (Just TransformHealthYellow :: Maybe TransformHealth)
decode "\"red\"" `shouldBe` (Just TransformHealthRed :: Maybe TransformHealth)
it "round-trips a custom health through the escape hatch" $ do
let Just (TransformHealthCustom t) =
decode "\"unknown\"" :: Maybe TransformHealth
t `shouldBe` "unknown"
describe "TransformConfig JSON (PUT body)" $ do
it "decodes a canonical pivot transform" $ do
let Just decoded = decode samplePivotConfigBytes :: Maybe TransformConfig
NE.toList (tsocIndex (tcSource decoded))
`shouldBe` [IndexPattern "kibana_sample_data_ecommerce"]
tdestIndex (tcDest decoded) `shouldBe` idxName "kibana_sample_data_ecommerce_transform"
-- The pivot body is carried as an opaque Value.
tcPivot decoded `shouldSatisfy` isJust
tcLatest decoded `shouldBe` Nothing
tcDescription decoded `shouldBe` Just "ecommerce transform"
tcFrequency decoded `shouldBe` Just "1m"
tcId decoded `shouldBe` Nothing
it "decodes a latest transform with typed latest config" $ do
let Just decoded = decode sampleLatestConfigBytes :: Maybe TransformConfig
-- A bare-string-free array index list.
NE.toList (tsocIndex (tcSource decoded)) `shouldBe` [IndexPattern "logs-*"]
tdestPipeline (tcDest decoded) `shouldBe` Just "logs-pipeline"
tcPivot decoded `shouldBe` Nothing
let Just latest = tcLatest decoded
NE.toList (tlUniqueKey latest)
`shouldBe` [FieldName "host", FieldName "service"]
tlSort latest `shouldBe` FieldName "@timestamp"
it "preserves an opaque query, typed sync/settings and unknown fields in extras" $ do
let Just decoded = decode sampleContinuousConfigBytes :: Maybe TransformConfig
-- The opaque query survives on the source.
tsocQuery (tcSource decoded) `shouldSatisfy` isJust
-- The sync.time sub-object is typed.
let Just sync = tcSync decoded
Just syncTime = tsyTime sync
tstField syncTime `shouldBe` FieldName "@timestamp"
tstDelay syncTime `shouldBe` Just "60s"
-- The settings scalars are typed.
let Just settings = tcSettings decoded
tsMaxPageSearchSize settings `shouldBe` Just 500
tsDocsPerSecond settings `shouldBe` Just 200
tsAlignCheckpoints settings `shouldBe` Just True
tsDatesAsEpochMillis settings `shouldBe` Just False
-- The unknown @created_by@ key survives in the config extras catch-all.
KM.lookup "created_by" (tcExtras decoded) `shouldSatisfy` isJust
it "round-trips a pivot config through encode . decode" $ do
let Just decoded = decode samplePivotConfigBytes :: Maybe TransformConfig
Just roundTripped = decode (encode decoded) :: Maybe TransformConfig
NE.toList (tsocIndex (tcSource roundTripped))
`shouldBe` [IndexPattern "kibana_sample_data_ecommerce"]
tcDescription roundTripped `shouldBe` Just "ecommerce transform"
tcPivot roundTripped `shouldSatisfy` isJust
it "round-trips a continuous config with extras through encode . decode" $ do
let Just decoded = decode sampleContinuousConfigBytes :: Maybe TransformConfig
Just roundTripped = decode (encode decoded) :: Maybe TransformConfig
-- The extras catch-all preserves the unknown sibling.
KM.lookup "created_by" (tcExtras roundTripped) `shouldSatisfy` isJust
-- The known @sync@ key is NOT duplicated into extras.
KM.lookup "sync" (tcExtras roundTripped) `shouldBe` Nothing
it "rejects a config missing required 'source'" $ do
let decoded =
decode
"{\"dest\":{\"index\":\"d\"},\"pivot\":{}}" ::
Maybe TransformConfig
decoded `shouldBe` Nothing
it "rejects a config missing required 'dest'" $ do
let decoded =
decode
"{\"source\":{\"index\":\"s\"},\"pivot\":{}}" ::
Maybe TransformConfig
decoded `shouldBe` Nothing
it "rejects a source with an empty 'index' array" $ do
let decoded =
decode
"{\"source\":{\"index\":[]},\"dest\":{\"index\":\"d\"},\"pivot\":{}}" ::
Maybe TransformConfig
decoded `shouldBe` Nothing
it "accepts a bare-string 'index' and normalises it to a singleton list" $ do
let Just decoded =
decode
"{\"source\":{\"index\":\"single\"},\"dest\":{\"index\":\"d\"},\"pivot\":{}}" ::
Maybe TransformConfig
NE.toList (tsocIndex (tcSource decoded)) `shouldBe` [IndexPattern "single"]
describe "TransformLatest JSON" $ do
it "rejects a latest with an empty 'unique_key' array" $ do
let decoded =
decode
"{\"unique_key\":[],\"sort\":\"ts\"}" ::
Maybe TransformLatest
decoded `shouldBe` Nothing
it "accepts a bare-string 'unique_key'" $ do
let Just decoded =
decode
"{\"unique_key\":\"host\",\"sort\":\"ts\"}" ::
Maybe TransformLatest
NE.toList (tlUniqueKey decoded) `shouldBe` [FieldName "host"]
describe "TransformsResponse JSON (GET response)" $ do
it "decodes the {count, transforms:[...]} envelope" $ do
let Just decoded = decode sampleListResponseBytes :: Maybe TransformsResponse
trCount decoded `shouldBe` Just 2
length (trTransforms decoded) `shouldBe` 2
let firstCfg = head (trTransforms decoded)
tcId firstCfg `shouldBe` Just (TransformId "ecommerce-transform")
tcPivot firstCfg `shouldSatisfy` isJust
let secondCfg = trTransforms decoded !! 1
tcId secondCfg `shouldBe` Just (TransformId "latest-transform")
tcLatest secondCfg `shouldSatisfy` isJust
it "re-encodes to the same envelope shape" $ do
let Just decoded = decode sampleListResponseBytes :: Maybe TransformsResponse
Just reparsed = decode (encode decoded) :: Maybe TransformsResponse
trCount reparsed `shouldBe` Just 2
length (trTransforms reparsed) `shouldBe` 2
describe "TransformStatsResponse JSON" $ do
it "decodes the state enum and document counters" $ do
let Just decoded = decode sampleStatsResponseBytes :: Maybe TransformStatsResponse
tstrCount decoded `shouldBe` Just 1
length (tstrStats decoded) `shouldBe` 1
let entry = head (tstrStats decoded)
tstatId entry `shouldBe` Just (TransformId "ecommerce-transform")
tstatState entry `shouldBe` Just TransformStateStarted
tstatHealth entry `shouldBe` Just TransformHealthGreen
tstatDocumentsIndexed entry `shouldBe` Just 1000
tstatDocumentsProcessed entry `shouldBe` Just 1200
-- The opaque checkpointing object survives.
tstatCheckpointing entry `shouldSatisfy` isJust
it "round-trips through encode . decode" $ do
let Just decoded = decode sampleStatsResponseBytes :: Maybe TransformStatsResponse
Just reparsed = decode (encode decoded) :: Maybe TransformStatsResponse
tstatState (head (tstrStats reparsed)) `shouldBe` Just TransformStateStarted
describe "TransformExplainResponse JSON" $ do
it "decodes the transforms array carrying the id plus extras" $ do
let Just decoded = decode sampleExplainResponseBytes :: Maybe TransformExplainResponse
length (texrTransforms decoded) `shouldBe` 1
let entry = head (texrTransforms decoded)
teeId entry `shouldBe` Just (TransformId "ecommerce-transform")
-- The version-dependent @state@ sub-object survives in extras.
KM.lookup "state" (teeExtras entry) `shouldSatisfy` isJust
describe "PreviewTransformResponse JSON" $ do
it "decodes the preview array and generated dest index" $ do
let Just decoded = decode samplePreviewResponseBytes :: Maybe PreviewTransformResponse
prtvPreview decoded `shouldSatisfy` isJust
prtvGeneratedDestIndex decoded `shouldSatisfy` isJust
length <$> prtvPreview decoded `shouldBe` Just 1
describe "PutTransformResponse / DeleteTransformResponse / StopTransformResponse JSON" $ do
it "decodes a PUT response" $ do
let Just decoded = decode "{\"id\":\"x\",\"acknowledged\":true}" :: Maybe PutTransformResponse
ptrId decoded `shouldBe` Just (TransformId "x")
ptrAcknowledged decoded `shouldBe` Just True
it "decodes a DELETE response with deleted_tasks_count (ES 8+)" $ do
let Just decoded =
decode
"{\"acknowledged\":true,\"deleted_tasks_count\":1}" ::
Maybe DeleteTransformResponse
dtrAcknowledged decoded `shouldBe` Just True
dtrDeletedTasksCount decoded `shouldBe` Just 1
it "decodes a STOP response with stopped flag (ES 8+)" $ do
let Just decoded =
decode
"{\"acknowledged\":true,\"stopped\":true}" ::
Maybe StopTransformResponse
stopTAcknowledged decoded `shouldBe` Just True
stopTStopped decoded `shouldBe` Just True
describe "options params" $ do
it "default options render no query string" $ do
putTransformOptionsParams defaultPutTransformOptions `shouldBe` []
getTransformsOptionsParams defaultGetTransformsOptions `shouldBe` []
stopTransformOptionsParams defaultStopTransformOptions `shouldBe` []
it "bool params render as true/false strings" $ do
let opts =
defaultPutTransformOptions
{ ptoDeferValidation = Just True
}
putTransformOptionsParams opts
`shouldBe` [("defer_validation", Just "true")]
it "from/size render via showText" $ do
let opts =
defaultGetTransformsOptions
{ gtoFrom = Just 10,
gtoSize = Just 25
}
getTransformsOptionsParams opts
`shouldBe` [("from", Just "10"), ("size", Just "25")]
it "wait_for_completion passes the timeout string through" $ do
let opts =
defaultStopTransformOptions
{ sopoWaitForCompletion = Just "30s"
}
stopTransformOptionsParams opts
`shouldBe` [("wait_for_completion", Just "30s")]
describe "defaultTransformConfig" $ do
it "builds a minimal config with no pivot/latest" $ do
let cfg =
defaultTransformConfig
(defaultTransformSource (IndexPattern "src"))
(defaultTransformDestination (idxName "dst"))
NE.toList (tsocIndex (tcSource cfg)) `shouldBe` [IndexPattern "src"]
tdestIndex (tcDest cfg) `shouldBe` idxName "dst"
tcPivot cfg `shouldBe` Nothing
tcLatest cfg `shouldBe` Nothing
tcExtras cfg `shouldBe` KM.empty