bloodhound-1.0.0.0: tests/Test/MLTaskSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.MLTaskSpec (spec) where
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Database.Bloodhound.OpenSearch1.Requests qualified as OS1Requests
import Database.Bloodhound.OpenSearch1.Types qualified as OS1Types
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2Types
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Import
import Prelude
-- | A representative response body for @GET /_plugins/_ml/tasks/{task_id}@
-- after a successful model registration. Drawn from the shape documented at
-- <https://docs.opensearch.org/latest/ml-commons-plugin/api/model-apis/get-task/>.
sampleRegisterCompleted :: LBS.ByteString
sampleRegisterCompleted =
"{\
\ \"task_id\": \"ew8I44MBhyWuIwnfvDIH\",\
\ \"model_id\": \"d-MCYeIBUTn_O1uORDWuDw\",\
\ \"task_type\": \"REGISTER_MODEL\",\
\ \"function_name\": \"TEXT_EMBEDDING\",\
\ \"state\": \"COMPLETED\",\
\ \"worker_node\": [\"KzwtsZ7ySdeg1GqYuISPjg\"],\
\ \"create_time\": 1704110400000,\
\ \"last_update_time\": 1704110460000,\
\ \"is_async\": true\
\}"
-- | A @CREATED@ task that has not started running yet: no @last_update_time@,
-- no @worker_node@. Exercises the all-'Maybe' record's tolerance for the
-- sparse fields the plugin emits early in the lifecycle.
sampleCreatedSparse :: LBS.ByteString
sampleCreatedSparse =
"{\
\ \"task_id\": \"ew8I44MBhyWuIwnfvDIH\",\
\ \"model_id\": \"d-MCYeIBUTn_O1uORDWuDw\",\
\ \"task_type\": \"DEPLOY_MODEL\",\
\ \"state\": \"CREATED\",\
\ \"create_time\": 1704110400000,\
\ \"is_async\": true\
\}"
-- | A @FAILED@ task carries an @error@ string instead of a @worker_node@
-- list. Exercises the @error@ field plus an unrelated field (@model_id@)
-- being absent.
sampleFailed :: LBS.ByteString
sampleFailed =
"{\
\ \"task_type\": \"DEPLOY_MODEL\",\
\ \"state\": \"FAILED\",\
\ \"error\": \"node memory constraints prevented deployment\"\
\}"
-- | OS 1.x emits @worker_node@ as a bare string rather than an array. The
-- 'FromJSON' instance must coerce this into a one-element list so the same
-- client works against every backend version.
sampleWorkerNodeString :: LBS.ByteString
sampleWorkerNodeString =
"{\
\ \"task_type\": \"REGISTER_MODEL\",\
\ \"state\": \"RUNNING\",\
\ \"worker_node\": \"KzwtsZ7ySdeg1GqYuISPjg\"\
\}"
-- | A response with @state: NOT_FOUND@ — the plugin emits this when the
-- supplied task id has been cleaned up. No @task_type@, no @model_id@.
sampleNotFound :: LBS.ByteString
sampleNotFound =
"{\
\ \"state\": \"NOT_FOUND\"\
\}"
spec :: Spec
spec = describe "ML Commons Get Task API" $ do
describe "MLTaskId JSON" $ do
it "MLTaskId round-trips as a bare JSON string" $ do
encode (MLTaskId "task-xyz-123") `shouldBe` "\"task-xyz-123\""
decode "\"abc\"" `shouldBe` Just (MLTaskId "abc")
describe "MLTaskType JSON" $ do
it "decodes known task_type values" $ do
decode "\"REGISTER_MODEL\"" `shouldBe` Just MLTaskTypeRegisterModel
decode "\"DEPLOY_MODEL\"" `shouldBe` Just MLTaskTypeDeployModel
decode "\"TRAINING\"" `shouldBe` Just MLTaskTypeTraining
decode "\"REGISTER_REMOTE_MODEL\"" `shouldBe` Just MLTaskTypeRegisterRemoteModel
decode "\"DEPLOY_REMOTE_MODEL\"" `shouldBe` Just MLTaskTypeDeployRemoteModel
it "round-trips known values through ToJSON/FromJSON" $ do
encode MLTaskTypeRegisterModel `shouldBe` "\"REGISTER_MODEL\""
encode MLTaskTypeDeployModel `shouldBe` "\"DEPLOY_MODEL\""
it "decodes unknown values to MLTaskTypeOther without failing" $ do
let Just decoded = decode "\"SOMETHING_NEW_IN_OS4\"" :: Maybe MLTaskType
decoded `shouldBe` MLTaskTypeOther "SOMETHING_NEW_IN_OS4"
(decode . encode) decoded `shouldBe` Just decoded
it "rejects non-string task_type values" $ do
(decode "42" :: Maybe MLTaskType) `shouldBe` Nothing
(decode "{}" :: Maybe MLTaskType) `shouldBe` Nothing
describe "MLTaskState JSON" $ do
it "decodes known state values" $ do
decode "\"CREATED\"" `shouldBe` Just MLTaskStateCreated
decode "\"RUNNING\"" `shouldBe` Just MLTaskStateRunning
decode "\"COMPLETED\"" `shouldBe` Just MLTaskStateCompleted
decode "\"FAILED\"" `shouldBe` Just MLTaskStateFailed
decode "\"NOT_FOUND\"" `shouldBe` Just MLTaskStateNotFound
it "round-trips known values through ToJSON/FromJSON" $ do
encode MLTaskStateCreated `shouldBe` "\"CREATED\""
encode MLTaskStateFailed `shouldBe` "\"FAILED\""
it "decodes unknown values to MLTaskStateOther without failing" $ do
let Just decoded = decode "\"CANCELLED\"" :: Maybe MLTaskState
decoded `shouldBe` MLTaskStateOther "CANCELLED"
(decode . encode) decoded `shouldBe` Just decoded
it "rejects non-string state values" $ do
(decode "42" :: Maybe MLTaskState) `shouldBe` Nothing
(decode "[]" :: Maybe MLTaskState) `shouldBe` Nothing
describe "MLTaskInfo decode" $ do
it "decodes a fully-populated REGISTER_MODEL/COMPLETED response" $ do
let Just decoded = decode sampleRegisterCompleted :: Maybe MLTaskInfo
mlTaskInfoTaskId decoded `shouldBe` Just "ew8I44MBhyWuIwnfvDIH"
mlTaskInfoModelId decoded `shouldBe` Just "d-MCYeIBUTn_O1uORDWuDw"
mlTaskInfoTaskType decoded `shouldBe` Just MLTaskTypeRegisterModel
mlTaskInfoFunctionName decoded `shouldBe` Just "TEXT_EMBEDDING"
mlTaskInfoState decoded `shouldBe` Just MLTaskStateCompleted
mlTaskInfoWorkerNode decoded `shouldBe` Just ["KzwtsZ7ySdeg1GqYuISPjg"]
mlTaskInfoCreateTime decoded `shouldBe` Just 1704110400000
mlTaskInfoLastUpdateTime decoded `shouldBe` Just 1704110460000
mlTaskInfoIsAsync decoded `shouldBe` Just True
mlTaskInfoError decoded `shouldBe` Nothing
it "decodes a sparse CREATED response (absent fields become Nothing)" $ do
let Just decoded = decode sampleCreatedSparse :: Maybe MLTaskInfo
mlTaskInfoTaskId decoded `shouldBe` Just "ew8I44MBhyWuIwnfvDIH"
mlTaskInfoCreateTime decoded `shouldBe` Just 1704110400000
mlTaskInfoLastUpdateTime decoded `shouldBe` Nothing
mlTaskInfoWorkerNode decoded `shouldBe` Nothing
mlTaskInfoError decoded `shouldBe` Nothing
mlTaskInfoState decoded `shouldBe` Just MLTaskStateCreated
it "decodes a FAILED response carrying an error string" $ do
let Just decoded = decode sampleFailed :: Maybe MLTaskInfo
mlTaskInfoState decoded `shouldBe` Just MLTaskStateFailed
mlTaskInfoError decoded `shouldBe` Just "node memory constraints prevented deployment"
mlTaskInfoModelId decoded `shouldBe` Nothing
it "coerces a single-string worker_node (OS 1.x shape) to a one-element list" $ do
let Just decoded = decode sampleWorkerNodeString :: Maybe MLTaskInfo
mlTaskInfoWorkerNode decoded `shouldBe` Just ["KzwtsZ7ySdeg1GqYuISPjg"]
-- The tolerant worker_node parser silently shapes unexpected inputs into
-- the closest safe value rather than failing the record decode. These
-- three tests pin the chosen behaviour so a future tightening (or
-- loosening) of the parser is a deliberate decision rather than an
-- accidental side effect of a refactor.
it "decodes an empty worker_node array as Just [] (empty list, not Nothing)" $ do
let Just decoded = decode "{\"worker_node\":[]}" :: Maybe MLTaskInfo
mlTaskInfoWorkerNode decoded `shouldBe` Just ([] :: [Text])
it "silently drops non-String elements inside a worker_node array" $ do
-- OpenSearch never emits this, but the parser uses a list comprehension
-- @String t <- toList xs@ that filters rather than fails. Pin the
-- behaviour: the malformed element disappears and the rest survive.
let Just decoded = decode "{\"worker_node\":[\"ok\", 42, \"also-ok\"]}" :: Maybe MLTaskInfo
mlTaskInfoWorkerNode decoded `shouldBe` Just ["ok", "also-ok"]
it "coerces an unexpected worker_node shape (object) to Nothing" $ do
-- The catch-all @Just _ -> Nothing@ arm. If OpenSearch ever ships a
-- third shape, the field is silently absent rather than failing the
-- whole decode.
let Just decoded = decode "{\"worker_node\":{\"id\":\"x\"}}" :: Maybe MLTaskInfo
mlTaskInfoWorkerNode decoded `shouldBe` Nothing
it "decodes a NOT_FOUND response with only the state field" $ do
let Just decoded = decode sampleNotFound :: Maybe MLTaskInfo
mlTaskInfoState decoded `shouldBe` Just MLTaskStateNotFound
mlTaskInfoTaskType decoded `shouldBe` Nothing
it "decodes an empty object as all-Nothing fields" $ do
let Just decoded = decode "{}" :: Maybe MLTaskInfo
mlTaskInfoModelId decoded `shouldBe` Nothing
mlTaskInfoState decoded `shouldBe` Nothing
it "rejects a top-level array" $ do
(decode "[]" :: Maybe MLTaskInfo) `shouldBe` Nothing
it "rejects a field with the wrong type (state as a number)" $ do
let decoded = decode "{\"state\": 42}" :: Maybe MLTaskInfo
decoded `shouldBe` Nothing
it "rejects malformed JSON" $ do
(decode "{ not json" :: Maybe MLTaskInfo) `shouldBe` Nothing
describe "MLTaskInfo ToJSON round-trip" $ do
it "round-trips a fully-populated body through ToJSON/FromJSON" $ do
let Just decoded = decode sampleRegisterCompleted :: Maybe MLTaskInfo
(decode . encode) decoded `shouldBe` Just decoded
it "round-trips a sparse CREATED body through ToJSON/FromJSON" $ do
let Just decoded = decode sampleCreatedSparse :: Maybe MLTaskInfo
(decode . encode) decoded `shouldBe` Just decoded
it "ToJSON omits Nothing fields (no null leakage)" $ do
let Just decoded = decode sampleCreatedSparse :: Maybe MLTaskInfo
encoded = LBS.toStrict (encode decoded)
encoded `shouldSatisfy` not . BS.isInfixOf "\"error\""
encoded `shouldSatisfy` not . BS.isInfixOf "\"worker_node\""
encoded `shouldSatisfy` BS.isInfixOf "\"state\""
describe "getMLTask endpoint shape (OS1)" $ do
it "GETs /_plugins/_ml/tasks/{task_id}" $ do
let req = OS1Requests.getMLTask (OS1Types.MLTaskId "task-abc-123")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_ml", "tasks", "task-abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the GET method" $ do
let req = OS1Requests.getMLTask (OS1Types.MLTaskId "task-abc-123")
bhRequestMethod req `shouldBe` "GET"
it "does not attach a body" $ do
let req = OS1Requests.getMLTask (OS1Types.MLTaskId "task-abc-123")
bhRequestBody req `shouldBe` Nothing
describe "getMLTask endpoint shape (OS2)" $ do
it "GETs /_plugins/_ml/tasks/{task_id}" $ do
let req = OS2Requests.getMLTask (OS2Types.MLTaskId "task-abc-123")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_ml", "tasks", "task-abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the GET method" $ do
let req = OS2Requests.getMLTask (OS2Types.MLTaskId "task-abc-123")
bhRequestMethod req `shouldBe` "GET"
it "does not attach a body" $ do
let req = OS2Requests.getMLTask (OS2Types.MLTaskId "task-abc-123")
bhRequestBody req `shouldBe` Nothing
describe "getMLTask endpoint shape (OS3)" $ do
it "GETs /_plugins/_ml/tasks/{task_id}" $ do
let req = OS3Requests.getMLTask (MLTaskId "task-abc-123")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_ml", "tasks", "task-abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the GET method" $ do
let req = OS3Requests.getMLTask (MLTaskId "task-abc-123")
bhRequestMethod req `shouldBe` "GET"
it "does not attach a body" $ do
let req = OS3Requests.getMLTask (MLTaskId "task-abc-123")
bhRequestBody req `shouldBe` Nothing