packages feed

bloodhound-1.0.0.0: tests/Test/LogstashSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.LogstashSpec (spec) where

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

-- | A canonical pipeline body matching the ES 7.17 docs example: a
-- description, the pipeline DSL string, and metadata.
samplePipelineBytes :: LBS.ByteString
samplePipelineBytes =
  "{\
  \  \"description\": \"Sample pipeline\",\
  \  \"pipeline\": \"input { stdin { } } output { stdout { } }\",\
  \  \"pipeline_metadata\": {\
  \    \"type\": \"logstash_pipeline\",\
  \    \"version\": 1\
  \  }\
  \}"

-- | A pipeline carrying an optional @pipeline_settings@ map, a server-set
-- @last_modified@ (ES 7.x epoch-millis shape) and an unknown sibling field
-- (@@username_extra@) which the @lpcExtras@ catch-all must preserve.
samplePipelineWithExtrasBytes :: LBS.ByteString
samplePipelineWithExtrasBytes =
  "{\
  \  \"description\": \"with settings\",\
  \  \"pipeline\": \"input { } output { }\",\
  \  \"pipeline_settings\": {\
  \    \"pipeline.batch.size\": \"125\",\
  \    \"pipeline.batch.delay\": \"50\"\
  \  },\
  \  \"username\": \"elastic\",\
  \  \"last_modified\": 1719394582880,\
  \  \"username_extra\": true\
  \}"

-- | Full @GET /_logstash/pipeline@ response: a bare object keyed by pipeline
-- id, with two entries.
sampleListResponseBytes :: LBS.ByteString
sampleListResponseBytes =
  "{\
  \  \"my-pipeline\": {\
  \    \"description\": \"first\",\
  \    \"pipeline\": \"input { stdin { } } output { stdout { } }\"\
  \  },\
  \  \"other-pipeline\": {\
  \    \"description\": \"second\",\
  \    \"pipeline\": \"input { beats { } } output { elasticsearch { } }\",\
  \    \"pipeline_metadata\": {\"type\": \"logstash_pipeline\", \"version\": 2}\
  \  }\
  \}"

-- | @GET /_logstash/pipeline/{id}@ response shape: still a bare object, but
-- with a single entry keyed by the requested id.
sampleSingleResponseBytes :: LBS.ByteString
sampleSingleResponseBytes =
  "{\
  \  \"my-pipeline\": {\
  \    \"description\": \"only one\",\
  \    \"pipeline\": \"input { stdin { } } output { stdout { } }\"\
  \  }\
  \}"

spec :: Spec
spec = describe "Logstash pipeline API" $ do
  describe "LogstashPipelineId JSON" $ do
    it "round-trips as a bare JSON string" $ do
      let Just decoded = decode "\"my-pipeline\"" :: Maybe LogstashPipelineId
      unLogstashPipelineId decoded `shouldBe` "my-pipeline"
      encode (LogstashPipelineId "my-pipeline") `shouldBe` "\"my-pipeline\""

    it "rejects a non-string value" $ do
      let decoded = decode "42" :: Maybe LogstashPipelineId
      decoded `shouldBe` Nothing

  describe "LogstashPipelineConfig JSON (PUT body)" $ do
    it "decodes a canonical pipeline body" $ do
      let Just decoded = decode samplePipelineBytes :: Maybe LogstashPipelineConfig
      lpcDescription decoded `shouldBe` Just "Sample pipeline"
      lpcPipeline decoded
        `shouldBe` Just "input { stdin { } } output { stdout { } }"
      -- The metadata body is carried as an opaque Value.
      lpcPipelineMetadata decoded `shouldSatisfy` isJust
      lpcPipelineSettings decoded `shouldBe` Nothing
      lpcUsername decoded `shouldBe` Nothing
      lpcLastModified decoded `shouldBe` Nothing
      lpcExtras decoded `shouldBe` KM.empty

    it "decodes settings, username, last_modified and preserves extras" $ do
      let Just decoded = decode samplePipelineWithExtrasBytes :: Maybe LogstashPipelineConfig
      lpcDescription decoded `shouldBe` Just "with settings"
      lpcPipelineSettings decoded `shouldSatisfy` isJust
      lpcUsername decoded `shouldBe` Just "elastic"
      -- @last_modified@ is carried as an opaque epoch-millis Value.
      lpcLastModified decoded `shouldSatisfy` isJust
      -- The unknown @username_extra@ key survives in the extras catch-all.
      KM.lookup "username_extra" (lpcExtras decoded) `shouldSatisfy` isJust

    it "round-trips a pipeline with extras through encode . decode" $ do
      let Just decoded = decode samplePipelineWithExtrasBytes :: Maybe LogstashPipelineConfig
          Just roundTripped = decode (encode decoded) :: Maybe LogstashPipelineConfig
      -- The @username_extra@ extra survives the round-trip.
      KM.lookup "username_extra" (lpcExtras roundTripped) `shouldSatisfy` isJust
      -- The known keys are NOT duplicated into extras.
      KM.lookup "username" (lpcExtras roundTripped) `shouldBe` Nothing
      KM.lookup "pipeline" (lpcExtras roundTripped) `shouldBe` Nothing
      -- Typed fields survive too.
      lpcUsername roundTripped `shouldBe` Just "elastic"
      lpcDescription roundTripped `shouldBe` Just "with settings"

    it "decodes an empty object into all-Nothing fields" $ do
      let Just decoded = decode "{}" :: Maybe LogstashPipelineConfig
      lpcDescription decoded `shouldBe` Nothing
      lpcPipeline decoded `shouldBe` Nothing
      lpcExtras decoded `shouldBe` KM.empty

    it "preserves a null-valued extra through encode . decode" $ do
      -- The 'object' (not 'omitNulls') encoder is chosen so an extra
      -- carrying @null@ survives the round-trip — locking that guarantee.
      let Just decoded =
            decode
              "{\"description\":\"d\",\"unknown_null\":null}" ::
              Maybe LogstashPipelineConfig
          Just roundTripped = decode (encode decoded) :: Maybe LogstashPipelineConfig
      KM.lookup "unknown_null" (lpcExtras roundTripped) `shouldBe` Just Null

  describe "LogstashPipelinesResponse JSON (GET response)" $ do
    it "decodes the bare multi-entry object shape" $ do
      let Just decoded =
            decode sampleListResponseBytes :: Maybe LogstashPipelinesResponse
      M.size (unLogstashPipelinesResponse decoded) `shouldBe` 2
      let Just firstCfg =
            M.lookup "my-pipeline" (unLogstashPipelinesResponse decoded)
      lpcDescription firstCfg `shouldBe` Just "first"
      let Just secondCfg =
            M.lookup "other-pipeline" (unLogstashPipelinesResponse decoded)
      -- The metadata body round-trips opaquely.
      lpcPipelineMetadata secondCfg `shouldSatisfy` isJust

    it "decodes the single-entry shape returned by GET /{id}" $ do
      let Just decoded =
            decode sampleSingleResponseBytes :: Maybe LogstashPipelinesResponse
      M.size (unLogstashPipelinesResponse decoded) `shouldBe` 1
      let Just cfg =
            M.lookup "my-pipeline" (unLogstashPipelinesResponse decoded)
      lpcDescription cfg `shouldBe` Just "only one"

    it "re-encodes to the same bare-object shape and round-trips" $ do
      let Just decoded =
            decode sampleListResponseBytes :: Maybe LogstashPipelinesResponse
          reEncoded = encode decoded
          Just reparsed =
            decode reEncoded :: Maybe LogstashPipelinesResponse
      M.size (unLogstashPipelinesResponse reparsed) `shouldBe` 2

    it "decodes an empty object to an empty map" $ do
      let Just decoded = decode "{}" :: Maybe LogstashPipelinesResponse
      unLogstashPipelinesResponse decoded `shouldBe` M.empty

    it "rejects a non-object response" $ do
      let decoded = decode "[]" :: Maybe LogstashPipelinesResponse
      decoded `shouldBe` Nothing

  describe "defaultLogstashPipelineConfig" $ do
    it "builds an empty config" $ do
      let cfg = defaultLogstashPipelineConfig
      lpcDescription cfg `shouldBe` Nothing
      lpcPipeline cfg `shouldBe` Nothing
      lpcPipelineMetadata cfg `shouldBe` Nothing
      lpcPipelineSettings cfg `shouldBe` Nothing
      lpcUsername cfg `shouldBe` Nothing
      lpcLastModified cfg `shouldBe` Nothing
      lpcExtras cfg `shouldBe` KM.empty

    it "serializes to the minimal empty PUT body" $ do
      -- Every known field is Nothing, no extras -> empty object on the wire.
      encode defaultLogstashPipelineConfig `shouldBe` "{}"