packages feed

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