packages feed

jev-0.1.0.0: test/JevSpec.hs

module JevSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Exception (AsyncException (ThreadKilled), throwIO, toException, try)
import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy qualified as LBS
import Data.IORef
import Data.IntMap.Strict qualified as IM
import Data.Text (Text)
import Data.Text qualified as T
import Jev
import Network.HTTP.Client (defaultManagerSettings, newManager)
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types
import Network.Wai hiding (Response, requestBody)
import Network.Wai.Handler.Warp (testWithApplication)
import System.Timeout qualified as Timeout
import Test.Hspec

data Team = Billing | Technical deriving (Eq, Show)

route :: Question (Choice Team)
route = choice "Route this" [Option Billing "billing" Nothing, Option Technical "technical" (Just "Bugs")]

choiceAnswer :: Value
choiceAnswer = object ["type" .= ("choice" :: Text), "choice" .= ("billing" :: Text), "confidence" .= (0.9 :: Double), "probabilities" .= object ["billing" .= (0.95 :: Double), "technical" .= (0.05 :: Double)]]

scoreAnswer :: Value
scoreAnswer = object ["type" .= ("score" :: Text), "score" .= (1.4 :: Double), "confidence" .= (0.6 :: Double), "probabilities" .= object ["1" .= (0.6 :: Double), "2" .= (0.4 :: Double)], "legend" .= object ["1" .= ("Today" :: Text), "2" .= ("Now" :: Text)]]

noulAnswer :: Double -> Value
noulAnswer p = object ["type" .= ("noul" :: Text), "noul" .= p]

envelope :: Value -> Value
envelope answers = object ["model" .= ("test-model" :: Text), "answers" .= answers, "usage" .= object ["input_tokens" .= (12 :: Int), "output_tokens" .= (3 :: Int)]]

serve :: Status -> LBS.ByteString -> Application
serve status body _ respond = respond (responseLBS status [(hContentType, "application/json")] body)

withServer :: Application -> (Client -> IO a) -> IO a
withServer application action = testWithApplication (pure application) $ \port ->
  withClient ((defaultConfig TypeSafe "test-key") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port) <> "/v1/systemone")}) action

answer :: Value -> Question a -> IO (Either JevError (Response a))
answer value question = withServer (serve status200 (encode (envelope value))) $ \client -> decide client "A prompt" question

isDecodeError :: Either JevError a -> Bool
isDecodeError (Left (DecodeError _)) = True
isDecodeError (Left (ResponseDecodeError _ _)) = True
isDecodeError _ = False

isValidationError :: Either JevError a -> Bool
isValidationError (Left (ValidationError _)) = True
isValidationError _ = False

spec :: Spec
spec = do
  describe "pure preparation and validation" $ do
    it "validates without credentials and identifies invalid composed questions" $ do
      validateQuestion TypeSafe route `shouldBe` Right ()
      validateQuestion TypeSafe ((,) <$> route <*> score "Bad rubric" ["Only"])
        `shouldBe` Left (ValidationError "q1 (score): Score requires 2 to 10 levels")
      validateQuestion TypeSafe (pure ()) `shouldSatisfy` isValidationError
    it "exposes the encoded request and decodes fixtures without a server" $ do
      case prepareRequest TypeSafe "test-model" (String "State") route of
        Left err -> expectationFailure (show err)
        Right prepared -> do
          case eitherDecode (requestBody prepared) of
            Right (Object body) -> do
              KM.lookup "state" body `shouldBe` Just (String "State")
              KM.lookup "model" body `shouldBe` Just (String "test-model")
            other -> expectationFailure (show (other :: Either String Value))
          fmap (\r -> r.answers.choice) (decodeResponse prepared (encode (envelope (object ["q0" .= choiceAnswer])))) `shouldBe` Right Billing
          decodeResponse prepared "not json" `shouldSatisfy` isDecodeError
    it "encodes OpenRouter settings and rejects incompatible settings locally" $ do
      let options = defaultRequestOptions {providerRouting = Just (object ["allow_fallbacks" .= False]), sessionId = Just "session", trace = Just (object ["trace_id" .= ("trace" :: Text)]), user = Just "user"}
      case prepareRequestWith OpenRouter options "model" (String "State") route of
        Left err -> expectationFailure (show err)
        Right prepared -> case eitherDecode (requestBody prepared) of
          Right (Object body) -> do
            KM.lookup "provider" body `shouldBe` options.providerRouting
            KM.lookup "session_id" body `shouldBe` Just (String "session")
            KM.lookup "trace" body `shouldBe` options.trace
            KM.lookup "user" body `shouldBe` Just (String "user")
          other -> expectationFailure (show (other :: Either String Value))
      let validateOptions provider settings = (() <$ prepareRequestWith provider settings "model" (String "State") route)
      validateOptions TypeSafe options `shouldSatisfy` isValidationError
      validateOptions OpenRouter (options {sessionId = Just (T.replicate 257 "x")}) `shouldSatisfy` isValidationError
      validateOptions OpenRouter (options {user = Just (T.replicate 257 "x")}) `shouldSatisfy` isValidationError
      validateOptions OpenRouter (options {providerRouting = Just Null}) `shouldSatisfy` isValidationError
      validateOptions OpenRouter (options {trace = Just (Bool True)}) `shouldSatisfy` isValidationError
    it "maps answer values without losing metadata or distribution entries" $ do
      let original = Response (Choice Billing 0.9 [(Billing, 0.95), (Technical, 0.05)]) "model" (Usage Nothing Nothing Nothing) (Just "id") Nothing
          mapped = fmap (fmap show) original
      mapped.answers `shouldBe` Choice "Billing" 0.9 [("Billing", 0.95), ("Technical", 0.05)]
      mapped.requestId `shouldBe` original.requestId
      mapped.usage `shouldBe` original.usage
  describe "typed results" $ do
    it "maps wire labels and probabilities to domain constructors" $ do
      result <- answer (object ["q0" .= choiceAnswer]) route
      fmap (\r -> r.answers) result `shouldBe` Right (Choice Billing 0.9 [(Billing, 0.95), (Technical, 0.05)])
    it "composes all primitives into one request independent of answer order" $ do
      result <- answer (object ["q2" .= noulAnswer 0.8, "q0" .= choiceAnswer, "q1" .= scoreAnswer]) ((,,) <$> route <*> score "Urgency" ["Routine", "Today", "Now"] <*> noul "Human?")
      fmap (\r -> let (c, s, n) = r.answers in (c.choice, s.score, s.probabilities, n.probability)) result
        `shouldBe` Right (Billing, 1.4, IM.fromList [(1, 0.6), (2, 0.4)], 0.8)
    it "supports nested composition and runtime collections" $ do
      result <- answer (object ["q0" .= noulAnswer 0.1, "q1" .= noulAnswer 0.9]) ((,) <$> pure "tag" <*> traverse noul ["One", "Two"])
      fmap (\r -> r.answers) result `shouldBe` Right ("tag" :: Text, [Noul 0.1, Noul 0.9])
    it "retains provider metadata and optional usage fields" $ do
      let body = object ["answers" .= object ["q0" .= noulAnswer 1], "model" .= ("typesafe/jev-1.13" :: Text), "id" .= ("request-1" :: Text), "provider" .= ("TypeSafe" :: Text), "usage" .= object ["cost" .= (0.001 :: Double)], "future" .= True]
      result <- withServer (serve status200 (encode body)) $ \client -> decide client "State" (noul "Yes?")
      fmap (\r -> (r.requestId, r.provider, r.usage)) result `shouldBe` Right (Just "request-1", Just "TypeSafe", Usage Nothing Nothing (Just 0.001))
  describe "documented structured content" $ do
    it "accepts TypeSafe null instructions and nullable Choice and Noul descriptions" $ do
      captured <- newIORef Nothing
      let levels = [object ["meaning" .= ("Routine" :: Text)], toJSON (["Urgent"] :: [Text])]
          scoreValue = object ["type" .= ("score" :: Text), "score" .= (1 :: Int), "confidence" .= (1 :: Int), "probabilities" .= object ["1" .= (1 :: Int)], "legend" .= object ["0" .= headLevel, "1" .= toJSON (["Urgent"] :: [Text])]]
          headLevel = object ["meaning" .= ("Routine" :: Text)]
          app request respond = do
            body <- strictRequestBody request
            writeIORef captured (decode body :: Maybe Value)
            serve status200 (encode (envelope (object ["q0" .= choiceAnswer, "q1" .= scoreValue, "q2" .= noulAnswer 0.9]))) request respond
          questions =
            (,,)
              <$> choiceJSON Null [JsonOption Billing "billing" (Just Null), JsonOption Technical "technical" Nothing]
              <*> scoreJSON Null levels
              <*> noulJSON Null (Just (NoulCriteria (String "Needs help") Null))
      result <- withServer app $ \client -> decideJSON client (toJSON (["Please help", "Duplicate payment"] :: [Text])) questions
      fmap (\r -> let (_, rating, _) = r.answers in rating.legend) result `shouldBe` Right (IM.fromList (zip [0 ..] levels))
      body <- readIORef captured
      case body of
        Just (Object o) -> case KM.lookup "questions" o of
          Just (Object qs) -> do
            KM.lookup "q0" qs `shouldBe` Just (object ["type" .= ("choice" :: Text), "instructions" .= Null, "criteria" .= object ["billing" .= Null, "technical" .= Null]])
            KM.lookup "q2" qs `shouldBe` Just (object ["type" .= ("noul" :: Text), "instructions" .= Null, "criteria" .= object ["true" .= ("Needs help" :: Text), "false" .= Null]])
          other -> expectationFailure (show other)
        other -> expectationFailure (show other)
    it "rejects null Score levels and invalid legend values" $ do
      withServer (serve status500 "must not be called") $ \client ->
        decide client "State" (scoreJSON (String "Urgency") [Null, String "High"]) >>= (`shouldSatisfy` isValidationError)
      let malformed = object ["type" .= ("score" :: Text), "score" .= (0 :: Int), "confidence" .= (1 :: Int), "probabilities" .= object ["0" .= (1 :: Int)], "legend" .= object ["0" .= True]]
      answer (object ["q0" .= malformed]) (score "Urgency" ["Low", "High"]) >>= (`shouldSatisfy` isDecodeError)
    it "rejects TypeSafe-only null fields before sending to OpenRouter" $ do
      calls <- newIORef (0 :: Int)
      let app request respond = modifyIORef' calls (+ 1) >> serve status500 "unexpected" request respond
      testWithApplication (pure app) $ \port ->
        withClient ((defaultConfig OpenRouter "key") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port))}) $ \client -> do
          decide client "State" (noulJSON Null Nothing) >>= (`shouldSatisfy` isValidationError)
          decide client "State" (noulJSON (String "Help?") (Just (NoulCriteria (String "Yes") Null))) >>= (`shouldSatisfy` isValidationError)
      readIORef calls `shouldReturn` 0
    it "accepts explicit null Choice descriptions through OpenRouter" $ do
      testWithApplication (pure (serve status200 (encode (envelope (object ["q0" .= choiceAnswer]))))) $ \port ->
        withClient ((defaultConfig OpenRouter "key") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port))}) $ \client -> do
          result <- decide client "State" (choiceJSON (String "Team?") [JsonOption Billing "billing" (Just Null), JsonOption Technical "technical" Nothing])
          fmap (\r -> r.answers.choice) result `shouldBe` Right Billing
    it "reads TypeSafe request IDs from headers and preserves gateway body IDs" $ do
      let bodyWithId = object ["model" .= ("test-model" :: Text), "usage" .= object [], "answers" .= object ["q0" .= noulAnswer 1], "id" .= ("gateway-id" :: Text)]
          app body _ respond = respond (responseLBS status200 [("x-typesafe-request-id", "direct-id")] (encode body))
      direct <- withServer (app (envelope (object ["q0" .= noulAnswer 1]))) $ \client -> decide client "State" (noul "Yes?")
      fmap (\r -> r.requestId) direct `shouldBe` Right (Just "direct-id")
      gateway <- withServer (app bodyWithId) $ \client -> decide client "State" (noul "Yes?")
      fmap (\r -> r.requestId) gateway `shouldBe` Right (Just "gateway-id")
  describe "validation" $ do
    it "rejects invalid questions before contacting the server" $ do
      calls <- newIORef (0 :: Int)
      let app request respond = modifyIORef' calls (+ 1) >> serve status500 "unexpected" request respond
      withServer app $ \client -> do
        decide client "State" (choice "Empty" ([] :: [Option Team])) >>= (`shouldSatisfy` isValidationError)
        decide client "State" (choice "Duplicate" [Option Billing "same" Nothing, Option Technical "same" Nothing]) >>= (`shouldSatisfy` isValidationError)
        decide client "State" (choice "Too many" [Option Billing (T.pack (show i)) Nothing | i <- [1 .. 256 :: Int]]) >>= (`shouldSatisfy` isValidationError)
        decide client "State" (score "Short" ["Only"]) >>= (`shouldSatisfy` isValidationError)
        decide client "State" (score "Long" (replicate 11 "Level")) >>= (`shouldSatisfy` isValidationError)
        decide client "State" (pure True) >>= (`shouldSatisfy` isValidationError)
        decideJSON client Null (noul "Yes?") >>= (`shouldSatisfy` isValidationError)
        decide client "State" (noulJSON (Bool True) Nothing) >>= (`shouldSatisfy` isValidationError)
      readIORef calls `shouldReturn` 0
  describe "response failures" $ do
    it "rejects missing answers and incorrect primitive tags" $ do
      answer (object []) route >>= (`shouldSatisfy` isDecodeError)
      answer (object ["q0" .= noulAnswer 0.5]) route >>= (`shouldSatisfy` isDecodeError)
    it "rejects unknown choices, missing probabilities, and invalid values" $ do
      let replace key value (Object o) = Object (KM.insert key value o)
          replace _ _ v = v
      answer (object ["q0" .= replace "choice" (String "unknown") choiceAnswer]) route >>= (`shouldSatisfy` isDecodeError)
      answer (object ["q0" .= replace "probabilities" (object []) choiceAnswer]) route >>= (`shouldSatisfy` isDecodeError)
      answer (object ["q0" .= noulAnswer 1.1]) (noul "Yes?") >>= (`shouldSatisfy` isDecodeError)
      answer (object ["q0" .= replace "score" (Number 3) scoreAnswer]) (score "Urgency" ["Low", "High"]) >>= (`shouldSatisfy` isDecodeError)
      answer (object ["q0" .= replace "probabilities" (object ["9" .= (1 :: Int)]) scoreAnswer]) (score "Urgency" ["Low", "Medium", "High"]) >>= (`shouldSatisfy` isDecodeError)
    it "reports malformed JSON and HTTP status bodies" $ do
      withServer (serve status200 "not json") (\client -> decide client "State" (noul "Yes?")) >>= (`shouldSatisfy` isDecodeError)
      mapM_
        ( \status -> do
            result <- withServer (serve status "provider error") (\client -> decide client "State" (noul "Yes?"))
            case result of
              Left (HttpError metadata body) -> do
                metadata.statusCode `shouldBe` statusCode status
                body `shouldBe` "provider error"
              other -> expectationFailure (show other)
        )
        [status401, status422, status429, mkStatus 529 "Overloaded"]
    it "rejects contradictory distributions while accepting rounding and sparse rubrics" $ do
      let replace key value (Object o) = Object (KM.insert key value o)
          replace _ _ v = v
          decodeFixture question value = do
            prepared <- prepareRequest TypeSafe "model" (String "State") question
            decodeResponse prepared (encode (envelope (object ["q0" .= value])))
      decodeFixture route (replace "probabilities" (object ["billing" .= (0 :: Int), "technical" .= (0 :: Int)]) choiceAnswer) `shouldSatisfy` isDecodeError
      decodeFixture route (replace "choice" (String "technical") choiceAnswer) `shouldSatisfy` isDecodeError
      fmap (\r -> r.answers.choice) (decodeFixture route (replace "probabilities" (object ["billing" .= (0.9501 :: Double), "technical" .= (0.05 :: Double)]) choiceAnswer)) `shouldBe` Right Billing
      let rubric = score "Urgency" ["Routine", "Today", "Now"]
      decodeFixture rubric (replace "score" (Number 0) scoreAnswer) `shouldSatisfy` isDecodeError
      decodeFixture rubric (replace "legend" (object []) scoreAnswer) `shouldSatisfy` isDecodeError
      fmap (\r -> r.answers.probabilities) (decodeFixture rubric scoreAnswer) `shouldBe` Right (IM.fromList [(1, 0.6), (2, 0.4)])
    it "retains retry headers and request IDs on HTTP and decoding failures" $ do
      let app status body _ respond = respond (responseLBS status [("Retry-After", "7"), ("x-typesafe-request-id", "header-id")] body)
      failed <- withServer (app status429 "limited") $ \client -> decide client "State" (noul "Yes?")
      case failed of
        Left (HttpError metadata body) -> do
          metadata.statusCode `shouldBe` 429
          metadata.requestId `shouldBe` Just "header-id"
          lookup "Retry-After" metadata.headers `shouldBe` Just "7"
          body `shouldBe` "limited"
        other -> expectationFailure (show other)
      malformed <- withServer (app status200 "not json") $ \client -> decide client "State" (noul "Yes?")
      case malformed of
        Left (ResponseDecodeError metadata _) -> metadata.requestId `shouldBe` Just "header-id"
        other -> expectationFailure (show other)
      gateway <- withServer (app status200 "{\"id\":\"body-id\"}") $ \client -> decide client "State" (noul "Yes?")
      case gateway of
        Left (ResponseDecodeError metadata _) -> metadata.requestId `shouldBe` Just "body-id"
        other -> expectationFailure (show other)
  describe "HTTP transport" $ do
    it "sends one authenticated request with structured inputs" $ do
      requests <- newIORef []
      let app request respond = do
            body <- strictRequestBody request
            modifyIORef' requests ((requestMethod request, rawPathInfo request, requestHeaders request, either (Left . T.pack) Right (eitherDecode body) :: Either Text Value) :)
            serve status200 (encode (envelope (object ["q0" .= choiceAnswer, "q1" .= noulAnswer 0.7]))) request respond
          instructions = object ["question" .= ("Route" :: Text)]
          question = (,) <$> choiceJSON instructions [JsonOption Billing "billing" (Just instructions), JsonOption Technical "technical" Nothing] <*> noulJSON (String "Human?") (Just (NoulCriteria instructions (String "No")))
      withServer app $ \client -> do
        result <- decideJSON client (object ["ticket" .= ("Broken" :: Text)]) question
        fmap (\r -> (fst r.answers).choice) result `shouldBe` Right Billing
      captured <- readIORef requests
      case captured of
        [(method, path, headers, Right (Object body))] -> do
          method `shouldBe` "POST"
          path `shouldBe` "/v1/systemone"
          lookup hAuthorization headers `shouldBe` Just "Bearer test-key"
          lookup hContentType headers `shouldBe` Just "application/json"
          KM.lookup "state" body `shouldBe` Just (object ["ticket" .= ("Broken" :: Text)])
          KM.lookup "model" body `shouldBe` Just (String "jev-latest")
          case KM.lookup "questions" body of
            Just (Object qs) -> do
              KM.size qs `shouldBe` 2
              KM.lookup "q0" qs `shouldBe` Just (object ["type" .= ("choice" :: Text), "instructions" .= instructions, "criteria" .= object ["billing" .= instructions, "technical" .= Null]])
              KM.lookup "q1" qs `shouldBe` Just (object ["type" .= ("noul" :: Text), "instructions" .= ("Human?" :: Text), "criteria" .= object ["true" .= instructions, "false" .= ("No" :: Text)]])
            other -> expectationFailure (show other)
        other -> expectationFailure (show other)
    it "supports OpenRouter configuration and a caller-owned manager" $ do
      let app request respond = do
            rawPathInfo request `shouldBe` "/api/alpha/decisions"
            body <- strictRequestBody request
            case either (Left . T.pack) Right (eitherDecode body) of
              Right (Object o) -> KM.lookup "model" o `shouldBe` Just (String "typesafe/jev-1.13")
              other -> expectationFailure (show (other :: Either Text Value))
            serve status200 (encode (envelope (object ["q0" .= noulAnswer 0.2]))) request respond
      testWithApplication (pure app) $ \port -> do
        manager <- newManager defaultManagerSettings
        let config = (defaultConfig OpenRouter "router-key") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port) <> "/api/alpha/decisions")}
            client = clientWithManager config manager
        closeClient client
        result <- decide client "State" (noul "Yes?")
        fmap (\r -> r.answers) result `shouldBe` Right (Noul 0.2)
    it "returns a timeout without exposing credentials" $ do
      let app request respond = threadDelay 200000 >> serve status200 "{}" request respond
      testWithApplication (pure app) $ \port ->
        withClient ((defaultConfig TypeSafe "secret") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port)), timeoutMicros = 10000}) $ \client -> do
          result <- decide client "State" (noul "Yes?")
          result `shouldBe` Left (TransportError DeadlineExceeded)

    it "enforces the deadline after response headers have arrived" $ do
      let app _ respond =
            respond
              ( responseStream
                  status200
                  []
                  ( \write flush -> do
                      write " "
                      flush
                      threadDelay 2000000
                      write "{}"
                  )
              )
      testWithApplication (pure app) $ \port ->
        withClient ((defaultConfig TypeSafe "secret") {endpoint = Just ("http://127.0.0.1:" <> T.pack (show port)), timeoutMicros = 50000}) $ \client -> do
          result <- Timeout.timeout 1000000 (decide client "State" (noul "Yes?"))
          result `shouldBe` Just (Left (TransportError DeadlineExceeded))

    it "categorizes manager failures without retaining request contents" $ do
      let cases = [(HTTP.ConnectionFailure (toException (userError "secret")), ConnectionFailed), (HTTP.InternalException (toException (userError "secret")), InternalTransportFailure), (HTTP.ResponseBodyTooShort 10 2, InvalidResponse)]
      mapM_
        ( \(failure, expected) -> do
            manager <- newManager (defaultManagerSettings {HTTP.managerModifyRequest = \request -> throwIO (HTTP.HttpExceptionRequest request failure)})
            decide (clientWithManager (defaultConfig TypeSafe "secret") manager) "private" (noul "Yes?") `shouldReturn` Left (TransportError expected)
        )
        cases

    it "selects the documented provider URLs without endpoint overrides" $ do
      let app = serve status200 (encode (envelope (object ["q0" .= noulAnswer 0.5])))
      testWithApplication (pure app) $ \port -> do
        forProviders port [(TypeSafe, "api.typesafe.ai", "/v1/systemone"), (OpenRouter, "openrouter.ai", "/api/alpha/decisions")]
    it "closes owned clients idempotently" $ do
      client <- newClient (defaultConfig TypeSafe "test-key")
      closeClient client
      closeClient client
      decide client "State" (noul "Yes?") `shouldReturn` Left (ValidationError "Client is closed")
    it "propagates asynchronous cancellation" $ do
      manager <- newManager (defaultManagerSettings {HTTP.managerModifyRequest = \_ -> throwIO ThreadKilled})
      let client = clientWithManager (defaultConfig TypeSafe "test-key") manager
      result <- try (decide client "State" (noul "Yes?")) :: IO (Either AsyncException (Either JevError (Response Noul)))
      result `shouldBe` Left ThreadKilled
  where
    forProviders port = mapM_ $ \(provider, host, path) -> do
      observed <- newIORef []
      manager <-
        newManager
          ( defaultManagerSettings
              { HTTP.managerModifyRequest = \request -> do
                  modifyIORef' observed ((HTTP.host request, HTTP.path request, HTTP.secure request) :)
                  pure request {HTTP.host = "127.0.0.1", HTTP.port = port, HTTP.secure = False, HTTP.proxy = Nothing}
              }
          )
      result <- decide (clientWithManager (defaultConfig provider "test-key") manager) "State" (noul "Yes?")
      fmap (\r -> r.answers) result `shouldBe` Right (Noul 0.5)
      requests <- readIORef observed
      take 1 (reverse requests) `shouldBe` [(host, path, True)]