packages feed

bloodhound-1.0.0.0: tests/Test/RerouteSpec.hs

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

module Test.RerouteSpec where

import Data.Aeson qualified as Aeson
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

-- | Encode a 'ToJSON' value and decode it straight back as an untyped
-- 'Value'. Object key order is normalised away by the round-trip, so the
-- resulting 'Value' compares equal regardless of the encoder's key order.
encodedAs :: (ToJSON a) => a -> Maybe Value
encodedAs = Aeson.decode . Aeson.encode

spec :: Spec
spec = do
  describe "RerouteCommand ToJSON" $ do
    let idx = [qqIndexName|twitter|]
        shard = ShardId 0
        n1 = NodeName "node-1"
        n2 = NodeName "node-2"

    it "renders move as {\"move\":{index,shard,from_node,to_node}}" $ do
      let cmd = RerouteMove idx shard n1 n2
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "move"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "from_node" .= ("node-1" :: Text),
                      "to_node" .= ("node-2" :: Text)
                    ]
              ]
          )

    it "renders cancel without allow_primary when it is Nothing" $ do
      let cmd = RerouteCancel idx shard n1 Nothing
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "cancel"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "node" .= ("node-1" :: Text)
                    ]
              ]
          )

    it "renders cancel with allow_primary when it is Just" $ do
      let cmd = RerouteCancel idx shard n1 (Just True)
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "cancel"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "node" .= ("node-1" :: Text),
                      "allow_primary" .= True
                    ]
              ]
          )

    it "renders allocate_replica as {\"allocate_replica\":{index,shard,node}}" $ do
      let cmd = RerouteAllocateReplica idx shard n1
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "allocate_replica"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "node" .= ("node-1" :: Text)
                    ]
              ]
          )

    it "renders allocate_stale_primary with mandatory accept_data_loss" $ do
      let cmd = RerouteAllocateStalePrimary idx shard n1 True
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "allocate_stale_primary"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "node" .= ("node-1" :: Text),
                      "accept_data_loss" .= True
                    ]
              ]
          )

    it "renders allocate_empty_primary with mandatory accept_data_loss" $ do
      let cmd = RerouteAllocateEmptyPrimary idx shard n2 False
      encodedAs cmd
        `shouldBe` Just
          ( object
              [ "allocate_empty_primary"
                  .= object
                    [ "index" .= ("twitter" :: Text),
                      "shard" .= (0 :: Int),
                      "node" .= ("node-2" :: Text),
                      "accept_data_loss" .= False
                    ]
              ]
          )

    it "passes RerouteCmdOther through verbatim" $ do
      let v = object ["allocate" .= object ["foo" .= ("bar" :: Text)]] :: Value
      -- The opaque command object is sent unchanged.
      encodedAs (RerouteCmdOther v) `shouldBe` Just v

  describe "rerouteCluster request body" $ do
    it "wraps the commands in a {\"commands\":[...]} object" $ do
      let idx = [qqIndexName|twitter|]
          cmds =
            [ RerouteMove idx (ShardId 0) (NodeName "a") (NodeName "b"),
              RerouteCancel idx (ShardId 1) (NodeName "a") Nothing
            ]
          req = Common.rerouteCluster cmds
          Just body = bhRequestBody req
      Aeson.decode body `shouldBe` Just (object ["commands" .= cmds])

  describe "rerouteOptionsParams URI rendering" $ do
    let normalize :: [(T.Text, Maybe T.Text)] -> [(T.Text, Maybe T.Text)]
        normalize = L.sort

    it "defaultRerouteOptions emits no params" $
      rerouteOptionsParams defaultRerouteOptions `shouldBe` []

    it "renders dry_run as lowercase true/false" $ do
      let opts = defaultRerouteOptions {roDryRun = Just True}
      normalize (rerouteOptionsParams opts) `shouldBe` [("dry_run", Just "true")]
      let opts' = defaultRerouteOptions {roDryRun = Just False}
      normalize (rerouteOptionsParams opts') `shouldBe` [("dry_run", Just "false")]

    it "renders explain as lowercase true/false" $ do
      let opts = defaultRerouteOptions {roExplain = Just True}
      normalize (rerouteOptionsParams opts) `shouldBe` [("explain", Just "true")]

    it "renders metric as a comma-separated list" $ do
      let opts = defaultRerouteOptions {roMetric = Just ("blocks" NE.:| ["nodes"])}
      normalize (rerouteOptionsParams opts) `shouldBe` [("metric", Just "blocks,nodes")]

    it "renders master_timeout and timeout as <n><suffix>" $ do
      let opts =
            defaultRerouteOptions
              { roMasterTimeout = Just (TimeUnitSeconds, 30),
                roTimeout = Just (TimeUnitMinutes, 1)
              }
      normalize (rerouteOptionsParams opts)
        `shouldBe` [("master_timeout", Just "30s"), ("timeout", Just "1m")]

  describe "RerouteResponse FromJSON" $ do
    it "decodes a bare acknowledgement" $ do
      let payload = "{\"acknowledged\":true}"
          Just decoded = Aeson.decode payload :: Maybe RerouteResponse
      rrAcknowledged decoded `shouldBe` True
      rrState decoded `shouldBe` Nothing
      rrExplanations decoded `shouldBe` Nothing

    it "decodes an acknowledgement carrying a cluster state (dry_run)" $ do
      let payload =
            "{\"acknowledged\":true,\"state\":{\"cluster_name\":\"es\",\
            \\"cluster_uuid\":\"uuid\",\"version\":7,\"state_uuid\":\"s\",\
            \\"master_node\":\"m\",\"nodes\":{},\"metadata\":{},\
            \\"routing_table\":{}}}"
          Just decoded = Aeson.decode payload :: Maybe RerouteResponse
      rrAcknowledged decoded `shouldBe` True
      rrState decoded `shouldSatisfy` isJust

    it "decodes an acknowledgement carrying explanations (explain)" $ do
      let payload =
            "{\"acknowledged\":true,\"state\":{\"cluster_name\":\"es\",\
            \\"cluster_uuid\":\"u\",\"version\":1,\"state_uuid\":\"s\",\
            \\"nodes\":{}},\"explanations\":[{\"node\":\"n\",\"decisions\":[]}]}"
          Just decoded = Aeson.decode payload :: Maybe RerouteResponse
      rrAcknowledged decoded `shouldBe` True
      rrExplanations decoded `shouldSatisfy` (maybe False (not . null))

    it "rejects a payload missing the acknowledged key" $
      (Aeson.decode "{\"state\":{}}" :: Maybe RerouteResponse) `shouldBe` Nothing

  -- ------------------------------------------------------------------ --
  -- rerouteCluster endpoint shape: pure checks against the BHRequest     --
  -- value; no live backend needed. Mirrors the updateClusterSettings     --
  -- shape tests in Test.ClusterSettingsSpec.                            --
  -- ------------------------------------------------------------------ --
  describe "rerouteCluster endpoint shape" $ do
    let cmds = [RerouteAllocateReplica [qqIndexName|twitter|] (ShardId 0) (NodeName "n")]

    it "POSTs /_cluster/reroute with no queries by default" $ do
      let req = Common.rerouteCluster cmds
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_cluster", "reroute"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "attaches the encoded commands as the request body" $ do
      let req = Common.rerouteCluster cmds
      bhRequestBody req `shouldBe` Just (encode (object ["commands" .= cmds]))

    it "forwards dry_run via rerouteClusterWith" $ do
      let opts = defaultRerouteOptions {roDryRun = Just True}
          req = Common.rerouteClusterWith opts cmds
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_cluster", "reroute"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("dry_run", Just "true")]

  -- ------------------------------------------------------------------ --
  -- Live round-trip: a dry_run reroute applies nothing to the cluster   --
  -- but exercises the full RerouteResponse decoder (acknowledged +      --
  -- ClusterState) against a real backend.                              --
  -- ------------------------------------------------------------------ --
  describe "rerouteClusterWith live dry-run round-trip" $
    it "dry_run=true with empty commands acknowledges and returns the state" $
      withTestEnv $
        do
          let opts = defaultRerouteOptions {roDryRun = Just True}
          resp <- Client.rerouteClusterWith opts []
          liftIO $ rrAcknowledged resp `shouldBe` True
          -- ES 9 dropped the (very large) cluster @state@ from the
          -- dry_run reroute response — only @acknowledged@ comes back.
          -- ES 7/8 still include it, so the state assertion is gated
          -- to majors below 9.
          major <- liftIO fetchMajorVersion
          liftIO $ when (major < 9) $ rrState resp `shouldSatisfy` isJust