bloodhound-1.0.0.0: tests/Test/ConnectorsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
module Test.ConnectorsSpec (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 "Connectors API (/_connector*)" $ do
describe "ConnectorId / ConnectorSyncJobId JSON" $ do
it "round-trips a ConnectorId as a bare string" $
decode (encode (Types.ConnectorId "my-conn" :: Types.ConnectorId))
`shouldBe` Just (Types.ConnectorId "my-conn")
it "round-trips a ConnectorSyncJobId" $
decode (encode (Types.ConnectorSyncJobId "job-1"))
`shouldBe` Just (Types.ConnectorSyncJobId "job-1")
describe "Connector (GET entity) decoding" $ do
it "decodes the typed scalar fields and parks the rest in extras" $ do
let raw =
LBS.pack
"{\"id\":\"c1\",\"name\":\"wiki\",\"index_name\":\"wiki-index\",\
\\"is_native\":false,\"service_type\":\"sharepoint\",\"status\":\"configured\",\
\\"configuration\":{\"foo\":\"bar\"},\"last_seen\":\"2024-01-01\"}"
case decode raw :: Maybe Types.Connector of
Just c -> do
Types.cId c `shouldBe` Just "c1"
Types.cName c `shouldBe` Just "wiki"
Types.cIndexName c `shouldBe` Just "wiki-index"
Types.cIsNative c `shouldBe` Just False
Types.cServiceType c `shouldBe` Just "sharepoint"
Types.cStatus c `shouldBe` Just "configured"
-- extras carries configuration + last_seen
case Types.cExtras c of
Just _ -> pure ()
Nothing -> expectationFailure "extras should retain untyped fields"
Nothing -> expectationFailure "failed to decode Connector"
it "round-trips a Connector (typed fields survive, extras merge)" $ do
let c =
Types.Connector
{ Types.cId = Just "c1",
Types.cName = Just "wiki",
Types.cDescription = Nothing,
Types.cIndexName = Just "wiki-index",
Types.cIsNative = Just False,
Types.cLanguage = Nothing,
Types.cServiceType = Just "sharepoint",
Types.cStatus = Just "configured",
Types.cError = Nothing,
Types.cApiKeyId = Nothing,
Types.cApiKeySecretId = Nothing,
Types.cSyncNow = Nothing,
Types.cExtras = Nothing
}
decode (encode c) `shouldBe` Just c
describe "envelope responses" $ do
it "ConnectorMutationResult decodes {result}" $
decode "{\"result\":\"created\"}"
`shouldBe` Just (Types.ConnectorMutationResult (Just "created"))
it "ConnectorCreateResponse decodes {result, id}" $
decode "{\"result\":\"created\",\"id\":\"abc\"}"
`shouldBe` Just
( Types.ConnectorCreateResponse
{ Types.ccrResult = Just "created",
Types.ccrId = Just "abc"
}
)
it "ConnectorSyncJobCreateResponse decodes bare {id} (no result)" $
decode "{\"id\":\"job-7\"}"
`shouldBe` Just
(Types.ConnectorSyncJobCreateResponse {Types.sjcrId = Just "job-7"})
it "ConnectorListResponse decodes {count, results}" $ do
let raw =
LBS.pack
"{\"count\":1,\"results\":[{\"id\":\"c1\",\"name\":\"wiki\"}]}"
case decode raw :: Maybe Types.ConnectorListResponse of
Just resp -> do
Types.clrCount resp `shouldBe` Just 1
length (Types.clrResults resp) `shouldBe` 1
Nothing -> expectationFailure "failed to decode ConnectorListResponse"
describe "connectorListOptionsParams URI rendering" $ do
it "default emits no params" $
Types.connectorListOptionsParams Types.defaultConnectorListOptions
`shouldBe` []
it "renders from/size and the filters" $ do
let opts =
Types.defaultConnectorListOptions
{ Types.cloFrom = Just 0,
Types.cloSize = Just 25,
Types.cloServiceType = Just "sharepoint",
Types.cloQuery = Just "wiki"
}
normalize (Types.connectorListOptionsParams opts)
`shouldBe` sort
[ ("from", Just "0"),
("size", Just "25"),
("service_type", Just "sharepoint"),
("q", Just "wiki")
]
describe "CreateConnectorRequest JSON" $ do
it "omits Nothing fields" $ do
let req =
Types.defaultCreateConnectorRequest
{ Types.ccrName = Just "wiki",
Types.ccrIndexName = Just "wiki-index",
Types.ccrServiceType = Just "sharepoint"
}
encode req
`shouldBe` "{\"index_name\":\"wiki-index\",\"name\":\"wiki\",\"service_type\":\"sharepoint\"}"
decode (encode req) `shouldBe` Just req
describe "endpoint shape (core CRUD)" $ do
it "POSTs /_connector with a JSON body" $ do
let req =
RequestsES8.createConnector
Types.defaultCreateConnectorRequest
{ Types.ccrName = Just "wiki"
}
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_connector"]
bhRequestBody req `shouldSatisfy` isJust
it "PUTs /_connector/<id> with a body" $ do
let req =
RequestsES8.putConnector
"c1"
Types.defaultCreateConnectorRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_connector/<id> with no body" $ do
let req = RequestsES8.getConnector "c1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1"]
bhRequestBody req `shouldSatisfy` isNothing
it "DELETEs /_connector/<id> (no params by default)" $ do
let req = RequestsES8.deleteConnector "c1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "deleteConnectorWith appends delete_sync_jobs" $ do
let req = RequestsES8.deleteConnectorWith (Just True) "c1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("delete_sync_jobs", Just "true")]
it "listConnectorsWith appends query params" $ do
let req =
RequestsES8.listConnectorsWith
Types.defaultConnectorListOptions {Types.cloSize = Just 10}
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_connector"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("size", Just "10")]
describe "endpoint shape (connector actions)" $ do
it "PUTs /_connector/<id>/_check_in with empty body" $ do
let req = RequestsES8.checkInConnector "c1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1", "_check_in"]
bhRequestBody req `shouldSatisfy` isJust
it "PUTs /_connector/<id>/_filtering/_validation" $ do
let req =
RequestsES8.updateConnectorDraftFilteringValidation
"c1"
(Types.UpdateConnectorFilteringValidationRequest Nothing)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1", "_filtering", "_validation"]
it "PUTs /_connector/<id>/_filtering/_activate (no body)" $ do
let req = RequestsES8.activateConnectorDraftFiltering "c1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "c1", "_filtering", "_activate"]
bhRequestBody req `shouldSatisfy` isJust
describe "endpoint shape (sync jobs)" $ do
it "POSTs /_connector/_sync_job with a body" $ do
let req =
RequestsES8.createConnectorSyncJob
( Types.CreateConnectorSyncJobRequest
{ Types.csjrId = Just "c1",
Types.csjrJobType = Just "full",
Types.csjrTriggerMethod = Just "on_demand"
}
)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "_sync_job"]
bhRequestBody req `shouldSatisfy` isJust
it "GETs /_connector/_sync_job/<id>" $ do
let req = RequestsES8.getConnectorSyncJob "job-1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "_sync_job", "job-1"]
bhRequestBody req `shouldSatisfy` isNothing
it "DELETEs /_connector/_sync_job/<id>" $ do
let req = RequestsES8.deleteConnectorSyncJob "job-1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "_sync_job", "job-1"]
it "PUTs /_connector/_sync_job/<id>/_cancel" $ do
let req = RequestsES8.cancelConnectorSyncJob "job-1"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "_sync_job", "job-1", "_cancel"]
it "PUTs /_connector/_sync_job/<id>/_claim with a body" $ do
let req =
RequestsES8.claimConnectorSyncJob
"job-1"
(Types.ClaimConnectorSyncJobRequest Nothing Nothing)
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_connector", "_sync_job", "job-1", "_claim"]
bhRequestBody req `shouldSatisfy` isJust
describe "live integration (requires ES8+ backend)" $
backendSpecific [ElasticSearch8] $ do
it "round-trips a connector via POST then GET then DELETE" $
withTestEnv $ do
let cid = Types.ConnectorId "bloodhound-test-connector"
body =
Types.defaultCreateConnectorRequest
{ Types.ccrName = Just "bloodhound-test-connector",
Types.ccrIndexName = Just "bloodhound-test-connector",
Types.ccrServiceType = Just "sharepoint"
}
_ <- tryPerformBHRequest $ RequestsES8.deleteConnector cid
createResp <- tryEsError (ClientES8.createConnector body)
case createResp of
Left e
| errorStatus e == Just 403 ->
liftIO $
pendingWith
"connectors API requires the edit_connectors \
\cluster privilege; skip on locked-down clusters"
| "privilege" `T.isInfixOf` T.toLower (errorMessage e) ->
liftIO $
pendingWith
"connectors API rejected the request for missing \
\privilege on this cluster"
| errorStatus e == Just 404 ->
liftIO $
pendingWith
"connectors API returned 404 on POST /_connector; \
\feature not provisioned on this cluster"
| "not found" `T.isInfixOf` T.toLower (errorMessage e) ->
liftIO $
pendingWith
"connectors API reported 'not found' on create; \
\endpoint unavailable on this cluster"
Left e ->
liftIO $
expectationFailure ("unexpected POST error: " <> show e)
Right resp -> do
-- The ES Connectors API auto-generates the connector _id
-- on POST; the @name@ / @index_name@ we send in the body
-- are *not* the id. Prefer the id the server returned in
-- 'ConnectorCreateResponse' and only fall back to the
-- caller-supplied @cid@ if the server omitted it.
let lookupId = fromMaybe cid (Types.ccrId resp)
fetched <- tryPerformBHRequest $ RequestsES8.getConnector lookupId
case fetched of
Right c -> liftIO $ Types.cName c `shouldBe` Just "bloodhound-test-connector"
Left _ -> liftIO $ pure ()
-- Best-effort cleanup: a 404 here (e.g. the connector was
-- already reaped by a prior run, or the server returned no
-- id and the fallback cid does not match) must not fail
-- the test.
_ <- tryEsError (ClientES8.deleteConnector lookupId)
pure ()