packages feed

bloodhound-1.0.0.0: tests/Test/CatSpec.hs

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

module Test.CatSpec (spec) where

import Control.Monad.Catch (bracket_)
import Data.Aeson.Key (fromText)
import Data.Aeson.KeyMap qualified as KM
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.Common.Client qualified as Client
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- Pure rendering + decoding tests for the cat indices option type and
-- response row. Mirrors the structure of "Test.ClusterHealthSpec": a
-- "URI rendering" describe block that does not need a running ES, plus
-- a JSON-parsing describe block driven by realistic cat wire payloads.
spec :: Spec
spec = do
  -- ------------------------------------------------------------------ --
  -- catIndicesOptionsParams: pure URI rendering (no ES required)        --
  -- ------------------------------------------------------------------ --
  describe "catIndicesOptionsParams URI rendering" $ do
    it "defaultCatIndicesOptions emits only format=json" $
      catIndicesOptionsParams defaultCatIndicesOptions
        `shouldBe` [("format", Just "json")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatIndicesOptions {catoBytes = Just CatBytesKb}
      catIndicesOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatIndicesOptions
              { catoColumns = Just (CatColIndex :| [CatColDocsCount, CatColStoreSize])
              }
      catIndicesOptionsParams opts
        `shouldContain` [("h", Just "index,docs.count,store.size")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatIndicesOptions
              { catoColumns = Just (CatColOther "rep.throttle.time" :| [])
              }
      catIndicesOptionsParams opts
        `shouldContain` [("h", Just "rep.throttle.time")]

    it "renders expand_wildcards as a comma-joined list" $ do
      let opts =
            defaultCatIndicesOptions
              { catoExpandWildcards = Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
              }
      catIndicesOptionsParams opts
        `shouldContain` [("expand_wildcards", Just "open,closed")]

    it "renders health via catHealthFilterText" $ do
      let opts = defaultCatIndicesOptions {catoHealth = Just CatHealthFilterYellow}
      catIndicesOptionsParams opts
        `shouldContain` [("health", Just "yellow")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatIndicesOptions
              { catoHelp = Just True,
                catoIncludeUnloadedSegments = Just False,
                catoLocal = Just True,
                catoPri = Just False,
                catoVerbose = Just True
              }
      let ps = catIndicesOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("include_unloaded_segments", Just "false")]
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("pri", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatIndicesOptions {catoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catIndicesOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatIndicesOptions
              { catoSort =
                  Just
                    ( CatSortSpec CatColDocsCount (Just CatSortDesc)
                        :| [CatSortSpec CatColIndex Nothing]
                    )
              }
      catIndicesOptionsParams opts
        `shouldContain` [("s", Just "docs.count:desc,index")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                             --
  -- ------------------------------------------------------------------ --
  describe "CatBytesSize rendering" $
    it "covers every documented unit" $
      map
        catBytesSizeText
        [ CatBytesBytes,
          CatBytesK,
          CatBytesKb,
          CatBytesM,
          CatBytesMb,
          CatBytesG,
          CatBytesGb,
          CatBytesT,
          CatBytesTb,
          CatBytesP,
          CatBytesPb
        ]
        `shouldBe` ["b", "k", "kb", "m", "mb", "g", "gb", "t", "tb", "p", "pb"]

  -- ------------------------------------------------------------------ --
  -- CatIndicesRow JSON decoding                                         --
  -- ------------------------------------------------------------------ --
  describe "CatIndicesRow FromJSON" $ do
    let fullRow =
          "{\
          \  \"health\":\"green\",\
          \  \"status\":\"open\",\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"uuid\":\"XYZ123\",\
          \  \"pri\":\"1\",\
          \  \"rep\":\"1\",\
          \  \"docs.count\":\"42\",\
          \  \"docs.deleted\":\"3\",\
          \  \"store.size\":\"5kb\",\
          \  \"pri.store.size\":\"5kb\",\
          \  \"dataset\":\"time-series\"\
          \}"
        minimalRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\"\
          \}"
        subsetRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"docs.count\":\"100\",\
          \  \"docs.deleted\":\"0\"\
          \}"
        fractionalRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"store.size\":\"5.2gb\",\
          \  \"pri.store.size\":\"5.2gb\"\
          \}"
        bareRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"store.size\":\"208\",\
          \  \"pri.store.size\":\"208\"\
          \}"
        unknownStatusRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"status\":\"warm\"\
          \}"
        extraColumnRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"memory.total\":\"1gb\"\
          \}"
        badUnitRow =
          "{\
          \  \"index\":\"bloodhound-tests-cat\",\
          \  \"store.size\":\"5qb\"\
          \}"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String CatIndicesRow of
        Right row -> do
          cirHealth row `shouldBe` Just CatIndexHealthGreen
          cirStatus row `shouldBe` Just CatIndexStatusOpen
          cirIndex row `shouldBe` [qqIndexName|bloodhound-tests-cat|]
          cirUuid row `shouldBe` Just "XYZ123"
          cirPrimaryShards row `shouldBe` Just 1
          cirReplicaCount row `shouldBe` Just 1
          cirDocsCount row `shouldBe` Just 42
          cirDocsDeleted row `shouldBe` Just 3
          cirStoreSize row `shouldBe` Just (Bytes 5, CatBytesKb)
          cirPrimaryStoreSize row `shouldBe` Just (Bytes 5, CatBytesKb)
          cirDataset row `shouldBe` Just "time-series"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only `index`" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String CatIndicesRow of
        Right row -> do
          cirIndex row `shouldBe` [qqIndexName|bloodhound-tests-cat|]
          cirHealth row `shouldBe` Nothing
          cirStatus row `shouldBe` Nothing
          cirDocsCount row `shouldBe` Nothing
          cirStoreSize row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a column-subset row (as produced by h=index,docs.count,docs.deleted)" $
      case parseEither parseJSON =<< eitherDecode subsetRow :: Either String CatIndicesRow of
        Right row -> do
          cirIndex row `shouldBe` [qqIndexName|bloodhound-tests-cat|]
          cirDocsCount row `shouldBe` Just 100
          cirDocsDeleted row `shouldBe` Just 0
          cirStoreSize row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rounds fractional magnitudes to the nearest whole byte" $
      case parseEither parseJSON =<< eitherDecode fractionalRow :: Either String CatIndicesRow of
        Right row -> do
          cirStoreSize row `shouldBe` Just (Bytes 5, CatBytesGb)
          cirPrimaryStoreSize row `shouldBe` Just (Bytes 5, CatBytesGb)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "treats a bare number as CatBytesBytes" $
      case parseEither parseJSON =<< eitherDecode bareRow :: Either String CatIndicesRow of
        Right row ->
          cirStoreSize row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "preserves unknown status values via CatIndexStatusOther" $
      case parseEither parseJSON =<< eitherDecode unknownStatusRow :: Either String CatIndicesRow of
        Right row -> cirStatus row `shouldBe` Just (CatIndexStatusOther "warm")
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cirOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String CatIndicesRow of
        Right row ->
          -- 'cirOther' is the original JSON object with every key the
          -- server sent, including ones we model and ones we don't.
          case cirOther row of
            Object o -> KM.keys o `shouldContain` ["index", "memory.total"]
            _ -> expectationFailure "expected cirOther to be an Object"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised byte-size suffix" $
      case parseEither parseJSON =<< eitherDecode badUnitRow :: Either String CatIndicesRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

    -- Defensive coverage: if OpenSearch emits a native JSON number for
    -- an integer column (instead of the usual string), the parser
    -- should still succeed by stringifying the integer-valued
    -- Scientific without a trailing ".0". See 'parseAsString'.
    it "tolerates a native JSON number for an integer column" $
      case parseEither parseJSON =<< eitherDecode "{\"index\":\"x\",\"docs.count\":42}" :: Either String CatIndicesRow of
        Right row -> cirDocsCount row `shouldBe` Just 42
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for store.size (treated as bare bytes)" $
      case parseEither parseJSON =<< eitherDecode "{\"index\":\"x\",\"store.size\":208}" :: Either String CatIndicesRow of
        Right row -> cirStoreSize row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catSizeInBytes                                                     --
  -- ------------------------------------------------------------------ --
  describe "catSizeInBytes" $ do
    it "treats bare bytes as identity" $
      catSizeInBytes (Bytes 208, CatBytesBytes) `shouldBe` Bytes 208

    it "applies the base-10 multiplier for k/m/g" $ do
      catSizeInBytes (Bytes 5, CatBytesK) `shouldBe` Bytes 5000
      catSizeInBytes (Bytes 5, CatBytesM) `shouldBe` Bytes 5000000
      catSizeInBytes (Bytes 5, CatBytesG) `shouldBe` Bytes 5000000000

    it "applies the base-2 multiplier for kb/mb/gb" $ do
      catSizeInBytes (Bytes 5, CatBytesKb) `shouldBe` Bytes 5120
      catSizeInBytes (Bytes 5, CatBytesMb) `shouldBe` Bytes 5242880
      catSizeInBytes (Bytes 5, CatBytesGb) `shouldBe` Bytes 5368709120

  -- ------------------------------------------------------------------ --
  -- Smoke test against a real backend (mirrors Test.ClusterHealthSpec) --
  -- ------------------------------------------------------------------ --
  describe "catIndicesWith integration" $ do
    it "returns a row for an index we just created" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-tests-cat-indices|] $ \idx -> do
          rows <- Client.catIndicesWith defaultCatIndicesOptions
          liftIO $
            case filter ((== idx) . cirIndex) rows of
              (row : _) -> do
                cirIndex row `shouldBe` idx
                cirStatus row `shouldSatisfy` (`elem` [Just CatIndexStatusOpen, Just CatIndexStatusClosed])
              [] ->
                expectationFailure "expected at least one row for the freshly-created index"

    it "honours the h= parameter and leaves unrequested columns absent" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-tests-cat-indices-col|] $ \idx -> do
          let opts =
                defaultCatIndicesOptions
                  { catoColumns = Just (CatColIndex :| [CatColDocsCount])
                  }
          rows <- Client.catIndicesWith opts
          liftIO $
            case filter ((== idx) . cirIndex) rows of
              (row : _) -> do
                cirIndex row `shouldBe` idx
                cirDocsCount row `shouldSatisfy` isJust
                -- Columns we did not request should be absent:
                cirHealth row `shouldBe` Nothing
                cirUuid row `shouldBe` Nothing
                cirStoreSize row `shouldBe` Nothing
              [] ->
                expectationFailure "expected at least one row for the freshly-created index"

  -- ------------------------------------------------------------------ --
  -- catAliasesOptionsParams: pure URI rendering (no ES required)        --
  -- ------------------------------------------------------------------ --
  describe "catAliasesOptionsParams URI rendering" $ do
    it "defaultCatAliasesOptions emits only format=json" $
      catAliasesOptionsParams defaultCatAliasesOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatAliasesOptions
              { caaColumns = Just (CatAliasColAlias :| [CatAliasColIndex, CatAliasColIsWriteIndex])
              }
      catAliasesOptionsParams opts
        `shouldContain` [("h", Just "alias,index,is_write_index")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatAliasesOptions
              { caaColumns = Just (CatAliasColOther "ts" :| [])
              }
      catAliasesOptionsParams opts
        `shouldContain` [("h", Just "ts")]

    it "renders expand_wildcards as a comma-joined list" $ do
      let opts =
            defaultCatAliasesOptions
              { caaExpandWildcards = Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
              }
      catAliasesOptionsParams opts
        `shouldContain` [("expand_wildcards", Just "open,closed")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatAliasesOptions
              { caaHelp = Just True,
                caaLocal = Just False,
                caaVerbose = Just True
              }
      let ps = catAliasesOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatAliasesOptions {caaMasterTimeout = Just (TimeUnitSeconds, 30)}
      catAliasesOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatAliasesOptions
              { caaSort =
                  Just
                    ( CatAliasesSortSpec CatAliasColAlias (Just CatSortDesc)
                        :| [CatAliasesSortSpec CatAliasColIndex Nothing]
                    )
              }
      catAliasesOptionsParams opts
        `shouldContain` [("s", Just "alias:desc,index")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                             --
  -- ------------------------------------------------------------------ --
  describe "catAliasesColumnText rendering" $
    it "covers every documented column" $
      map
        catAliasesColumnText
        [ CatAliasColAlias,
          CatAliasColIndex,
          CatAliasColFilter,
          CatAliasColRoutingIndex,
          CatAliasColRoutingSearch,
          CatAliasColIsWriteIndex
        ]
        `shouldBe` ["alias", "index", "filter", "routing.index", "routing.search", "is_write_index"]

  describe "catAliasesIsWriteIndexToText / FromText round-trip" $ do
    it "encodes Nothing as \"-\"" $
      catAliasesIsWriteIndexToText Nothing `shouldBe` "-"
    it "encodes Just True / Just False as \"true\" / \"false\"" $ do
      catAliasesIsWriteIndexToText (Just True) `shouldBe` "true"
      catAliasesIsWriteIndexToText (Just False) `shouldBe` "false"
    it "round-trips every documented sentinel through FromText" $ do
      catAliasesIsWriteIndexFromText "-" `shouldBe` Just Nothing
      catAliasesIsWriteIndexFromText "true" `shouldBe` Just (Just True)
      catAliasesIsWriteIndexFromText "false" `shouldBe` Just (Just False)
    it "rejects unknown sentinels" $
      catAliasesIsWriteIndexFromText "maybe" `shouldBe` Nothing

  -- ------------------------------------------------------------------ --
  -- CatAliasesRow JSON decoding                                         --
  -- ------------------------------------------------------------------ --
  describe "CatAliasesRow FromJSON" $ do
    let fullRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-full\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"filter\":\"*-*\",\
          \  \"routing.index\":\"1\",\
          \  \"routing.search\":\"2\",\
          \  \"is_write_index\":\"true\"\
          \}"
        minimalRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-min\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\"\
          \}"
        noWriteIndexRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-nowrite\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"is_write_index\":\"-\"\
          \}"
        falseWriteIndexRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-false\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"is_write_index\":\"false\"\
          \}"
        nativeBoolRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-native\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"is_write_index\":true\
          \}"
        extraColumnRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-extra\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"ts\":\"1700000000000\"\
          \}"
        badWriteIndexRow =
          "{\
          \  \"alias\":\"bloodhound-tests-cat-alias-bad\",\
          \  \"index\":\"bloodhound-tests-cat-aliases-src\",\
          \  \"is_write_index\":\"maybe\"\
          \}"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String CatAliasesRow of
        Right row -> do
          carAlias row `shouldBe` [qqIndexName|bloodhound-tests-cat-alias-full|]
          carIndex row `shouldBe` [qqIndexName|bloodhound-tests-cat-aliases-src|]
          carFilter row `shouldBe` Just "*-*"
          carRoutingIndex row `shouldBe` Just "1"
          carRoutingSearch row `shouldBe` Just "2"
          carIsWriteIndex row `shouldBe` Just True
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only alias and index" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String CatAliasesRow of
        Right row -> do
          carAlias row `shouldBe` [qqIndexName|bloodhound-tests-cat-alias-min|]
          carIndex row `shouldBe` [qqIndexName|bloodhound-tests-cat-aliases-src|]
          carFilter row `shouldBe` Nothing
          carRoutingIndex row `shouldBe` Nothing
          carRoutingSearch row `shouldBe` Nothing
          carIsWriteIndex row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "decodes the \"-\" sentinel for is_write_index as Nothing" $
      case parseEither parseJSON =<< eitherDecode noWriteIndexRow :: Either String CatAliasesRow of
        Right row -> carIsWriteIndex row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "decodes \"false\" for is_write_index as Just False" $
      case parseEither parseJSON =<< eitherDecode falseWriteIndexRow :: Either String CatAliasesRow of
        Right row -> carIsWriteIndex row `shouldBe` Just False
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON bool for is_write_index" $
      case parseEither parseJSON =<< eitherDecode nativeBoolRow :: Either String CatAliasesRow of
        Right row -> carIsWriteIndex row `shouldBe` Just True
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in carOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String CatAliasesRow of
        Right row ->
          case carOther row of
            Object o -> KM.keys o `shouldContain` ["alias", "index", "ts"]
            _ -> expectationFailure "expected carOther to be an Object"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised is_write_index string" $
      case parseEither parseJSON =<< eitherDecode badWriteIndexRow :: Either String CatAliasesRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

  -- ------------------------------------------------------------------ --
  -- catAliasesWith integration (live ES)                                --
  -- ------------------------------------------------------------------ --
  describe "catAliasesWith integration" $ do
    it "returns a row for an alias we just created"
      $ withTestEnv
      $ withCatAlias
        [qqIndexName|bloodhound-tests-cat-aliases-src|]
        [qqIndexName|bloodhound-tests-cat-alias|]
      $ \idx alias -> do
        rows <- Client.catAliases
        liftIO $
          case filter ((== alias) . carAlias) rows of
            (row : _) -> do
              carAlias row `shouldBe` alias
              carIndex row `shouldBe` idx
              carIsWriteIndex row `shouldSatisfy` isJust
            [] ->
              expectationFailure "expected at least one row for the freshly-created alias"

    it "filters by alias name via the /<alias> path segment"
      $ withTestEnv
      $ withCatAlias
        [qqIndexName|bloodhound-tests-cat-aliases-filter-src|]
        [qqIndexName|bloodhound-tests-cat-alias-filter|]
      $ \_idx alias -> do
        rows <- Client.catAliasesWith (Just (AliasName alias)) defaultCatAliasesOptions
        liftIO $ do
          -- At least one row matches the filter, and every returned
          -- row points at our alias (the path-segment filter narrows
          -- the result to a single name).
          rows `shouldSatisfy` not . null
          map carAlias rows `shouldSatisfy` all (== alias)

    it "honours the h= parameter and leaves unrequested columns absent"
      $ withTestEnv
      $ withCatAlias
        [qqIndexName|bloodhound-tests-cat-aliases-col-src|]
        [qqIndexName|bloodhound-tests-cat-alias-col|]
      $ \idx alias -> do
        let opts =
              defaultCatAliasesOptions
                { caaColumns = Just (CatAliasColAlias :| [CatAliasColIndex])
                }
        rows <- Client.catAliasesWith (Just (AliasName alias)) opts
        liftIO $
          case filter ((== alias) . carAlias) rows of
            (row : _) -> do
              -- Requested columns come back populated:
              carAlias row `shouldBe` alias
              carIndex row `shouldBe` idx
              -- Columns we did not request should be absent:
              carFilter row `shouldBe` Nothing
              carRoutingIndex row `shouldBe` Nothing
              carRoutingSearch row `shouldBe` Nothing
            [] ->
              expectationFailure "expected at least one row for the freshly-created alias"

  -- ------------------------------------------------------------------ --
  -- catAllocationOptionsParams: pure URI rendering (no ES required)      --
  -- ------------------------------------------------------------------ --
  describe "catAllocationOptionsParams URI rendering" $ do
    it "defaultCatAllocationOptions emits only format=json" $
      catAllocationOptionsParams defaultCatAllocationOptions
        `shouldBe` [("format", Just "json")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatAllocationOptions {caloBytes = Just CatBytesKb}
      catAllocationOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatAllocationOptions
              { caloColumns = Just (CatAllocColNode :| [CatAllocColShards, CatAllocColDiskUsed])
              }
      catAllocationOptionsParams opts
        `shouldContain` [("h", Just "node,shards,disk.used")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatAllocationOptions
              { caloColumns = Just (CatAllocColOther "di" :| [])
              }
      catAllocationOptionsParams opts
        `shouldContain` [("h", Just "di")]

    it "renders expand_wildcards as a comma-joined list" $ do
      let opts =
            defaultCatAllocationOptions
              { caloExpandWildcards = Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
              }
      catAllocationOptionsParams opts
        `shouldContain` [("expand_wildcards", Just "open,closed")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatAllocationOptions
              { caloHelp = Just True,
                caloLocal = Just False,
                caloVerbose = Just True
              }
      let ps = catAllocationOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatAllocationOptions {caloMasterTimeout = Just (TimeUnitSeconds, 30)}
      catAllocationOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatAllocationOptions
              { caloSort =
                  Just
                    ( CatAllocationSortSpec CatAllocColShards (Just CatSortDesc)
                        :| [CatAllocationSortSpec CatAllocColNode Nothing]
                    )
              }
      catAllocationOptionsParams opts
        `shouldContain` [("s", Just "shards:desc,node")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catAllocationColumnText rendering" $
    it "covers every documented column" $
      map
        catAllocationColumnText
        [ CatAllocColShards,
          CatAllocColShardsUndesired,
          CatAllocColWriteLoadForecast,
          CatAllocColDiskIndicesForecast,
          CatAllocColDiskIndices,
          CatAllocColDiskUsed,
          CatAllocColDiskAvail,
          CatAllocColDiskTotal,
          CatAllocColDiskPercent,
          CatAllocColHost,
          CatAllocColIp,
          CatAllocColNode,
          CatAllocColNodeRole
        ]
        `shouldBe` [ "shards",
                     "shards.undesired",
                     "write_load.forecast",
                     "disk.indices.forecast",
                     "disk.indices",
                     "disk.used",
                     "disk.avail",
                     "disk.total",
                     "disk.percent",
                     "host",
                     "ip",
                     "node",
                     "node.role"
                   ]

  describe "catShardsUndesiredFromText / ToText round-trip" $ do
    it "decodes \"-1\" as NotApplicable" $
      catShardsUndesiredFromText "-1" `shouldBe` Just CatShardsUndesiredNotApplicable
    it "decodes non-negative integers as Count" $ do
      catShardsUndesiredFromText "0" `shouldBe` Just (CatShardsUndesiredCount 0)
      catShardsUndesiredFromText "42" `shouldBe` Just (CatShardsUndesiredCount 42)
    it "rejects non-sentinel negative values and garbage" $ do
      catShardsUndesiredFromText "-2" `shouldBe` (Nothing :: Maybe CatShardsUndesired)
      catShardsUndesiredFromText "abc" `shouldBe` (Nothing :: Maybe CatShardsUndesired)
    it "round-trips every constructor through ToText" $ do
      catShardsUndesiredToText CatShardsUndesiredNotApplicable `shouldBe` "-1"
      catShardsUndesiredToText (CatShardsUndesiredCount 7) `shouldBe` "7"

  -- ------------------------------------------------------------------ --
  -- CatAllocationRow JSON decoding                                       --
  -- ------------------------------------------------------------------ --
  describe "CatAllocationRow FromJSON" $ do
    let fullRow =
          "{\
          \  \"shards\":\"1\",\
          \  \"shards.undesired\":\"0\",\
          \  \"write_load.forecast\":\"0.0\",\
          \  \"disk.indices.forecast\":\"260b\",\
          \  \"disk.indices\":\"260b\",\
          \  \"disk.used\":\"47.3gb\",\
          \  \"disk.avail\":\"43.4gb\",\
          \  \"disk.total\":\"100.7gb\",\
          \  \"disk.percent\":\"46\",\
          \  \"host\":\"127.0.0.1\",\
          \  \"ip\":\"127.0.0.1\",\
          \  \"node\":\"CSUXak2\",\
          \  \"node.role\":\"himrst\"\
          \}"
        minimalRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\"\
          \}"
        subsetRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"shards\":\"4\",\
          \  \"disk.indices\":\"100kb\"\
          \}"
        sentinelRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"shards.undesired\":\"-1\"\
          \}"
        fractionalRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"disk.used\":\"5.2gb\",\
          \  \"disk.avail\":\"5.2gb\"\
          \}"
        bareRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"disk.used\":\"208\",\
          \  \"disk.avail\":\"208\"\
          \}"
        extraColumnRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"ragney.future\":\"xyz\"\
          \}"
        badUnitRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"disk.used\":\"5qb\"\
          \}"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String CatAllocationRow of
        Right row -> do
          calrShards row `shouldBe` Just 1
          calrShardsUndesired row `shouldBe` Just (CatShardsUndesiredCount 0)
          calrWriteLoadForecast row `shouldBe` Just 0.0
          calrDiskIndicesForecast row `shouldBe` Just (Bytes 260, CatBytesBytes)
          calrDiskIndices row `shouldBe` Just (Bytes 260, CatBytesBytes)
          calrDiskUsed row `shouldBe` Just (Bytes 47, CatBytesGb)
          calrDiskAvail row `shouldBe` Just (Bytes 43, CatBytesGb)
          calrDiskTotal row `shouldBe` Just (Bytes 101, CatBytesGb)
          calrDiskPercent row `shouldBe` Just 46
          calrHost row `shouldBe` Just "127.0.0.1"
          calrIp row `shouldBe` Just "127.0.0.1"
          calrNode row `shouldBe` "CSUXak2"
          calrNodeRole row `shouldBe` Just "himrst"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only `node`" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String CatAllocationRow of
        Right row -> do
          calrNode row `shouldBe` "bloodhound-tests-cat-node"
          calrShards row `shouldBe` Nothing
          calrDiskUsed row `shouldBe` Nothing
          calrDiskPercent row `shouldBe` Nothing
          calrNodeRole row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a column-subset row (as produced by h=node,shards,disk.indices)" $
      case parseEither parseJSON =<< eitherDecode subsetRow :: Either String CatAllocationRow of
        Right row -> do
          calrNode row `shouldBe` "bloodhound-tests-cat-node"
          calrShards row `shouldBe` Just 4
          calrDiskIndices row `shouldBe` Just (Bytes 100, CatBytesKb)
          calrDiskUsed row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "decodes the \"-1\" sentinel for shards.undesired as NotApplicable" $
      case parseEither parseJSON =<< eitherDecode sentinelRow :: Either String CatAllocationRow of
        Right row -> calrShardsUndesired row `shouldBe` Just CatShardsUndesiredNotApplicable
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rounds fractional disk magnitudes to the nearest whole byte" $
      case parseEither parseJSON =<< eitherDecode fractionalRow :: Either String CatAllocationRow of
        Right row -> do
          calrDiskUsed row `shouldBe` Just (Bytes 5, CatBytesGb)
          calrDiskAvail row `shouldBe` Just (Bytes 5, CatBytesGb)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "treats a bare number as CatBytesBytes" $
      case parseEither parseJSON =<< eitherDecode bareRow :: Either String CatAllocationRow of
        Right row -> do
          calrDiskUsed row `shouldBe` Just (Bytes 208, CatBytesBytes)
          calrDiskAvail row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in calrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String CatAllocationRow of
        Right row ->
          case calrOther row of
            Object o -> KM.keys o `shouldContain` ["node", "ragney.future"]
            _ -> expectationFailure "expected calrOther to be an Object"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised byte-size suffix" $
      case parseEither parseJSON =<< eitherDecode badUnitRow :: Either String CatAllocationRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

    -- Defensive coverage: native JSON numbers should be tolerated for
    -- the integer columns, matching the CatIndicesRow precedent.
    it "tolerates a native JSON number for an integer column (shards)" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"shards\":7}" :: Either String CatAllocationRow of
        Right row -> calrShards row `shouldBe` Just 7
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for disk.percent" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"disk.percent\":50}" :: Either String CatAllocationRow of
        Right row -> calrDiskPercent row `shouldBe` Just 50
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for disk.used (treated as bare bytes)" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"disk.used\":208}" :: Either String CatAllocationRow of
        Right row -> calrDiskUsed row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

    -- Real-world shape: ES emits a synthetic "UNASSIGNED" pseudo-row
    -- for unassigned shards, with the disk columns null. Locks in that
    -- the parser tolerates the null disk values and preserves the
    -- documented pseudo-node name verbatim.
    it "parses the UNASSIGNED pseudo-node row with null disk columns" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"UNASSIGNED\",\"shards\":\"3\",\"disk.percent\":null}" :: Either String CatAllocationRow of
        Right row -> do
          calrNode row `shouldBe` "UNASSIGNED"
          calrShards row `shouldBe` Just 3
          calrDiskPercent row `shouldBe` Nothing
          calrDiskUsed row `shouldBe` Nothing
          calrDiskIndices row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON -1 for shards.undesired as NotApplicable" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"shards.undesired\":-1}" :: Either String CatAllocationRow of
        Right row -> calrShardsUndesired row `shouldBe` Just CatShardsUndesiredNotApplicable
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for write_load.forecast (fractional)" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"write_load.forecast\":1.5}" :: Either String CatAllocationRow of
        Right row -> calrWriteLoadForecast row `shouldBe` Just 1.5
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catAllocationWith integration (live ES)                              --
  -- ------------------------------------------------------------------ --
  describe "catAllocationWith integration" $ do
    it "returns at least one row after creating an index" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-tests-cat-allocation|] $
          \_idx -> do
            rows <- Client.catAllocation
            liftIO $
              rows `shouldSatisfy` (not . null)

    it "filters by node name via the /<node_id> path segment" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-tests-cat-allocation-filter|] $
          \_idx -> do
            allRows <- Client.catAllocation
            -- Exclude the synthetic UNASSIGNED pseudo-row, which has no
            -- real node name to filter by and would produce an empty
            -- (and confusing) result if pinned.
            case filter (\r -> calrNode r /= "UNASSIGNED") allRows of
              [] -> liftIO $ expectationFailure "expected at least one data node row"
              (firstRow : _) -> do
                let name = calrNode firstRow
                filtered <- Client.catAllocationWith (Just (NodeName name)) defaultCatAllocationOptions
                -- OpenSearch's /_cat/allocation/<node_id> includes the
                -- synthetic UNASSIGNED pseudo-row even when filtering by
                -- node id (ES does not); strip it before asserting so
                -- the test passes on both families.
                let realFiltered = filter (\r -> calrNode r /= "UNASSIGNED") filtered
                liftIO $ do
                  realFiltered `shouldSatisfy` (not . null)
                  map calrNode realFiltered `shouldSatisfy` all (== name)

  -- ------------------------------------------------------------------ --
  -- catCountOptionsParams: pure URI rendering (no ES required)           --
  -- ------------------------------------------------------------------ --
  describe "catCountOptionsParams URI rendering" $ do
    it "defaultCatCountOptions emits only format=json" $
      catCountOptionsParams defaultCatCountOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatCountOptions
              { ccoColumns = Just (CatCountColEpoch :| [CatCountColCount])
              }
      catCountOptionsParams opts
        `shouldContain` [("h", Just "epoch,count")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatCountOptions
              { ccoColumns = Just (CatCountColOther "t" :| [])
              }
      catCountOptionsParams opts
        `shouldContain` [("h", Just "t")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatCountOptions
              { ccoHelp = Just True,
                ccoLocal = Just False,
                ccoVerbose = Just True
              }
      let ps = catCountOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatCountOptions {ccoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catCountOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catCountColumnText rendering" $
    it "covers every documented column" $
      map
        catCountColumnText
        [CatCountColEpoch, CatCountColTimestamp, CatCountColCount]
        `shouldBe` ["epoch", "timestamp", "count"]

  -- ------------------------------------------------------------------ --
  -- CatCountRow JSON decoding                                            --
  -- ------------------------------------------------------------------ --
  describe "CatCountRow FromJSON" $ do
    let fullRow =
          "[{\"epoch\":\"1782112550\",\"timestamp\":\"07:15:50\",\"count\":\"1217\"}]"
        minimalRow = "[{\"count\":\"0\"}]"
        extraColumnRow = "[{\"count\":\"5\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatCountRow] of
        Right (row : _) -> do
          ccrEpoch row `shouldBe` Just 1782112550
          ccrTimestamp row `shouldBe` Just "07:15:50"
          ccrCount row `shouldBe` Just 1217
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only count" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatCountRow] of
        Right (row : _) -> ccrCount row `shouldBe` Just 0
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in ccrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatCountRow] of
        Right (row : _) ->
          case ccrOther row of
            Object o -> KM.keys o `shouldContain` ["count", "ragney.future"]
            _ -> expectationFailure "expected ccrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for count" $
      case parseEither parseJSON =<< eitherDecode "[{\"count\":42}]" :: Either String [CatCountRow] of
        Right (row : _) -> ccrCount row `shouldBe` Just 42
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for epoch" $
      case parseEither parseJSON =<< eitherDecode "[{\"epoch\":1782112550}]" :: Either String [CatCountRow] of
        Right (row : _) -> ccrEpoch row `shouldBe` Just 1782112550
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catCountWith integration (live ES)                                   --
  -- ------------------------------------------------------------------ --
  describe "catCountWith integration" $
    it "returns a count row after creating an index" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-tests-cat-count|] $
          \_idx -> do
            rows <- Client.catCount
            liftIO $
              rows `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- catMasterOptionsParams: pure URI rendering (no ES required)          --
  -- ------------------------------------------------------------------ --
  describe "catMasterOptionsParams URI rendering" $ do
    it "defaultCatMasterOptions emits only format=json" $
      catMasterOptionsParams defaultCatMasterOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatMasterOptions
              { cmoColumns = Just (CatMasterColId :| [CatMasterColNode])
              }
      catMasterOptionsParams opts
        `shouldContain` [("h", Just "id,node")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatMasterOptions
              { cmoHelp = Just True,
                cmoLocal = Just True
              }
      let ps = catMasterOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatMasterOptions {cmoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catMasterOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catMasterColumnText rendering" $
    it "covers every documented column" $
      map
        catMasterColumnText
        [CatMasterColId, CatMasterColHost, CatMasterColIp, CatMasterColNode]
        `shouldBe` ["id", "host", "ip", "node"]

  -- ------------------------------------------------------------------ --
  -- CatMasterRow JSON decoding                                           --
  -- ------------------------------------------------------------------ --
  describe "CatMasterRow FromJSON" $ do
    let fullRow =
          "[{\"id\":\"CBH3C4RiQcK3CxFvPJyFtQ\",\"host\":\"172.19.0.7\",\"ip\":\"172.19.0.7\",\"node\":\"elasticsearch1\"}]"
        minimalRow = "[{\"node\":\"bloodhound-tests-cat-node\"}]"
        extraColumnRow = "[{\"node\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatMasterRow] of
        Right (row : _) -> do
          cmrId row `shouldBe` Just (FullNodeId "CBH3C4RiQcK3CxFvPJyFtQ")
          cmrHost row `shouldBe` Just "172.19.0.7"
          cmrIp row `shouldBe` Just "172.19.0.7"
          cmrNode row `shouldBe` Just "elasticsearch1"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only node" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatMasterRow] of
        Right (row : _) -> do
          cmrNode row `shouldBe` Just "bloodhound-tests-cat-node"
          cmrId row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cmrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatMasterRow] of
        Right (row : _) ->
          case cmrOther row of
            Object o -> KM.keys o `shouldContain` ["node", "ragney.future"]
            _ -> expectationFailure "expected cmrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catMasterWith integration (live ES)                                  --
  -- ------------------------------------------------------------------ --
  describe "catMasterWith integration" $
    it "returns at least one master row" $
      withTestEnv $ do
        rows <- Client.catMaster
        liftIO $
          rows `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- catHealthOptionsParams: pure URI rendering (no ES required)          --
  -- ------------------------------------------------------------------ --
  describe "catHealthOptionsParams URI rendering" $ do
    it "defaultCatHealthOptions emits only format=json" $
      catHealthOptionsParams defaultCatHealthOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatHealthOptions
              { chtoColumns = Just (CatHealthColCluster :| [CatHealthColStatus])
              }
      catHealthOptionsParams opts
        `shouldContain` [("h", Just "cluster,status")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatHealthOptions
              { chtoHelp = Just True,
                chtoLocal = Just False,
                chtoTs = Just True,
                chtoVerbose = Just True
              }
      let ps = catHealthOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("ts", Just "true")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatHealthOptions {chtoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catHealthOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catHealthColumnText rendering" $
    it "covers every documented column" $
      map
        catHealthColumnText
        [ CatHealthColEpoch,
          CatHealthColTimestamp,
          CatHealthColCluster,
          CatHealthColStatus,
          CatHealthColNodeTotal,
          CatHealthColNodeData,
          CatHealthColShards,
          CatHealthColPri,
          CatHealthColRelo,
          CatHealthColInit,
          CatHealthColUnassign,
          CatHealthColPendingTasks,
          CatHealthColMaxTaskWaitTime,
          CatHealthColActiveShardsPercent
        ]
        `shouldBe` [ "epoch",
                     "timestamp",
                     "cluster",
                     "status",
                     "node.total",
                     "node.data",
                     "shards",
                     "pri",
                     "relo",
                     "init",
                     "unassign",
                     "pending_tasks",
                     "max_task_wait_time",
                     "active_shards_percent"
                   ]

  describe "catHealthStatusFromText / ToText round-trip" $ do
    it "decodes green/yellow/red" $ do
      catHealthStatusFromText "green" `shouldBe` Just CatHealthStatusGreen
      catHealthStatusFromText "yellow" `shouldBe` Just CatHealthStatusYellow
      catHealthStatusFromText "red" `shouldBe` Just CatHealthStatusRed
    it "rejects unknown values" $
      catHealthStatusFromText "purple" `shouldBe` (Nothing :: Maybe CatHealthStatus)
    it "round-trips every constructor through ToText" $ do
      catHealthStatusText CatHealthStatusGreen `shouldBe` "green"
      catHealthStatusText CatHealthStatusYellow `shouldBe` "yellow"
      catHealthStatusText CatHealthStatusRed `shouldBe` "red"

  -- ------------------------------------------------------------------ --
  -- CatHealthRow JSON decoding                                           --
  -- ------------------------------------------------------------------ --
  describe "CatHealthRow FromJSON" $ do
    let fullRow =
          "[{\"epoch\":\"1782112550\",\"timestamp\":\"07:15:50\",\"cluster\":\"docker-cluster\",\"status\":\"green\",\"node.total\":\"1\",\"node.data\":\"1\",\"shards\":\"14\",\"pri\":\"14\",\"relo\":\"0\",\"init\":\"0\",\"unassign\":\"0\",\"pending_tasks\":\"0\",\"max_task_wait_time\":\"-\",\"active_shards_percent\":\"100.0%\"}]"
        minimalRow = "[{\"cluster\":\"bloodhound-tests\"}]"
        extraColumnRow = "[{\"cluster\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatHealthRow] of
        Right (row : _) -> do
          chrEpoch row `shouldBe` Just 1782112550
          chrTimestamp row `shouldBe` Just "07:15:50"
          chrCluster row `shouldBe` Just "docker-cluster"
          chrStatus row `shouldBe` Just CatHealthStatusGreen
          chrNodeTotal row `shouldBe` Just 1
          chrNodeData row `shouldBe` Just 1
          chrShards row `shouldBe` Just 14
          chrPri row `shouldBe` Just 14
          chrRelo row `shouldBe` Just 0
          chrInit row `shouldBe` Just 0
          chrUnassign row `shouldBe` Just 0
          chrPendingTasks row `shouldBe` Just 0
          chrMaxTaskWaitTime row `shouldBe` Just "-"
          chrActiveShardsPercent row `shouldBe` Just "100.0%"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only cluster" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatHealthRow] of
        Right (row : _) -> do
          chrCluster row `shouldBe` Just "bloodhound-tests"
          chrStatus row `shouldBe` Nothing
          chrShards row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in chrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatHealthRow] of
        Right (row : _) ->
          case chrOther row of
            Object o -> KM.keys o `shouldContain` ["cluster", "ragney.future"]
            _ -> expectationFailure "expected chrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for shards" $
      case parseEither parseJSON =<< eitherDecode "[{\"cluster\":\"x\",\"shards\":14}]" :: Either String [CatHealthRow] of
        Right (row : _) -> chrShards row `shouldBe` Just 14
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised status value" $
      case parseEither parseJSON =<< eitherDecode "[{\"cluster\":\"x\",\"status\":\"purple\"}]" :: Either String [CatHealthRow] of
        Right _ -> expectationFailure "expected parse failure"
        Left _ -> return ()

  -- ------------------------------------------------------------------ --
  -- catHealthWith integration (live ES)                                  --
  -- ------------------------------------------------------------------ --
  describe "catHealthWith integration" $
    it "returns at least one health row" $
      withTestEnv $ do
        rows <- Client.catHealth
        liftIO $
          rows `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- catPendingTasksOptionsParams: pure URI rendering (no ES required)    --
  -- ------------------------------------------------------------------ --
  describe "catPendingTasksOptionsParams URI rendering" $ do
    it "defaultCatPendingTasksOptions emits only format=json" $
      catPendingTasksOptionsParams defaultCatPendingTasksOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatPendingTasksOptions
              { cptoColumns = Just (CatPendingTasksColPriority :| [CatPendingTasksColSource])
              }
      catPendingTasksOptionsParams opts
        `shouldContain` [("h", Just "priority,source")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatPendingTasksOptions
              { cptoHelp = Just True,
                cptoLocal = Just True
              }
      let ps = catPendingTasksOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatPendingTasksOptions {cptoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catPendingTasksOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catPendingTasksColumnText rendering" $
    it "covers every documented column" $
      map
        catPendingTasksColumnText
        [ CatPendingTasksColInsertOrder,
          CatPendingTasksColTimeInQueue,
          CatPendingTasksColPriority,
          CatPendingTasksColSource
        ]
        `shouldBe` ["insertOrder", "timeInQueue", "priority", "source"]

  -- ------------------------------------------------------------------ --
  -- CatPendingTasksRow JSON decoding                                     --
  -- ------------------------------------------------------------------ --
  describe "CatPendingTasksRow FromJSON" $ do
    let fullRow =
          "[{\"insertOrder\":\"1\",\"timeInQueue\":\"500ms\",\"priority\":\"URGENT\",\"source\":\"create-index [foo]\"}]"
        minimalRow = "[{\"source\":\"x\"}]"
        extraColumnRow = "[{\"source\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatPendingTasksRow] of
        Right (row : _) -> do
          cptrInsertOrder row `shouldBe` Just 1
          cptrTimeInQueue row `shouldBe` Just "500ms"
          cptrPriority row `shouldBe` Just PendingTaskUrgent
          cptrSource row `shouldBe` Just "create-index [foo]"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only source" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatPendingTasksRow] of
        Right (row : _) -> do
          cptrSource row `shouldBe` Just "x"
          cptrPriority row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cptrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatPendingTasksRow] of
        Right (row : _) ->
          case cptrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["source"]
              KM.keys o `shouldContain` ["ragney.future"]
            _ -> expectationFailure "expected cptrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for insertOrder" $
      case parseEither parseJSON =<< eitherDecode "[{\"insertOrder\":42}]" :: Either String [CatPendingTasksRow] of
        Right (row : _) -> cptrInsertOrder row `shouldBe` Just 42
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty pending-tasks list" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatPendingTasksRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catPendingTasksWith integration (live ES)                            --
  -- ------------------------------------------------------------------ --
  describe "catPendingTasksWith integration" $
    it "parses the pending-tasks list without error" $
      withTestEnv $ do
        _rows <- Client.catPendingTasks
        return ()

  -- ------------------------------------------------------------------ --
  -- catPluginsOptionsParams: pure URI rendering (no ES required)          --
  -- ------------------------------------------------------------------ --
  describe "catPluginsOptionsParams URI rendering" $ do
    it "defaultCatPluginsOptions emits only format=json" $
      catPluginsOptionsParams defaultCatPluginsOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatPluginsOptions
              { cpoColumns = Just (CatPluginsColComponent :| [CatPluginsColVersion])
              }
      catPluginsOptionsParams opts
        `shouldContain` [("h", Just "component,version")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatPluginsOptions
              { cpoHelp = Just True,
                cpoLocal = Just True
              }
      let ps = catPluginsOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatPluginsOptions {cpoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catPluginsOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catPluginsColumnText rendering" $
    it "covers every documented column" $
      map
        catPluginsColumnText
        [ CatPluginsColId,
          CatPluginsColName,
          CatPluginsColComponent,
          CatPluginsColVersion,
          CatPluginsColDescription,
          CatPluginsColType
        ]
        `shouldBe` ["id", "name", "component", "version", "description", "type"]

  -- ------------------------------------------------------------------ --
  -- CatPluginsRow JSON decoding                                          --
  -- ------------------------------------------------------------------ --
  describe "CatPluginsRow FromJSON" $ do
    let fullRow =
          "[{\"id\":\"CBH3C4RiQcK3CxFvPJyFtQ\",\"name\":\"elasticsearch1\",\"component\":\"analysis-icu\",\"version\":\"7.17.25\",\"description\":\"The ICU Analysis plugin\",\"type\":\"isolated\"}]"
        minimalRow = "[{\"component\":\"x\"}]"
        extraColumnRow = "[{\"component\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatPluginsRow] of
        Right (row : _) -> do
          cprId row `shouldBe` Just (FullNodeId "CBH3C4RiQcK3CxFvPJyFtQ")
          cprName row `shouldBe` Just "elasticsearch1"
          cprComponent row `shouldBe` Just "analysis-icu"
          cprVersion row `shouldBe` Just "7.17.25"
          cprDescription row `shouldBe` Just "The ICU Analysis plugin"
          cprType row `shouldBe` Just "isolated"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only component" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatPluginsRow] of
        Right (row : _) -> do
          cprComponent row `shouldBe` Just "x"
          cprId row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cprOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatPluginsRow] of
        Right (row : _) ->
          case cprOther row of
            Object o -> KM.keys o `shouldContain` ["component", "ragney.future"]
            _ -> expectationFailure "expected cprOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catPluginsWith integration (live ES)                                 --
  -- ------------------------------------------------------------------ --
  describe "catPluginsWith integration" $
    it "parses the plugins list without error" $
      withTestEnv $ do
        _rows <- Client.catPlugins
        return ()

  -- ------------------------------------------------------------------ --
  -- catTemplatesOptionsParams: pure URI rendering (no ES required)        --
  -- ------------------------------------------------------------------ --
  describe "catTemplatesOptionsParams URI rendering" $ do
    it "defaultCatTemplatesOptions emits only format=json" $
      catTemplatesOptionsParams defaultCatTemplatesOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatTemplatesOptions
              { ctmoColumns = Just (CatTemplatesColName :| [CatTemplatesColOrder])
              }
      catTemplatesOptionsParams opts
        `shouldContain` [("h", Just "name,order")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatTemplatesOptions
              { ctmoHelp = Just True,
                ctmoLocal = Just True
              }
      let ps = catTemplatesOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatTemplatesOptions {ctmoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catTemplatesOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catTemplatesColumnText rendering" $
    it "covers every documented column" $
      map
        catTemplatesColumnText
        [ CatTemplatesColName,
          CatTemplatesColIndexPatterns,
          CatTemplatesColOrder,
          CatTemplatesColVersion,
          CatTemplatesColComposedOf
        ]
        `shouldBe` ["name", "index_patterns", "order", "version", "composed_of"]

  -- ------------------------------------------------------------------ --
  -- CatTemplatesRow JSON decoding                                        --
  -- ------------------------------------------------------------------ --
  describe "CatTemplatesRow FromJSON" $ do
    let fullRow =
          "[{\"name\":\".monitoring-beats\",\"index_patterns\":\"[.monitoring-beats-7-*]\",\"order\":\"0\",\"version\":\"7140099\",\"composed_of\":\"\"}]"
        minimalRow = "[{\"name\":\"bloodhound-tests\"}]"
        extraColumnRow = "[{\"name\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatTemplatesRow] of
        Right (row : _) -> do
          ctprName row `shouldBe` Just ".monitoring-beats"
          ctprIndexPatterns row `shouldBe` Just "[.monitoring-beats-7-*]"
          ctprOrder row `shouldBe` Just 0
          ctprVersion row `shouldBe` Just 7140099
          ctprComposedOf row `shouldBe` Just ""
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only name" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatTemplatesRow] of
        Right (row : _) -> do
          ctprName row `shouldBe` Just "bloodhound-tests"
          ctprOrder row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in ctprOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatTemplatesRow] of
        Right (row : _) ->
          case ctprOther row of
            Object o -> KM.keys o `shouldContain` ["name", "ragney.future"]
            _ -> expectationFailure "expected ctprOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for order" $
      case parseEither parseJSON =<< eitherDecode "[{\"name\":\"x\",\"order\":3}]" :: Either String [CatTemplatesRow] of
        Right (row : _) -> ctprOrder row `shouldBe` Just 3
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for version" $
      case parseEither parseJSON =<< eitherDecode "[{\"name\":\"x\",\"version\":7140099}]" :: Either String [CatTemplatesRow] of
        Right (row : _) -> ctprVersion row `shouldBe` Just 7140099
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catTemplatesWith integration (live ES)                               --
  -- ------------------------------------------------------------------ --
  describe "catTemplatesWith integration" $
    it "returns at least one template row" $
      withTestEnv $ do
        let tplName = TemplateName "bloodhound-tests-cat-templates"
            TemplateName tplText = tplName
        bracket_
          ( void $
              performBHRequest $
                putTemplate
                  (IndexTemplate [IndexPattern "bloodhound-tests-cat-templates-*"] Nothing (object []))
                  tplName
          )
          (void $ tryPerformBHRequest $ deleteTemplate tplName)
          $ do
            rows <- Client.catTemplates
            liftIO $
              map ctprName rows `shouldSatisfy` elem (Just tplText)

  -- ------------------------------------------------------------------ --
  -- catThreadPoolOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catThreadPoolOptionsParams URI rendering" $ do
    it "defaultCatThreadPoolOptions emits only format=json" $
      catThreadPoolOptionsParams defaultCatThreadPoolOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatThreadPoolOptions
              { ctpoColumns = Just (CatThreadPoolColName :| [CatThreadPoolColActive])
              }
      catThreadPoolOptionsParams opts
        `shouldContain` [("h", Just "name,active")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatThreadPoolOptions
              { ctpoHelp = Just True,
                ctpoLocal = Just True
              }
      let ps = catThreadPoolOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatThreadPoolOptions {ctpoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catThreadPoolOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

  describe "catThreadPoolColumnText rendering" $
    it "covers every documented column" $
      map
        catThreadPoolColumnText
        [ CatThreadPoolColNodeName,
          CatThreadPoolColNodeId,
          CatThreadPoolColEphemeralNodeId,
          CatThreadPoolColPid,
          CatThreadPoolColHost,
          CatThreadPoolColIp,
          CatThreadPoolColPort,
          CatThreadPoolColName,
          CatThreadPoolColType,
          CatThreadPoolColActive,
          CatThreadPoolColPoolSize,
          CatThreadPoolColQueue,
          CatThreadPoolColQueueSize,
          CatThreadPoolColRejected,
          CatThreadPoolColLargest,
          CatThreadPoolColCompleted,
          CatThreadPoolColCore,
          CatThreadPoolColMax,
          CatThreadPoolColSize,
          CatThreadPoolColKeepAlive
        ]
        `shouldBe` [ "node_name",
                     "node_id",
                     "ephemeral_node_id",
                     "pid",
                     "host",
                     "ip",
                     "port",
                     "name",
                     "type",
                     "active",
                     "pool_size",
                     "queue",
                     "queue_size",
                     "rejected",
                     "largest",
                     "completed",
                     "core",
                     "max",
                     "size",
                     "keep_alive"
                   ]

  -- ------------------------------------------------------------------ --
  -- CatThreadPoolRow JSON decoding                                       --
  -- ------------------------------------------------------------------ --
  describe "CatThreadPoolRow FromJSON" $ do
    let fullRow =
          "[{\"node_name\":\"elasticsearch1\",\"node_id\":\"CBH3C4RiQcK3CxFvPJyFtQ\",\"ephemeral_node_id\":\"2rK7\",\"pid\":\"8\",\"host\":\"172.19.0.7\",\"ip\":\"172.19.0.7\",\"port\":\"9300\",\"name\":\"analyze\",\"type\":\"fixed\",\"active\":\"0\",\"pool_size\":\"1\",\"queue\":\"0\",\"queue_size\":\"16\",\"rejected\":\"0\",\"largest\":\"1\",\"completed\":\"5\",\"core\":null,\"max\":null,\"size\":\"1\",\"keep_alive\":null}]"
        minimalRow = "[{\"name\":\"analyze\"}]"
        extraColumnRow = "[{\"name\":\"x\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatThreadPoolRow] of
        Right (row : _) -> do
          ctprlNodeName row `shouldBe` Just "elasticsearch1"
          ctprlNodeId row `shouldBe` Just "CBH3C4RiQcK3CxFvPJyFtQ"
          ctprlEphemeralNodeId row `shouldBe` Just "2rK7"
          ctprlPid row `shouldBe` Just 8
          ctprlHost row `shouldBe` Just "172.19.0.7"
          ctprlIp row `shouldBe` Just "172.19.0.7"
          ctprlPort row `shouldBe` Just 9300
          ctprlName row `shouldBe` Just "analyze"
          ctprlType row `shouldBe` Just "fixed"
          ctprlActive row `shouldBe` Just 0
          ctprlPoolSize row `shouldBe` Just 1
          ctprlQueue row `shouldBe` Just 0
          ctprlQueueSize row `shouldBe` Just 16
          ctprlRejected row `shouldBe` Just 0
          ctprlLargest row `shouldBe` Just 1
          ctprlCompleted row `shouldBe` Just 5
          ctprlCore row `shouldBe` Nothing
          ctprlMax row `shouldBe` Nothing
          ctprlSize row `shouldBe` Just 1
          ctprlKeepAlive row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only name" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatThreadPoolRow] of
        Right (row : _) -> do
          ctprlName row `shouldBe` Just "analyze"
          ctprlActive row `shouldBe` Nothing
          ctprlPid row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in ctprlOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatThreadPoolRow] of
        Right (row : _) ->
          case ctprlOther row of
            Object o -> KM.keys o `shouldContain` ["name", "ragney.future"]
            _ -> expectationFailure "expected ctprlOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for active" $
      case parseEither parseJSON =<< eitherDecode "[{\"name\":\"x\",\"active\":3}]" :: Either String [CatThreadPoolRow] of
        Right (row : _) -> ctprlActive row `shouldBe` Just 3
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catThreadPoolWith integration (live ES)                              --
  -- ------------------------------------------------------------------ --
  describe "catThreadPoolWith integration" $
    it "returns at least one thread-pool row" $
      withTestEnv $ do
        rows <- Client.catThreadPool
        liftIO $
          rows `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- catFielddataOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catFielddataOptionsParams URI rendering" $ do
    it "defaultCatFielddataOptions emits only format=json" $
      catFielddataOptionsParams defaultCatFielddataOptions
        `shouldBe` [("format", Just "json")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatFielddataOptions {cfdoBytes = Just CatBytesKb}
      catFielddataOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatFielddataOptions
              { cfdoColumns = Just (CatFielddataColNode :| [CatFielddataColField, CatFielddataColSize])
              }
      catFielddataOptionsParams opts
        `shouldContain` [("h", Just "node,field,size")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatFielddataOptions
              { cfdoColumns = Just (CatFielddataColOther "description" :| [])
              }
      catFielddataOptionsParams opts
        `shouldContain` [("h", Just "description")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatFielddataOptions
              { cfdoHelp = Just True,
                cfdoLocal = Just False,
                cfdoVerbose = Just True
              }
      let ps = catFielddataOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatFielddataOptions {cfdoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catFielddataOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatFielddataOptions
              { cfdoSort =
                  Just
                    ( CatFielddataSortSpec CatFielddataColSize (Just CatSortDesc)
                        :| [CatFielddataSortSpec CatFielddataColField Nothing]
                    )
              }
      catFielddataOptionsParams opts
        `shouldContain` [("s", Just "size:desc,field")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catFielddataColumnText rendering" $
    it "covers every documented column" $
      map
        catFielddataColumnText
        [ CatFielddataColId,
          CatFielddataColHost,
          CatFielddataColIp,
          CatFielddataColNode,
          CatFielddataColField,
          CatFielddataColSize
        ]
        `shouldBe` ["id", "host", "ip", "node", "field", "size"]

  -- ------------------------------------------------------------------ --
  -- CatFielddataRow JSON decoding                                       --
  -- ------------------------------------------------------------------ --
  describe "CatFielddataRow FromJSON" $ do
    let fullRow =
          "{\
          \  \"id\":\"Qk6MnTpSQ9CZ-Q\",\
          \  \"host\":\"127.0.0.1\",\
          \  \"ip\":\"127.0.0.1\",\
          \  \"node\":\"bloodhound-tests-node\",\
          \  \"field\":\"message\",\
          \  \"size\":\"5kb\"\
          \}"
        minimalRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"field\":\"message\"\
          \}"
        fractionalRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"field\":\"message\",\
          \  \"size\":\"5.2gb\"\
          \}"
        bareRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"field\":\"message\",\
          \  \"size\":\"208\"\
          \}"
        extraColumnRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"field\":\"message\",\
          \  \"ragney.future\":\"xyz\"\
          \}"
        badUnitRow =
          "{\
          \  \"node\":\"bloodhound-tests-cat-node\",\
          \  \"field\":\"message\",\
          \  \"size\":\"5qb\"\
          \}"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String CatFielddataRow of
        Right row -> do
          cfdrId row `shouldBe` Just "Qk6MnTpSQ9CZ-Q"
          cfdrHost row `shouldBe` Just "127.0.0.1"
          cfdrIp row `shouldBe` Just "127.0.0.1"
          cfdrNode row `shouldBe` "bloodhound-tests-node"
          cfdrField row `shouldBe` "message"
          cfdrSize row `shouldBe` Just (Bytes 5, CatBytesKb)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only node and field" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String CatFielddataRow of
        Right row -> do
          cfdrNode row `shouldBe` "bloodhound-tests-cat-node"
          cfdrField row `shouldBe` "message"
          cfdrSize row `shouldBe` Nothing
          cfdrHost row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rounds fractional size magnitudes to the nearest whole byte" $
      case parseEither parseJSON =<< eitherDecode fractionalRow :: Either String CatFielddataRow of
        Right row -> cfdrSize row `shouldBe` Just (Bytes 5, CatBytesGb)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "treats a bare number as CatBytesBytes" $
      case parseEither parseJSON =<< eitherDecode bareRow :: Either String CatFielddataRow of
        Right row -> cfdrSize row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cfdrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String CatFielddataRow of
        Right row ->
          case cfdrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["ragney.future"]
              KM.keys o `shouldContain` ["field"]
              KM.keys o `shouldContain` ["node"]
            _ -> expectationFailure "expected cfdrOther to be an Object"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised byte-size suffix" $
      case parseEither parseJSON =<< eitherDecode badUnitRow :: Either String CatFielddataRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

    it "tolerates a native JSON number for size (treated as bare bytes)" $
      case parseEither parseJSON =<< eitherDecode "{\"node\":\"x\",\"field\":\"f\",\"size\":208}" :: Either String CatFielddataRow of
        Right row -> cfdrSize row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catFielddataWith integration (live ES)                              --
  -- ------------------------------------------------------------------ --
  describe "catFielddataWith integration" $ do
    it "returns a row for a field after loading its fielddata"
      $ withTestEnv
      $ withCatFielddata
        [qqIndexName|bloodhound-tests-cat-fielddata|]
        (FieldName "message")
      $ \idx field -> do
        let loadSearch =
              (mkSearch Nothing Nothing)
                { sortBody = Just [DefaultSortSpec (mkSort field Ascending)]
                }
        _ <- tryPerformBHRequest $ searchByIndex @Value idx loadSearch
        rows <- Client.catFielddata
        liftIO $
          case filter ((== unFieldName field) . cfdrField) rows of
            (row : _) -> cfdrField row `shouldBe` unFieldName field
            [] ->
              expectationFailure "expected a fielddata row for the freshly-loaded field"

    it "honours the h= parameter and leaves unrequested columns absent"
      $ withTestEnv
      $ withCatFielddata
        [qqIndexName|bloodhound-tests-cat-fielddata-h|]
        (FieldName "message")
      $ \idx field -> do
        let loadSearch =
              (mkSearch Nothing Nothing)
                { sortBody = Just [DefaultSortSpec (mkSort field Ascending)]
                }
        _ <- tryPerformBHRequest $ searchByIndex @Value idx loadSearch
        let opts =
              defaultCatFielddataOptions
                { cfdoColumns = Just (CatFielddataColNode :| [CatFielddataColField])
                }
        rows <- Client.catFielddataWith Nothing opts
        liftIO $
          case filter ((== "message") . cfdrField) rows of
            (row : _) -> do
              cfdrField row `shouldBe` "message"
              cfdrNode row `shouldNotBe` ""
              cfdrSize row `shouldBe` Nothing
            [] ->
              expectationFailure "expected a fielddata row for the freshly-loaded field"

    it "filters by field name via the /<fields> path segment"
      $ withTestEnv
      $ withCatFielddata
        [qqIndexName|bloodhound-tests-cat-fielddata-filter|]
        (FieldName "message")
      $ \idx field -> do
        let loadSearch =
              (mkSearch Nothing Nothing)
                { sortBody = Just [DefaultSortSpec (mkSort field Ascending)]
                }
        _ <- tryPerformBHRequest $ searchByIndex @Value idx loadSearch
        rows <- Client.catFielddataWith (Just field) defaultCatFielddataOptions
        liftIO $ do
          rows `shouldSatisfy` (not . null)
          map cfdrField rows `shouldSatisfy` all (== "message")

  -- ------------------------------------------------------------------ --
  -- catNodeattrsOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catNodeattrsOptionsParams URI rendering" $ do
    it "defaultCatNodeattrsOptions emits only format=json" $
      catNodeattrsOptionsParams defaultCatNodeattrsOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatNodeattrsOptions
              { cnaoColumns = Just (CatNodeattrsColNode :| [CatNodeattrsColAttr, CatNodeattrsColValue])
              }
      catNodeattrsOptionsParams opts
        `shouldContain` [("h", Just "node,attr,value")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatNodeattrsOptions
              { cnaoColumns = Just (CatNodeattrsColOther "attr.name" :| [])
              }
      catNodeattrsOptionsParams opts
        `shouldContain` [("h", Just "attr.name")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatNodeattrsOptions
              { cnaoHelp = Just True,
                cnaoLocal = Just False,
                cnaoVerbose = Just True
              }
      let ps = catNodeattrsOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatNodeattrsOptions {cnaoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catNodeattrsOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatNodeattrsOptions
              { cnaoSort =
                  Just
                    ( CatNodeattrsSortSpec CatNodeattrsColNode (Just CatSortDesc)
                        :| [CatNodeattrsSortSpec CatNodeattrsColAttr Nothing]
                    )
              }
      catNodeattrsOptionsParams opts
        `shouldContain` [("s", Just "node:desc,attr")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catNodeattrsColumnText rendering" $
    it "covers every documented column" $
      map
        catNodeattrsColumnText
        [ CatNodeattrsColNode,
          CatNodeattrsColId,
          CatNodeattrsColPid,
          CatNodeattrsColHost,
          CatNodeattrsColIp,
          CatNodeattrsColPort,
          CatNodeattrsColAttr,
          CatNodeattrsColValue
        ]
        `shouldBe` ["node", "id", "pid", "host", "ip", "port", "attr", "value"]

  -- ------------------------------------------------------------------ --
  -- CatNodeattrsRow JSON decoding                                        --
  -- ------------------------------------------------------------------ --
  describe "CatNodeattrsRow FromJSON" $ do
    let fullRow =
          "[{\"node\":\"elasticsearch1\",\"id\":\"CBH3C4RiQcK3CxFvPJyFtQ\",\"pid\":\"42\",\"host\":\"172.19.0.7\",\"ip\":\"172.19.0.7\",\"port\":\"9300\",\"attr\":\"box_type\",\"value\":\"hot\"}]"
        defaultRow =
          "[{\"node\":\"elasticsearch1\",\"host\":\"172.19.0.7\",\"ip\":\"172.19.0.7\",\"attr\":\"box_type\",\"value\":\"hot\"}]"
        extraColumnRow = "[{\"node\":\"x\",\"attr\":\"a\",\"value\":\"v\",\"ragney.future\":\"xyz\"}]"

    it "parses a fully-populated row (all 8 documented columns)" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatNodeattrsRow] of
        Right (row : _) -> do
          cnarNode row `shouldBe` Just "elasticsearch1"
          cnarId row `shouldBe` Just "CBH3C4RiQcK3CxFvPJyFtQ"
          cnarPid row `shouldBe` Just 42
          cnarHost row `shouldBe` Just "172.19.0.7"
          cnarIp row `shouldBe` Just "172.19.0.7"
          cnarPort row `shouldBe` Just 9300
          cnarAttr row `shouldBe` Just "box_type"
          cnarValue row `shouldBe` Just "hot"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a default-column row (id/pid/port absent)" $
      case parseEither parseJSON =<< eitherDecode defaultRow :: Either String [CatNodeattrsRow] of
        Right (row : _) -> do
          cnarNode row `shouldBe` Just "elasticsearch1"
          cnarHost row `shouldBe` Just "172.19.0.7"
          cnarAttr row `shouldBe` Just "box_type"
          cnarValue row `shouldBe` Just "hot"
          cnarId row `shouldBe` Nothing
          cnarPid row `shouldBe` Nothing
          cnarPort row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cnarOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatNodeattrsRow] of
        Right (row : _) ->
          case cnarOther row of
            Object o -> do
              KM.keys o `shouldContain` ["node"]
              KM.keys o `shouldContain` ["attr"]
              KM.keys o `shouldContain` ["value"]
              KM.keys o `shouldContain` ["ragney.future"]
            _ -> expectationFailure "expected cnarOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers for pid and port (some OS builds)" $
      case parseEither parseJSON =<< eitherDecode "[{\"node\":\"x\",\"pid\":42,\"port\":9300}]" :: Either String [CatNodeattrsRow] of
        Right (row : _) -> do
          cnarPid row `shouldBe` Just 42
          cnarPort row `shouldBe` Just 9300
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catNodeattrs integration (live ES)                                  --
  -- ------------------------------------------------------------------ --
  describe "catNodeattrs integration" $
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        _ <- Client.catNodeattrs
        pure ()

  -- ------------------------------------------------------------------ --
  -- catRepositoriesOptionsParams: pure URI rendering (no ES required)   --
  -- ------------------------------------------------------------------ --
  describe "catRepositoriesOptionsParams URI rendering" $ do
    it "defaultCatRepositoriesOptions emits only format=json" $
      catRepositoriesOptionsParams defaultCatRepositoriesOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatRepositoriesOptions
              { creoColumns = Just (CatRepositoriesColId :| [CatRepositoriesColType])
              }
      catRepositoriesOptionsParams opts
        `shouldContain` [("h", Just "id,type")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatRepositoriesOptions
              { creoColumns = Just (CatRepositoriesColOther "alias" :| [])
              }
      catRepositoriesOptionsParams opts
        `shouldContain` [("h", Just "alias")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatRepositoriesOptions
              { creoHelp = Just False,
                creoLocal = Just True,
                creoVerbose = Just True
              }
      let ps = catRepositoriesOptionsParams opts
      ps `shouldContain` [("help", Just "false")]
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatRepositoriesOptions {creoMasterTimeout = Just (TimeUnitMinutes, 1)}
      catRepositoriesOptionsParams opts
        `shouldContain` [("master_timeout", Just "1m")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatRepositoriesOptions
              { creoSort =
                  Just
                    ( CatRepositoriesSortSpec CatRepositoriesColId (Just CatSortAsc)
                        :| [CatRepositoriesSortSpec CatRepositoriesColType Nothing]
                    )
              }
      catRepositoriesOptionsParams opts
        `shouldContain` [("s", Just "id:asc,type")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catRepositoriesColumnText rendering" $
    it "covers every documented column" $
      map
        catRepositoriesColumnText
        [CatRepositoriesColId, CatRepositoriesColType]
        `shouldBe` ["id", "type"]

  -- ------------------------------------------------------------------ --
  -- CatRepositoriesRow JSON decoding                                    --
  -- ------------------------------------------------------------------ --
  describe "CatRepositoriesRow FromJSON" $ do
    let multiRow =
          "[{\"id\":\"repo1\",\"type\":\"fs\"},{\"id\":\"repo2\",\"type\":\"s3\"}]"
        extraColumnRow = "[{\"id\":\"repo1\",\"type\":\"fs\",\"alias\":\"backup\"}]"

    it "parses a multi-row response" $
      case parseEither parseJSON =<< eitherDecode multiRow :: Either String [CatRepositoriesRow] of
        Right rows -> do
          rows `shouldSatisfy` ((== 2) . length)
          crrId (head rows) `shouldBe` Just "repo1"
          crrType (head rows) `shouldBe` Just "fs"
          crrId (rows !! 1) `shouldBe` Just "repo2"
          crrType (rows !! 1) `shouldBe` Just "s3"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty response as []" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatRepositoriesRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in crrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatRepositoriesRow] of
        Right (row : _) ->
          case crrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["id"]
              KM.keys o `shouldContain` ["type"]
              KM.keys o `shouldContain` ["alias"]
            _ -> expectationFailure "expected crrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catRepositories integration (live ES)                               --
  -- ------------------------------------------------------------------ --
  describe "catRepositories integration" $
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        _ <- Client.catRepositories
        pure ()

  -- ------------------------------------------------------------------ --
  -- catShardsOptionsParams: pure URI rendering (no ES required)         --
  -- ------------------------------------------------------------------ --
  describe "catShardsOptionsParams URI rendering" $ do
    it "defaultCatShardsOptions emits only format=json" $
      catShardsOptionsParams defaultCatShardsOptions
        `shouldBe` [("format", Just "json")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatShardsOptions {cshoBytes = Just CatBytesKb}
      catShardsOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatShardsOptions
              { cshoColumns = Just (CatShardsColIndex :| [CatShardsColShard, CatShardsColNode])
              }
      catShardsOptionsParams opts
        `shouldContain` [("h", Just "index,shard,node")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatShardsOptions
              { cshoColumns = Just (CatShardsColOther "unassigned.reason" :| [])
              }
      catShardsOptionsParams opts
        `shouldContain` [("h", Just "unassigned.reason")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatShardsOptions
              { cshoHelp = Just False,
                cshoLocal = Just True,
                cshoVerbose = Just True
              }
      let ps = catShardsOptionsParams opts
      ps `shouldContain` [("help", Just "false")]
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatShardsOptions {cshoMasterTimeout = Just (TimeUnitMinutes, 1)}
      catShardsOptionsParams opts
        `shouldContain` [("master_timeout", Just "1m")]

    it "renders time via timeUnitsSuffix" $ do
      let opts = defaultCatShardsOptions {cshoTime = Just (TimeUnitSeconds, 5)}
      catShardsOptionsParams opts
        `shouldContain` [("time", Just "5s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatShardsOptions
              { cshoSort =
                  Just
                    ( CatShardsSortSpec CatShardsColIndex (Just CatSortAsc)
                        :| [CatShardsSortSpec CatShardsColShard Nothing]
                    )
              }
      catShardsOptionsParams opts
        `shouldContain` [("s", Just "index:asc,shard")]

  -- ------------------------------------------------------------------ --
  -- catSegmentsOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catSegmentsOptionsParams URI rendering" $ do
    it "defaultCatSegmentsOptions emits only format=json" $
      catSegmentsOptionsParams defaultCatSegmentsOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatSegmentsOptions
              { csgoColumns = Just (CatSegmentsColIndex :| [CatSegmentsColShard, CatSegmentsColSegment])
              }
      catSegmentsOptionsParams opts
        `shouldContain` [("h", Just "index,shard,segment")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatSegmentsOptions
              { csgoColumns = Just (CatSegmentsColOther "size" :| [])
              }
      catSegmentsOptionsParams opts
        `shouldContain` [("h", Just "size")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatSegmentsOptions {csgoBytes = Just CatBytesKb}
      catSegmentsOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatSegmentsOptions
              { csgoHelp = Just False,
                csgoLocal = Just True,
                csgoVerbose = Just True
              }
      let ps = catSegmentsOptionsParams opts
      ps `shouldContain` [("help", Just "false")]
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatSegmentsOptions {csgoMasterTimeout = Just (TimeUnitMinutes, 1)}
      catSegmentsOptionsParams opts
        `shouldContain` [("master_timeout", Just "1m")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatSegmentsOptions
              { csgoSort =
                  Just
                    ( CatSegmentsSortSpec CatSegmentsColIndex (Just CatSortAsc)
                        :| [CatSegmentsSortSpec CatSegmentsColShard Nothing]
                    )
              }
      catSegmentsOptionsParams opts
        `shouldContain` [("s", Just "index:asc,shard")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catShardsColumnText rendering" $
    it "covers every documented default column" $
      map
        catShardsColumnText
        [ CatShardsColIndex,
          CatShardsColShard,
          CatShardsColPrirep,
          CatShardsColState,
          CatShardsColDocs,
          CatShardsColStore,
          CatShardsColDataset,
          CatShardsColNode
        ]
        `shouldBe` ["index", "shard", "prirep", "state", "docs", "store", "dataset", "node"]

  -- ------------------------------------------------------------------ --
  -- CatShardsRow JSON decoding                                          --
  -- ------------------------------------------------------------------ --
  describe "CatShardsRow FromJSON" $ do
    let multiRow =
          "[{\"index\":\"logs\",\"shard\":\"0\",\"prirep\":\"p\",\"state\":\"STARTED\",\"docs\":\"42\",\"store\":\"5kb\",\"dataset\":\"tsdb\",\"node\":\"node-1\"},\
          \{\"index\":\"logs\",\"shard\":\"1\",\"prirep\":\"r\",\"state\":\"STARTED\",\"docs\":\"42\",\"store\":\"5kb\",\"node\":\"node-2\"}]"
        unassignedRow =
          "[{\"index\":\"logs\",\"shard\":\"0\",\"prirep\":\"p\",\"state\":\"UNASSIGNED\",\"docs\":\"-\",\"store\":\"-\",\"node\":\"-\"}]"
        extraColumnRow =
          "[{\"index\":\"logs\",\"shard\":\"0\",\"prirep\":\"p\",\"state\":\"STARTED\",\"docs\":\"42\",\"store\":\"5kb\",\"node\":\"node-1\",\"sync_id\":\"abc\"}]"
        nativeNumberRow =
          "[{\"index\":\"logs\",\"shard\":0,\"prirep\":\"p\",\"state\":\"STARTED\",\"docs\":42,\"store\":\"5kb\",\"node\":\"node-1\"}]"

    it "parses a multi-row response" $
      case parseEither parseJSON =<< eitherDecode multiRow :: Either String [CatShardsRow] of
        Right rows -> do
          rows `shouldSatisfy` ((== 2) . length)
          let r0 = head rows
          csrIndex r0 `shouldBe` Just "logs"
          csrShard r0 `shouldBe` Just 0
          csrPrirep r0 `shouldBe` Just "p"
          csrState r0 `shouldBe` Just "STARTED"
          csrDocs r0 `shouldBe` Just 42
          csrDataset r0 `shouldBe` Just "tsdb"
          csrNode r0 `shouldBe` Just "node-1"
          case csrStore r0 of
            Just (Bytes n, CatBytesKb) -> n `shouldBe` 5
            other -> expectationFailure ("expected 5kb store, got " <> show other)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty response as []" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatShardsRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates the \"-\" sentinel for unassigned shards" $
      case parseEither parseJSON =<< eitherDecode unassignedRow :: Either String [CatShardsRow] of
        Right (row : _) -> do
          csrState row `shouldBe` Just "UNASSIGNED"
          csrDocs row `shouldBe` Nothing
          csrStore row `shouldBe` Nothing
          csrNode row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in csrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatShardsRow] of
        Right (row : _) ->
          case csrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["index"]
              KM.keys o `shouldContain` ["sync_id"]
            _ -> expectationFailure "expected csrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers (OpenSearch parity)" $
      case parseEither parseJSON =<< eitherDecode nativeNumberRow :: Either String [CatShardsRow] of
        Right (row : _) -> do
          csrShard row `shouldBe` Just 0
          csrDocs row `shouldBe` Just 42
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catShards integration (live ES)                                     --
  -- ------------------------------------------------------------------ --
  describe "catShards integration" $ do
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        _ <- Client.catShards
        pure ()

    it "narrows by index via catShardsWith" $
      withTestEnv $
        withCatIndex [qqIndexName|bloodhound-cat-shards-spec|] $ \idx -> do
          rows <- Client.catShardsWith (Just idx) defaultCatShardsOptions
          liftIO $ do
            rows `shouldSatisfy` (not . null)
            rows `shouldSatisfy` all ((== Just (unIndexName idx)) . csrIndex)

  -- ------------------------------------------------------------------ --
  -- catTasksOptionsParams: pure URI rendering (no ES required)          --
  -- ------------------------------------------------------------------ --
  describe "catTasksOptionsParams URI rendering" $ do
    it "defaultCatTasksOptions emits only format=json" $
      catTasksOptionsParams defaultCatTasksOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatTasksOptions
              { cttoColumns = Just (CatTasksColAction :| [CatTasksColTaskId])
              }
      catTasksOptionsParams opts
        `shouldContain` [("h", Just "action,task_id")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatTasksOptions
              { cttoDetailed = Just True,
                cttoHelp = Just True,
                cttoLocal = Just True
              }
      let ps = catTasksOptionsParams opts
      ps `shouldContain` [("detailed", Just "true")]
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatTasksOptions {cttoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catTasksOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders actions and nodes as comma-joined lists" $ do
      let opts =
            defaultCatTasksOptions
              { cttoActions = Just ("cluster:monitor/tasks/lists" :| []),
                cttoNodes = Just ("node-1" :| ["node-2"])
              }
      let ps = catTasksOptionsParams opts
      ps `shouldContain` [("actions", Just "cluster:monitor/tasks/lists")]
      ps `shouldContain` [("nodes", Just "node-1,node-2")]

    it "renders parent_task_id verbatim" $ do
      let opts = defaultCatTasksOptions {cttoParentTaskId = Just "oTUltX4IQMOUUVeiohTt8A:123"}
      catTasksOptionsParams opts
        `shouldContain` [("parent_task_id", Just "oTUltX4IQMOUUVeiohTt8A:123")]

    it "renders sort specifiers (s) as column[:direction]" $ do
      let opts =
            defaultCatTasksOptions
              { cttoSort =
                  Just
                    ( CatTasksSortSpec CatTasksColAction Nothing
                        :| [CatTasksSortSpec CatTasksColStartTime (Just CatSortDesc)]
                    )
              }
      catTasksOptionsParams opts
        `shouldContain` [("s", Just "action,start_time:desc")]

  describe "catTasksColumnText rendering" $
    it "covers every documented column" $
      map
        catTasksColumnText
        [ CatTasksColId,
          CatTasksColAction,
          CatTasksColTaskId,
          CatTasksColParentTaskId,
          CatTasksColType,
          CatTasksColStartTime,
          CatTasksColTimestamp,
          CatTasksColRunningTime,
          CatTasksColRunningTimeNs,
          CatTasksColNodeId,
          CatTasksColIp,
          CatTasksColPort,
          CatTasksColNode,
          CatTasksColVersion,
          CatTasksColXOpaqueId,
          CatTasksColDescription
        ]
        `shouldBe` [ "id",
                     "action",
                     "task_id",
                     "parent_task_id",
                     "type",
                     "start_time",
                     "timestamp",
                     "running_time",
                     "running_time_ns",
                     "node_id",
                     "ip",
                     "port",
                     "node",
                     "version",
                     "x_opaque_id",
                     "description"
                   ]

  -- ------------------------------------------------------------------ --
  -- CatTasksRow JSON decoding                                           --
  -- ------------------------------------------------------------------ --
  describe "CatTasksRow FromJSON" $ do
    let fullRow =
          "[{\"action\":\"cluster:monitor/tasks/lists[n]\",\"task_id\":\"oTUltX4IQMOUUVeiohTt8A:124\",\"parent_task_id\":\"oTUltX4IQMOUUVeiohTt8A:123\",\"type\":\"direct\",\"start_time\":\"1458585884904\",\"timestamp\":\"01:48:24\",\"running_time\":\"44.1micros\",\"ip\":\"127.0.0.1:9300\",\"node\":\"oTUltX4IQMOUUVeiohTt8A\"}]"
        minimalRow = "[{\"action\":\"x\"}]"
        extraColumnRow = "[{\"action\":\"x\",\"future.column\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatTasksRow] of
        Right (row : _) -> do
          ctrrAction row `shouldBe` Just "cluster:monitor/tasks/lists[n]"
          ctrrTaskId row `shouldBe` Just "oTUltX4IQMOUUVeiohTt8A:124"
          ctrrParentTaskId row `shouldBe` Just "oTUltX4IQMOUUVeiohTt8A:123"
          ctrrType row `shouldBe` Just "direct"
          ctrrStartTime row `shouldBe` Just "1458585884904"
          ctrrTimestamp row `shouldBe` Just "01:48:24"
          ctrrRunningTime row `shouldBe` Just "44.1micros"
          ctrrIp row `shouldBe` Just "127.0.0.1:9300"
          ctrrNode row `shouldBe` Just "oTUltX4IQMOUUVeiohTt8A"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only action" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatTasksRow] of
        Right (row : _) -> do
          ctrrAction row `shouldBe` Just "x"
          ctrrTaskId row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in ctrrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatTasksRow] of
        Right (row : _) ->
          case ctrrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["action"]
              KM.keys o `shouldContain` ["future.column"]
            _ -> expectationFailure "expected ctrrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty tasks list" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatTasksRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catTasksWith integration (live ES)                                  --
  -- ------------------------------------------------------------------ --
  describe "catTasksWith integration" $
    it "parses the tasks list without error" $
      withTestEnv $ do
        _rows <- Client.catTasks
        return ()

  -- ------------------------------------------------------------------ --
  -- catSnapshotsOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catSnapshotsOptionsParams URI rendering" $ do
    it "defaultCatSnapshotsOptions emits only format=json" $
      catSnapshotsOptionsParams defaultCatSnapshotsOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatSnapshotsOptions
              { csnoColumns = Just (CatSnapshotsColId :| [CatSnapshotsColStatus, CatSnapshotsColRepository])
              }
      catSnapshotsOptionsParams opts
        `shouldContain` [("h", Just "id,status,repository")]

    it "renders ignore_unavailable as true/false" $ do
      let opts = defaultCatSnapshotsOptions {csnoIgnoreUnavailable = Just True}
      catSnapshotsOptionsParams opts
        `shouldContain` [("ignore_unavailable", Just "true")]

    it "renders booleans (help, local) as true/false" $ do
      let opts =
            defaultCatSnapshotsOptions
              { csnoHelp = Just True,
                csnoLocal = Just True
              }
      let ps = catSnapshotsOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatSnapshotsOptions {csnoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catSnapshotsOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specifiers (s) as column[:direction]" $ do
      let opts =
            defaultCatSnapshotsOptions
              { csnoSort =
                  Just
                    ( CatSnapshotsSortSpec CatSnapshotsColStartEpoch Nothing
                        :| [CatSnapshotsSortSpec CatSnapshotsColDuration (Just CatSortDesc)]
                    )
              }
      catSnapshotsOptionsParams opts
        `shouldContain` [("s", Just "start_epoch,duration:desc")]

  describe "catSnapshotsColumnText rendering" $
    it "covers every documented column" $
      map
        catSnapshotsColumnText
        [ CatSnapshotsColId,
          CatSnapshotsColRepository,
          CatSnapshotsColStatus,
          CatSnapshotsColStartEpoch,
          CatSnapshotsColStartTime,
          CatSnapshotsColEndEpoch,
          CatSnapshotsColEndTime,
          CatSnapshotsColDuration,
          CatSnapshotsColIndices,
          CatSnapshotsColSuccessfulShards,
          CatSnapshotsColFailedShards,
          CatSnapshotsColTotalShards,
          CatSnapshotsColReason
        ]
        `shouldBe` [ "id",
                     "repository",
                     "status",
                     "start_epoch",
                     "start_time",
                     "end_epoch",
                     "end_time",
                     "duration",
                     "indices",
                     "successful_shards",
                     "failed_shards",
                     "total_shards",
                     "reason"
                   ]

  -- ------------------------------------------------------------------ --
  -- CatSnapshotsRow JSON decoding                                       --
  -- ------------------------------------------------------------------ --
  describe "CatSnapshotsRow FromJSON" $ do
    let fullRow =
          "[{\"id\":\"snap1\",\"repository\":\"repo1\",\"status\":\"FAILED\",\"start_epoch\":\"1445616705\",\"start_time\":\"18:11:45\",\"end_epoch\":\"1445616978\",\"end_time\":\"18:16:18\",\"duration\":\"4.6m\",\"indices\":\"1\",\"successful_shards\":\"4\",\"failed_shards\":\"1\",\"total_shards\":\"5\"}]"
        minimalRow = "[{\"id\":\"snap1\"}]"
        extraColumnRow = "[{\"id\":\"snap1\",\"future.column\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatSnapshotsRow] of
        Right (row : _) -> do
          csnrId row `shouldBe` Just "snap1"
          csnrRepository row `shouldBe` Just "repo1"
          csnrStatus row `shouldBe` Just "FAILED"
          csnrStartEpoch row `shouldBe` Just "1445616705"
          csnrStartTime row `shouldBe` Just "18:11:45"
          csnrEndEpoch row `shouldBe` Just "1445616978"
          csnrEndTime row `shouldBe` Just "18:16:18"
          csnrDuration row `shouldBe` Just "4.6m"
          csnrIndices row `shouldBe` Just "1"
          csnrSuccessfulShards row `shouldBe` Just "4"
          csnrFailedShards row `shouldBe` Just "1"
          csnrTotalShards row `shouldBe` Just "5"
          csnrReason row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only id" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatSnapshotsRow] of
        Right (row : _) -> do
          csnrId row `shouldBe` Just "snap1"
          csnrStatus row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in csnrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatSnapshotsRow] of
        Right (row : _) ->
          case csnrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["id"]
              KM.keys o `shouldContain` ["future.column"]
            _ -> expectationFailure "expected csnrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty snapshots list" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatSnapshotsRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catSnapshots integration (live ES)                                  --
  -- ------------------------------------------------------------------ --
  describe "catSnapshots integration" $
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        -- The cat snapshots endpoint requires a registered snapshot
        -- repository; @GET /_cat/snapshots@ with no repo returns HTTP
        -- 400 "repository is missing" on every supported backend. The
        -- docker-compose test cluster doesn't configure @path.repo@,
        -- so an fs repository cannot be registered from the test
        -- either. Skip the live check when no repo is available,
        -- rather than failing on infrastructure the test cannot
        -- provision.
        repoResult <-
          tryPerformBHRequest $
            catSnapshotsWith
              (Just (SnapshotRepoName "_all"))
              defaultCatSnapshotsOptions
        case repoResult of
          Right rows -> liftIO $ rows `shouldSatisfy` (\_ -> True)
          Left e
            | "repository" `T.isInfixOf` errorMessage e
                || "no such" `T.isInfixOf` errorMessage e
                || "missing" `T.isInfixOf` errorMessage e ->
                liftIO $ pendingWith "no snapshot repository registered on this cluster"
          Left e -> liftIO $ expectationFailure ("catSnapshots failed unexpectedly: " <> show e)

  -- ------------------------------------------------------------------ --
  -- catNodesOptionsParams: pure URI rendering (no ES required)            --
  -- ------------------------------------------------------------------ --
  describe "catNodesOptionsParams URI rendering" $ do
    it "defaultCatNodesOptions emits only format=json" $
      catNodesOptionsParams defaultCatNodesOptions
        `shouldBe` [("format", Just "json")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatNodesOptions {cnoBytes = Just CatBytesKb}
      catNodesOptionsParams opts
        `shouldContain` [("bytes", Just "kb")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatNodesOptions
              { cnoColumns = Just (CatNodesColName :| [CatNodesColHeapPercent, CatNodesColCpu])
              }
      catNodesOptionsParams opts
        `shouldContain` [("h", Just "name,heap.percent,cpu")]

    it "renders unknown columns verbatim" $ do
      let opts =
            defaultCatNodesOptions
              { cnoColumns = Just (CatNodesColOther "uptime" :| [])
              }
      catNodesOptionsParams opts
        `shouldContain` [("h", Just "uptime")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatNodesOptions
              { cnoHelp = Just True,
                cnoLocal = Just False,
                cnoVerbose = Just True
              }
      let ps = catNodesOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatNodesOptions {cnoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catNodesOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders time via timeUnitsSuffix" $ do
      let opts = defaultCatNodesOptions {cnoTime = Just (TimeUnitMinutes, 5)}
      catNodesOptionsParams opts
        `shouldContain` [("time", Just "5m")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatNodesOptions
              { cnoSort =
                  Just
                    ( CatNodesSortSpec CatNodesColName (Just CatSortAsc)
                        :| [CatNodesSortSpec CatNodesColCpu Nothing]
                    )
              }
      catNodesOptionsParams opts
        `shouldContain` [("s", Just "name:asc,cpu")]

  describe "catSegmentsColumnText rendering" $
    it "covers every documented column" $
      map
        catSegmentsColumnText
        [ CatSegmentsColIndex,
          CatSegmentsColShard,
          CatSegmentsColPrirep,
          CatSegmentsColIp,
          CatSegmentsColSegment,
          CatSegmentsColId,
          CatSegmentsColGeneration,
          CatSegmentsColDocsCount,
          CatSegmentsColDocsDeleted,
          CatSegmentsColSize,
          CatSegmentsColSizeMemory,
          CatSegmentsColCommitted,
          CatSegmentsColSearchable,
          CatSegmentsColVersion,
          CatSegmentsColCompound
        ]
        `shouldBe` [ "index",
                     "shard",
                     "prirep",
                     "ip",
                     "segment",
                     "id",
                     "generation",
                     "docs.count",
                     "docs.deleted",
                     "size",
                     "size.memory",
                     "committed",
                     "searchable",
                     "version",
                     "compound"
                   ]

  -- ------------------------------------------------------------------ --
  -- CatSegmentsRow JSON decoding                                        --
  -- ------------------------------------------------------------------ --
  describe "CatSegmentsRow FromJSON" $ do
    let fullRow =
          "[{\"index\":\"test\",\"shard\":\"0\",\"prirep\":\"p\",\"ip\":\"127.0.0.1\",\"segment\":\"_0\",\"id\":\"abc123\",\"generation\":\"0\",\"docs.count\":\"42\",\"docs.deleted\":\"5\",\"size\":\"5kb\",\"size.memory\":\"208\",\"committed\":\"true\",\"searchable\":\"true\",\"version\":\"9.0.0\",\"compound\":\"true\"}]"
        minimalRow = "[{\"index\":\"test\",\"shard\":\"0\"}]"
        extraColumnRow = "[{\"index\":\"x\",\"custom.col\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatSegmentsRow] of
        Right (row : _) -> do
          cserIndex row `shouldBe` Just "test"
          cserShard row `shouldBe` Just 0
          cserPrirep row `shouldBe` Just "p"
          cserIp row `shouldBe` Just "127.0.0.1"
          cserSegment row `shouldBe` Just "_0"
          cserId row `shouldBe` Just "abc123"
          cserGeneration row `shouldBe` Just 0
          cserDocsCount row `shouldBe` Just 42
          cserDocsDeleted row `shouldBe` Just 5
          cserCommitted row `shouldBe` Just True
          cserSearchable row `shouldBe` Just True
          cserVersion row `shouldBe` Just "9.0.0"
          cserCompound row `shouldBe` Just True
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only index and shard" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatSegmentsRow] of
        Right (row : _) -> do
          cserIndex row `shouldBe` Just "test"
          cserShard row `shouldBe` Just 0
          cserCompound row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cserOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatSegmentsRow] of
        Right (row : _) ->
          case cserOther row of
            Object o -> do
              KM.keys o `shouldContain` ["index"]
              KM.keys o `shouldContain` ["custom.col"]
            _ -> expectationFailure "expected cserOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers for shard and generation" $
      case parseEither parseJSON =<< eitherDecode "[{\"shard\":3,\"generation\":7,\"docs.count\":100}]" :: Either String [CatSegmentsRow] of
        Right (row : _) -> do
          cserShard row `shouldBe` Just 3
          cserGeneration row `shouldBe` Just 7
          cserDocsCount row `shouldBe` Just 100
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON booleans for committed/searchable/compound" $
      case parseEither parseJSON =<< eitherDecode "[{\"committed\":false,\"searchable\":true,\"compound\":false}]" :: Either String [CatSegmentsRow] of
        Right (row : _) -> do
          cserCommitted row `shouldBe` Just False
          cserSearchable row `shouldBe` Just True
          cserCompound row `shouldBe` Just False
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty segments list" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatSegmentsRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catSegments integration (live ES)                                   --
  -- ------------------------------------------------------------------ --
  describe "catSegments integration" $
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        _ <- Client.catSegments
        pure ()

  -- ------------------------------------------------------------------ --
  -- catRecoveryOptionsParams: pure URI rendering (no ES required)       --
  -- ------------------------------------------------------------------ --
  describe "catRecoveryOptionsParams URI rendering" $ do
    it "defaultCatRecoveryOptions emits format=json and bytes=b" $
      catRecoveryOptionsParams defaultCatRecoveryOptions
        `shouldBe` [("format", Just "json"), ("bytes", Just "b")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatRecoveryOptions
              { cryoColumns = Just (CatRecoveryColIndex :| [CatRecoveryColShard, CatRecoveryColStage])
              }
      catRecoveryOptionsParams opts
        `shouldContain` [("h", Just "index,shard,stage")]

    it "renders bytes via catBytesSizeText" $ do
      let opts = defaultCatRecoveryOptions {cryoBytes = Just CatBytesMb}
      catRecoveryOptionsParams opts
        `shouldContain` [("bytes", Just "mb")]

    it "renders active_only as true" $ do
      let opts = defaultCatRecoveryOptions {cryoActiveOnly = Just True}
      catRecoveryOptionsParams opts
        `shouldContain` [("active_only", Just "true")]

    it "renders detailed as true" $ do
      let opts = defaultCatRecoveryOptions {cryoDetailed = Just True}
      catRecoveryOptionsParams opts
        `shouldContain` [("detailed", Just "true")]

    it "renders booleans as true/false" $ do
      let opts =
            defaultCatRecoveryOptions
              { cryoHelp = Just False,
                cryoLocal = Just True,
                cryoVerbose = Just True
              }
      let ps = catRecoveryOptionsParams opts
      ps `shouldContain` [("help", Just "false")]
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("v", Just "true")]

    it "renders master_timeout via timeUnitsSuffix" $ do
      let opts = defaultCatRecoveryOptions {cryoMasterTimeout = Just (TimeUnitSeconds, 30)}
      catRecoveryOptionsParams opts
        `shouldContain` [("master_timeout", Just "30s")]

    it "renders sort specs as comma-joined column[:direction] terms" $ do
      let opts =
            defaultCatRecoveryOptions
              { cryoSort =
                  Just
                    ( CatRecoverySortSpec CatRecoveryColIndex (Just CatSortAsc)
                        :| [CatRecoverySortSpec CatRecoveryColStage Nothing]
                    )
              }
      catRecoveryOptionsParams opts
        `shouldContain` [("s", Just "index:asc,stage")]

  -- ------------------------------------------------------------------ --
  -- Renderer sanity for the leaf sum types                              --
  -- ------------------------------------------------------------------ --
  describe "catNodesColumnText rendering" $
    it "covers every documented column" $
      map
        catNodesColumnText
        [ CatNodesColIp,
          CatNodesColPort,
          CatNodesColHost,
          CatNodesColName,
          CatNodesColId,
          CatNodesColPid,
          CatNodesColMaster,
          CatNodesColNodeRole,
          CatNodesColHeapPercent,
          CatNodesColRamPercent,
          CatNodesColCpu,
          CatNodesColLoad1m,
          CatNodesColLoad5m,
          CatNodesColLoad15m,
          CatNodesColHeapCurrent,
          CatNodesColHeapMax,
          CatNodesColRamCurrent,
          CatNodesColRamMax,
          CatNodesColDiskUsedPercent,
          CatNodesColDiskUsed,
          CatNodesColDiskAvail,
          CatNodesColDiskTotal,
          CatNodesColFileDescPercent,
          CatNodesColFileDescCurrent,
          CatNodesColFileDescMax
        ]
        `shouldBe` [ "ip",
                     "port",
                     "host",
                     "name",
                     "id",
                     "pid",
                     "master",
                     "node.role",
                     "heap.percent",
                     "ram.percent",
                     "cpu",
                     "load_1m",
                     "load_5m",
                     "load_15m",
                     "heap.current",
                     "heap.max",
                     "ram.current",
                     "ram.max",
                     "disk.used_percent",
                     "disk.used",
                     "disk.avail",
                     "disk.total",
                     "file_desc.percent",
                     "file_desc.current",
                     "file_desc.max"
                   ]

  describe "catNodesMasterFromText / ToText round-trip" $ do
    it "decodes \"*\" as the elected master" $
      catNodesMasterFromText "*" `shouldBe` Just CatNodesMasterTrue
    it "decodes \"-\" as a non-master node" $
      catNodesMasterFromText "-" `shouldBe` Just CatNodesMasterFalse
    it "rejects unknown sentinels" $
      catNodesMasterFromText "maybe" `shouldBe` (Nothing :: Maybe CatNodesMaster)
    it "round-trips every constructor through ToText" $ do
      catNodesMasterToText CatNodesMasterTrue `shouldBe` "*"
      catNodesMasterToText CatNodesMasterFalse `shouldBe` "-"

  -- ------------------------------------------------------------------ --
  -- CatNodesRow JSON decoding                                            --
  -- ------------------------------------------------------------------ --
  describe "CatNodesRow FromJSON" $ do
    let fullRow =
          "{\
          \  \"ip\":\"127.0.0.1\",\
          \  \"port\":\"9300\",\
          \  \"host\":\"127.0.0.1\",\
          \  \"name\":\"node-1\",\
          \  \"id\":\"CSUXak2TQcK3CxFvPJyFtQ\",\
          \  \"pid\":\"42\",\
          \  \"master\":\"*\",\
          \  \"node.role\":\"dimr\",\
          \  \"heap.percent\":\"45\",\
          \  \"ram.percent\":\"60\",\
          \  \"cpu\":\"12\",\
          \  \"load_1m\":\"0.07\",\
          \  \"load_5m\":\"0.10\",\
          \  \"load_15m\":\"0.15\",\
          \  \"heap.current\":\"1.4gb\",\
          \  \"heap.max\":\"4gb\",\
          \  \"ram.current\":\"6.5gb\",\
          \  \"ram.max\":\"16gb\",\
          \  \"disk.used_percent\":\"46\",\
          \  \"disk.used\":\"47.3gb\",\
          \  \"disk.avail\":\"53.4gb\",\
          \  \"disk.total\":\"100.7gb\",\
          \  \"file_desc.percent\":\"3\",\
          \  \"file_desc.current\":\"312\",\
          \  \"file_desc.max\":\"65536\"\
          \}"
        minimalRow = "{\"name\":\"bloodhound-tests-cat-node\"}"
        subsetRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"heap.percent\":\"50\",\
          \  \"cpu\":\"99\"\
          \}"
        nonMasterRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"master\":\"-\"\
          \}"
        fractionalRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"heap.current\":\"5.2gb\",\
          \  \"heap.max\":\"5.2gb\"\
          \}"
        bareRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"heap.current\":\"208\",\
          \  \"heap.max\":\"208\"\
          \}"
        extraColumnRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"uptime\":\"5m\"\
          \}"
        badUnitRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"heap.current\":\"5qb\"\
          \}"
        openSearchMasterRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"cluster_manager\":\"*\"\
          \}"
        bothMasterColumnsRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"master\":\"-\",\
          \  \"cluster_manager\":\"*\"\
          \}"
        invalidMasterRow =
          "{\
          \  \"name\":\"bloodhound-tests-cat-node\",\
          \  \"master\":\"maybe\"\
          \}"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String CatNodesRow of
        Right row -> do
          cnrIp row `shouldBe` Just "127.0.0.1"
          cnrPort row `shouldBe` Just 9300
          cnrHost row `shouldBe` Just "127.0.0.1"
          cnrName row `shouldBe` "node-1"
          cnrId row `shouldBe` Just (FullNodeId "CSUXak2TQcK3CxFvPJyFtQ")
          cnrPid row `shouldBe` Just 42
          cnrMaster row `shouldBe` Just CatNodesMasterTrue
          cnrNodeRole row `shouldBe` Just "dimr"
          cnrHeapPercent row `shouldBe` Just 45
          cnrRamPercent row `shouldBe` Just 60
          cnrCpu row `shouldBe` Just 12
          cnrLoad1m row `shouldBe` Just 0.07
          cnrLoad5m row `shouldBe` Just 0.10
          cnrLoad15m row `shouldBe` Just 0.15
          cnrHeapCurrent row `shouldBe` Just (Bytes 1, CatBytesGb)
          cnrHeapMax row `shouldBe` Just (Bytes 4, CatBytesGb)
          cnrRamCurrent row `shouldBe` Just (Bytes 6, CatBytesGb)
          cnrRamMax row `shouldBe` Just (Bytes 16, CatBytesGb)
          cnrDiskUsedPercent row `shouldBe` Just 46
          cnrDiskUsed row `shouldBe` Just (Bytes 47, CatBytesGb)
          cnrDiskAvail row `shouldBe` Just (Bytes 53, CatBytesGb)
          cnrDiskTotal row `shouldBe` Just (Bytes 101, CatBytesGb)
          cnrFileDescPercent row `shouldBe` Just 3
          cnrFileDescCurrent row `shouldBe` Just 312
          cnrFileDescMax row `shouldBe` Just 65536
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only `name`" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String CatNodesRow of
        Right row -> do
          cnrName row `shouldBe` "bloodhound-tests-cat-node"
          cnrIp row `shouldBe` Nothing
          cnrMaster row `shouldBe` Nothing
          cnrHeapPercent row `shouldBe` Nothing
          cnrCpu row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a column-subset row (as produced by h=name,heap.percent,cpu)" $
      case parseEither parseJSON =<< eitherDecode subsetRow :: Either String CatNodesRow of
        Right row -> do
          cnrName row `shouldBe` "bloodhound-tests-cat-node"
          cnrHeapPercent row `shouldBe` Just 50
          cnrCpu row `shouldBe` Just 99
          -- Columns we did not request should be absent:
          cnrRamPercent row `shouldBe` Nothing
          cnrLoad1m row `shouldBe` Nothing
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "decodes the \"-\" sentinel for master as CatNodesMasterFalse" $
      case parseEither parseJSON =<< eitherDecode nonMasterRow :: Either String CatNodesRow of
        Right row -> cnrMaster row `shouldBe` Just CatNodesMasterFalse
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "falls back to the cluster_manager column when master is absent (OpenSearch)" $
      case parseEither parseJSON =<< eitherDecode openSearchMasterRow :: Either String CatNodesRow of
        Right row -> cnrMaster row `shouldBe` Just CatNodesMasterTrue
        Left e -> expectationFailure ("expected parse, got " <> e)

    -- OS3 emits both @master@ (when explicitly requested via @h=master@)
    -- and @cluster_manager@ in default output. Locks in that the ES
    -- column name wins when both are present, so the parser is
    -- deterministic regardless of which alias the server happens to
    -- emit.
    it "prefers master over cluster_manager when both are present" $
      case parseEither parseJSON =<< eitherDecode bothMasterColumnsRow :: Either String CatNodesRow of
        Right row -> cnrMaster row `shouldBe` Just CatNodesMasterFalse
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised master sentinel" $
      case parseEither parseJSON =<< eitherDecode invalidMasterRow :: Either String CatNodesRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

    it "rounds fractional heap magnitudes to the nearest whole byte" $
      case parseEither parseJSON =<< eitherDecode fractionalRow :: Either String CatNodesRow of
        Right row -> do
          cnrHeapCurrent row `shouldBe` Just (Bytes 5, CatBytesGb)
          cnrHeapMax row `shouldBe` Just (Bytes 5, CatBytesGb)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "treats a bare number as CatBytesBytes" $
      case parseEither parseJSON =<< eitherDecode bareRow :: Either String CatNodesRow of
        Right row -> do
          cnrHeapCurrent row `shouldBe` Just (Bytes 208, CatBytesBytes)
          cnrHeapMax row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cnrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String CatNodesRow of
        Right row ->
          case cnrOther row of
            Object o -> KM.keys o `shouldContain` ["name", "uptime"]
            _ -> expectationFailure "expected cnrOther to be an Object"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "rejects an unrecognised byte-size suffix" $
      case parseEither parseJSON =<< eitherDecode badUnitRow :: Either String CatNodesRow of
        Right row -> expectationFailure ("expected parse failure, got " <> show row)
        Left _ -> return ()

    -- Defensive coverage: if OpenSearch emits a native JSON number for
    -- an integer column (instead of the usual string), the parser
    -- should still succeed by stringifying the integer-valued
    -- Scientific without a trailing ".0". See 'parseAsString'.
    it "tolerates a native JSON number for an integer column (cpu)" $
      case parseEither parseJSON =<< eitherDecode "{\"name\":\"x\",\"cpu\":99}" :: Either String CatNodesRow of
        Right row -> cnrCpu row `shouldBe` Just 99
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for heap.percent" $
      case parseEither parseJSON =<< eitherDecode "{\"name\":\"x\",\"heap.percent\":50}" :: Either String CatNodesRow of
        Right row -> cnrHeapPercent row `shouldBe` Just 50
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for load_1m (fractional)" $
      case parseEither parseJSON =<< eitherDecode "{\"name\":\"x\",\"load_1m\":0.07}" :: Either String CatNodesRow of
        Right row -> cnrLoad1m row `shouldBe` Just 0.07
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates a native JSON number for heap.current (treated as bare bytes)" $
      case parseEither parseJSON =<< eitherDecode "{\"name\":\"x\",\"heap.current\":208}" :: Either String CatNodesRow of
        Right row -> cnrHeapCurrent row `shouldBe` Just (Bytes 208, CatBytesBytes)
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catNodesWith integration (live ES)                                   --
  -- ------------------------------------------------------------------ --
  describe "catNodesWith integration" $ do
    it "returns at least one node row" $
      withTestEnv $ do
        rows <- Client.catNodes
        liftIO $
          rows `shouldSatisfy` (not . null)

    -- @_cat\/nodes@ does not accept a path-segment filter on either ES
    -- or OS (unlike @_cat\/allocation@). Locks in that we see exactly
    -- one elected master in the default output, so the @master@ column
    -- round-trips the @"*"@ sentinel through 'CatNodesMaster' and the
    -- parser doesn't accidentally mark every node as master.
    it "exposes exactly one elected master via the master sentinel" $
      withTestEnv $ do
        rows <- Client.catNodes
        liftIO $
          length (filter ((== Just CatNodesMasterTrue) . cnrMaster) rows) `shouldBe` 1

    it "honours the h= parameter and leaves unrequested columns absent" $
      withTestEnv $ do
        allRows <- Client.catNodes
        case allRows of
          [] -> liftIO $ expectationFailure "expected at least one node row"
          (firstRow : _) -> do
            let name = cnrName firstRow
                opts =
                  defaultCatNodesOptions
                    { cnoColumns = Just (CatNodesColName :| [CatNodesColCpu])
                    }
            filtered <- Client.catNodesWith opts
            liftIO $
              case filter ((== name) . cnrName) filtered of
                (row : _) -> do
                  -- Requested columns come back populated:
                  cnrName row `shouldBe` name
                  cnrCpu row `shouldSatisfy` isJust
                  -- Columns we did not request should be absent:
                  cnrIp row `shouldBe` Nothing
                  cnrHost row `shouldBe` Nothing
                  cnrHeapPercent row `shouldBe` Nothing
                [] ->
                  expectationFailure "expected at least one row for the freshly-listed node"

  describe "catRecoveryColumnText rendering" $
    it "covers every documented column" $
      map
        catRecoveryColumnText
        [ CatRecoveryColIndex,
          CatRecoveryColShard,
          CatRecoveryColStartTime,
          CatRecoveryColStartTimeMillis,
          CatRecoveryColStopTime,
          CatRecoveryColStopTimeMillis,
          CatRecoveryColTime,
          CatRecoveryColType,
          CatRecoveryColStage,
          CatRecoveryColSourceHost,
          CatRecoveryColSourceNode,
          CatRecoveryColTargetHost,
          CatRecoveryColTargetNode,
          CatRecoveryColRepository,
          CatRecoveryColSnapshot,
          CatRecoveryColFiles,
          CatRecoveryColFilesRecovered,
          CatRecoveryColFilesPercent,
          CatRecoveryColFilesTotal,
          CatRecoveryColBytes,
          CatRecoveryColBytesRecovered,
          CatRecoveryColBytesPercent,
          CatRecoveryColBytesTotal,
          CatRecoveryColTranslogOps,
          CatRecoveryColTranslogOpsRecovered,
          CatRecoveryColTranslogOpsPercent
        ]
        `shouldBe` [ "index",
                     "shard",
                     "start_time",
                     "start_time_millis",
                     "stop_time",
                     "stop_time_millis",
                     "time",
                     "type",
                     "stage",
                     "source_host",
                     "source_node",
                     "target_host",
                     "target_node",
                     "repository",
                     "snapshot",
                     "files",
                     "files_recovered",
                     "files_percent",
                     "files_total",
                     "bytes",
                     "bytes_recovered",
                     "bytes_percent",
                     "bytes_total",
                     "translog_ops",
                     "translog_ops_recovered",
                     "translog_ops_percent"
                   ]

  -- ------------------------------------------------------------------ --
  -- CatRecoveryRow JSON decoding                                        --
  -- ------------------------------------------------------------------ --
  describe "CatRecoveryRow FromJSON" $ do
    let fullRow =
          "[{\"index\":\"test\",\"shard\":\"0\",\"start_time\":\"2024-01-01T00:00:00.000Z\",\"start_time_millis\":\"1704067200000\",\"stop_time\":\"2024-01-01T00:00:05.000Z\",\"stop_time_millis\":\"1704067205000\",\"time\":\"5s\",\"type\":\"store\",\"stage\":\"DONE\",\"source_host\":\"10.0.0.1\",\"source_node\":\"node-1\",\"target_host\":\"10.0.0.2\",\"target_node\":\"node-2\",\"repository\":\"repo1\",\"snapshot\":\"snap1\",\"files\":\"10\",\"files_recovered\":\"10\",\"files_percent\":\"100.0%\",\"files_total\":\"10\",\"bytes\":\"2048\",\"bytes_recovered\":\"2048\",\"bytes_percent\":\"100.0%\",\"bytes_total\":\"2048\",\"translog_ops\":\"5\",\"translog_ops_recovered\":\"5\",\"translog_ops_percent\":\"100.0%\"}]"
        minimalRow = "[{\"index\":\"test\",\"shard\":\"0\"}]"
        extraColumnRow = "[{\"index\":\"x\",\"custom.col\":\"xyz\"}]"

    it "parses a fully-populated default row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatRecoveryRow] of
        Right (row : _) -> do
          cryrIndex row `shouldBe` Just "test"
          cryrShard row `shouldBe` Just 0
          cryrStartTime row `shouldBe` Just "2024-01-01T00:00:00.000Z"
          cryrStartTimeMillis row `shouldBe` Just 1704067200000
          cryrStopTime row `shouldBe` Just "2024-01-01T00:00:05.000Z"
          cryrStopTimeMillis row `shouldBe` Just 1704067205000
          cryrTime row `shouldBe` Just "5s"
          cryrType row `shouldBe` Just "store"
          cryrStage row `shouldBe` Just "DONE"
          cryrSourceHost row `shouldBe` Just "10.0.0.1"
          cryrSourceNode row `shouldBe` Just "node-1"
          cryrTargetHost row `shouldBe` Just "10.0.0.2"
          cryrTargetNode row `shouldBe` Just "node-2"
          cryrRepository row `shouldBe` Just "repo1"
          cryrSnapshot row `shouldBe` Just "snap1"
          cryrFiles row `shouldBe` Just 10
          cryrFilesRecovered row `shouldBe` Just 10
          cryrFilesPercent row `shouldBe` Just "100.0%"
          cryrFilesTotal row `shouldBe` Just 10
          cryrBytes row `shouldBe` Just 2048
          cryrBytesRecovered row `shouldBe` Just 2048
          cryrBytesPercent row `shouldBe` Just "100.0%"
          cryrBytesTotal row `shouldBe` Just 2048
          cryrTranslogOps row `shouldBe` Just 5
          cryrTranslogOpsRecovered row `shouldBe` Just 5
          cryrTranslogOpsPercent row `shouldBe` Just "100.0%"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses a minimal row containing only index and shard" $
      case parseEither parseJSON =<< eitherDecode minimalRow :: Either String [CatRecoveryRow] of
        Right (row : _) -> do
          cryrIndex row `shouldBe` Just "test"
          cryrShard row `shouldBe` Just 0
          cryrStage row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cryrOther" $
      case parseEither parseJSON =<< eitherDecode extraColumnRow :: Either String [CatRecoveryRow] of
        Right (row : _) ->
          case cryrOther row of
            Object o -> do
              KM.keys o `shouldContain` ["index"]
              KM.keys o `shouldContain` ["custom.col"]
            _ -> expectationFailure "expected cryrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers for shard and bytes" $
      case parseEither parseJSON =<< eitherDecode "[{\"shard\":3,\"bytes\":4096,\"start_time_millis\":1704067200000}]" :: Either String [CatRecoveryRow] of
        Right (row : _) -> do
          cryrShard row `shouldBe` Just 3
          cryrBytes row `shouldBe` Just 4096
          cryrStartTimeMillis row `shouldBe` Just 1704067200000
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "parses an empty recovery list" $
      case parseEither parseJSON =<< eitherDecode "[]" :: Either String [CatRecoveryRow] of
        Right rows -> rows `shouldBe` []
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catRecovery integration (live ES)                                   --
  -- ------------------------------------------------------------------ --
  describe "catRecovery integration" $
    it "returns a (possibly empty) list without throwing" $
      withTestEnv $ do
        _ <- Client.catRecovery
        pure ()

  -- ------------------------------------------------------------------ --
  -- catComponentTemplatesOptionsParams: pure URI rendering               --
  -- ------------------------------------------------------------------ --
  describe "catComponentTemplatesOptionsParams URI rendering" $ do
    it "default emits only format=json" $
      catComponentTemplatesOptionsParams defaultCatComponentTemplatesOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatComponentTemplatesOptions
              { cctoColumns = Just (CatComponentTemplatesColName :| [CatComponentTemplatesColIncludedIn])
              }
      catComponentTemplatesOptionsParams opts
        `shouldContain` [("h", Just "name,included_in")]

    it "renders sort (s) as a comma-joined list" $ do
      let opts = defaultCatComponentTemplatesOptions {cctoSort = Just ("name" :| ["version:desc"])}
      catComponentTemplatesOptionsParams opts
        `shouldContain` [("s", Just "name,version:desc")]

    it "renders booleans and master_timeout" $ do
      let opts =
            defaultCatComponentTemplatesOptions
              { cctoHelp = Just True,
                cctoLocal = Just False,
                cctoVerbose = Just True,
                cctoMasterTimeout = Just (TimeUnitSeconds, 30)
              }
      let ps = catComponentTemplatesOptionsParams opts
      ps `shouldContain` [("help", Just "true")]
      ps `shouldContain` [("local", Just "false")]
      ps `shouldContain` [("v", Just "true")]
      ps `shouldContain` [("master_timeout", Just "30s")]

  describe "catComponentTemplatesColumnText rendering" $
    it "covers documented columns" $
      map
        catComponentTemplatesColumnText
        [CatComponentTemplatesColName, CatComponentTemplatesColAliasCount, CatComponentTemplatesColIncludedIn]
        `shouldBe` ["name", "alias_count", "included_in"]

  describe "CatComponentTemplatesRow FromJSON" $ do
    let fullRow =
          "[{\"name\":\"my-template-1\",\"version\":\"null\",\"alias_count\":\"0\",\
          \\"mapping_count\":\"0\",\"settings_count\":\"1\",\"metadata_count\":\"0\",\
          \\"included_in\":\"[my-index-template]\"}]"

    it "parses a fully-populated row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatComponentTemplatesRow] of
        Right (row : _) -> do
          cctrName row `shouldBe` Just "my-template-1"
          cctrVersion row `shouldBe` Just "null"
          cctrAliasCount row `shouldBe` Just 0
          cctrSettingsCount row `shouldBe` Just 1
          cctrIncludedIn row `shouldBe` Just "[my-index-template]"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "maps a native JSON null version to Nothing" $
      case parseEither parseJSON =<< eitherDecode "[{\"name\":\"t2\",\"version\":null}]" :: Either String [CatComponentTemplatesRow] of
        Right (row : _) -> cctrVersion row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in cctrOther" $
      case parseEither parseJSON =<< eitherDecode "[{\"name\":\"t3\",\"future.col\":\"x\"}]" :: Either String [CatComponentTemplatesRow] of
        Right (row : _) ->
          case cctrOther row of
            Object o -> do
              KM.keys o `shouldSatisfy` ("name" `elem`)
              KM.keys o `shouldSatisfy` ("future.col" `elem`)
            _ -> expectationFailure "expected cctrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers for counts" $
      case parseEither parseJSON =<< eitherDecode "[{\"alias_count\":5,\"mapping_count\":2}]" :: Either String [CatComponentTemplatesRow] of
        Right (row : _) -> do
          cctrAliasCount row `shouldBe` Just 5
          cctrMappingCount row `shouldBe` Just 2
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  describe "catComponentTemplates integration" $
    backendSpecific [ElasticSearch8, ElasticSearch9] $
      it "returns a (possibly empty) list without throwing" $
        withTestEnv $ do
          _ <- Client.catComponentTemplates
          pure ()

  -- ------------------------------------------------------------------ --
  -- catCircuitBreakersOptionsParams: pure URI rendering                  --
  -- ------------------------------------------------------------------ --
  describe "catCircuitBreakersOptionsParams URI rendering" $ do
    it "default emits only format=json" $
      catCircuitBreakersOptionsParams defaultCatCircuitBreakersOptions
        `shouldBe` [("format", Just "json")]

    it "renders columns (h) as a comma-joined list" $ do
      let opts =
            defaultCatCircuitBreakersOptions
              { ccboColumns = Just (CatCircuitBreakersColBreaker :| [CatCircuitBreakersColTripped])
              }
      catCircuitBreakersOptionsParams opts
        `shouldContain` [("h", Just "breaker,tripped")]

    it "renders booleans and master_timeout" $ do
      let opts =
            defaultCatCircuitBreakersOptions
              { ccboLocal = Just True,
                ccboMasterTimeout = Just (TimeUnitMinutes, 1)
              }
      let ps = catCircuitBreakersOptionsParams opts
      ps `shouldContain` [("local", Just "true")]
      ps `shouldContain` [("master_timeout", Just "1m")]

  describe "CatCircuitBreakersRow FromJSON" $ do
    let fullRow =
          "[{\"node_id\":\"ozKxpP9oS3SL0Sp-Mfxc6w\",\"node_name\":\"node-1\",\
          \\"breaker\":\"request\",\"limit\":\"614.3mb\",\"limit_bytes\":\"644245094\",\
          \\"estimated\":\"0b\",\"estimated_bytes\":\"0\",\"tripped\":\"0\",\"overhead\":\"1.03\"}]"

    it "parses a fully-populated row" $
      case parseEither parseJSON =<< eitherDecode fullRow :: Either String [CatCircuitBreakersRow] of
        Right (row : _) -> do
          ccbrBreaker row `shouldBe` Just "request"
          ccbrLimit row `shouldBe` Just "614.3mb"
          ccbrLimitBytes row `shouldBe` Just 644245094
          ccbrEstimatedBytes row `shouldBe` Just 0
          ccbrTripped row `shouldBe` Just 0
          ccbrOverhead row `shouldBe` Just "1.03"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers for bytes/tripped" $
      case parseEither parseJSON =<< eitherDecode "[{\"tripped\":3,\"limit_bytes\":1024}]" :: Either String [CatCircuitBreakersRow] of
        Right (row : _) -> do
          ccbrTripped row `shouldBe` Just 3
          ccbrLimitBytes row `shouldBe` Just 1024
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "captures unknown columns verbatim in ccbrOther" $
      case parseEither parseJSON =<< eitherDecode "[{\"breaker\":\"x\",\"future.col\":\"y\"}]" :: Either String [CatCircuitBreakersRow] of
        Right (row : _) ->
          case ccbrOther row of
            Object o -> do
              KM.keys o `shouldSatisfy` ("breaker" `elem`)
              KM.keys o `shouldSatisfy` ("future.col" `elem`)
            _ -> expectationFailure "expected ccbrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  describe "catCircuitBreakers integration" $
    backendSpecific [ElasticSearch9] $
      it "returns a non-empty list on any cluster" $
        withTestEnv $ do
          rows <- Client.catCircuitBreakers
          liftIO $ rows `shouldSatisfy` (not . null)

  -- ------------------------------------------------------------------ --
  -- catMlJobsOptionsParams / CatMlJobsRow                               --
  -- ------------------------------------------------------------------ --
  describe "catMlJobsOptionsParams URI rendering" $ do
    it "default emits only format=json" $
      catMlJobsOptionsParams defaultCatMlJobsOptions
        `shouldBe` [("format", Just "json")]

    it "renders allow_no_match and columns" $ do
      let opts =
            defaultCatMlJobsOptions
              { cmjoAllowNoMatch = Just True,
                cmjoColumns = Just (CatMlJobsColId :| [CatMlJobsColState])
              }
      let ps = catMlJobsOptionsParams opts
      ps `shouldContain` [("allow_no_match", Just "true")]
      ps `shouldContain` [("h", Just "id,state")]

  describe "CatMlJobsRow FromJSON" $ do
    it "parses a row with dotted keys" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"high_sum_total_sales\",\"state\":\"closed\",\"data.processed_records\":\"14022\",\"model.bytes\":\"1600000\"}]" :: Either String [CatMlJobsRow] of
        Right (row : _) -> do
          cmjrId row `shouldBe` Just "high_sum_total_sales"
          cmjrState row `shouldBe` Just "closed"
          cmjrDataProcessedRecords row `shouldBe` Just 14022
          cmjrModelBytes row `shouldBe` Just 1600000
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "tolerates native JSON numbers and captures extras in cmjrOther" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"j\",\"buckets.count\":10,\"model.memory_status\":\"ok\",\"future.col\":\"z\"}]" :: Either String [CatMlJobsRow] of
        Right (row : _) -> do
          cmjrBucketsCount row `shouldBe` Just 10
          cmjrModelMemoryStatus row `shouldBe` Just "ok"
          case cmjrOther row of
            Object o -> KM.keys o `shouldContain` ["future.col"]
            _ -> expectationFailure "expected cmjrOther to be an Object"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catMlDataFrameAnalyticsOptionsParams / CatMlDataFrameAnalyticsRow    --
  -- ------------------------------------------------------------------ --
  describe "catMlDataFrameAnalyticsOptionsParams URI rendering" $
    it "renders allow_no_match" $ do
      let opts = defaultCatMlDataFrameAnalyticsOptions {cmfaoAllowNoMatch = Just False}
      catMlDataFrameAnalyticsOptionsParams opts
        `shouldContain` [("allow_no_match", Just "false")]

  describe "CatMlDataFrameAnalyticsRow FromJSON" $
    it "parses a documented row" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"classifier_job_1\",\"type\":\"classification\",\"create_time\":\"2020-02-12T11:49:09.594Z\",\"state\":\"stopped\"}]" :: Either String [CatMlDataFrameAnalyticsRow] of
        Right (row : _) -> do
          cmfaId row `shouldBe` Just "classifier_job_1"
          cmfaType row `shouldBe` Just "classification"
          cmfaState row `shouldBe` Just "stopped"
          cmfaCreateTime row `shouldBe` Just "2020-02-12T11:49:09.594Z"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catMlDatafeedsOptionsParams / CatMlDatafeedsRow                     --
  -- ------------------------------------------------------------------ --
  describe "catMlDatafeedsOptionsParams URI rendering" $
    it "renders sort" $ do
      let opts = defaultCatMlDatafeedsOptions {cmdfoSort = Just ("id" :| [])}
      catMlDatafeedsOptionsParams opts
        `shouldContain` [("s", Just "id")]

  describe "CatMlDatafeedsRow FromJSON" $
    it "parses dotted search/buckets keys" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"datafeed-high_sum_total_sales\",\"state\":\"stopped\",\"buckets.count\":\"743\",\"search.count\":\"7\"}]" :: Either String [CatMlDatafeedsRow] of
        Right (row : _) -> do
          cmdfId row `shouldBe` Just "datafeed-high_sum_total_sales"
          cmdfBucketsCount row `shouldBe` Just 743
          cmdfSearchCount row `shouldBe` Just 7
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catMlTrainedModelsOptionsParams / CatMlTrainedModelsRow             --
  -- ------------------------------------------------------------------ --
  describe "catMlTrainedModelsOptionsParams URI rendering" $ do
    it "renders from/size pagination" $ do
      let opts =
            defaultCatMlTrainedModelsOptions
              { cmtoFrom = Just 10,
                cmtoSize = Just 25
              }
      let ps = catMlTrainedModelsOptionsParams opts
      ps `shouldContain` [("from", Just "10")]
      ps `shouldContain` [("size", Just "25")]

  describe "CatMlTrainedModelsRow FromJSON" $
    it "parses a documented row including the __none__ sentinel" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"lang_ident_model_1\",\"heap_size\":\"1mb\",\"operations\":\"39629\",\"create_time\":\"2019-12-05T12:28:34.594Z\",\"type\":\"lang_ident\",\"ingest.pipelines\":\"0\",\"data_frame.id\":\"__none__\"}]" :: Either String [CatMlTrainedModelsRow] of
        Right (row : _) -> do
          ctmrId row `shouldBe` Just "lang_ident_model_1"
          ctmrHeapSize row `shouldBe` Just "1mb"
          ctmrType row `shouldBe` Just "lang_ident"
          ctmrIngestPipelines row `shouldBe` Just "0"
          ctmrDataFrameId row `shouldBe` Just "__none__"
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catTransformsOptionsParams / CatTransformsRow                       --
  -- ------------------------------------------------------------------ --
  describe "catTransformsOptionsParams URI rendering" $
    it "renders from/size pagination" $ do
      let opts = defaultCatTransformsOptions {ctxoSize = Just 50}
      catTransformsOptionsParams opts
        `shouldContain` [("size", Just "50")]

  describe "CatTransformsRow FromJSON" $ do
    it "parses a documented row" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"ecommerce_transform\",\"state\":\"started\",\"checkpoint\":\"1\",\"documents_processed\":\"705\",\"checkpoint_progress\":\"100.00\",\"changes_last_detection_time\":null}]" :: Either String [CatTransformsRow] of
        Right (row : _) -> do
          ctxrId row `shouldBe` Just "ecommerce_transform"
          ctxrState row `shouldBe` Just "started"
          ctxrCheckpoint row `shouldBe` Just "1"
          ctxrDocumentsProcessed row `shouldBe` Just 705
          ctxrCheckpointProgress row `shouldBe` Just "100.00"
          ctxrChangesLastDetectionTime row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

    it "maps a native JSON null last_search_time to Nothing" $
      case parseEither parseJSON =<< eitherDecode "[{\"id\":\"t\",\"last_search_time\":null}]" :: Either String [CatTransformsRow] of
        Right (row : _) -> ctxrLastSearchTime row `shouldBe` Nothing
        Right [] -> expectationFailure "expected at least one row"
        Left e -> expectationFailure ("expected parse, got " <> e)

  -- ------------------------------------------------------------------ --
  -- catHelp: plain-text meta endpoint (no params, non-JSON body)         --
  -- ------------------------------------------------------------------ --
  describe "catHelp endpoint shape" $ do
    let path req = getRawEndpoint (bhRequestEndpoint req)
        queries req = getRawEndpointQueries (bhRequestEndpoint req)
    it "targets /_cat (the cross-version help root)" $
      path catHelp `shouldBe` ["_cat"]
    it "carries no query string" $
      queries catHelp `shouldBe` []

  describe "catHelp integration" $
    it "returns a non-empty plain-text help listing" $
      withTestEnv $ do
        help <- Client.catHelp
        liftIO $ help `shouldSatisfy` (not . T.null)
  where
    -- \| Create a freshly-emptied cat-test index, run the action with
    -- the index name, and delete the index afterwards even if the
    -- action throws (so a failing assertion cannot leak the index into
    -- subsequent runs).
    withCatIndex :: IndexName -> (IndexName -> BH IO a) -> BH IO a
    withCatIndex idx action = bracket_ setup teardown (action idx)
      where
        setup = do
          _ <- tryPerformBHRequest $ deleteIndex idx
          void $
            performBHRequest $
              createIndex
                (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
                idx
        teardown = void $ tryPerformBHRequest $ deleteIndex idx

    -- \| Create a freshly-emptied source index and a write-index alias on
    -- top of it, run the action with both names, and tear the alias and
    -- the index down afterwards even if the action throws. Mirrors
    -- 'withCatIndex' but additionally wires up 'createIndexAlias' /
    -- 'deleteIndexAlias' so the @_cat\/aliases@ smoke test always has a
    -- concrete alias to look for.
    withCatAlias :: IndexName -> IndexName -> (IndexName -> IndexName -> BH IO a) -> BH IO a
    withCatAlias idx alias action =
      bracket_ setup teardown (action idx alias)
      where
        aliasName = IndexAliasName alias
        writeCreate = defaultIndexAliasCreate {aliasCreateIsWriteIndex = Just True}
        setup = do
          _ <- tryPerformBHRequest $ deleteIndexAlias aliasName
          _ <- tryPerformBHRequest $ deleteIndex idx
          void $
            performBHRequest $
              createIndex
                (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
                idx
          void $ performBHRequest $ createIndexAlias idx aliasName writeCreate
        teardown = do
          _ <- tryPerformBHRequest $ deleteIndexAlias aliasName
          void $ tryPerformBHRequest $ deleteIndex idx

    -- \| Create a freshly-emptied cat-test index whose mapping enables
    -- fielddata on a text field, index a single document populating that
    -- field, and refresh. The fielddata cache itself is loaded lazily by
    -- the caller (via a sort on the field); the helper only sets up the
    -- precondition. Mirrors 'withCatIndex' / 'withCatAlias'.
    withCatFielddata :: IndexName -> FieldName -> (IndexName -> FieldName -> BH IO a) -> BH IO a
    withCatFielddata idx field action =
      bracket_ setup teardown (action idx field)
      where
        setup = do
          _ <- tryPerformBHRequest $ deleteIndex idx
          void $
            performBHRequest $
              createIndex
                (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
                idx
          void $
            performBHRequest $
              putMapping @Value idx mappingBody
          void $
            performBHRequest $
              indexDocument idx defaultIndexDocumentSettings docBody (DocId "1")
          void $ performBHRequest $ refreshIndex idx
        teardown = void $ tryPerformBHRequest $ deleteIndex idx
        mappingBody =
          object
            [ "properties"
                .= object
                  [ fromText (unFieldName field)
                      .= object
                        [ "type" .= ("text" :: Text),
                          "fielddata" .= True
                        ]
                  ]
            ]
        docBody = object [fromText (unFieldName field) .= ("hello world" :: Text)]