packages feed

bloodhound-1.0.0.0: tests/Test/RepositoriesMeteringSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.RepositoriesMeteringSpec (spec) where

import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import TestsUtils.Import
import Prelude

-- | A canonical two-node response matching the documented ES shape: one
-- node with a metered S3 repository (every documented field populated)
-- and one node with an empty @repository_metering@ map.
sampleResponseBytes :: LBS.ByteString
sampleResponseBytes =
  "{\
  \  \"_nodes\": {\"total\": 2, \"successful\": 2, \"failed\": 0},\
  \  \"cluster_name\": \"bloodhound-tests\",\
  \  \"nodes\": {\
  \    \"node-1\": {\
  \      \"repository_metering\": {\
  \        \"s3-repo:my-bucket:base/path\": {\
  \          \"cloud_provider\": \"aws\",\
  \          \"repo_type\": \"s3\",\
  \          \"repo_endpoint\": \"s3.amazonaws.com\",\
  \          \"repo_bucket\": \"my-bucket\",\
  \          \"repo_base_path\": \"base/path\",\
  \          \"creation_current_rate_limit\": {\"type\": \"bytes_per_second\", \"value\": 10485760},\
  \          \"deletion_current_rate_limit\": {\"type\": \"bytes_per_second\", \"value\": 10485760},\
  \          \"creation_total_count\": 42,\
  \          \"deletion_total_count\": 7\
  \        }\
  \      }\
  \    },\
  \    \"node-2\": {\
  \      \"repository_metering\": {}\
  \    }\
  \  }\
  \}"

-- | A repository info payload carrying an unknown sibling field
-- (@future_field@) so the @rmiExtras@ round-trip catch-all is exercised.
sampleRepoInfoWithExtrasBytes :: LBS.ByteString
sampleRepoInfoWithExtrasBytes =
  "{\
  \  \"cloud_provider\": \"gcp\",\
  \  \"repo_type\": \"gcs\",\
  \  \"future_field\": true\
  \}"

spec :: Spec
spec = describe "Repositories metering API" $ do
  describe "RepositoriesMeteringNodesSummary JSON" $ do
    it "decodes a populated _nodes summary" $ do
      let Just decoded =
            decode "{ \"total\": 3, \"successful\": 2, \"failed\": 1 }" ::
              Maybe RepositoriesMeteringNodesSummary
      rmnsTotal decoded `shouldBe` 3
      rmnsSuccessful decoded `shouldBe` 2
      rmnsFailed decoded `shouldBe` 1

    it "defaults missing counts to 0" $ do
      let Just decoded = decode "{}" :: Maybe RepositoriesMeteringNodesSummary
      rmnsTotal decoded `shouldBe` 0

    it "round-trips through encode . decode" $ do
      let original = RepositoriesMeteringNodesSummary 1 1 0
          Just roundTripped = decode (encode original)
      roundTripped `shouldBe` original

  describe "RepositoryMeteringInfo JSON" $ do
    it "decodes the documented fields" $ do
      let Just decoded =
            decode sampleRepoInfoWithExtrasBytes ::
              Maybe RepositoryMeteringInfo
      rmiCloudProvider decoded `shouldBe` Just "gcp"
      rmiRepoType decoded `shouldBe` Just "gcs"
      rmiRepoEndpoint decoded `shouldBe` Nothing
      rmiCreationTotalCount decoded `shouldBe` Nothing

    it "preserves unknown fields in extras and round-trips" $ do
      let Just decoded =
            decode sampleRepoInfoWithExtrasBytes ::
              Maybe RepositoryMeteringInfo
          Just roundTripped =
            decode (encode decoded) ::
              Maybe RepositoryMeteringInfo
      KM.lookup "future_field" (rmiExtras decoded) `shouldSatisfy` isJust
      -- Known fields are NOT duplicated into extras.
      KM.lookup "cloud_provider" (rmiExtras decoded) `shouldBe` Nothing
      rmiCloudProvider roundTripped `shouldBe` Just "gcp"
      KM.lookup "future_field" (rmiExtras roundTripped) `shouldSatisfy` isJust

    it "decodes a fully-populated metering entry" $ do
      let body =
            "{\
            \  \"cloud_provider\": \"azure\",\
            \  \"repo_type\": \"azure\",\
            \  \"repo_endpoint\": \"blob.core.windows.net\",\
            \  \"repo_bucket\": \"snapshots\",\
            \  \"repo_base_path\": \"repo\",\
            \  \"creation_current_rate_limit\": {\"type\": \"mb_per_second\", \"value\": 10},\
            \  \"deletion_current_rate_limit\": {\"type\": \"mb_per_second\", \"value\": 5},\
            \  \"creation_total_count\": 100,\
            \  \"deletion_total_count\": 20\
            \}"
          Just decoded = decode body :: Maybe RepositoryMeteringInfo
      rmiCloudProvider decoded `shouldBe` Just "azure"
      rmiRepoBucket decoded `shouldBe` Just "snapshots"
      rmiCreationTotalCount decoded `shouldBe` Just 100
      rmiDeletionTotalCount decoded `shouldBe` Just 20
      rmiCreationCurrentRateLimit decoded `shouldSatisfy` isJust

    it "decodes a minimal empty object without failure" $ do
      let Just decoded = decode "{}" :: Maybe RepositoryMeteringInfo
      rmiRepoType decoded `shouldBe` Nothing
      KM.null (rmiExtras decoded) `shouldBe` True

  describe "NodeRepositoriesMetering JSON" $ do
    it "unwraps the repository_metering key" $ do
      let Just decoded =
            decode "{ \"repository_metering\": {} }" ::
              Maybe NodeRepositoriesMetering
      KM.null (nrmRepositories decoded) `shouldBe` True

    it "defaults a missing repository_metering to empty" $ do
      let Just decoded = decode "{}" :: Maybe NodeRepositoriesMetering
      KM.null (nrmRepositories decoded) `shouldBe` True

  describe "RepositoriesMeteringResponse JSON" $ do
    it "decodes a two-node response" $ do
      let Just decoded =
            decode sampleResponseBytes ::
              Maybe RepositoriesMeteringResponse
      rmrClusterName decoded `shouldBe` Just "bloodhound-tests"
      rmnsTotal <$> rmrNodesSummary decoded `shouldBe` Just 2
      KM.size (rmrNodes decoded) `shouldBe` 2
      let Just node1 = KM.lookup "node-1" (rmrNodes decoded)
          repos = nrmRepositories node1
      KM.size repos `shouldBe` 1
      let Just repo = KM.lookup "s3-repo:my-bucket:base/path" repos
      rmiCloudProvider repo `shouldBe` Just "aws"
      rmiCreationTotalCount repo `shouldBe` Just 42
      let Just node2 = KM.lookup "node-2" (rmrNodes decoded)
      KM.null (nrmRepositories node2) `shouldBe` True

    it "round-trips a two-node response through encode . decode" $ do
      let Just decoded =
            decode sampleResponseBytes ::
              Maybe RepositoriesMeteringResponse
          Just roundTripped =
            decode (encode decoded) ::
              Maybe RepositoriesMeteringResponse
      rmrClusterName roundTripped `shouldBe` Just "bloodhound-tests"
      KM.size (rmrNodes roundTripped) `shouldBe` 2
      let Just node1 = KM.lookup "node-1" (rmrNodes roundTripped)
          Just repo =
            KM.lookup "s3-repo:my-bucket:base/path" (nrmRepositories node1)
      rmiRepoType repo `shouldBe` Just "s3"

    it "decodes a response missing _nodes and cluster_name" $ do
      let Just decoded =
            decode "{ \"nodes\": {} }" ::
              Maybe RepositoriesMeteringResponse
      rmrNodesSummary decoded `shouldBe` Nothing
      rmrClusterName decoded `shouldBe` Nothing
      KM.null (rmrNodes decoded) `shouldBe` True

    it "encodes a minimal response, dropping Nothing fields (omitNulls)" $ do
      let minimal = RepositoriesMeteringResponse Nothing Nothing mempty
          encoded = encode minimal
      -- @_nodes@/@cluster_name@ are 'Nothing' → dropped; @nodes@ is an
      -- empty object (not 'Null') → kept.
      encoded `shouldBe` "{\"nodes\":{}}"
      decode encoded `shouldBe` Just minimal

  describe "getRepositoriesMetering endpoint shape" $ do
    let mkReq = getRepositoriesMetering
    it "is a GET with no body and no query params (cluster-wide)" $ do
      let req = mkReq Nothing
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "_repositories_metering"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "scopes to _local for Just LocalNode" $ do
      let req = mkReq (Just LocalNode)
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "_local", "_repositories_metering"]

    it "scopes to _all for Just AllNodes" $ do
      let req = mkReq (Just AllNodes)
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "_all", "_repositories_metering"]

    it "scopes to a comma-joined NodeList" $ do
      let req =
            mkReq
              ( Just
                  (NodeList (NodeByName (NodeName "n1") :| [NodeByName (NodeName "n2")]))
              )
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "n1,n2", "_repositories_metering"]

  describe "deleteRepositoriesMetering endpoint shape" $ do
    it "is a DELETE with no body (cluster-wide)" $ do
      let req = deleteRepositoriesMetering Nothing
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "_repositories_metering"]
      bhRequestBody req `shouldBe` Nothing

    it "scopes to a node selection" $ do
      let req = deleteRepositoriesMetering (Just AllNodes)
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_nodes", "_all", "_repositories_metering"]