bloodhound-1.0.0.0: tests/Test/RankEvalSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Tests for the @POST /_rank_eval@ and @POST \/{index}\/_rank_eval@
-- endpoints.
--
-- The first sections are pure unit tests of the JSON instances and
-- 'RankEvalOptions' rendering — no live backend required. The last
-- section is live integration coverage under 'withTestEnv', which is
-- only meaningful when an Elasticsearch\/OpenSearch backend is
-- reachable on 'ES_TEST_SERVER' (default @http://localhost:9200@).
module Test.RankEvalSpec (spec) where
import Data.Aeson
import Data.Aeson.KeyMap qualified as X
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (sort)
import Data.Map.Strict qualified as M
import Database.Bloodhound.Common.Requests qualified as Common
import TestsUtils.Common
import TestsUtils.Import
-- ---------------------------------------------------------------------------
-- - URI param rendering
-- ---------------------------------------------------------------------------
normalize :: [(Text, Maybe Text)] -> [(Text, Maybe Text)]
normalize = sort
-- ---------------------------------------------------------------------------
-- - Fixtures
-- ---------------------------------------------------------------------------
-- | Response fixture modelled on the canonical ES example at
-- <https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html>.
sampleRankEvalResponse :: LBS.ByteString
sampleRankEvalResponse =
"{\
\ \"rank_eval_score\": 0.4347826086956522,\
\ \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0},\
\ \"details\": {\
\ \"coffee_beans\": {\
\ \"metric_score\": 1.0,\
\ \"unrated_docs\": [\
\ {\"_index\": \"my-index-000001\", \"_id\": \"dQfYfIkEcA24zrCA0p3U\"}\
\ ],\
\ \"hits\": [\
\ {\"_index\": \"my-index-000001\", \"_id\": \"FojvYIkEcA24zrCA0p3W\", \"_score\": 0.5753642, \"rating\": 1}\
\ ],\
\ \"metric_details\": {\
\ \"precision\": {\
\ \"relevant_docs_retrieved\": 1,\
\ \"docs_retrieved\": 1\
\ }\
\ }\
\ }\
\ },\
\ \"failures\": {}\
\}"
-- | OpenSearch-shaped response. Two differences from ES:
--
-- * Top-level score is keyed @metric_score@ (not @rank_eval_score@).
-- * No @_shards@ block.
-- * Each hit's @_index\/_id\/_score@ is nested under a @hit@ key.
--
-- The 'FromJSON RankEvalResponse' / 'FromJSON RankEvalHit' instances
-- accept either shape; this fixture pins the OpenSearch side so a
-- future refactor that drops the tolerant parse is caught.
sampleOpenSearchRankEvalResponse :: LBS.ByteString
sampleOpenSearchRankEvalResponse =
"{\
\ \"metric_score\": 1.0,\
\ \"details\": {\
\ \"coffee_beans\": {\
\ \"metric_score\": 1.0,\
\ \"unrated_docs\": [],\
\ \"hits\": [\
\ {\"hit\": {\"_index\": \"my-index-000001\", \"_id\": \"FojvYIkEcA24zrCA0p3W\", \"_score\": 0.5753642}, \"rating\": 1}\
\ ],\
\ \"metric_details\": {\
\ \"mean_reciprocal_rank\": {\"first_relevant\": 1}\
\ }\
\ }\
\ },\
\ \"failures\": {}\
\}"
-- | A response with a non-empty 'rerResponseFailures' map: server may
-- report per-item errors when, e.g., the rated document does not exist
-- in the target index.
sampleRankEvalResponseWithFailure :: LBS.ByteString
sampleRankEvalResponseWithFailure =
"{\
\ \"rank_eval_score\": 0.0,\
\ \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0},\
\ \"details\": {},\
\ \"failures\": {\
\ \"bad_request\": {\
\ \"reason\": \"rated document does not exist\",\
\ \"type\": \"illegal_argument_exception\"\
\ }\
\ }\
\}"
sampleRequest :: RankEvalRequest
sampleRequest =
RankEvalRequest
{ rerRequests =
[ RankEvalRequestItem
{ reiId = "q1",
reiRequest = mkSearch (Just (MatchAllQuery Nothing)) Nothing,
reiRatings =
[ DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 1,
drPassthrough = Nothing
}
],
reiTemplateId = Nothing,
reiParams = Nothing,
reiSummaryFields = Nothing,
reiIgnoreUnlabeled = Nothing
}
],
rerMetric = Nothing,
rerMaxConcurrentSearches = Nothing
}
-- ---------------------------------------------------------------------------
-- - Spec
-- ---------------------------------------------------------------------------
spec :: Spec
spec = do
---------------------------------------------------------------------------
-- URI param rendering
---------------------------------------------------------------------------
describe "RankEvalOptions URI param rendering" $ do
it "defaultRankEvalOptions emits no params" $
rankEvalOptionsParams defaultRankEvalOptions `shouldBe` []
it "omits search_type when set to the server default (QueryThenFetch)" $ do
let opts = defaultRankEvalOptions {reoSearchType = SearchTypeQueryThenFetch}
rankEvalOptionsParams opts `shouldBe` []
it "emits search_type only when set to DfsQueryThenFetch" $ do
let opts = defaultRankEvalOptions {reoSearchType = SearchTypeDfsQueryThenFetch}
rankEvalOptionsParams opts `shouldBe` [("search_type", Just "dfs_query_then_fetch")]
it "renders Bool fields as true/false strings" $ do
let opts =
defaultRankEvalOptions
{ reoAllowNoIndices = Just True,
reoIgnoreUnavailable = Just False
}
normalize (rankEvalOptionsParams opts)
`shouldBe` [ ("allow_no_indices", Just "true"),
("ignore_unavailable", Just "false")
]
it "renders expand_wildcards as a comma-separated list" $ do
let opts =
defaultRankEvalOptions
{ reoExpandWildcards = Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
}
rankEvalOptionsParams opts
`shouldBe` [("expand_wildcards", Just "open,closed")]
it "emits every field when all are populated alongside DfsQueryThenFetch" $ do
let opts =
defaultRankEvalOptions
{ reoSearchType = SearchTypeDfsQueryThenFetch,
reoAllowNoIndices = Just True,
reoExpandWildcards = Just (ExpandWildcardsOpen :| []),
reoIgnoreUnavailable = Just True
}
let rendered = rankEvalOptionsParams opts
length rendered `shouldBe` 4
sort (map fst rendered)
`shouldBe` [ "allow_no_indices",
"expand_wildcards",
"ignore_unavailable",
"search_type"
]
---------------------------------------------------------------------------
-- RankEvalMetric JSON
---------------------------------------------------------------------------
describe "RankEvalMetric JSON" $ do
it "encodes MeanReciprocalRank under its server key" $
encode (RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig)
`shouldBe` "{\"mean_reciprocal_rank\":{}}"
it "encodes MeanReciprocalRank with k when set" $
encode (RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 10})
`shouldBe` "{\"mean_reciprocal_rank\":{\"k\":10}}"
it "encodes Precision under its server key" $
encode (RankEvalPrecision defaultPrecisionConfig {pcK = Just 5})
`shouldBe` "{\"precision\":{\"k\":5}}"
it "encodes Precision with all three config fields" $ do
-- NB: aeson 'object' sorts keys alphabetically; assert
-- structurally rather than on the exact wire bytes.
let encoded =
encode
( RankEvalPrecision
defaultPrecisionConfig
{ pcK = Just 5,
pcRelevantRatingThreshold = Just 1,
pcIgnoreUnlabeled = Just True
}
)
let Just (top :: Object) = decode encoded
Just (Object (cfg :: Object)) = X.lookup "precision" top
X.lookup "k" cfg `shouldBe` Just (Number 5)
X.lookup "relevant_rating_threshold" cfg `shouldBe` Just (Number 1)
X.lookup "ignore_unlabeled" cfg `shouldBe` Just (Bool True)
it "encodes Recall under its server key" $
encode (RankEvalRecall defaultRecallConfig {rcK = Just 10, rcRelevantRatingThreshold = Just 2})
`shouldBe` "{\"recall\":{\"k\":10,\"relevant_rating_threshold\":2}}"
it "encodes DCG as an empty object" $
encode RankEvalDCG `shouldBe` "{\"dcg\":{}}"
it "encodes NDCG as an empty object" $
encode RankEvalNDCG `shouldBe` "{\"ndcg\":{}}"
it "encodes ExpectedReciprocalRank with k and maximum_relevance" $
encode
( RankEvalExpectedReciprocalRank
defaultExpectedReciprocalRankConfig
{ errecK = Just 10,
errecMaximumRelevance = Just 3
}
)
`shouldBe` "{\"expected_reciprocal_rank\":{\"k\":10,\"maximum_relevance\":3}}"
it "round-trips every constructor through ToJSON/FromJSON" $ do
let metrics =
[ RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 20},
RankEvalPrecision defaultPrecisionConfig {pcK = Just 5, pcIgnoreUnlabeled = Just False},
RankEvalRecall defaultRecallConfig {rcK = Just 10},
RankEvalDCG,
RankEvalNDCG,
RankEvalExpectedReciprocalRank defaultExpectedReciprocalRankConfig {errecK = Just 5, errecMaximumRelevance = Just 2}
]
forM_ metrics $ \m ->
(decode . encode) m `shouldBe` Just m
it "decodes a metric when an unknown extra key is present" $
-- The decoder must not bail on extension keys it doesn't
-- recognise; it picks the first known one.
decode "{\"future_metric\":{}, \"precision\":{\"k\":3}}"
`shouldBe` Just (RankEvalPrecision defaultPrecisionConfig {pcK = Just 3})
it "fails when no recognised metric key is present" $ do
let decoded = decode "{\"unknown_metric\":{}}" :: Maybe RankEvalMetric
decoded `shouldBe` Nothing
---------------------------------------------------------------------------
-- RankEvalRequest JSON
---------------------------------------------------------------------------
describe "RankEvalRequest JSON" $ do
it "encodes a bare request as {\"requests\":[...]}" $ do
let Just (obj :: Object) = decode (encode sampleRequest)
X.lookup "requests" obj `shouldSatisfy` isJust
it "omits the metric key when rerMetric is Nothing" $ do
let Just (obj :: Object) = decode (encode sampleRequest)
X.lookup "metric" obj `shouldBe` Nothing
it "includes the metric key when rerMetric is Just" $ do
let req = sampleRequest {rerMetric = Just RankEvalDCG}
Just (obj :: Object) = decode (encode req)
X.lookup "metric" obj `shouldSatisfy` isJust
it "omits max_concurrent_searches when rerMaxConcurrentSearches is Nothing" $ do
let Just (obj :: Object) = decode (encode sampleRequest)
X.lookup "max_concurrent_searches" obj `shouldBe` Nothing
it "includes max_concurrent_searches as a number when rerMaxConcurrentSearches is Just" $ do
let req = sampleRequest {rerMaxConcurrentSearches = Just 4}
Just (obj :: Object) = decode (encode req)
X.lookup "max_concurrent_searches" obj `shouldSatisfy` isJust
---------------------------------------------------------------------------
-- DocumentRating JSON
---------------------------------------------------------------------------
describe "DocumentRating JSON" $ do
it "encodes the _index/_id/rating triplet and omits passthrough when Nothing" $ do
-- NB: key order is unspecified; assert structurally.
let Just (obj :: Object) =
decode
( encode
( DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 2,
drPassthrough = Nothing
}
)
)
X.lookup "_index" obj `shouldBe` Just (toJSON testIndex)
X.lookup "_id" obj `shouldBe` Just (toJSON (DocId "1"))
X.lookup "rating" obj `shouldBe` Just (Number 2)
X.lookup "passthrough" obj `shouldBe` Nothing
it "encodes passthrough verbatim when present" $
let passthrough = case object ["context" .= ("mobile" :: Text)] of Object o -> o; _ -> mempty
in do
let Just (obj :: Object) =
decode
( encode
( DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 1,
drPassthrough = Just passthrough
}
)
)
X.lookup "_index" obj `shouldBe` Just (toJSON testIndex)
X.lookup "_id" obj `shouldBe` Just (toJSON (DocId "1"))
X.lookup "rating" obj `shouldBe` Just (Number 1)
-- passthrough carried through verbatim
let Just (Object (pt :: Object)) = X.lookup "passthrough" obj
X.lookup "context" pt `shouldBe` Just (String "mobile")
it "round-trips through ToJSON/FromJSON (with passthrough)" $ do
let passthrough = case object ["note" .= ("x" :: Text)] of Object o -> o; _ -> mempty
original =
DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 3,
drPassthrough = Just passthrough
}
(decode . encode) original `shouldBe` Just original
it "round-trips a fractional rating through ToJSON/FromJSON" $ do
-- The rating is typed as 'Double' precisely so fractional
-- relevance scores (which both ES and OS accept) survive a
-- round-trip.
let original =
DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 0.75,
drPassthrough = Nothing
}
(decode . encode) original `shouldBe` Just original
---------------------------------------------------------------------------
-- RankEvalRequestItem: summary_fields / ignore_unlabeled
---------------------------------------------------------------------------
describe "RankEvalRequestItem summary_fields / ignore_unlabeled" $ do
it "omits summary_fields and ignore_unlabeled when both are Nothing" $ do
let item =
(head (rerRequests sampleRequest))
{ reiSummaryFields = Nothing,
reiIgnoreUnlabeled = Nothing
}
Just (obj :: Object) = decode (encode item)
X.lookup "summary_fields" obj `shouldBe` Nothing
X.lookup "ignore_unlabeled" obj `shouldBe` Nothing
it "renders summary_fields as a JSON array of field names" $ do
let item =
(head (rerRequests sampleRequest))
{ reiSummaryFields = Just [FieldName "message", FieldName "user"]
}
Just (obj :: Object) = decode (encode item)
X.lookup "summary_fields" obj
`shouldBe` Just (toJSON ["message" :: Text, "user"])
it "renders ignore_unlabeled as a Bool when set" $ do
let item =
(head (rerRequests sampleRequest))
{ reiIgnoreUnlabeled = Just True
}
Just (obj :: Object) = decode (encode item)
X.lookup "ignore_unlabeled" obj `shouldBe` Just (Bool True)
---------------------------------------------------------------------------
-- RankEvalResponse JSON
---------------------------------------------------------------------------
describe "RankEvalResponse JSON" $ do
it "decodes the canonical response fixture, preserving details and metric_details" $ do
let Just (decoded :: RankEvalResponse) = decode sampleRankEvalResponse
rerResponseScore decoded `shouldSatisfy` (\x -> x > 0 && x <= 1)
-- _shards total/successful parsed
shardTotal (rerResponseShards decoded) `shouldBe` 1
shardsSuccessful (rerResponseShards decoded) `shouldBe` 1
-- details keyed by request id
M.keysSet (rerResponseDetails decoded) `shouldBe` M.keysSet (M.singleton "coffee_beans" ())
-- the metric_details shape survives
let Just detail = M.lookup "coffee_beans" (rerResponseDetails decoded)
redMetricScore detail `shouldBe` 1.0
length (redHits detail) `shouldBe` 1
-- failures present but empty
rerResponseFailures decoded `shouldBe` M.empty
it "decodes the OpenSearch-shaped fixture (metric_score key, hit envelope, no _shards)" $ do
let Just (decoded :: RankEvalResponse) = decode sampleOpenSearchRankEvalResponse
-- metric_score read in place of rank_eval_score.
rerResponseScore decoded `shouldBe` 1.0
-- No _shards block on OS → defaults to zero counts.
shardTotal (rerResponseShards decoded) `shouldBe` 0
let Just detail = M.lookup "coffee_beans" (rerResponseDetails decoded)
length (redHits detail) `shouldBe` 1
-- Hit fields extracted from the nested "hit" envelope.
let hit = head (redHits detail)
unIndexName (rehIndex hit) `shouldBe` "my-index-000001"
rehRating hit `shouldBe` Just 1
it "decodes a response with per-item failures into rerResponseFailures" $ do
let Just (decoded :: RankEvalResponse) = decode sampleRankEvalResponseWithFailure
M.keysSet (rerResponseFailures decoded) `shouldBe` M.keysSet (M.singleton "bad_request" ())
rerResponseDetails decoded `shouldBe` M.empty
it "treats missing 'details'/'failures' fields as empty maps" $ do
let Just (decoded :: RankEvalResponse) =
decode
"{\
\ \"rank_eval_score\": 0.5,\
\ \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0}\
\}"
rerResponseDetails decoded `shouldBe` M.empty
rerResponseFailures decoded `shouldBe` M.empty
---------------------------------------------------------------------------
-- Endpoint wire shape
---------------------------------------------------------------------------
describe "evaluateRank endpoint envelope" $ do
it "evaluateRank targets /_rank_eval with no query parameters" $ do
let req = Common.evaluateRank sampleRequest
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_rank_eval"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestMethod req `shouldBe` "POST"
bhRequestBody req `shouldSatisfy` isJust
it "evaluateRankByIndex targets /{index}/_rank_eval" $ do
let req = Common.evaluateRankByIndex testIndex sampleRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [unIndexName testIndex, "_rank_eval"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "evaluateRankWith Nothing matches evaluateRank's path" $ do
let reqA = Common.evaluateRank sampleRequest
reqB = Common.evaluateRankWith Nothing defaultRankEvalOptions sampleRequest
getRawEndpoint (bhRequestEndpoint reqA)
`shouldBe` getRawEndpoint (bhRequestEndpoint reqB)
it "evaluateRankWith (Just []) matches evaluateRank's path" $ do
let reqA = Common.evaluateRank sampleRequest
reqB = Common.evaluateRankWith (Just []) defaultRankEvalOptions sampleRequest
getRawEndpoint (bhRequestEndpoint reqA)
`shouldBe` getRawEndpoint (bhRequestEndpoint reqB)
it "evaluateRankWith (Just [i]) matches evaluateRankByIndex's path" $ do
let reqA = Common.evaluateRankByIndex testIndex sampleRequest
reqB = Common.evaluateRankWith (Just [testIndex]) defaultRankEvalOptions sampleRequest
getRawEndpoint (bhRequestEndpoint reqA)
`shouldBe` getRawEndpoint (bhRequestEndpoint reqB)
it "evaluateRankWith joins multiple indices with a comma" $ do
let req =
Common.evaluateRankWith
(Just [testIndex, testIndex])
defaultRankEvalOptions
sampleRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [ unIndexName testIndex <> "," <> unIndexName testIndex,
"_rank_eval"
]
it "evaluateRankWith forwards RankEvalOptions as query parameters" $ do
let opts =
defaultRankEvalOptions
{ reoIgnoreUnavailable = Just True,
reoExpandWildcards = Just (ExpandWildcardsOpen :| [])
}
req = Common.evaluateRankWith (Just [testIndex]) opts sampleRequest
normalize (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` [ ("expand_wildcards", Just "open"),
("ignore_unavailable", Just "true")
]
---------------------------------------------------------------------------
-- Live integration
---------------------------------------------------------------------------
-- The local OpenSearch backend rejects requests with
-- @metric = Nothing@ (@Required [metric]@); always set one in live
-- tests even though the type allows it.
let liveMetric = RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 10}
sampleLiveRequest = sampleRequest {rerMetric = Just liveMetric}
describe "evaluateRank (integration)" $ do
it "evaluateRankByIndex scores the indexed tweet as relevant for a matching query" $
withTestEnv $ do
_ <- insertData
let q = QueryMatchQuery $ mkMatchQuery (FieldName "message") (QueryString "haskell")
item =
RankEvalRequestItem
{ reiId = "haskell-search",
reiRequest = (mkSearch (Just q) Nothing) {size = Size 1},
reiRatings =
[ DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 1,
drPassthrough = Nothing
}
],
reiTemplateId = Nothing,
reiParams = Nothing,
reiSummaryFields = Nothing,
reiIgnoreUnlabeled = Nothing
}
req =
RankEvalRequest
{ rerRequests = [item],
rerMetric = Just liveMetric,
rerMaxConcurrentSearches = Nothing
}
result <- tryPerformBHRequest $ Common.evaluateRankByIndex testIndex req
case result of
Left e ->
liftIO $
expectationFailure $
"expected a successful rank-eval, got EsError: " <> show e
Right resp ->
liftIO $ do
-- Perfect ranking => overall score is 1.0 across both
-- MRR and precision@k. We accept >= 0.99 to leave room
-- for floating-point noise.
rerResponseScore resp `shouldSatisfy` (>= 0.99)
-- The request id surfaces under details.
M.keysSet (rerResponseDetails resp)
`shouldBe` M.keysSet (M.singleton "haskell-search" ())
-- No failures expected on a clean run.
rerResponseFailures resp `shouldBe` M.empty
it "evaluateRankByIndex surfaces per-request details with hits and unrated_docs" $
withTestEnv $ do
_ <- insertData
-- Index a second document we deliberately do NOT rate, so the
-- search returns it as an 'unrated_doc' in the breakdown.
_ <- insertOther
let q = QueryMatchQuery $ mkMatchQuery (FieldName "message") (QueryString "haskell")
item =
RankEvalRequestItem
{ reiId = "mixed",
reiRequest = (mkSearch (Just q) Nothing) {size = Size 10},
reiRatings =
[ DocumentRating
{ drIndex = testIndex,
drId = DocId "1",
drRating = 1,
drPassthrough = Nothing
}
],
reiTemplateId = Nothing,
reiParams = Nothing,
reiSummaryFields = Nothing,
reiIgnoreUnlabeled = Nothing
}
req =
RankEvalRequest
{ rerRequests = [item],
rerMetric = Just liveMetric,
rerMaxConcurrentSearches = Nothing
}
result <- tryPerformBHRequest $ Common.evaluateRankByIndex testIndex req
case result of
Left e ->
liftIO $
expectationFailure $
"expected a successful rank-eval, got EsError: " <> show e
Right resp -> do
let Just detail = M.lookup "mixed" (rerResponseDetails resp)
liftIO $ do
-- At least one hit was returned by the search.
length (redHits detail) `shouldSatisfy` (>= 1)
-- DocId "1" should be among the rated hits with rating 1.
let ratedIds = [unpackId (rehId h) | h <- redHits detail]
"1" `elem` ratedIds `shouldBe` True
it "evaluateRankWith surfaces per-request failures for a missing index" $ do
let missing = [qqIndexName|bloodhound-missing-index-rank-eval-spec|]
req =
Common.evaluateRankWith
(Just [missing])
defaultRankEvalOptions
sampleLiveRequest
-- Two equally valid responses exist across backends:
-- * ES returns HTTP 404 → 'EsError' from 'tryEsError'.
-- * OpenSearch returns HTTP 200 with @failures@ populated on
-- the parsed 'RankEvalResponse' (and @metric_score: "NaN"@).
-- Accept either: the only invariant we pin is "the missing
-- index did not silently evaluate to a clean response".
result <- withTestEnv $ tryEsError (performBHRequest req)
case result of
Left _ -> pure ()
Right resp ->
rerResponseFailures resp `shouldSatisfy` (not . M.null)
it "evaluateRankWith forwards ignore_unavailable to keep a missing index from failing the request" $
withTestEnv $ do
_ <- insertData
let missing = [qqIndexName|bloodhound-missing-index-rank-eval-spec|]
opts = defaultRankEvalOptions {reoIgnoreUnavailable = Just True}
req =
Common.evaluateRankWith
(Just [missing, testIndex])
opts
sampleLiveRequest
-- The request should not raise an EsError. We don't assert on
-- the score (the missing index is silently dropped, leaving
-- testIndex to evaluate against itself), only on the absence
-- of an error path.
result <- tryEsError (performBHRequest req)
case result of
Left e ->
liftIO $
expectationFailure $
"expected success with ignore_unavailable=true, got EsError: " <> show e
Right _ -> pure ()