packages feed

bloodhound-1.0.0.0: tests/Test/SQLStatsSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.SQLStatsSpec (spec) where

import Data.Aeson.Types qualified as Aeson
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List qualified as L
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
import TestsUtils.Import
import Prelude

-- ---------------------------------------------------------------------------
-- Sample bodies.
--
-- The SQL plugin's @GET /_plugins/_sql/stats@ returns a flat JSON object
-- mapping metric-name strings to integer values. The metric-name set
-- grows across OpenSearch releases — 8 keys on 1.x (sampleOS1Response),
-- ~34 keys on 3.x (sampleOS3Response). The decoder is a flat 'Map', so
-- both shapes round-trip unchanged.
-- ---------------------------------------------------------------------------

-- | The canonical 8-field response drawn verbatim from the OS 1.x SQL
-- plugin docs (@docs/user/admin/monitoring.rst@). Every value is a
-- JSON integer: counters are 'Integer', the @circuit_breaker@ gauge is
-- @0@ or @1@.
sampleOS1Response :: LBS.ByteString
sampleOS1Response =
  "{\
  \  \"failed_request_count_cb\" : 0,\
  \  \"default_cursor_request_count\" : 10,\
  \  \"default_cursor_request_total\" : 3,\
  \  \"failed_request_count_cuserr\" : 0,\
  \  \"circuit_breaker\" : 0,\
  \  \"request_total\" : 70,\
  \  \"request_count\" : 0,\
  \  \"failed_request_count_syserr\" : 0\
  \}"

-- | A synthetic 3.x response exercising the broader metric set the
-- plugin emits once PPL, datasource, async-query, EMR and streaming
-- metrics have landed. Every value is still a JSON integer. Used to
-- guard forward-compatibility: a decoder that hard-coded the 1.x key
-- set would silently drop these.
sampleOS3Response :: LBS.ByteString
sampleOS3Response =
  "{\
  \  \"request_total\" : 130,\
  \  \"request_count\" : 5,\
  \  \"default_cursor_request_total\" : 12,\
  \  \"default_cursor_request_count\" : 2,\
  \  \"failed_request_count_syserr\" : 1,\
  \  \"failed_request_count_cuserr\" : 3,\
  \  \"failed_request_count_cb\" : 0,\
  \  \"circuit_breaker\" : 1,\
  \  \"ppl_request_total\" : 40,\
  \  \"ppl_request_count\" : 4,\
  \  \"ppl_failed_request_count_syserr\" : 0,\
  \  \"ppl_failed_request_count_cuserr\" : 1,\
  \  \"datasource_create_request_count\" : 2,\
  \  \"datasource_get_request_count\" : 17,\
  \  \"datasource_put_request_count\" : 0,\
  \  \"datasource_patch_request_count\" : 0,\
  \  \"datasource_delete_request_count\" : 0,\
  \  \"datasource_failed_request_count_syserr\" : 0,\
  \  \"datasource_failed_request_count_cuserr\" : 0,\
  \  \"async_query_create_api_request_count\" : 6,\
  \  \"async_query_get_api_request_count\" : 22,\
  \  \"async_query_cancel_api_request_count\" : 1,\
  \  \"emr_start_job_request_failure_count\" : 0,\
  \  \"streaming_job_housekeeper_task_failure_count\" : 0\
  \}"

spec :: Spec
spec = describe "SQL Stats API" $ do
  describe "SQLPluginStats JSON" $ do
    it "decodes the documented 1.x response (8 fields)" $ do
      let Just decoded = decode sampleOS1Response :: Maybe SQLPluginStats
          entries = sqlPluginStatsEntries decoded
      Map.size entries `shouldBe` 8
      Map.member "request_total" entries `shouldBe` True
      Map.member "request_count" entries `shouldBe` True
      Map.member "default_cursor_request_total" entries `shouldBe` True
      Map.member "default_cursor_request_count" entries `shouldBe` True
      Map.member "failed_request_count_syserr" entries `shouldBe` True
      Map.member "failed_request_count_cuserr" entries `shouldBe` True
      Map.member "failed_request_count_cb" entries `shouldBe` True
      Map.member "circuit_breaker" entries `shouldBe` True

    it "preserves the documented 1.x integer values" $ do
      let Just decoded = decode sampleOS1Response :: Maybe SQLPluginStats
          entries = sqlPluginStatsEntries decoded
      Map.lookup "request_total" entries `shouldBe` Just (toJSON (70 :: Int))
      Map.lookup "default_cursor_request_count" entries `shouldBe` Just (toJSON (10 :: Int))
      Map.lookup "circuit_breaker" entries `shouldBe` Just (toJSON (0 :: Int))
      Map.lookup "failed_request_count_cb" entries `shouldBe` Just (toJSON (0 :: Int))

    it "decodes a current 3.x response (24 fields, forward compatibility)" $ do
      let Just decoded = decode sampleOS3Response :: Maybe SQLPluginStats
          entries = sqlPluginStatsEntries decoded
      Map.size entries `shouldBe` 24
      -- The 1.x key set is fully preserved...
      Map.member "request_total" entries `shouldBe` True
      Map.member "circuit_breaker" entries `shouldBe` True
      -- ...and the newer metric families are present too.
      Map.member "ppl_request_total" entries `shouldBe` True
      Map.member "datasource_create_request_count" entries `shouldBe` True
      Map.member "async_query_create_api_request_count" entries `shouldBe` True
      Map.member "emr_start_job_request_failure_count" entries `shouldBe` True
      Map.member "streaming_job_housekeeper_task_failure_count" entries `shouldBe` True

    it "preserves the 3.x integer values verbatim" $ do
      let Just decoded = decode sampleOS3Response :: Maybe SQLPluginStats
          entries = sqlPluginStatsEntries decoded
      Map.lookup "request_total" entries `shouldBe` Just (toJSON (130 :: Int))
      Map.lookup "circuit_breaker" entries `shouldBe` Just (toJSON (1 :: Int))
      Map.lookup "ppl_request_total" entries `shouldBe` Just (toJSON (40 :: Int))

    it "tolerates unknown fields (inherent to the flat Map decoder)" $ do
      let Just decoded =
            decode
              "{\"request_total\":1,\"future_metric\":99}" ::
              Maybe SQLPluginStats
          entries = sqlPluginStatsEntries decoded
      Map.size entries `shouldBe` 2
      Map.member "future_metric" entries `shouldBe` True

    it "decodes an empty object as an empty map" $ do
      let Just decoded = decode "{}" :: Maybe SQLPluginStats
      sqlPluginStatsEntries decoded `shouldBe` Map.empty

    it "rejects a top-level null" $
      decode "null" `shouldBe` (Nothing :: Maybe SQLPluginStats)

    it "rejects a top-level array" $
      decode "[]" `shouldBe` (Nothing :: Maybe SQLPluginStats)

    it "rejects a top-level scalar" $
      decode "42" `shouldBe` (Nothing :: Maybe SQLPluginStats)

    it "rejects malformed JSON" $
      decode "{ not json" `shouldBe` (Nothing :: Maybe SQLPluginStats)

    it "round-trips the 1.x response through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleOS1Response :: Maybe SQLPluginStats
      (decode . encode) decoded `shouldBe` Just decoded

    it "round-trips the 3.x response through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleOS3Response :: Maybe SQLPluginStats
      (decode . encode) decoded `shouldBe` Just decoded

    it "ToJSON emits the documented keys" $ do
      let Just decoded = decode sampleOS1Response :: Maybe SQLPluginStats
          encoded = LBS.toStrict (encode decoded)
      encoded `shouldSatisfy` BS.isInfixOf "\"request_total\""
      encoded `shouldSatisfy` BS.isInfixOf "\"circuit_breaker\""
      encoded `shouldSatisfy` BS.isInfixOf "\"default_cursor_request_count\""

    it "lets callers decode individual metrics via aeson" $ do
      let Just decoded = decode sampleOS1Response :: Maybe SQLPluginStats
          Just (Aeson.Number n) = Map.lookup "request_total" (sqlPluginStatsEntries decoded)
      n `shouldBe` 70

  -- =========================================================================
  -- Endpoint shape. Pure checks against the BHRequest; no live backend.
  -- =========================================================================

  describe "getSQLStats endpoint shape (OpenSearch 3)" $ do
    it "GETs /_plugins/_sql/stats" $ do
      let req = OS3Requests.getSQLStats
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_sql", "stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the GET method" $
      bhRequestMethod OS3Requests.getSQLStats `shouldBe` "GET"

    it "does not attach a body (GET semantics)" $
      bhRequestBody OS3Requests.getSQLStats `shouldBe` Nothing

  describe "getSQLStats endpoint shape (OpenSearch 2)" $ do
    it "GETs /_plugins/_sql/stats" $ do
      let req = OS2Requests.getSQLStats
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_sql", "stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the GET method" $
      bhRequestMethod OS2Requests.getSQLStats `shouldBe` "GET"

    it "does not attach a body (GET semantics)" $
      bhRequestBody OS2Requests.getSQLStats `shouldBe` Nothing

  describe "getSQLStats endpoint shape (OpenSearch 1)" $ do
    it "GETs /_plugins/_sql/stats" $ do
      let req = OS1Requests.getSQLStats
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_sql", "stats"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the GET method" $
      bhRequestMethod OS1Requests.getSQLStats `shouldBe` "GET"

    it "does not attach a body (GET semantics)" $
      bhRequestBody OS1Requests.getSQLStats `shouldBe` Nothing

  -- =========================================================================
  -- Cross-backend parity: OS1, OS2, OS3 must agree on path/method/body.
  -- =========================================================================

  describe "getSQLStats cross-backend parity" $ do
    it "emits identical paths across OS1/OS2/OS3" $ do
      let p1 = getRawEndpoint (bhRequestEndpoint OS1Requests.getSQLStats)
          p2 = getRawEndpoint (bhRequestEndpoint OS2Requests.getSQLStats)
          p3 = getRawEndpoint (bhRequestEndpoint OS3Requests.getSQLStats)
      p1 `shouldBe` ["_plugins", "_sql", "stats"]
      p2 `shouldBe` p1
      p3 `shouldBe` p1

    it "emits identical methods across OS1/OS2/OS3" $ do
      let methods =
            [ bhRequestMethod OS1Requests.getSQLStats,
              bhRequestMethod OS2Requests.getSQLStats,
              bhRequestMethod OS3Requests.getSQLStats
            ]
      length (L.nub methods) `shouldBe` 1
      head methods `shouldBe` "GET"

    it "emits no body on any version" $ do
      let bodies =
            [ bhRequestBody OS1Requests.getSQLStats,
              bhRequestBody OS2Requests.getSQLStats,
              bhRequestBody OS3Requests.getSQLStats
            ]
      L.nub bodies `shouldBe` [Nothing]