packages feed

bloodhound-1.0.0.0: tests/Test/ClusterStateSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.ClusterStateSpec where

import Data.List qualified as L
import Data.List.NonEmpty qualified as NE
import Data.Text qualified as T
import Database.Bloodhound.Common.Client qualified as Client
import Database.Bloodhound.Common.Requests qualified as Common
import TestsUtils.Common
import TestsUtils.Import

spec :: Spec
spec = do
  describe "cluster state API" $ do
    it "returns cluster state information" $
      withTestEnv $ do
        state <- Client.getClusterState
        liftIO $ clusterStateClusterName state `shouldSatisfy` (not . T.null)
        liftIO $ clusterStateClusterUuid state `shouldSatisfy` (not . T.null)
        liftIO $ clusterStateVersion state `shouldSatisfy` (> 0)

    it "returns nodes information" $
      withTestEnv $ do
        state <- Client.getClusterState
        liftIO $ clusterStateNodes state `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- renderClusterStateMetric: pure wire-name rendering (no ES required). --
  -- Locks in the exact text ES/OS expect for each metric so a typo or   --
  -- rename surfaces here rather than as a silent 400 from the server.   --
  -- ------------------------------------------------------------------ --
  describe "renderClusterStateMetric" $ do
    it "renders every constructor at its canonical wire name" $
      L.sort (map renderClusterStateMetric [minBound .. maxBound])
        `shouldBe` L.sort
          [ "blocks",
            "custom_metadata",
            "master_node",
            "metadata",
            "nodes",
            "routing_nodes",
            "routing_table",
            "settings",
            "version"
          ]

    it "renderClusterStateMetric is injective across constructors" $
      -- Catches a copy/paste typo where two constructors render to the
      -- same wire name (would silently collapse the metric filter).
      noDuplicates (map renderClusterStateMetric [minBound .. maxBound])

  -- ------------------------------------------------------------------ --
  -- clusterStateOptionsPathSegments + clusterStateOptionsParams:        --
  -- pure URI rendering, no live backend needed. Mirrors the             --
  -- ClusterSettingsSpec "URI rendering" block.                          --
  -- ------------------------------------------------------------------ --
  describe "ClusterStateOptions URI rendering" $ do
    let normalize :: [(T.Text, Maybe T.Text)] -> [(T.Text, Maybe T.Text)]
        normalize = L.sort

    it "defaultClusterStateOptions emits no path segments and no params" $ do
      clusterStateOptionsPathSegments defaultClusterStateOptions `shouldBe` []
      clusterStateOptionsParams defaultClusterStateOptions `shouldBe` []

    it "renders a single metric as a path segment" $ do
      let opts = defaultClusterStateOptions {cstoMetrics = Just (NE.fromList [ClusterStateMetricMetadata])}
      clusterStateOptionsPathSegments opts `shouldBe` ["metadata"]

    it "renders multiple metrics as a comma-joined path segment" $ do
      let opts =
            defaultClusterStateOptions
              { cstoMetrics =
                  Just $
                    NE.fromList [ClusterStateMetricMetadata, ClusterStateMetricNodes]
              }
      clusterStateOptionsPathSegments opts `shouldBe` ["metadata,nodes"]

    it "renders multi-metric then multi-index as two CSV path segments, in order" $ do
      -- Locks in the path-segment ordering when both filters carry
      -- multiple values: the comma-joined metric segment precedes
      -- the comma-joined index segment. A regression that swaps the
      -- order, or flattens both into one CSV, surfaces here.
      let opts =
            defaultClusterStateOptions
              { cstoMetrics =
                  Just $
                    NE.fromList [ClusterStateMetricMetadata, ClusterStateMetricVersion],
                cstoIndices = Just (NE.fromList [testIndex, otherIndex])
              }
      clusterStateOptionsPathSegments opts
        `shouldBe` [ "metadata,version",
                     unIndexName testIndex <> "," <> unIndexName otherIndex
                   ]

    it "renders the index filter only when a metric filter is also set" $ do
      -- Without a metric segment, GET /_cluster/state//foo is a 400.
      -- The renderer must drop a lone index filter rather than emit
      -- an empty path segment.
      let withMetrics =
            defaultClusterStateOptions
              { cstoMetrics = Just (NE.fromList [ClusterStateMetricMetadata]),
                cstoIndices = Just (NE.fromList [testIndex])
              }
          withoutMetrics =
            defaultClusterStateOptions
              { cstoIndices = Just (NE.fromList [testIndex])
              }
      clusterStateOptionsPathSegments withMetrics
        `shouldBe` ["metadata", unIndexName testIndex]
      clusterStateOptionsPathSegments withoutMetrics `shouldBe` []

    it "renders multiple indices as a comma-joined path segment" $ do
      let opts =
            defaultClusterStateOptions
              { cstoMetrics = Just (NE.fromList [ClusterStateMetricRoutingTable]),
                cstoIndices = Just (NE.fromList [testIndex, otherIndex])
              }
      clusterStateOptionsPathSegments opts
        `shouldBe` ["routing_table", unIndexName testIndex <> "," <> unIndexName otherIndex]

    it "renders local as lowercase true/false" $ do
      let trueOpts = defaultClusterStateOptions {cstoLocal = Just True}
          falseOpts = defaultClusterStateOptions {cstoLocal = Just False}
      clusterStateOptionsParams trueOpts `shouldBe` [("local", Just "true")]
      clusterStateOptionsParams falseOpts `shouldBe` [("local", Just "false")]

    it "renders master_timeout as <n><suffix>" $ do
      let opts = defaultClusterStateOptions {cstoMasterTimeout = Just (TimeUnitSeconds, 30)}
      clusterStateOptionsParams opts `shouldBe` [("master_timeout", Just "30s")]

    it "renders wait_for_metadata_version as a decimal" $ do
      let opts = defaultClusterStateOptions {cstoWaitForMetadataVersion = Just 42}
      clusterStateOptionsParams opts
        `shouldBe` [("wait_for_metadata_version", Just "42")]

    it "renders filter_path as a comma-joined list" $ do
      let opts =
            defaultClusterStateOptions
              { cstoFilterPath = Just (NE.fromList ["cluster_name", "metadata.indices.*"])
              }
      clusterStateOptionsParams opts
        `shouldBe` [("filter_path", Just "cluster_name,metadata.indices.*")]

    it "emits path segments and query params together when all are set" $ do
      let opts =
            defaultClusterStateOptions
              { cstoMetrics = Just (NE.fromList [ClusterStateMetricMetadata]),
                cstoIndices = Just (NE.fromList [testIndex]),
                cstoLocal = Just True,
                cstoMasterTimeout = Just (TimeUnitMilliseconds, 500),
                cstoWaitForMetadataVersion = Just 7,
                cstoFilterPath = Just (NE.fromList ["metadata.indices.*"])
              }
      clusterStateOptionsPathSegments opts
        `shouldBe` ["metadata", unIndexName testIndex]
      normalize (clusterStateOptionsParams opts)
        `shouldBe` [ ("filter_path", Just "metadata.indices.*"),
                     ("local", Just "true"),
                     ("master_timeout", Just "500ms"),
                     ("wait_for_metadata_version", Just "7")
                   ]

  -- ------------------------------------------------------------------ --
  -- getClusterStateWith endpoint shape: pure checks against the         --
  -- BHRequest value, no live backend needed. Mirrors the                --
  -- updateClusterSettings shape tests in Test.ClusterSettingsSpec.      --
  -- ------------------------------------------------------------------ --
  describe "getClusterStateWith endpoint shape" $ do
    it "GETs /_cluster/state with no extra path or query by default" $ do
      let req = Common.getClusterStateWith defaultClusterStateOptions
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_cluster", "state"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards metric and index filters as additional path segments" $ do
      let opts =
            defaultClusterStateOptions
              { cstoMetrics = Just (NE.fromList [ClusterStateMetricNodes]),
                cstoIndices = Just (NE.fromList [testIndex])
              }
          req = Common.getClusterStateWith opts
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_cluster", "state", "nodes", unIndexName testIndex]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "forwards local, master_timeout and filter_path via the query string" $ do
      let opts =
            defaultClusterStateOptions
              { cstoLocal = Just True,
                cstoMasterTimeout = Just (TimeUnitSeconds, 30),
                cstoFilterPath = Just (NE.fromList ["master_node"])
              }
          req = Common.getClusterStateWith opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_cluster", "state"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [ ("local", Just "true"),
                     ("master_timeout", Just "30s"),
                     ("filter_path", Just "master_node")
                   ]

-- | Local-only fixture index name so the spec doesn't depend on the
-- cluster's test-index bootstrap beyond 'testIndex'. Pure tests only
-- ever render this to text, never send it.
otherIndex :: IndexName
otherIndex = [qqIndexName|bloodhound-tests-twitter-2|]