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]