packages feed

bloodhound-1.0.0.0: tests/Test/NodesSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module Test.NodesSpec (spec) where

import Data.Aeson (Value (..), decode, encode)
import Data.Aeson.Key (toText)
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.ByteString.Lazy.Char8 qualified as BL
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text qualified as T
import Test.Hspec (Spec, describe, it, shouldBe)
import TestsUtils.Common
import TestsUtils.Import
import Prelude

spec :: Spec
spec = do
  describe "getNodesInfo" $
    it "fetches the responding node when LocalNode is used" $
      withTestEnv $ do
        NodesInfo {..} <- performBHRequest $ getNodesInfo LocalNode
        -- This is really just a smoke test for response
        -- parsing. Node info is so variable, there's not much I can
        -- assert here.
        liftIO $ length nodesInfo `shouldBe` 1

  describe "getNodesStats" $
    it "fetches the responding node when LocalNode is used" $
      withTestEnv $ do
        NodesStats {..} <- performBHRequest $ getNodesStats LocalNode
        liftIO $ length nodesStats `shouldBe` 1

  describe "getNodesUsage" $
    it "fetches the responding node when LocalNode is used" $
      withTestEnv $ do
        NodesUsage {..} <- performBHRequest $ getNodesUsage LocalNode
        liftIO $ length nodesUsage `shouldBe` 1

  describe "getNodesHotThreads" $ do
    -- The hot_threads endpoint returns plain text rather than JSON, so
    -- this is a smoke test for the UTF-8 body decoder in
    -- 'Internal.Utils.Requests.getText'.
    it "returns a non-empty plain-text report with no selector" $
      withTestEnv $ do
        report <- performBHRequest $ getNodesHotThreads Nothing Nothing
        liftIO $ (not $ T.null report) `shouldBe` True

    it "honours the threads query parameter (ThreadCount 1)" $
      withTestEnv $ do
        report <- performBHRequest $ getNodesHotThreads Nothing (Just (ThreadCount 1))
        liftIO $ (not $ T.null report) `shouldBe` True

    it "accepts a specific node selector" $
      withTestEnv $ do
        NodesInfo {..} <- performBHRequest $ getNodesInfo LocalNode
        let targetNodeName = nodeInfoName $ head nodesInfo
        report <- performBHRequest $ getNodesHotThreads (Just (NodeByName targetNodeName)) Nothing
        liftIO $ (not $ T.null report) `shouldBe` True

    -- Backend-agnostic live smoke test for the new URI parameters.
    -- 'snapshots' and 'ignore_idle_threads' are accepted by both ES and
    -- OpenSearch, so they are safe to exercise against whatever backend
    -- is up. (The 'type' parameter is also universal, but is covered by
    -- the pure endpoint-shape tests below.)
    it "getNodesHotThreadsWith honours snapshots and ignore_idle_threads" $
      withTestEnv $ do
        let opts = defaultHotThreadsOptions {htoSnapshots = Just 1, htoIgnoreIdleThreads = Just False}
        report <- performBHRequest $ getNodesHotThreadsWith Nothing opts
        liftIO $ (not $ T.null report) `shouldBe` True

    -- The hot-threads kind is named @type@ on every backend (the
    -- historical @doc_type@ is rejected with HTTP 400). This live test
    -- pins that the emitted @type=cpu@ request is accepted by the server.
    it "getNodesHotThreadsWith honours type (the universally accepted kind param)" $
      withTestEnv $ do
        let opts = defaultHotThreadsOptions {htoDocType = Just HotThreadsDocTypeCPU}
        report <- performBHRequest $ getNodesHotThreadsWith Nothing opts
        liftIO $ (not $ T.null report) `shouldBe` True

  -- Endpoint-shape tests for the fully-parameterised variant. Pure (no
  -- live ES) — they pin the URL path and query string so the wire format
  -- cannot drift silently. Mirrors the precedent set by
  -- @getNodesInfoWith endpoint shape@ above.
  describe "getNodesHotThreadsWith endpoint shape" $ do
    let path req = getRawEndpoint (bhRequestEndpoint req)
        queries req = getRawEndpointQueries (bhRequestEndpoint req)

    it "default options -> identical to legacy getNodesHotThreads Nothing Nothing" $
      path (getNodesHotThreadsWith Nothing defaultHotThreadsOptions)
        `shouldBe` path (getNodesHotThreads Nothing Nothing)

    it "default options -> no query string" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions) `shouldBe` []

    it "threads preserved via legacy delegation (Just 1)" $
      path (getNodesHotThreads Nothing (Just (ThreadCount 1)))
        `shouldBe` path (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoThreads = Just (ThreadCount 1)})

    it "Just selector renders /_nodes/{seg}/hot_threads" $
      path (getNodesHotThreadsWith (Just (NodeByName (NodeName "n1"))) defaultHotThreadsOptions)
        `shouldBe` ["_nodes", "n1", "hot_threads"]

    it "interval renders as {n}{unit}" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoInterval = Just (TimeUnitMilliseconds, 500)})
        `shouldBe` [("interval", Just "500ms")]

    it "snapshots renders the bare integer" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoSnapshots = Just 5})
        `shouldBe` [("snapshots", Just "5")]

    it "type emitted under the type key only (no doc_type)" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoDocType = Just HotThreadsDocTypeCPU})
        `shouldBe` [("type", Just "cpu")]

    it "ignore_idle_threads renders as lowercase bool" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoIgnoreIdleThreads = Just False})
        `shouldBe` [("ignore_idle_threads", Just "false")]

    it "master_timeout renders as {n}{unit}" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoMasterTimeout = Just (TimeUnitSeconds, 5)})
        `shouldBe` [("master_timeout", Just "5s")]

    it "timeout renders as {n}{unit}" $
      queries (getNodesHotThreadsWith Nothing defaultHotThreadsOptions {htoTimeout = Just (TimeUnitSeconds, 10)})
        `shouldBe` [("timeout", Just "10s")]

    it "all params together render in field order" $
      let opts =
            defaultHotThreadsOptions
              { htoThreads = Just (ThreadCount 1),
                htoInterval = Just (TimeUnitMilliseconds, 500),
                htoSnapshots = Just 5,
                htoDocType = Just HotThreadsDocTypeWait,
                htoIgnoreIdleThreads = Just True,
                htoMasterTimeout = Just (TimeUnitSeconds, 5),
                htoTimeout = Just (TimeUnitSeconds, 10)
              }
       in queries (getNodesHotThreadsWith Nothing opts)
            `shouldBe` [ ("threads", Just "1"),
                         ("interval", Just "500ms"),
                         ("snapshots", Just "5"),
                         ("type", Just "wait"),
                         ("ignore_idle_threads", Just "true"),
                         ("master_timeout", Just "5s"),
                         ("timeout", Just "10s")
                       ]

  describe "HotThreadsDocType JSON" $ do
    it "renders HotThreadsDocTypeCPU as \"cpu\"" $
      encode HotThreadsDocTypeCPU `shouldBe` "\"cpu\""

    it "renders HotThreadsDocTypeWait as \"wait\"" $
      encode HotThreadsDocTypeWait `shouldBe` "\"wait\""

    it "renders HotThreadsDocTypeBlock as \"block\"" $
      encode HotThreadsDocTypeBlock `shouldBe` "\"block\""

    it "parses \"cpu\" as HotThreadsDocTypeCPU" $
      decode "\"cpu\"" `shouldBe` Just HotThreadsDocTypeCPU

    it "parses \"wait\" as HotThreadsDocTypeWait" $
      decode "\"wait\"" `shouldBe` Just HotThreadsDocTypeWait

    it "parses \"block\" as HotThreadsDocTypeBlock" $
      decode "\"block\"" `shouldBe` Just HotThreadsDocTypeBlock

    it "round-trips every named constructor" $ do
      decode (encode HotThreadsDocTypeCPU) `shouldBe` Just HotThreadsDocTypeCPU
      decode (encode HotThreadsDocTypeWait) `shouldBe` Just HotThreadsDocTypeWait
      decode (encode HotThreadsDocTypeBlock) `shouldBe` Just HotThreadsDocTypeBlock

    it "rejects unknown strings (reject-unknown convention, matches NodeInfoMetric)" $
      decode "\"future_kind\"" `shouldBe` (Nothing :: Maybe HotThreadsDocType)

    it "encodes the Other escape hatch verbatim (Haskell-only, not decodable)" $
      encode (OtherHotThreadsDocType "future_kind") `shouldBe` "\"future_kind\""

  describe "ThreadPoolType FromJSON" $ do
    it "parses \"resizable\" as ThreadPoolResizable" $
      decode "\"resizable\"" `shouldBe` Just ThreadPoolResizable

    it "parses \"scaling\" as ThreadPoolScaling" $
      decode "\"scaling\"" `shouldBe` Just ThreadPoolScaling

    it "parses \"fixed\" as ThreadPoolFixed" $
      decode "\"fixed\"" `shouldBe` Just ThreadPoolFixed

    it "parses \"cached\" as ThreadPoolCached" $
      decode "\"cached\"" `shouldBe` Just ThreadPoolCached

    it "parses \"fixed_auto_queue_size\" as ThreadPoolFixedAutoQueueSize" $
      decode "\"fixed_auto_queue_size\"" `shouldBe` Just ThreadPoolFixedAutoQueueSize

    it "rejects unknown thread pool types" $
      decode "\"bogus\"" `shouldBe` (Nothing :: Maybe ThreadPoolType)

  -- Endpoint-shape tests for the metric-filter variants. These are pure
  -- (no live ES) — they pin the URL path that 'getNodesInfoWith' /
  -- 'getNodesStatsWith' emit so the wire format cannot drift silently.
  describe "getNodesInfoWith endpoint shape" $ do
    let path req = getRawEndpoint (bhRequestEndpoint req)
        queries req = getRawEndpointQueries (bhRequestEndpoint req)

    it "no metrics -> identical to getNodesInfo (LocalNode)" $
      path (getNodesInfoWith LocalNode defaultNodeInfoOptions)
        `shouldBe` path (getNodesInfo LocalNode)

    it "no metrics -> identical to getNodesInfo (AllNodes)" $
      path (getNodesInfoWith AllNodes defaultNodeInfoOptions)
        `shouldBe` path (getNodesInfo AllNodes)

    it "no metrics -> identical to getNodesInfo (NodeList)" $
      let sel = NodeList (NodeByName (NodeName "node-1") :| [])
       in path (getNodesInfoWith sel defaultNodeInfoOptions) `shouldBe` path (getNodesInfo sel)

    it "LocalNode + [jvm, os] -> /_nodes/_local/info/jvm,os" $
      path (getNodesInfoWith LocalNode defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricJVM, NodeInfoMetricOS]})
        `shouldBe` ["_nodes", "_local", "info", "jvm,os"]

    it "AllNodes + [jvm] -> /_nodes/_all/info/jvm" $
      path (getNodesInfoWith AllNodes defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricJVM]})
        `shouldBe` ["_nodes", "_all", "info", "jvm"]

    it "NodeList + [jvm] -> /_nodes/{sel}/info/jvm" $
      let sel = NodeList (NodeByName (NodeName "node-1") :| [NodeByFullNodeId (FullNodeId "abc")])
       in path (getNodesInfoWith sel defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricJVM]})
            `shouldBe` ["_nodes", "node-1,abc", "info", "jvm"]

    it "NodeByHost selector renders host as-is" $
      let sel = NodeList (NodeByHost (Server "10.0.0.1") :| [])
       in path (getNodesInfoWith sel defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricHTTP]})
            `shouldBe` ["_nodes", "10.0.0.1", "info", "http"]

    it "NodeByAttribute renders as key:value" $
      let sel = NodeList (NodeByAttribute (NodeAttrName "rack") "r1" :| [])
       in path (getNodesInfoWith sel defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricOS]})
            `shouldBe` ["_nodes", "rack:r1", "info", "os"]

    it "OtherNodeInfoMetric renders verbatim" $
      path (getNodesInfoWith LocalNode defaultNodeInfoOptions {nioMetrics = Just [OtherNodeInfoMetric "custom_metric"]})
        `shouldBe` ["_nodes", "_local", "info", "custom_metric"]

    it "multiple metrics comma-joined in declaration order" $
      path
        ( getNodesInfoWith
            LocalNode
            defaultNodeInfoOptions {nioMetrics = Just [NodeInfoMetricPlugins, NodeInfoMetricIngest, NodeInfoMetricJVM]}
        )
        `shouldBe` ["_nodes", "_local", "info", "plugins,ingest,jvm"]

    it "default options -> no query string" $
      queries (getNodesInfoWith LocalNode defaultNodeInfoOptions) `shouldBe` []

    it "flat_settings renders as lowercase bool" $
      queries (getNodesInfoWith LocalNode defaultNodeInfoOptions {nioFlatSettings = Just True})
        `shouldBe` [("flat_settings", Just "true")]

    it "master_timeout renders as {n}{unit}" $
      queries (getNodesInfoWith LocalNode defaultNodeInfoOptions {nioMasterTimeout = Just (TimeUnitSeconds, 5)})
        `shouldBe` [("master_timeout", Just "5s")]

    it "timeout renders as {n}{unit}" $
      queries (getNodesInfoWith LocalNode defaultNodeInfoOptions {nioTimeout = Just (TimeUnitSeconds, 10)})
        `shouldBe` [("timeout", Just "10s")]

    it "all params together render in field order" $
      let opts =
            defaultNodeInfoOptions
              { nioFlatSettings = Just True,
                nioMasterTimeout = Just (TimeUnitSeconds, 5),
                nioTimeout = Just (TimeUnitSeconds, 10)
              }
       in queries (getNodesInfoWith LocalNode opts)
            `shouldBe` [ ("flat_settings", Just "true"),
                         ("master_timeout", Just "5s"),
                         ("timeout", Just "10s")
                       ]

  describe "getNodesStatsWith endpoint shape" $ do
    let path req = getRawEndpoint (bhRequestEndpoint req)
        queries req = getRawEndpointQueries (bhRequestEndpoint req)

    it "no metrics -> identical to getNodesStats (LocalNode)" $
      path (getNodesStatsWith LocalNode defaultNodeStatsOptions)
        `shouldBe` path (getNodesStats LocalNode)

    it "no metrics -> identical to getNodesStats (AllNodes)" $
      path (getNodesStatsWith AllNodes defaultNodeStatsOptions)
        `shouldBe` path (getNodesStats AllNodes)

    it "LocalNode + [jvm, os] -> /_nodes/_local/stats/jvm,os" $
      path (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoMetrics = Just [NodeStatsMetricJVM, NodeStatsMetricOS]})
        `shouldBe` ["_nodes", "_local", "stats", "jvm,os"]

    it "AllNodes + [fs] -> /_nodes/_all/stats/fs" $
      path (getNodesStatsWith AllNodes defaultNodeStatsOptions {nsoMetrics = Just [NodeStatsMetricFS]})
        `shouldBe` ["_nodes", "_all", "stats", "fs"]

    it "NodeList + [jvm, breakers] preserves selector rendering" $
      let sel = NodeList (NodeByName (NodeName "n1") :| [])
       in path (getNodesStatsWith sel defaultNodeStatsOptions {nsoMetrics = Just [NodeStatsMetricJVM, NodeStatsMetricBreakers]})
            `shouldBe` ["_nodes", "n1", "stats", "jvm,breakers"]

    it "OtherNodeStatsMetric renders verbatim" $
      path (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoMetrics = Just [OtherNodeStatsMetric "indexing_pressure"]})
        `shouldBe` ["_nodes", "_local", "stats", "indexing_pressure"]

    it "default options -> no query string" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions) `shouldBe` []

    it "completion_fields renders verbatim" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoCompletionFields = Just "text,*_suggest"})
        `shouldBe` [("completion_fields", Just "text,*_suggest")]

    it "fielddata_fields renders verbatim" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoFielddataFields = Just "text"})
        `shouldBe` [("fielddata_fields", Just "text")]

    it "fields renders verbatim" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoFields = Just "*"})
        `shouldBe` [("fields", Just "*")]

    it "groups renders verbatim" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoGroups = Just "g1,g2"})
        `shouldBe` [("groups", Just "g1,g2")]

    it "level renders via renderNodeStatsLevel" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoLevel = Just NodeStatsLevelShards})
        `shouldBe` [("level", Just "shards")]

    it "level=cluster renders via renderNodeStatsLevel" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoLevel = Just NodeStatsLevelCluster})
        `shouldBe` [("level", Just "cluster")]

    it "types renders verbatim" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoTypes = Just "_doc"})
        `shouldBe` [("types", Just "_doc")]

    it "master_timeout renders as {n}{unit}" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoMasterTimeout = Just (TimeUnitSeconds, 5)})
        `shouldBe` [("master_timeout", Just "5s")]

    it "timeout renders as {n}{unit}" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoTimeout = Just (TimeUnitSeconds, 10)})
        `shouldBe` [("timeout", Just "10s")]

    it "include_segment_file_sizes renders as lowercase bool" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoIncludeSegmentFileSizes = Just True})
        `shouldBe` [("include_segment_file_sizes", Just "true")]

    it "include_unloaded_segments renders as lowercase bool" $
      queries (getNodesStatsWith LocalNode defaultNodeStatsOptions {nsoIncludeUnloadedSegments = Just True})
        `shouldBe` [("include_unloaded_segments", Just "true")]

    it "all params together render in field order" $
      let opts =
            defaultNodeStatsOptions
              { nsoCompletionFields = Just "cf",
                nsoFielddataFields = Just "fdf",
                nsoFields = Just "f",
                nsoGroups = Just "g",
                nsoLevel = Just NodeStatsLevelIndices,
                nsoTypes = Just "t",
                nsoMasterTimeout = Just (TimeUnitSeconds, 5),
                nsoTimeout = Just (TimeUnitSeconds, 10),
                nsoIncludeSegmentFileSizes = Just True,
                nsoIncludeUnloadedSegments = Just False
              }
       in queries (getNodesStatsWith LocalNode opts)
            `shouldBe` [ ("completion_fields", Just "cf"),
                         ("fielddata_fields", Just "fdf"),
                         ("fields", Just "f"),
                         ("groups", Just "g"),
                         ("level", Just "indices"),
                         ("types", Just "t"),
                         ("master_timeout", Just "5s"),
                         ("timeout", Just "10s"),
                         ("include_segment_file_sizes", Just "true"),
                         ("include_unloaded_segments", Just "false")
                       ]

  describe "getNodesUsageWith endpoint shape" $ do
    let path req = getRawEndpoint (bhRequestEndpoint req)
        queries req = getRawEndpointQueries (bhRequestEndpoint req)

    it "no metrics -> identical to getNodesUsage (LocalNode)" $
      path (getNodesUsageWith LocalNode defaultNodeUsageOptions)
        `shouldBe` path (getNodesUsage LocalNode)

    it "no metrics -> identical to getNodesUsage (AllNodes)" $
      path (getNodesUsageWith AllNodes defaultNodeUsageOptions)
        `shouldBe` path (getNodesUsage AllNodes)

    it "LocalNode + [RestActions] -> /_nodes/_local/usage/rest_actions" $
      path (getNodesUsageWith LocalNode defaultNodeUsageOptions {nuoMetrics = Just [NodeUsageMetricRestActions]})
        `shouldBe` ["_nodes", "_local", "usage", "rest_actions"]

    it "AllNodes no metrics -> /_nodes/_all/usage" $
      path (getNodesUsageWith AllNodes defaultNodeUsageOptions)
        `shouldBe` ["_nodes", "_all", "usage"]

    it "NodeList + [RestActions] -> /_nodes/{sel}/usage/rest_actions" $
      let sel = NodeList (NodeByName (NodeName "n1") :| [NodeByFullNodeId (FullNodeId "abc")])
       in path (getNodesUsageWith sel defaultNodeUsageOptions {nuoMetrics = Just [NodeUsageMetricRestActions]})
            `shouldBe` ["_nodes", "n1,abc", "usage", "rest_actions"]

    it "OtherNodeUsageMetric renders verbatim" $
      path (getNodesUsageWith LocalNode defaultNodeUsageOptions {nuoMetrics = Just [OtherNodeUsageMetric "custom_metric"]})
        `shouldBe` ["_nodes", "_local", "usage", "custom_metric"]

    it "default options -> no query string" $
      queries (getNodesUsageWith LocalNode defaultNodeUsageOptions) `shouldBe` []

    it "master_timeout renders as {n}{unit}" $
      queries (getNodesUsageWith LocalNode defaultNodeUsageOptions {nuoMasterTimeout = Just (TimeUnitSeconds, 5)})
        `shouldBe` [("master_timeout", Just "5s")]

    it "timeout renders as {n}{unit}" $
      queries (getNodesUsageWith LocalNode defaultNodeUsageOptions {nuoTimeout = Just (TimeUnitSeconds, 10)})
        `shouldBe` [("timeout", Just "10s")]

    it "all params together render in field order" $
      let opts =
            defaultNodeUsageOptions
              { nuoMasterTimeout = Just (TimeUnitSeconds, 5),
                nuoTimeout = Just (TimeUnitSeconds, 10)
              }
       in queries (getNodesUsageWith LocalNode opts)
            `shouldBe` [ ("master_timeout", Just "5s"),
                         ("timeout", Just "10s")
                       ]

  -- Endpoint-shape tests for 'reloadSecureSettings'. Pure (no live ES)
  -- — they pin the HTTP method, URL path, query string and body so the
  -- wire format cannot drift silently. Mirrors the precedent set by
  -- @startILM@\/@stopILM@ in @Test/ILMSpec.hs@ and @cleanupSnapshotRepo@
  -- in @Test/SnapshotsSpec.hs@.
  describe "reloadSecureSettings endpoint shape" $ do
    let mkReq = reloadSecureSettings
        assertShape req expectedPath = do
          bhRequestMethod req `shouldBe` "POST"
          getRawEndpoint (bhRequestEndpoint req) `shouldBe` expectedPath
          getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
          bhRequestBody req `shouldBe` Just ""

    it "Nothing -> POST /_nodes/reload_secure_settings, empty body, no queries" $
      assertShape (mkReq Nothing) ["_nodes", "reload_secure_settings"]

    it "Just LocalNode -> POST /_nodes/_local/reload_secure_settings" $
      assertShape (mkReq (Just LocalNode)) ["_nodes", "_local", "reload_secure_settings"]

    it "Just AllNodes -> POST /_nodes/_all/reload_secure_settings" $
      assertShape (mkReq (Just AllNodes)) ["_nodes", "_all", "reload_secure_settings"]

    it "Just (NodeList ...) renders selectors comma-joined" $ do
      let sel = NodeList (NodeByName (NodeName "n1") :| [NodeByFullNodeId (FullNodeId "abc")])
      assertShape (mkReq (Just sel)) ["_nodes", "n1,abc", "reload_secure_settings"]

    it "Just (NodeList NodeByAttribute ...) renders key:value" $ do
      let sel = NodeList (NodeByAttribute (NodeAttrName "rack") "r1" :| [])
      assertShape (mkReq (Just sel)) ["_nodes", "rack:r1", "reload_secure_settings"]

    it "Just (NodeList NodeByHost ...) renders host verbatim" $ do
      let sel = NodeList (NodeByHost (Server "10.0.0.1") :| [])
      assertShape (mkReq (Just sel)) ["_nodes", "10.0.0.1", "reload_secure_settings"]

  describe "NodeInfoMetric JSON" $ do
    it "renders NodeInfoMetricJVM as \"jvm\"" $
      encode NodeInfoMetricJVM `shouldBe` "\"jvm\""

    it "renders NodeInfoMetricThreadPool as \"thread_pool\"" $
      encode NodeInfoMetricThreadPool `shouldBe` "\"thread_pool\""

    it "rejects unknown metrics" $
      decode "\"bogus\"" `shouldBe` (Nothing :: Maybe NodeInfoMetric)

    mapM_ roundTripInfo allNodeInfoMetricsForRoundTrip

  describe "NodeStatsMetric JSON" $ do
    it "renders NodeStatsMetricIndices as \"indices\"" $
      encode NodeStatsMetricIndices `shouldBe` "\"indices\""

    it "renders NodeStatsMetricAdaptiveSelection as \"adaptive_selection\"" $
      encode NodeStatsMetricAdaptiveSelection `shouldBe` "\"adaptive_selection\""

    it "rejects unknown metrics" $
      decode "\"bogus\"" `shouldBe` (Nothing :: Maybe NodeStatsMetric)

    mapM_ roundTripStats allNodeStatsMetricsForRoundTrip

  describe "NodeUsageMetric JSON" $ do
    it "renders NodeUsageMetricRestActions as \"rest_actions\"" $
      encode NodeUsageMetricRestActions `shouldBe` "\"rest_actions\""

    it "rejects unknown metrics" $
      decode "\"bogus\"" `shouldBe` (Nothing :: Maybe NodeUsageMetric)

    mapM_ roundTripUsage allNodeUsageMetricsForRoundTrip
  where
    roundTripInfo m =
      it ("round-trips " <> show m) $
        decode (encode m) `shouldBe` (Just m :: Maybe NodeInfoMetric)
    roundTripStats m =
      it ("round-trips " <> show m) $
        decode (encode m) `shouldBe` (Just m :: Maybe NodeStatsMetric)
    roundTripUsage m =
      it ("round-trips " <> show m) $
        decode (encode m) `shouldBe` (Just m :: Maybe NodeUsageMetric)
    -- Exhaustive lists of the named constructors (excluding the open-ended
    -- 'Other*Metric Text' escape hatches). Replaces the previous
    -- @[minBound .. maxBound]@ enumeration, which stopped being derivable
    -- when the escape hatches landed. Listed inline here to keep the test
    -- self-contained.
    allNodeInfoMetricsForRoundTrip =
      [ NodeInfoMetricSettings,
        NodeInfoMetricOS,
        NodeInfoMetricProcess,
        NodeInfoMetricJVM,
        NodeInfoMetricThreadPool,
        NodeInfoMetricTransport,
        NodeInfoMetricHTTP,
        NodeInfoMetricPlugins,
        NodeInfoMetricIngest,
        NodeInfoMetricAggregations,
        NodeInfoMetricIndices,
        NodeInfoMetricDiscovery,
        NodeInfoMetricScripting,
        NodeInfoMetricAction
      ]
    allNodeStatsMetricsForRoundTrip =
      [ NodeStatsMetricIndices,
        NodeStatsMetricOS,
        NodeStatsMetricProcess,
        NodeStatsMetricJVM,
        NodeStatsMetricThreadPool,
        NodeStatsMetricFS,
        NodeStatsMetricTransport,
        NodeStatsMetricHTTP,
        NodeStatsMetricBreakers,
        NodeStatsMetricScript,
        NodeStatsMetricDiscovery,
        NodeStatsMetricIngest,
        NodeStatsMetricAdaptiveSelection,
        NodeStatsMetricScriptCache,
        NodeStatsMetricIndexingPressure,
        NodeStatsMetricCgroup
      ]
    allNodeUsageMetricsForRoundTrip =
      [ NodeUsageMetricRestActions,
        NodeUsageMetricAggregations
      ]

-- | Extract the top-level keys of the first (and typically only) entry
-- under @.nodes@ in a @_nodes/{sel}/info@ or @/_nodes/{sel}/stats@
-- response body. Returns the empty list if the body doesn't match the
-- expected shape.
nodeMetricKeys :: BL.ByteString -> [T.Text]
nodeMetricKeys body =
  case decode body :: Maybe Value of
    Just topVal
      | Just nodeData <- firstNodeObject topVal ->
          toText <$> KM.keys nodeData
    _ -> []
  where
    firstNodeObject val = do
      nodesObj <- parseMaybe (withObject "top" (.: "nodes")) val
      case KM.toList nodesObj of
        (_, Object fields) : _ -> Just fields
        _ -> Nothing