bloodhound-1.0.0.0: tests/Test/MlAnomalySpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.MlAnomalySpec (spec) where
import Data.Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Scientific (Scientific)
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import Database.Bloodhound.ElasticSearch9.Types qualified as Types
import TestsUtils.Import
spec :: Spec
spec =
describe "ML anomaly APIs (/_ml/anomaly_detectors, /_ml/datafeeds, /_ml/filters, /_ml/calendars)" $ do
describe "identities and enums" $ do
it "round-trips MlJobId / MlModelSnapshotId / MlDatafeedId / MlFilterId / MlCalendarId" $ do
decode (encode (Types.MlJobId "j")) `shouldBe` Just (Types.MlJobId "j")
decode (encode (Types.MlModelSnapshotId "s"))
`shouldBe` Just (Types.MlModelSnapshotId "s")
decode (encode (Types.MlDatafeedId "f"))
`shouldBe` Just (Types.MlDatafeedId "f")
decode (encode (Types.MlFilterId "fi"))
`shouldBe` Just (Types.MlFilterId "fi")
decode (encode (Types.MlCalendarId "c"))
`shouldBe` Just (Types.MlCalendarId "c")
it "round-trips documented job states and absorbs unknowns" $ do
mapM_
(\s -> decode (encode s) `shouldBe` Just s)
[ Types.MlJobStateOpening,
Types.MlJobStateOpened,
Types.MlJobStateClosing,
Types.MlJobStateClosed,
Types.MlJobStateFailed
]
decode "\"paused\"" `shouldBe` Just (Types.MlJobStateCustom "paused")
it "maps result types to documented path segments" $ do
Types.mlAnomalyResultTypeSegment Types.MlAnomalyResultBuckets
`shouldBe` ("buckets" :: Text)
Types.mlAnomalyResultTypeSegment Types.MlAnomalyResultRecords
`shouldBe` ("records" :: Text)
Types.mlAnomalyResultTypeSegment Types.MlAnomalyResultOverallBuckets
`shouldBe` ("overall_buckets" :: Text)
describe "MlAnomalyJob JSON" $ do
it "encodes the minimal body as analysis_config + data_description" $ do
let job =
Types.defaultMlAnomalyJob
{ Types.mlajAnalysisConfig = object ["detectors" .= ([] :: [Int])],
Types.mlajDataDescription = object ["time_field" .= ("timestamp" :: Text)]
}
decode (encode job) `shouldBe` Just job
it "omits optional fields" $ do
let job =
Types.defaultMlAnomalyJob
{ Types.mlajAnalysisConfig = object []
}
case decode (encode job) :: Maybe Value of
Just (Object o) -> KeyMap.member "description" o `shouldBe` False
_ -> expectationFailure "expected a JSON object"
describe "MlAnomalyJobUpdate JSON" $ do
it "encodes to {} when empty" $
encode Types.defaultMlAnomalyJobUpdate `shouldBe` "{}"
describe "MlForecastOptions / MlFlushOptions JSON" $ do
it "forecast round-trips a populated body" $ do
let o =
Types.defaultMlForecastOptions
{ Types.mlfoDuration = Just "1d"
}
decode (encode o) `shouldBe` Just o
it "flush encodes to {} when empty" $
encode Types.defaultMlFlushOptions `shouldBe` "{}"
describe "MlAnomalyJobOptions params" $ do
it "renders nothing by default" $
Types.mlAnomalyJobOptionsParams Types.defaultMlAnomalyJobOptions
`shouldBe` []
it "renders force/timeout/from in order" $ do
let opts =
Types.defaultMlAnomalyJobOptions
{ Types.mlajoForce = Just True,
Types.mlajoTimeout = Just "30s",
Types.mlajoFrom = Just 10
}
Types.mlAnomalyJobOptionsParams opts
`shouldBe` [ ("from", Just "10"),
("force", Just "true"),
("timeout", Just "30s")
]
describe "MlAnomalyJobDocument JSON" $ do
it "decodes a stored job, folding extras" $ do
let raw =
LBS.pack
"{\"job_id\":\"j\",\"state\":\"open\",\"job_type\":\"anomaly_detector\",\
\\"create_time\":1700000000000,\"analysis_config\":{}}"
case decode raw :: Maybe Types.MlAnomalyJobDocument of
Just d -> do
Types.mlajdId d `shouldBe` Just ("j" :: Text)
Types.mlajdState d `shouldBe` Just Types.MlJobStateOpened
extras <- pure (Types.mlajdExtras d)
extras `shouldSatisfy` isJust
forM_ extras $ \km ->
KeyMap.lookup "analysis_config" km `shouldSatisfy` isJust
Nothing -> expectationFailure "failed to decode document"
describe "MlAnomalyJobsResponse JSON" $ do
it "decodes a jobs envelope, defaulting count" $ do
let raw = LBS.pack "{\"jobs\":[{\"job_id\":\"a\"},{\"job_id\":\"b\"}]}"
case decode raw :: Maybe Types.MlAnomalyJobsResponse of
Just r -> do
length (Types.mlajsrJobs r) `shouldBe` 2
Types.mlajsrCount r `shouldBe` Just 2
Nothing -> expectationFailure "failed to decode jobs response"
describe "MlAnomalyJobStatsResponse JSON" $ do
it "decodes a stats envelope" $ do
let raw =
LBS.pack
"{\"jobs\":[{\"job_id\":\"a\",\"state\":\"closed\",\"data_counts\":{}}]}"
case decode raw :: Maybe Types.MlAnomalyJobStatsResponse of
Just r -> do
length (Types.mlajsrrStats r) `shouldBe` 1
case Types.mlajsrrStats r of
(s : _) -> Types.mlajssState s `shouldBe` Just Types.MlJobStateClosed
[] -> expectationFailure "expected one stats entry"
Nothing -> expectationFailure "failed to decode stats response"
describe "forecast / flush responses" $ do
it "decodes a forecast response, defaulting acknowledged" $ do
let raw = LBS.pack "{\"forecast_id\":\"abc\"}"
case decode raw :: Maybe Types.MlForecastResponse of
Just r -> do
Types.mlfrAcknowledged r `shouldBe` Just True
Types.mlfrForecastId r `shouldBe` Just ("abc" :: Text)
Nothing -> expectationFailure "failed to decode forecast response"
it "decodes a flush response" $ do
let raw = LBS.pack "{\"acknowledged\":true,\"last_finalized_bucket_end\":1234}"
case decode raw :: Maybe Types.MlFlushResponse of
Just r -> Types.mlflrLastFinalizedBucketEnd r `shouldBe` Just (1234 :: Scientific)
Nothing -> expectationFailure "failed to decode flush response"
describe "model snapshots" $ do
it "decodes a snapshots envelope, defaulting count" $ do
let raw =
LBS.pack
"{\"model_snapshots\":[{\"id\":\"s1\",\"description\":\"d\"}]}"
case decode raw :: Maybe Types.MlModelSnapshotsResponse of
Just r -> do
length (Types.mlmsrSnapshots r) `shouldBe` 1
Types.mlmsrCount r `shouldBe` Just 1
Nothing -> expectationFailure "failed to decode snapshots response"
it "decodes a revert response" $ do
let raw =
LBS.pack
"{\"model\":{\"foo\":1},\"model_snapshot_id\":\"s1\"}"
case decode raw :: Maybe Types.MlRevertSnapshotResponse of
Just r -> do
Types.mlrsrModelSnapshotId r `shouldBe` Just ("s1" :: Text)
Types.mlrsrModel r `shouldSatisfy` isJust
Nothing -> expectationFailure "failed to decode revert response"
describe "MlDatafeed JSON" $ do
it "encodes the minimal body with job_id only" $ do
let df = Types.defaultMlDatafeed {Types.mldfJobId = "myjob"}
case decode (encode df) :: Maybe Types.MlDatafeed of
Just d -> Types.mldfJobId d `shouldBe` ("myjob" :: Text)
Nothing -> expectationFailure "datafeed should round-trip"
it "decodes a datafeed document" $ do
let raw = LBS.pack "{\"datafeeds\":[{\"datafeed_id\":\"f\",\"job_id\":\"j\"}]}"
case decode raw :: Maybe Types.MlDatafeedsResponse of
Just r -> do
length (Types.mldfrDatafeeds r) `shouldBe` 1
case Types.mldfrDatafeeds r of
(d : _) -> Types.mldfdJobId d `shouldBe` Just ("j" :: Text)
[] -> expectationFailure "expected one datafeed"
Nothing -> expectationFailure "failed to decode datafeeds response"
describe "filters" $ do
it "decodes a filters response" $ do
let raw =
LBS.pack
"{\"filters\":[{\"id\":\"fi\",\"items\":[\"a\"]}]}"
case decode raw :: Maybe Types.MlFiltersResponse of
Just r -> length (Types.mlfsrFilters r) `shouldBe` 1
Nothing -> expectationFailure "failed to decode filters response"
it "round-trips a filter body" $ do
let f =
Types.defaultMlFilter
{ Types.mlfItems = Just ["a", "b"]
}
decode (encode f) `shouldBe` Just f
describe "calendars and scheduled events" $ do
it "decodes a calendars response" $ do
let raw =
LBS.pack
"{\"calendars\":[{\"calendar_id\":\"c\",\"description\":\"d\"}]}"
case decode raw :: Maybe Types.MlCalendarsResponse of
Just r -> length (Types.mlcsrCalendars r) `shouldBe` 1
Nothing -> expectationFailure "failed to decode calendars response"
it "encodes scheduled events as {events: [...]}" $ do
let e =
Types.MlScheduledEvents
{ Types.mlseEvents = [object ["description" .= ("d" :: Text)]]
}
encode e `shouldBe` "{\"events\":[{\"description\":\"d\"}]}"
describe "endpoint shape" $ do
it "PUTs /_ml/anomaly_detectors/<id>" $ do
let req = RequestsES9.putMlAnomalyJob "j" Types.defaultMlAnomalyJob
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_ml/anomaly_detectors/<id>" $ do
let req = RequestsES9.getMlAnomalyJobs (Just "j")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j"]
it "POSTs /_ml/anomaly_detectors/<id>/_open (empty body)" $ do
let req = RequestsES9.openMlAnomalyJob "j"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j", "_open"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_ml/anomaly_detectors/<id>/_results/<kind>" $ do
let req = RequestsES9.getMlAnomalyJobResults "j" Types.MlAnomalyResultBuckets
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j", "_results", "buckets"]
it "GETs /_ml/anomaly_detectors/<id>/_model_snapshots/<snap>" $ do
let req = RequestsES9.getMlModelSnapshot "j" "snap"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j", "_model_snapshots", "snap"]
it "POSTs /_ml/anomaly_detectors/<id>/_model_snapshots/<snap>/_revert" $ do
let req = RequestsES9.revertMlModelSnapshot "j" "snap" (object [])
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "j", "_model_snapshots", "snap", "_revert"]
bhRequestBody req `shouldSatisfy` isJust
it "POSTs /_ml/anomaly_detectors/_validate" $ do
let req = RequestsES9.validateMlAnomalyJob Types.defaultMlAnomalyJob
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "anomaly_detectors", "_validate"]
it "PUTs /_ml/datafeeds/<id>" $ do
let req = RequestsES9.putMlDatafeed "f" Types.defaultMlDatafeed
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "datafeeds", "f"]
it "POSTs /_ml/datafeeds/<id>/_start with a body" $ do
let req = RequestsES9.startMlDatafeed "f" (object [])
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "datafeeds", "f", "_start"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_ml/datafeeds/<id>/_preview" $ do
let req = RequestsES9.previewMlDatafeed "f"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "datafeeds", "f", "_preview"]
it "PUTs /_ml/filters/<id>" $ do
let req = RequestsES9.putMlFilter "fi" Types.defaultMlFilter
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "filters", "fi"]
it "GETs /_ml/filters/<id>" $ do
let req = RequestsES9.getMlFilters (Just "fi")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "filters", "fi"]
it "PUTs /_ml/calendars/<id>" $ do
let req = RequestsES9.putMlCalendar "c" Types.defaultMlCalendar
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "calendars", "c"]
it "POSTs /_ml/calendars/<id>/events" $ do
let req = RequestsES9.setMlScheduledEvents "c" (Types.MlScheduledEvents [])
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "calendars", "c", "events"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_ml/calendars/<id>/events" $ do
let req = RequestsES9.getMlScheduledEvents "c"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_ml", "calendars", "c", "events"]