packages feed

bloodhound-1.0.0.0: tests/Test/JobSchedulerSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.JobSchedulerSpec (spec) where

import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as Map
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3Types
import TestsUtils.Import
import Prelude

-- ---------------------------------------------------------------------------
-- Pure endpoint-shape and JSON-decoding tests for the OpenSearch Job
-- Scheduler plugin (@\/_plugins\/_job_scheduler\/api@). No live backend
-- is needed: every assertion runs against the in-memory 'BHRequest'
-- produced by the OS3 'Requests' module, or against JSON fixtures quoted
-- verbatim from a live OS 3.7.0 cluster.
--
-- The @\/api\/jobs@ and @\/api\/locks@ REST handlers are only registered
-- on OpenSearch 3.x (a 1.x or 2.x cluster answers @404 no handler found@
-- even when the @opensearch-job-scheduler@ plugin is installed), so this
-- surface lives solely in the OS3 module — see the type module Haddock
-- for the availability caveat.
--
-- See <https://docs.opensearch.org/latest/monitoring-your-cluster/job-scheduler/>
spec :: Spec
spec = describe "OpenSearch Job Scheduler API" $ do
  -- ===============================================================
  -- Endpoint-shape tests (no live backend)
  -- ===============================================================
  describe "getJobSchedulerJobs endpoint shape" $
    it "GETs /_plugins/_job_scheduler/api/jobs with no body or query" $ do
      let req = OS3Requests.getJobSchedulerJobs
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_job_scheduler", "api", "jobs"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  describe "getJobSchedulerLocks endpoint shape" $
    it "GETs /_plugins/_job_scheduler/api/locks with no body or query" $ do
      let req = OS3Requests.getJobSchedulerLocks
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_job_scheduler", "api", "locks"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  describe "getJobSchedulerLock endpoint shape" $
    it "GETs /_plugins/_job_scheduler/api/locks/{lock_id} with no body or query" $ do
      let req = OS3Requests.getJobSchedulerLock (OS3Types.LockId ".opendistro-ism-config-myjob_123")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_job_scheduler", "api", "locks", ".opendistro-ism-config-myjob_123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

  -- ===============================================================
  -- LockId JSON
  -- ===============================================================
  describe "LockId JSON" $ do
    it "encodes LockId as a bare JSON string" $
      encode (OS3Types.LockId "index-jobid_42")
        `shouldBe` "\"index-jobid_42\""
    it "decodes a bare JSON string into LockId" $
      decode "\"index-jobid_42\"" `shouldBe` Just (OS3Types.LockId "index-jobid_42")
    it "round-trips LockId through encode -> decode" $ do
      let original = OS3Types.LockId "round-trip-id"
      (decode . encode) original `shouldBe` Just original

  -- ===============================================================
  -- LocksResponse / LockEntry JSON (live-verified OS 3.7.0 shapes)
  -- ===============================================================
  describe "LocksResponse JSON" $ do
    it "decodes the verbatim two-lock fixture" $ do
      let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
      OS3Types.locksResponseTotalLocks resp `shouldBe` 2
      Map.size (OS3Types.locksResponseLocks resp) `shouldBe` 2
    it "exposes each lock's typed fields" $ do
      let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
          Just entry = OS3Types.lookupLockEntry ".opendistro-ism-config-delprobe_tid_1859609" resp
      OS3Types.lockEntryJobIndexName entry `shouldBe` Just ".opendistro-ism-config"
      OS3Types.lockEntryJobId entry `shouldBe` Just "delprobe_tid_1859609"
      OS3Types.lockEntryLockTime entry `shouldBe` Just 1782421825
      OS3Types.lockEntryLockDurationSeconds entry `shouldBe` Just 1800
      OS3Types.lockEntryReleased entry `shouldBe` Just True
      OS3Types.lockEntryExtras entry `shouldBe` Nothing
    it "lookupLockEntry returns Nothing for an absent id" $ do
      let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
      OS3Types.lookupLockEntry "nope" resp `shouldBe` Nothing
    it "decodes an empty envelope (valid-format but unknown id)" $ do
      let Just resp = decode "{ \"total_locks\": 0, \"locks\": {} }" :: Maybe OS3Types.LocksResponse
      OS3Types.locksResponseTotalLocks resp `shouldBe` 0
      OS3Types.locksResponseLocks resp `shouldBe` Map.empty
    it "defaults total_locks/locks when the keys are absent" $ do
      let Just resp = decode "{}" :: Maybe OS3Types.LocksResponse
      OS3Types.locksResponseTotalLocks resp `shouldBe` 0
      OS3Types.locksResponseLocks resp `shouldBe` Map.empty

  describe "LockEntry lenient decoding" $ do
    it "captures unrecognised fields in lockEntryExtras" $ do
      let Just (e :: OS3Types.LockEntry) =
            decode
              "{ \"job_id\": \"j1\", \
              \\"released\": false, \
              \\"custom_meta\": 7 }"
      OS3Types.lockEntryJobId e `shouldBe` Just "j1"
      OS3Types.lockEntryReleased e `shouldBe` Just False
      OS3Types.lockEntryExtras e `shouldSatisfy` isJust
    it "reads Nothing for every field of a minimal object" $ do
      let Just (e :: OS3Types.LockEntry) = decode "{}"
      OS3Types.lockEntryJobIndexName e `shouldBe` Nothing
      OS3Types.lockEntryLockTime e `shouldBe` Nothing

  -- ===============================================================
  -- JobsResponse / JobEntry JSON
  -- ===============================================================
  describe "JobsResponse JSON" $ do
    it "decodes the empty envelope" $ do
      let Just resp = decode sampleJobsEmptyJson :: Maybe OS3Types.JobsResponse
      OS3Types.jobsResponseTotalJobs resp `shouldBe` 0
      OS3Types.jobsResponseJobs resp `shouldBe` []
      OS3Types.jobsResponseFailures resp `shouldBe` []
    it "decodes a populated job, routing plugin-specific fields to extras" $ do
      let Just resp = decode sampleJobsPopulatedJson :: Maybe OS3Types.JobsResponse
      OS3Types.jobsResponseTotalJobs resp `shouldBe` 1
      length (OS3Types.jobsResponseJobs resp) `shouldBe` 1
      let job = head (OS3Types.jobsResponseJobs resp)
      OS3Types.jobEntryName job `shouldBe` Just "Snx1-J4BQHJI9IqNYJBj"
      OS3Types.jobEntryEnabled job `shouldBe` Just False
      OS3Types.jobEntryLockDurationSeconds job `shouldBe` Just 60
      OS3Types.jobEntryJobType job `shouldBe` Just "AD"
      OS3Types.jobEntryJobId job `shouldBe` Nothing
      -- window_delay / disabled_time are not known keys -> land in extras.
      let Just extras = OS3Types.jobEntryExtras job
      KeyMap.lookup "window_delay" extras `shouldSatisfy` isJust
      KeyMap.lookup "disabled_time" extras `shouldSatisfy` isJust
    it "defaults total_jobs/jobs/failures when the keys are absent" $ do
      let Just resp = decode "{}" :: Maybe OS3Types.JobsResponse
      OS3Types.jobsResponseTotalJobs resp `shouldBe` 0
      OS3Types.jobsResponseJobs resp `shouldBe` []

  describe "JobEntry _source wrapper tolerance" $
    it "decodes a job wrapped in a {_source: ...} search hit" $ do
      let wrapped =
            "{ \"_source\": { \"name\": \"wrapped\", \"enabled\": true } }"
          Just (e :: OS3Types.JobEntry) = decode wrapped
      OS3Types.jobEntryName e `shouldBe` Just "wrapped"
      OS3Types.jobEntryEnabled e `shouldBe` Just True

  -- ===============================================================
  -- JobSchedulerSchedule JSON
  -- ===============================================================
  describe "JobSchedulerSchedule JSON" $ do
    it "decodes the interval form" $ do
      let Just (s :: OS3Types.JobSchedulerSchedule) =
            decode "{ \"interval\": { \"period\": 1, \"unit\": \"Minutes\", \"start_time\": 42 } }"
          Just (OS3Types.JobSchedulerScheduleInterval i) = Just s
      OS3Types.jobSchedulerIntervalPeriod i `shouldBe` Just 1
      OS3Types.jobSchedulerIntervalUnit i `shouldBe` Just "Minutes"
      OS3Types.jobSchedulerIntervalStartTime i `shouldBe` Just 42
    it "decodes the cron form" $ do
      let Just (s :: OS3Types.JobSchedulerSchedule) =
            decode "{ \"cron\": { \"expression\": \"0 * * * *\", \"timezone\": \"UTC\" } }"
          Just (OS3Types.JobSchedulerScheduleCron c) = Just s
      OS3Types.jobSchedulerCronExpression c `shouldBe` Just "0 * * * *"
      OS3Types.jobSchedulerCronTimezone c `shouldBe` Just "UTC"
    it "decodes an unknown schedule kind via the Custom escape hatch" $ do
      let Just (s :: OS3Types.JobSchedulerSchedule) =
            decode "{ \"daily\": { \"at\": \"12:00\" } }"
      s `shouldSatisfy` (\case OS3Types.JobSchedulerScheduleCustom _ -> True; _ -> False)

  -- ===============================================================
  -- Round-trip symmetry
  -- ===============================================================
  describe "Round-trip symmetry" $ do
    it "JobsResponse round-trips through encode -> decode" $ do
      let Just original = decode sampleJobsPopulatedJson :: Maybe OS3Types.JobsResponse
      (decode . encode) original `shouldBe` Just original
    it "LocksResponse round-trips through encode -> decode" $ do
      let Just original = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
      (decode . encode) original `shouldBe` Just original

-- ---------------------------------------------------------------------------
-- Fixtures, quoted verbatim from a live OS 3.7.0 cluster.
-- ---------------------------------------------------------------------------

-- | @GET /_plugins/_job_scheduler/api/locks@ on a cluster running an ISM
-- probe job and an Anomaly Detection detector.
sampleLocksJson :: LBS.ByteString
sampleLocksJson =
  "{\
  \  \"total_locks\": 2,\
  \  \"locks\": {\
  \    \".opendistro-ism-config-delprobe_tid_1859609\": {\
  \      \"job_index_name\": \".opendistro-ism-config\",\
  \      \"job_id\": \"delprobe_tid_1859609\",\
  \      \"lock_time\": 1782421825,\
  \      \"lock_duration_seconds\": 1800,\
  \      \"released\": true\
  \    },\
  \    \".opendistro-anomaly-detector-jobs-Snx1-J4BQHJI9IqNYJBj\": {\
  \      \"job_index_name\": \".opendistro-anomaly-detector-jobs\",\
  \      \"job_id\": \"Snx1-J4BQHJI9IqNYJBj\",\
  \      \"lock_time\": 1782285322,\
  \      \"lock_duration_seconds\": 60,\
  \      \"released\": true\
  \    }\
  \  }\
  \}"

-- | @GET /_plugins/_job_scheduler/api/jobs@ on an idle scheduler (the
-- endpoint lists no jobs even though locks exist — see the type module
-- Haddock for the availability caveat).
sampleJobsEmptyJson :: LBS.ByteString
sampleJobsEmptyJson =
  "{\
  \  \"total_jobs\": 0,\
  \  \"jobs\": [],\
  \  \"failures\": []\
  \}"

-- | A populated jobs response. The @jobs@ element is reconstructed from
-- the @ScheduledJobParameter@ document stored in
-- @.opendistro-anomaly-detector-jobs@ (its @_source@, fetched via a
-- direct index search): the @window_delay@ / @disabled_time@ keys are
-- plugin-specific and exercise the @extras@ catch-all.
sampleJobsPopulatedJson :: LBS.ByteString
sampleJobsPopulatedJson =
  "{\
  \  \"total_jobs\": 1,\
  \  \"jobs\": [\
  \    {\
  \      \"name\": \"Snx1-J4BQHJI9IqNYJBj\",\
  \      \"schedule\": {\
  \        \"interval\": { \"start_time\": 1782284902663, \"period\": 1, \"unit\": \"Minutes\" }\
  \      },\
  \      \"enabled\": false,\
  \      \"enabled_time\": 1782284902663,\
  \      \"last_update_time\": 1782285322682,\
  \      \"lock_duration_seconds\": 60,\
  \      \"type\": \"AD\",\
  \      \"window_delay\": { \"period\": { \"interval\": 0, \"unit\": \"Seconds\" } },\
  \      \"disabled_time\": 1782285322682\
  \    }\
  \  ],\
  \  \"failures\": []\
  \}"