packages feed

bloodhound-1.0.0.0: tests/Test/HealthReportSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.HealthReportSpec (spec) where

import Data.Aeson
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as M
import Data.Text qualified as T
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import Database.Bloodhound.ElasticSearch9.Types qualified as ES9
import TestsUtils.Common
import TestsUtils.Import
import Prelude

------------------------------------------------------------------------------
-- Sample payloads (trimmed to the wire fields decoded by the types).
------------------------------------------------------------------------------

-- | A healthy cluster: green status, one green indicator whose subtype
-- adds an extra @details@ object that must survive in 'ES9.hiOther'.
sampleReportGreenBytes :: LBS.ByteString
sampleReportGreenBytes =
  "{\
  \  \"cluster_name\": \"my-cluster\",\n\
  \  \"status\": \"green\",\
  \  \"indicators\": {\
  \    \"master_is_stable\": {\
  \      \"status\": \"green\",\
  \      \"symptom\": \"The cluster has a healthy master node.\",\
  \      \"details\": {\"is_stable\": true}\
  \    }\
  \  }\
  \}"

-- | An unhealthy indicator carrying @impacts@ and @diagnosis@ (the fields
-- the server only populates when status is not green).
sampleIndicatorRedBytes :: LBS.ByteString
sampleIndicatorRedBytes =
  "{\
  \  \"status\": \"red\",\
  \  \"symptom\": \"Disk usage is high.\",\
  \  \"impacts\": [\
  \    {\
  \      \"description\": \"Reduced indexing capacity.\",\
  \      \"id\": \"disk@ingest-disabled\",\
  \      \"impact_areas\": [\"ingest\"],\
  \      \"severity\": 1\
  \    }\
  \  ],\
  \  \"diagnosis\": [\
  \    {\
  \      \"id\": \"disk@high-disk-watermark\",\
  \      \"action\": \"Free up disk space.\",\
  \      \"cause\": \"Disk watermark exceeded.\",\
  \      \"help_url\": \"https://www.elastic.co/guide/\",\
  \      \"affected_resources\": {\
  \        \"nodes\": [{\"node_id\": \"n1\", \"name\": \"node-1\"}]\
  \      }\
  \    }\
  \  ]\
  \}"

spec :: Spec
spec = do
  --------------------------------------------------------------------------
  -- JSON (de)serialisation
  --------------------------------------------------------------------------
  describe "HealthReport JSON" $ do
    it "decodes a green report with its indicators map" $ do
      let decoded = eitherDecode sampleReportGreenBytes :: Either String ES9.HealthReport
      decoded `shouldSatisfy` isRight
      let Right report = decoded
      ES9.hrClusterName report `shouldBe` "my-cluster"
      ES9.hrStatus report `shouldBe` Just ES9.HealthReportStatusGreen
      M.size (ES9.hrIndicators report) `shouldBe` 1

    it "captures subtype-specific fields in hiOther" $ do
      let decoded = eitherDecode sampleReportGreenBytes :: Either String ES9.HealthReport
          Right report = decoded
          Just indicator = M.lookup "master_is_stable" (ES9.hrIndicators report)
      ES9.hiStatus indicator `shouldBe` ES9.HealthReportStatusGreen
      ES9.hiOther indicator `shouldSatisfy` isJust

    it "round-trips the known fields through encode/decode" $ do
      let decoded = eitherDecode sampleReportGreenBytes :: Either String ES9.HealthReport
      decoded `shouldSatisfy` isRight
      let Right report = decoded
      let reDecoded = eitherDecode (encode report) :: Either String ES9.HealthReport
      reDecoded `shouldSatisfy` isRight
      -- The known fields survive a round trip.
      let Right reReport = reDecoded
      ES9.hrClusterName reReport `shouldBe` ES9.hrClusterName report
      ES9.hrStatus reReport `shouldBe` ES9.hrStatus report

    it "preserves the hiOther catch-all across encode/decode" $ do
      -- The catch-all is the module's headline invariant: subtype-specific
      -- fields must survive a round trip. A wrong X.union bias would lose
      -- them, so this guards it explicitly.
      let decoded = eitherDecode sampleReportGreenBytes :: Either String ES9.HealthReport
          Right report = decoded
          Just indicator = M.lookup "master_is_stable" (ES9.hrIndicators report)
      let reDecoded = eitherDecode (encode report) :: Either String ES9.HealthReport
          Right reReport = reDecoded
          Just reIndicator = M.lookup "master_is_stable" (ES9.hrIndicators reReport)
      ES9.hiOther reIndicator `shouldSatisfy` isJust
      ES9.hiOther reIndicator `shouldBe` ES9.hiOther indicator

  describe "HealthIndicator JSON" $ do
    it "decodes impacts and diagnosis on a red indicator" $ do
      let decoded = eitherDecode sampleIndicatorRedBytes :: Either String ES9.HealthIndicator
      decoded `shouldSatisfy` isRight
      let Right indicator = decoded
      ES9.hiStatus indicator `shouldBe` ES9.HealthReportStatusRed
      length <$> ES9.hiImpacts indicator `shouldBe` Just 1
      length <$> ES9.hiDiagnosis indicator `shouldBe` Just 1

    it "decodes the HealthReportStatus enum both ways" $ do
      ES9.healthReportStatusToText ES9.HealthReportStatusRed `shouldBe` "red"
      ES9.healthReportStatusFromText "unavailable"
        `shouldBe` Just ES9.HealthReportStatusUnavailable

  --------------------------------------------------------------------------
  -- Endpoint shape
  --------------------------------------------------------------------------
  describe "getHealthReport endpoint shape" $ do
    it "GETs /_health_report when given Nothing" $ do
      let req = RequestsES9.getHealthReport Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_health_report"]

    it "GETs /_health_report/{feature} when given features" $ do
      let req = RequestsES9.getHealthReport (Just ["disk"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_health_report", "disk"]

    it "joins multiple features with a comma" $ do
      let req = RequestsES9.getHealthReport (Just ["disk", "ilm"])
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_health_report", "disk,ilm"]

    it "uses the GET method" $ do
      let req = RequestsES9.getHealthReport Nothing
      bhRequestMethod req `shouldBe` "GET"

    it "carries no request body" $ do
      let req = RequestsES9.getHealthReport Nothing
      bhRequestBody req `shouldBe` Nothing

    it "carries no query string by default" $ do
      let req = RequestsES9.getHealthReport Nothing
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "renders verbose via getHealthReportWith" $ do
      let opts = ES9.defaultHealthReportOptions {ES9.hroVerbose = Just False}
          req = RequestsES9.getHealthReportWith opts Nothing
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("verbose", Just "false")]

  --------------------------------------------------------------------------
  -- Live integration (ES9 only).
  --------------------------------------------------------------------------
  backendSpecific
    [ElasticSearch9]
    $ describe "Health report API (live integration)"
    $ do
      it "getHealthReport Nothing returns a parseable response" $
        withTestEnv $ do
          result <- tryEsError $ ClientES9.getHealthReport Nothing
          liftIO $
            case result of
              Left e
                | endpointMissing e ->
                    pendingWith "health report requires Elasticsearch 9+"
              _ -> pure ()
          case result of
            Left e -> liftIO $ expectationFailure $ "getHealthReport failed: " <> show e
            Right report ->
              liftIO $
                ES9.hrClusterName report `shouldSatisfy` (not . T.null)

-- | Detect the @no handler found for uri@shape returned by Elasticsearch
-- when an endpoint isn't registered on this version. Used to mark the
-- live tests as 'pendingWith' rather than failing.
endpointMissing :: EsError -> Bool
endpointMissing e =
  "no handler found" `T.isInfixOf` T.toLower (errorMessage e)