bloodhound-1.0.0.0: tests/Test/OSAsyncSearchSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Test.OSAsyncSearchSpec (spec) where
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Database.Bloodhound.OpenSearch1.Requests qualified as OS1Requests
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3Types
import TestsUtils.Import
import Prelude
-- | Pure endpoint-shape tests for the OpenSearch async search plugin
-- (@\/_plugins\/_asynchronous_search@). These mirror the Elasticsearch
-- 'Test.AsyncSearchSpec' shape tests but assert the @\/_plugins\/@ path
-- prefix and the OS-specific function names.
--
-- Only the OpenSearch 3 'Requests' module is exercised here: the three
-- functions produce identical 'BHRequest' values at runtime across
-- 'OpenSearch1.Requests', 'OpenSearch2.Requests' and 'OpenSearch3.Requests'
-- (the async search plugin ships in all three versions with the same surface;
-- only the source spelling differs — OS1 wraps path lists in 'mkEndpoint'
-- explicitly because it lacks @OverloadedLists@). The REST path is the
-- fully-spelled @\/_plugins\/_asynchronous_search@ on every OpenSearch
-- version. JSON decoding of the shared 'AsyncSearchResult' is already pinned
-- by 'Test.AsyncSearchSpec'.
spec :: Spec
spec = describe "OpenSearch Async Search API" $ do
describe "submitOSAsyncSearch endpoint shape" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
it "POSTs to /_plugins/_asynchronous_search with no query string" $ do
let search = mkSearch (Just (MatchAllQuery Nothing)) Nothing
req = OS3Requests.submitOSAsyncSearch @Value search
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "attaches the encoded Search as the JSON body" $ do
let search = mkSearch (Just (MatchAllQuery Nothing)) Nothing
req = OS3Requests.submitOSAsyncSearch @Value search
bhRequestBody req `shouldBe` Just (encode search)
it "tracks the input Search: a different query yields a different body" $ do
let reqA = OS3Requests.submitOSAsyncSearch @Value (mkSearch (Just (MatchAllQuery Nothing)) Nothing)
reqB = OS3Requests.submitOSAsyncSearch @Value (mkSearch (Just (TermQuery (Term "user" "bob") Nothing)) Nothing)
bhRequestBody reqA `shouldNotBe` bhRequestBody reqB
describe "submitOSAsyncSearchWith URI parameters" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
-- OpenSearch documents only @wait_for_completion@,
-- @keep_on_completion@ and @keep_alive@ for the
-- @POST /_plugins/_asynchronous_search@ endpoint; the ES-only
-- 'assoBatchedReduceSize' and 'assoCcsMinimizeRoundtrips' fields
-- are silently dropped by 'osAsyncSearchSubmitOptionsParams'.
let search = mkSearch (Just (MatchAllQuery Nothing)) Nothing
it "defaultAsyncSearchSubmitOptions emits no params" $ do
let req = OS3Requests.submitOSAsyncSearchWith @Value defaultAsyncSearchSubmitOptions search
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "defaultAsyncSearchSubmitOptions is byte-identical to submitOSAsyncSearch" $ do
let reqPlain = OS3Requests.submitOSAsyncSearch @Value search
reqDef = OS3Requests.submitOSAsyncSearchWith @Value defaultAsyncSearchSubmitOptions search
bhRequestMethod reqPlain `shouldBe` bhRequestMethod reqDef
getRawEndpoint (bhRequestEndpoint reqPlain) `shouldBe` getRawEndpoint (bhRequestEndpoint reqDef)
getRawEndpointQueries (bhRequestEndpoint reqPlain) `shouldBe` getRawEndpointQueries (bhRequestEndpoint reqDef)
bhRequestBody reqPlain `shouldBe` bhRequestBody reqDef
it "forwards wait_for_completion=true" $ do
let opts = defaultAsyncSearchSubmitOptions {assoWaitForCompletion = Just True}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("wait_for_completion", Just "true")]
it "forwards keep_on_completion=true" $ do
let opts = defaultAsyncSearchSubmitOptions {assoKeepOnCompletion = Just True}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("keep_on_completion", Just "true")]
it "forwards keep_alive as the KeepAlive wire format" $ do
let opts = defaultAsyncSearchSubmitOptions {assoKeepAlive = Just (keepAliveDays 5)}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("keep_alive", Just "5d")]
it "forwards every OS-supported parameter at once (set semantics — order-insensitive)" $ do
let opts =
defaultAsyncSearchSubmitOptions
{ assoWaitForCompletion = Just False,
assoKeepOnCompletion = Just True,
assoKeepAlive = Just (keepAliveHours 2)
}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
sort (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sort
[ ("wait_for_completion", Just "false"),
("keep_on_completion", Just "true"),
("keep_alive", Just "2h")
]
it "silently drops the ES-only batched_reduce_size on the OS path" $ do
let opts = defaultAsyncSearchSubmitOptions {assoBatchedReduceSize = Just 5}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "silently drops the ES-only ccs_minimize_roundtrips on the OS path" $ do
let opts = defaultAsyncSearchSubmitOptions {assoCcsMinimizeRoundtrips = Just True}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "silently drops both ES-only fields when set together on the OS path" $ do
let opts =
defaultAsyncSearchSubmitOptions
{ assoBatchedReduceSize = Just 5,
assoCcsMinimizeRoundtrips = Just True
}
req = OS3Requests.submitOSAsyncSearchWith @Value opts search
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "produces the same path across OS1, OS2 and OS3 (path equivalence)" $ do
-- OS1 uses 'mkEndpoint' explicitly; OS2/OS3 use 'OverloadedLists'.
-- Guard against refactors that silently diverge the three modules.
let opts = defaultAsyncSearchSubmitOptions {assoWaitForCompletion = Just True}
path1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.submitOSAsyncSearchWith @Value opts search))
path2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.submitOSAsyncSearchWith @Value opts search))
path3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.submitOSAsyncSearchWith @Value opts search))
qs1 = getRawEndpointQueries (bhRequestEndpoint (OS1Requests.submitOSAsyncSearchWith @Value opts search))
qs2 = getRawEndpointQueries (bhRequestEndpoint (OS2Requests.submitOSAsyncSearchWith @Value opts search))
qs3 = getRawEndpointQueries (bhRequestEndpoint (OS3Requests.submitOSAsyncSearchWith @Value opts search))
path1 `shouldBe` ["_plugins", "_asynchronous_search"]
path1 `shouldBe` path2
path2 `shouldBe` path3
qs1 `shouldBe` qs2
qs2 `shouldBe` qs3
describe "getOSAsyncSearch endpoint shape" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
it "GETs /_plugins/_asynchronous_search/{id} when given an AsyncSearchId" $ do
let req = OS3Requests.getOSAsyncSearch @Value (AsyncSearchId "FmRleEct")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "FmRleEct"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses GET and attaches no body" $ do
let req = OS3Requests.getOSAsyncSearch @Value (AsyncSearchId "FmRleEct")
bhRequestMethod req `shouldBe` "GET"
bhRequestBody req `shouldBe` Nothing
it "uses a distinct id in the path when given a different AsyncSearchId" $ do
let req = OS3Requests.getOSAsyncSearch @Value (AsyncSearchId "other-id")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "other-id"]
it "keeps the id as a single opaque path segment" $ do
let req = OS3Requests.getOSAsyncSearch @Value (AsyncSearchId "Fm_==_RxD_123")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "Fm_==_RxD_123"]
describe "deleteOSAsyncSearch endpoint shape" $ do
-- Pure checks against the BHRequest shape; no live backend needed.
it "DELETEs /_plugins/_asynchronous_search/{id} when given an AsyncSearchId" $ do
let req = OS3Requests.deleteOSAsyncSearch (AsyncSearchId "FmRleEct")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "FmRleEct"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses DELETE and does not attach a body (DELETE semantics)" $ do
let req = OS3Requests.deleteOSAsyncSearch (AsyncSearchId "FmRleEct")
bhRequestMethod req `shouldBe` "DELETE"
bhRequestBody req `shouldBe` Nothing
it "uses a distinct id in the path when given a different AsyncSearchId" $ do
let req = OS3Requests.deleteOSAsyncSearch (AsyncSearchId "other-id")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "other-id"]
it "keeps the id as a single opaque path segment" $ do
let req = OS3Requests.deleteOSAsyncSearch (AsyncSearchId "Fm_==_RxD_123")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "Fm_==_RxD_123"]
describe "getOSAsyncSearchStats endpoint shape" $ do
it "GETs /_plugins/_asynchronous_search/stats with no body" $ do
let req = OS3Requests.getOSAsyncSearchStats
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_plugins", "_asynchronous_search", "stats"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "OS1/OS2/OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint OS1Requests.getOSAsyncSearchStats)
`shouldBe` getRawEndpoint (bhRequestEndpoint OS2Requests.getOSAsyncSearchStats)
getRawEndpoint (bhRequestEndpoint OS2Requests.getOSAsyncSearchStats)
`shouldBe` getRawEndpoint (bhRequestEndpoint OS3Requests.getOSAsyncSearchStats)
bhRequestMethod OS1Requests.getOSAsyncSearchStats
`shouldBe` bhRequestMethod OS3Requests.getOSAsyncSearchStats
describe "AsyncSearchStatsResponse decoding (stats fixture)" $ do
let fixture =
LBS.pack
"{\"_nodes\":{\"total\":8,\"successful\":8,\"failed\":0},\
\\"cluster_name\":\"264071961897:asynchronous-search\",\
\\"nodes\":{\"JKEFl6pdRC-xNkKQauy7Yg\":{\"asynchronous_search_stats\":\
\{\"submitted\":18236,\"initialized\":112,\"search_failed\":56,\
\\"search_completed\":56,\"rejected\":18124,\"persist_failed\":0,\
\\"cancelled\":1,\"running_current\":399,\"persisted\":100}}}}"
it "decodes the documented stats fixture" $ do
let Just decoded = decode fixture :: Maybe OS3Types.AsyncSearchStatsResponse
let nodes_ = case OS3Types.asyncSearchStatsResponseNodes_ decoded of Just n -> n
OS3Types.asyncSearchStatsNodesTotal nodes_ `shouldBe` 8
OS3Types.asyncSearchStatsNodesSuccessful nodes_ `shouldBe` 8
OS3Types.asyncSearchStatsNodesFailed nodes_ `shouldBe` 0
OS3Types.asyncSearchStatsResponseClusterName decoded `shouldBe` Just "264071961897:asynchronous-search"
let stats = case Map.lookup "JKEFl6pdRC-xNkKQauy7Yg" (OS3Types.asyncSearchStatsResponseNodes decoded) of Just s -> s
OS3Types.asyncSearchNodeStatsSubmitted stats `shouldBe` Just 18236
OS3Types.asyncSearchNodeStatsRunningCurrent stats `shouldBe` Just 399
OS3Types.asyncSearchNodeStatsCancelled stats `shouldBe` Just 1