packages feed

keiro-ops-0.24.0.0: test/CategoryConsumersSpec.hs

module CategoryConsumersSpec (spec) where

import Control.Exception (finally)
import Control.Monad (forM_, when)
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as Encoding
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy qualified as Lazy
import Data.IORef
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector qualified as Vector
import Keiro.Inspection.Consumers qualified as C
import Keiro.Inspection.Cursor
import Keiro.Ops qualified as Ops
import Keiro.Ops.Env
import Keiro.Ops.Projection qualified as P
import Keiro.Ops.Render
import Keiro.Ops.WorkflowHeaderCache
import Keiro.Test.Postgres
import Kiroku.Store.Connection (ConnectionSettingsM (..))
import Kiroku.Store.Effect (runStoreIO)
import Kiroku.Store.Read (getStream)
import Kiroku.Store.Types (StreamName (..))
import Options.Applicative qualified as Opt
import Test.Hspec

spec :: Fixture -> Spec
spec fixture = describe "category consumers commands" $ do
  it "mounts for either metadata hook and distinguishes empty from missing coverage" $ do
    let empty = registry []
        hooks = Ops.emptyAppHooks {Ops.durableConsumers = Just empty}
        args = ["projection", "consumers", "--category", "order"]
        parses mounted = case Opt.execParserPure Opt.defaultPrefs (Opt.info (Ops.commandParser mounted) mempty) args of Opt.Success _ -> True; _ -> False
    parses Ops.emptyAppHooks `shouldBe` False
    parses hooks `shouldBe` True
    Right (result, _) <- pure (P.prepareConsumers Nothing (Just empty) (options "order"))
    field "items" result.jsonValue `shouldBe` Aeson.toJSON ([] :: [Value])
    field "coverage" result.jsonValue `shouldBe` object ["projection_catalog" .= False, "durable_subscribers" .= True]
    P.isMutation (P.Consumers (options "order")) `shouldBe` False
  it "pages a deterministic sequence without omissions and binds tokens to category and inventory" $ do
    let mounted = registry [description "z" "s-z", description "a" "s-a", description "m" "s-m", description "a" "s-a2"]
        prepare request = P.prepareConsumers Nothing (Just mounted) request
        firstRequest = P.ConsumersOptions "order" 1 Nothing
    Right (first, _) <- pure (prepare firstRequest)
    names first `shouldBe` ["a"]
    let String token = field "next_cursor" first.jsonValue
    Right (second, _) <- pure (prepare firstRequest {P.after = Just (InspectionCursor token)})
    names second `shouldBe` ["m"]
    let String next = field "next_cursor" second.jsonValue
    Right (last, _) <- pure (prepare firstRequest {P.after = Just (InspectionCursor next)})
    names last `shouldBe` ["z"]
    field "next_cursor" last.jsonValue `shouldBe` Null
    forM_ [first, second, last] $ \result -> Text.length (renderHuman result) `shouldSatisfy` (> 0)
    let changed = registry [description "new" "s-new"]
    failureCode (prepare firstRequest {P.category = "invoice", P.after = Just (InspectionCursor token)}) `shouldBe` Just "invalid_cursor"
    failureCode (P.prepareConsumers Nothing (Just changed) firstRequest {P.after = Just (InspectionCursor token)}) `shouldBe` Just "invalid_cursor"
    forM_ [InspectionCursor "bad", InspectionCursor (Text.replicate 4097 "a"), encodeCursor "other-kind" ("x" :: Text)] $ \cursor ->
      P.validateCommand (P.Consumers firstRequest {P.after = Just cursor}) `shouldSatisfy` (\case Left ("invalid_cursor", _) -> True; _ -> False)
    forM_ [0, 501] $ \limit -> failureCode (prepare firstRequest {P.limit = limit}) `shouldBe` Just "invalid_page_size"
    forM_ ["", " order", "order "] $ \category -> failureCode (prepare firstRequest {P.category = category}) `shouldBe` Just "invalid_category"
    let huge = registry [description (Text.replicate 5000 "x") "one", description (Text.replicate 5001 "x") "two"]
    failureCode (P.prepareConsumers Nothing (Just huge) firstRequest) `shouldBe` Just "inspection_result_too_large"
  it "renders and directly encodes afresh without touching the store or header cache" $ do
    armed <- newIORef False
    let observe _ = readIORef armed >>= \active -> when active (fail "category consumers touched the store")
    withFreshResourceStoreWith fixture (\settings -> settings {observationHandler = Just observe}) $ \(unusable, _) ->
      flip finally (writeIORef armed False) $ do
        writeIORef armed True
        let mounted = registry [description "escaped-\"\\\n界" "subscription", C.DurableConsumerDescription C.ProcessManagerConsumer "fulfillment" "orders" (C.ExactCategory "order")]
            hooks = Ops.emptyAppHooks {Ops.durableConsumers = Just mounted}
            env = OpsEnv unusable HumanTable False [] False
            command = Ops.Projection (P.Consumers (options "order"))
        runStoreIO unusable (getStream (StreamName "sentinel")) `shouldThrow` anyIOException
        Succeeded result <- Ops.runCommand hooks env command
        Text.length (renderHuman result) `shouldSatisfy` (> 0)
        Text.length (jsonText result.jsonValue) `shouldSatisfy` (> 0)
        cache <- newWorkflowHeaderCache 1048576 (const (fail "category consumers used the header cache"))
        calls <- newIORef (0 :: Int)
        let encode encoding = modifyIORef' calls (+ 1) >> pure (Just (Lazy.toStrict (Encoding.encodingToLazyByteString encoding)))
        forM_ [1, 2 :: Int] $ \_ -> do
          (Succeeded direct, Just bytes) <- Ops.runCommandWithReadEncoding encode cache hooks env command
          direct `shouldBe` result
          Aeson.eitherDecodeStrict bytes `shouldBe` Right result.jsonValue
        readIORef calls `shouldReturn` 2
        workflowHeaderCacheUsage cache `shouldReturn` (0, 0)
        (refused, bytes) <- Ops.runCommandWithReadEncoding (const (pure Nothing)) cache hooks env command
        refused `shouldSatisfy` (\case InspectionFailed "inspection_result_too_large" _ -> True; _ -> False)
        bytes `shouldBe` Nothing
        forM_ [0, 501] $ \limit -> do
          invalid <- Ops.runCommand hooks env (Ops.Projection (P.Consumers (options "order") {P.limit = limit}))
          invalid `shouldSatisfy` (\case InspectionFailed "invalid_page_size" _ -> True; _ -> False)

options :: Text -> P.ConsumersOptions
options category = P.ConsumersOptions category 100 Nothing

description :: Text -> Text -> C.DurableConsumerDescription
description name subscription = C.DurableConsumerDescription C.DurableSubscriber name subscription (C.ExactCategory "order")

registry :: [C.DurableConsumerDescription] -> C.DurableConsumerRegistry
registry = either (error . show) id . C.durableConsumerRegistry

field :: Aeson.Key -> Value -> Value
field key (Object fields) = maybe Null id (KeyMap.lookup key fields)
field _ _ = Null

names :: OpsResult -> [Text]
names result = case field "items" result.jsonValue of
  Array values -> [name | String name <- map (field "name") (Vector.toList values)]
  _ -> []

failureCode :: Either (Text, Text) a -> Maybe Text
failureCode (Left (code, _)) = Just code
failureCode _ = Nothing