packages feed

bloodhound-1.0.0.0: tests/Test/EsQueryViewsSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.EsQueryViewsSpec (spec) where

import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Maybe (isJust, isNothing)
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch8.Types qualified as ES8Types
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import Database.Bloodhound.ElasticSearch9.Types qualified as ES9Types
import TestsUtils.Common
import TestsUtils.Import

hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
  Just (Object obj) -> k `KM.member` obj
  _ -> False

spec :: Spec
spec = describe "ES|QL Views and running-query APIs" $ do
  describe "ESQLViewName JSON" $ do
    it "round-trips as a bare JSON string" $ do
      let n = ES9Types.ESQLViewName "my-view"
      decode (encode n) `shouldBe` Just n
      encode n `shouldBe` "\"my-view\""

    it "supports IsString literal syntax" $ do
      let n = ("literal" :: ES9Types.ESQLViewName)
      ES9Types.unESQLViewName n `shouldBe` "literal"

  describe "ESQLView JSON" $ do
    it "encodes a minimal view (name + query required)" $ do
      let v =
            ES9Types.ESQLView
              { esqlViewName = "logs-view",
                esqlViewQuery = "FROM logs | KEEP @timestamp, message | LIMIT 10"
              }
      let json = encode v
      json `shouldSatisfy` hasKey "name"
      json `shouldSatisfy` hasKey "query"

    it "decodes the wire shape returned by GET /_query/view/{name}" $ do
      let raw =
            LBS.pack
              "{\"name\":\"logs-view\",\"query\":\"FROM logs | LIMIT 10\"}"
      let expected =
            ES9Types.ESQLView
              { esqlViewName = "logs-view",
                esqlViewQuery = "FROM logs | LIMIT 10"
              }
      decode raw `shouldBe` Just expected

    it "decodes the {\"views\":[...]} envelope returned by ES 9.4+" $ do
      -- GET /_query/view/{name} on ES 9.4 wraps the single view in a
      -- `views` array. The parser must unwrap it transparently.
      let raw =
            LBS.pack
              "{\"views\":[{\"name\":\"logs-view\",\"query\":\"FROM logs | LIMIT 10\"}]}"
      let expected =
            ES9Types.ESQLView
              { esqlViewName = "logs-view",
                esqlViewQuery = "FROM logs | LIMIT 10"
              }
      decode raw `shouldBe` Just expected

    it "rejects an empty {\"views\":[]} envelope" $ do
      let raw = LBS.pack "{\"views\":[]}"
      decode raw `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLView -> Bool)

    it "round-trips through ToJSON/FromJSON" $ do
      let v =
            ES9Types.ESQLView
              { esqlViewName = "v",
                esqlViewQuery = "ROW x = 1"
              }
      decode (encode v) `shouldBe` Just v

    it "rejects a payload missing the required query field" $ do
      let raw = LBS.pack "{\"name\":\"v\"}"
      decode raw `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLView -> Bool)

    it "rejects a payload missing the required name field" $ do
      let raw = LBS.pack "{\"query\":\"ROW x = 1\"}"
      decode raw `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLView -> Bool)

    it "rejects malformed input (non-object)" $
      decode (LBS.pack "[1,2,3]") `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLView -> Bool)

  describe "ESQLQueryInfo JSON" $ do
    -- The OpenAPI spec lists id/node/coordinating_node/data_nodes as
    -- required, but ES 9.3 GA omits them on single-node clusters. The
    -- decoder must therefore tolerate their absence. The fields ES 9.3
    -- actually emits on this endpoint are start_time_millis,
    -- running_time_nanos and query (the documents_found/values_loaded
    -- counters belong to the AsyncESQLResult shape, not this one).
    let observedShapeRaw =
          LBS.pack
            "{\"start_time_millis\":1782072118262,\"running_time_nanos\":1032189,\"query\":\"ROW x = 1\"}"
        observedShapeExpected =
          ES9Types.ESQLQueryInfo
            { esqlQueryInfoId = Nothing,
              esqlQueryInfoNode = Nothing,
              esqlQueryInfoStartTimeMillis = Just 1782072118262,
              esqlQueryInfoRunningTimeNanos = Just 1032189,
              esqlQueryInfoQuery = "ROW x = 1",
              esqlQueryInfoCoordinatingNode = Nothing,
              esqlQueryInfoDataNodes = Nothing
            }
        -- The shape the OpenAPI spec promises — multi-node clusters or
        -- future ES versions may emit it. The decoder must accept every
        -- documented field.
        fullSpecRaw =
          LBS.pack
            "{\"id\":223601,\"node\":\"node-1\",\"start_time_millis\":1782071481880,\"running_time_nanos\":1500000,\"query\":\"FROM logs | LIMIT 10\",\"coordinating_node\":\"coord-1\",\"data_nodes\":[\"data-1\",\"data-2\"]}"
        fullSpecExpected =
          ES9Types.ESQLQueryInfo
            { esqlQueryInfoId = Just 223601,
              esqlQueryInfoNode = Just "node-1",
              esqlQueryInfoStartTimeMillis = Just 1782071481880,
              esqlQueryInfoRunningTimeNanos = Just 1500000,
              esqlQueryInfoQuery = "FROM logs | LIMIT 10",
              esqlQueryInfoCoordinatingNode = Just "coord-1",
              esqlQueryInfoDataNodes = Just ["data-1", "data-2"]
            }

    it "decodes the wire shape observed on an ES 9.3 single-node cluster" $
      decode observedShapeRaw `shouldBe` Just observedShapeExpected

    it "decodes the full shape documented in the OpenAPI spec" $
      decode fullSpecRaw `shouldBe` Just fullSpecExpected

    it "exposes every field through its lens on the observed shape" $ do
      let Just parsed = decode observedShapeRaw
      ES9Types.esqlQueryInfoId parsed `shouldSatisfy` isNothing
      ES9Types.esqlQueryInfoNode parsed `shouldSatisfy` isNothing
      ES9Types.esqlQueryInfoStartTimeMillis parsed `shouldBe` Just 1782072118262
      ES9Types.esqlQueryInfoRunningTimeNanos parsed `shouldBe` Just 1032189
      ES9Types.esqlQueryInfoQuery parsed `shouldBe` "ROW x = 1"
      ES9Types.esqlQueryInfoCoordinatingNode parsed `shouldSatisfy` isNothing
      ES9Types.esqlQueryInfoDataNodes parsed `shouldSatisfy` isNothing

    it "exposes every field through its lens on the spec shape" $ do
      let Just parsed = decode fullSpecRaw
      ES9Types.esqlQueryInfoId parsed `shouldBe` Just 223601
      ES9Types.esqlQueryInfoNode parsed `shouldBe` Just "node-1"
      ES9Types.esqlQueryInfoCoordinatingNode parsed `shouldBe` Just "coord-1"
      ES9Types.esqlQueryInfoDataNodes parsed `shouldBe` Just ["data-1", "data-2"]

    it "rejects a payload missing the required query field" $
      let malformed = LBS.pack "{\"id\":1,\"node\":\"n\"}"
       in decode malformed `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLQueryInfo -> Bool)

    it "rejects malformed input (non-object)" $
      decode (LBS.pack "42") `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLQueryInfo -> Bool)

  describe "ESQLQueryListResponse JSON" $ do
    -- The live endpoint returns {"queries": {"<encoded-async-id>": {...}}}
    -- — an object keyed by async-id, NOT a bare array. The decoder
    -- flattens it into [(AsyncESQLId, ESQLQueryInfo)] so callers keep the
    -- id they need to act on a listed query.
    let observedInfo =
          ES9Types.ESQLQueryInfo
            { esqlQueryInfoId = Nothing,
              esqlQueryInfoNode = Nothing,
              esqlQueryInfoStartTimeMillis = Just 1782072118262,
              esqlQueryInfoRunningTimeNanos = Just 1032189,
              esqlQueryInfoQuery = "ROW x = 1",
              esqlQueryInfoCoordinatingNode = Nothing,
              esqlQueryInfoDataNodes = Nothing
            }
        specInfo =
          ES9Types.ESQLQueryInfo
            { esqlQueryInfoId = Just 223601,
              esqlQueryInfoNode = Just "node-1",
              esqlQueryInfoStartTimeMillis = Just 1782071481880,
              esqlQueryInfoRunningTimeNanos = Just 1500000,
              esqlQueryInfoQuery = "FROM logs | LIMIT 10",
              esqlQueryInfoCoordinatingNode = Just "coord-1",
              esqlQueryInfoDataNodes = Just ["data-1", "data-2"]
            }
        observedJson =
          "{\"start_time_millis\":1782072118262,\"running_time_nanos\":1032189,\"query\":\"ROW x = 1\"}"
        specJson =
          "{\"id\":223601,\"node\":\"node-1\",\"start_time_millis\":1782071481880,\"running_time_nanos\":1500000,\"query\":\"FROM logs | LIMIT 10\",\"coordinating_node\":\"coord-1\",\"data_nodes\":[\"data-1\",\"data-2\"]}"
        liveAsyncId = "FmpYLUxnNVZyVE5pZ0gzNXZOdXpHYUEdZGhxczRoSWpUSWU5WEJrQ2ZaTzM0dzoxMjg1Nzc="
    it "decodes the live {\"queries\":{id:{...}}} keyed-map shape" $
      let raw = LBS.pack $ "{\"queries\":{\"" <> liveAsyncId <> "\":" <> observedJson <> "}}"
       in fmap ES9Types.unESQLQueryListResponse (decode raw)
            `shouldBe` Just [(ES8Types.AsyncESQLId (T.pack liveAsyncId), observedInfo)]

    it "decodes a bare keyed map (no queries wrapper)" $
      let raw = LBS.pack $ "{\"query-1\":" <> specJson <> "}"
       in fmap ES9Types.unESQLQueryListResponse (decode raw)
            `shouldBe` Just [(ES8Types.AsyncESQLId "query-1", specInfo)]

    it "decodes multiple entries under queries" $
      let raw =
            LBS.pack $
              "{\"queries\":{\"a\":" <> observedJson <> ",\"b\":" <> specJson <> "}}"
       in fmap ES9Types.unESQLQueryListResponse (decode raw)
            `shouldBe` Just [(ES8Types.AsyncESQLId "a", observedInfo), (ES8Types.AsyncESQLId "b", specInfo)]

    it "decodes an empty queries object as []" $
      fmap ES9Types.unESQLQueryListResponse (decode (LBS.pack "{\"queries\":{}}")) `shouldBe` Just []

    it "rejects a {\"queries\":[...]} array value" $
      let raw = LBS.pack "{\"queries\":[{\"query\":\"x\"}]}"
       in decode raw `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLQueryListResponse -> Bool)

    it "rejects a non-object top-level payload" $
      decode (LBS.pack "\"just a string\"")
        `shouldSatisfy` (isNothing :: Maybe ES9Types.ESQLQueryListResponse -> Bool)

  describe "endpoint shape" $ do
    -- These endpoints (ES 9.4+ views, ES 9.1+ running-query introspection)
    -- do not exist on Elasticsearch 8.x, so the canonical request builders
    -- live only in "Database.Bloodhound.ElasticSearch9.Requests". The
    -- endpoint/path assertions below guard against any future drift.
    it "putESQLView PUTs to /_query/view/{name} with a query body (ES9)" $ do
      let req = RequestsES9.putESQLView "my-view" "FROM logs | LIMIT 10"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_query", "view", "my-view"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let Just bodyBytes = bhRequestBody req
      hasKey "query" bodyBytes `shouldBe` True
      -- Body must NOT carry a "name" field — name comes from the path.
      hasKey "name" bodyBytes `shouldBe` False

    it "getESQLView GETs /_query/view/{name} with no body (ES9)" $ do
      let req = RequestsES9.getESQLView "my-view"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_query", "view", "my-view"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isNothing

    it "deleteESQLView DELETEs /_query/view/{name} with no body (ES9)" $ do
      let req = RequestsES9.deleteESQLView "my-view"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_query", "view", "my-view"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isNothing

    it "getESQLQuery GETs /_query/queries/{id} with no body (ES9)" $ do
      let req = RequestsES9.getESQLQuery (ES8Types.AsyncESQLId "Fm_==_RxD_123")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_query", "queries", "Fm_==_RxD_123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isNothing

    it "getESQLQueries GETs /_query/queries with no body (ES9)" $ do
      let req = RequestsES9.getESQLQueries
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_query", "queries"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldSatisfy` isNothing

  describe "live integration (requires ES9.4+ for views; ES9.1+ for queries)" $ do
    -- ES|QL views are experimental, added in 9.4.0. The current
    -- docker-compose ships ES 9.3.0, where PUT/GET/DELETE /_query/view
    -- return HTTP 400 "no handler found". These tests therefore
    -- `pendingWith` out on backends that lack the endpoints, but
    -- remain structurally valid against a 9.4+ cluster.
    backendSpecific [ElasticSearch9] $ do
      it "round-trips an ES|QL view via PUT then GET then DELETE" $
        withTestEnv $ do
          let viewName = ES9Types.ESQLViewName "bloodhound-test-esql-view"
          -- Clean up any pre-existing view from a prior run.
          _ <- tryEsError $ ClientES9.deleteESQLView viewName
          putResp <- tryEsError $ ClientES9.putESQLView viewName "FROM logs | LIMIT 10"
          case putResp of
            Left e
              | endpointMissing e ->
                  liftIO $ pendingWith "ES|QL views require Elasticsearch 9.4+"
              | otherwise ->
                  liftIO $ expectationFailure $ "putESQLView failed: " <> show e
            Right _ -> do
              getResp <- tryEsError $ ClientES9.getESQLView viewName
              liftIO $
                case getResp of
                  Left e -> expectationFailure $ "getESQLView failed: " <> show e
                  Right v -> do
                    ES9Types.esqlViewName v `shouldBe` ES9Types.unESQLViewName viewName
                    ES9Types.esqlViewQuery v `shouldBe` "FROM logs | LIMIT 10"
              delResp <- tryEsError $ ClientES9.deleteESQLView viewName
              liftIO $
                case delResp of
                  Left e -> expectationFailure $ "deleteESQLView failed: " <> show e
                  Right _ -> pure ()

      it "fetches information about a running ES|QL query" $
        withTestEnv $ do
          -- Submit a long-running async query, then poll /_query/queries/{id}.
          let base =
                ES9Types.ESQLRequest
                  { esqlQuery = "ROW x = 1",
                    esqlLocale = Nothing,
                    esqlTimeZone = Nothing,
                    esqlFilter = Nothing,
                    esqlParams = Nothing,
                    esqlColumnar = Nothing,
                    esqlProfile = Nothing,
                    esqlTables = Nothing,
                    esqlIncludeCcsMetadata = Nothing,
                    esqlIncludeExecutionMetadata = Nothing
                  }
              areq =
                (ES9Types.mkAsyncESQLRequest base)
                  { ES9Types.asyncESQLRequestWaitForCompletionTimeout = Just "0ms",
                    ES9Types.asyncESQLRequestKeepAlive = Just "5m"
                  }
          submitted <- ClientES9.esqlAsync areq
          case submitted of
            Left e
              | queryNotRunning e ->
                  liftIO $ pendingWith "Query completed before we could introspect it"
              | otherwise ->
                  liftIO $ expectationFailure $ "esqlAsync failed: " <> show e
            Right r -> case ES8Types.asyncESQLResultId r of
              Nothing -> liftIO $ pendingWith "Async submission did not return an id to poll"
              Just aid -> do
                infoResp <- tryEsError $ ClientES9.getESQLQuery aid
                case infoResp of
                  Left e
                    | queryNotRunning e ->
                        liftIO $ pendingWith "Query completed before we could introspect it"
                    | otherwise ->
                        liftIO $ expectationFailure $ "getESQLQuery failed: " <> show e
                  Right info -> liftIO $ do
                    ES9Types.esqlQueryInfoQuery info `shouldBe` "ROW x = 1"
                    ES9Types.esqlQueryInfoStartTimeMillis info `shouldSatisfy` isJust
                    ES9Types.esqlQueryInfoRunningTimeNanos info `shouldSatisfy` isJust
                -- Clean up.
                void $ tryEsError $ ClientES9.deleteAsyncESQL aid

      it "lists a running ES|QL query via GET /_query/queries" $
        withTestEnv $ do
          -- Submit a long-running async query, then list /_query/queries
          -- and confirm the submitted query is among the running ones.
          let base =
                ES9Types.ESQLRequest
                  { esqlQuery = "ROW x = 1",
                    esqlLocale = Nothing,
                    esqlTimeZone = Nothing,
                    esqlFilter = Nothing,
                    esqlParams = Nothing,
                    esqlColumnar = Nothing,
                    esqlProfile = Nothing,
                    esqlTables = Nothing,
                    esqlIncludeCcsMetadata = Nothing,
                    esqlIncludeExecutionMetadata = Nothing
                  }
              areq =
                (ES9Types.mkAsyncESQLRequest base)
                  { ES9Types.asyncESQLRequestWaitForCompletionTimeout = Just "0ms",
                    ES9Types.asyncESQLRequestKeepAlive = Just "5m"
                  }
          submitted <- ClientES9.esqlAsync areq
          case submitted of
            Left e
              | queryNotRunning e ->
                  liftIO $ pendingWith "Query completed before we could introspect it"
              | otherwise ->
                  liftIO $ expectationFailure $ "esqlAsync failed: " <> show e
            Right r -> case ES8Types.asyncESQLResultId r of
              Nothing -> liftIO $ pendingWith "Async submission did not return an id to poll"
              Just aid -> do
                listResp <- tryEsError $ ClientES9.getESQLQueries
                case listResp of
                  Left e ->
                    liftIO $ expectationFailure $ "getESQLQueries failed: " <> show e
                  Right infos ->
                    liftIO $
                      if any (("ROW x = 1" ==) . ES9Types.esqlQueryInfoQuery . snd) infos
                        then pure ()
                        else
                          pendingWith
                            "Submitted query completed before appearing in GET /_query/queries"
                -- Clean up.
                void $ tryEsError $ ClientES9.deleteAsyncESQL aid

-- | Detect the @no handler found for uri@shape returned by Elasticsearch
-- when an endpoint isn't registered on this version (e.g. ES|QL views
-- on 9.3.0). Used to mark the live view tests as 'pendingWith' rather
-- than failing.
endpointMissing :: EsError -> Bool
endpointMissing e =
  "no handler found" `T.isInfixOf` T.toLower (errorMessage e)

-- | Detect the @task isn't running@shape returned by
-- @GET /_query/queries/{id}@ when the queried async query has already
-- completed and been purged from the running-queries registry. Common
-- for sub-second queries; the test treats this as a soft skip.
queryNotRunning :: EsError -> Bool
queryNotRunning e =
  let msg = T.toLower (errorMessage e)
   in "isn't running" `T.isInfixOf` msg || "not running" `T.isInfixOf` msg