packages feed

mmzk-env-0.6.0.0: test/ValidateEnvSpec.hs

module ValidateEnvSpec (spec) where

import Data.Char (toUpper)
import Data.Env
import Data.Env.ExtractFields
import Data.Env.RecordParserW
import Data.Map qualified as M
import GHC.Generics
import System.Environment
import Test.Hspec

--------------------------------------------------------------------------------
-- Test records
--------------------------------------------------------------------------------

-- ^ Plain record for 'validateEnv' tests.
data PlainConfig = PlainConfig
  { plainHost  :: String
  , plainPort  :: Int
  , plainDebug :: Maybe Bool
  }
  deriving (Show, Eq, Generic, EnvSchema)

-- ^ Schema record for 'validateEnvW' tests.
data SchemaConfig c = SchemaConfig
  { schemaHost :: Col c String
  , schemaPort :: Col c Int
  , schemaDebug :: Col c Bool
  }
  deriving (Generic)

deriving stock instance Show (SchemaConfig 'Res)
deriving stock instance Eq (SchemaConfig 'Res)

instance EnvSchemaW (SchemaConfig 'Dec)

--------------------------------------------------------------------------------
-- camelToUpperSnake unit tests
--------------------------------------------------------------------------------

specCamelToUpperSnake :: Spec
specCamelToUpperSnake = describe "camelToUpperSnake" do
  it "converts simple camelCase" do
    camelToUpperSnake "plainHost" `shouldBe` "PLAIN_HOST"
  it "converts single-word lowercase" do
    camelToUpperSnake "host" `shouldBe` "HOST"
  it "leaves consecutive uppercase together" do
    camelToUpperSnake "myHTTPClient" `shouldBe` "MY_HTTPCLIENT"
  it "handles digit before uppercase" do
    camelToUpperSnake "http2Client" `shouldBe` "HTTP2_CLIENT"
  it "passes through literal underscores" do
    camelToUpperSnake "my_host" `shouldBe` "MY_HOST"
  it "handles leading underscore" do
    camelToUpperSnake "_host" `shouldBe` "_HOST"
  it "handles single character" do
    camelToUpperSnake "x" `shouldBe` "X"

--------------------------------------------------------------------------------
-- validateEnv tests
--------------------------------------------------------------------------------

specValidateEnv :: Spec
specValidateEnv = describe "validateEnv" do
  it "reads valid env vars" do
    setEnv "PLAIN_HOST" "localhost"
    setEnv "PLAIN_PORT" "8080"
    setEnv "PLAIN_DEBUG" "True"
    result <- validateEnv @PlainConfig
    unsetEnv "PLAIN_HOST"
    unsetEnv "PLAIN_PORT"
    unsetEnv "PLAIN_DEBUG"
    result `shouldBe` Right (PlainConfig "localhost" 8080 (Just True))

  it "succeeds with missing optional field" do
    setEnv "PLAIN_HOST" "localhost"
    setEnv "PLAIN_PORT" "3000"
    unsetEnv "PLAIN_DEBUG"
    result <- validateEnv @PlainConfig
    unsetEnv "PLAIN_HOST"
    unsetEnv "PLAIN_PORT"
    result `shouldBe` Right (PlainConfig "localhost" 3000 Nothing)

  it "fails when required var is missing" do
    setEnv "PLAIN_HOST" "localhost"
    unsetEnv "PLAIN_PORT"
    unsetEnv "PLAIN_DEBUG"
    result <- validateEnv @PlainConfig
    unsetEnv "PLAIN_HOST"
    case result of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

  it "fails on invalid port value" do
    setEnv "PLAIN_HOST" "localhost"
    setEnv "PLAIN_PORT" "not-a-number"
    unsetEnv "PLAIN_DEBUG"
    result <- validateEnv @PlainConfig
    unsetEnv "PLAIN_HOST"
    unsetEnv "PLAIN_PORT"
    case result of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

  it "collects errors from multiple fields" do
    setEnv "PLAIN_HOST" ""
    setEnv "PLAIN_PORT" "bad"
    setEnv "PLAIN_DEBUG" "bad"
    result <- validateEnv @PlainConfig
    unsetEnv "PLAIN_HOST"
    unsetEnv "PLAIN_PORT"
    unsetEnv "PLAIN_DEBUG"
    case result of
      Left (ParseError errs) -> length errs `shouldBe` 3
      Right _ -> expectationFailure "expected Left"

--------------------------------------------------------------------------------
-- validateEnvWith tests
--------------------------------------------------------------------------------

specValidateEnvWith :: Spec
specValidateEnvWith = describe "validateEnvWith" do
  it "uses custom mapping toUpper" do
    setEnv "PLAINHOST" "example.com"
    setEnv "PLAINPORT" "443"
    unsetEnv "PLAINDDEBUG"
    result <- validateEnvWith @PlainConfig (map toUpper)
    unsetEnv "PLAINHOST"
    unsetEnv "PLAINPORT"
    result `shouldBe` Right (PlainConfig "example.com" 443 Nothing)

--------------------------------------------------------------------------------
-- validateEnvFromMap / validateEnvFromMapWith tests
--------------------------------------------------------------------------------

specValidateEnvFromMap :: Spec
specValidateEnvFromMap = describe "validateEnvFromMap" do
  it "reads valid values from a simulated environment, no real env vars touched" do
    let simulatedEnv = M.fromList
          [ ("PLAIN_HOST", "localhost")
          , ("PLAIN_PORT", "8080")
          , ("PLAIN_DEBUG", "True")
          ]
    validateEnvFromMap @PlainConfig simulatedEnv
      `shouldBe` Right (PlainConfig "localhost" 8080 (Just True))

  it "succeeds with missing optional field" do
    let simulatedEnv = M.fromList [("PLAIN_HOST", "localhost"), ("PLAIN_PORT", "3000")]
    validateEnvFromMap @PlainConfig simulatedEnv
      `shouldBe` Right (PlainConfig "localhost" 3000 Nothing)

  it "fails when required var is missing" do
    let simulatedEnv = M.fromList [("PLAIN_HOST", "localhost")]
    case validateEnvFromMap @PlainConfig simulatedEnv of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

  it "collects errors from multiple fields" do
    let simulatedEnv = M.fromList
          [ ("PLAIN_HOST", "")
          , ("PLAIN_PORT", "bad")
          , ("PLAIN_DEBUG", "bad")
          ]
    case validateEnvFromMap @PlainConfig simulatedEnv of
      Left (ParseError errs) -> length errs `shouldBe` 3
      Right _ -> expectationFailure "expected Left"

  it "does not read from the real process environment" do
    setEnv "PLAIN_HOST" "real-host"
    let simulatedEnv = M.fromList [("PLAIN_HOST", "fake-host"), ("PLAIN_PORT", "1")]
    result <- pure $ validateEnvFromMap @PlainConfig simulatedEnv
    unsetEnv "PLAIN_HOST"
    result `shouldBe` Right (PlainConfig "fake-host" 1 Nothing)

specValidateEnvFromMapWith :: Spec
specValidateEnvFromMapWith = describe "validateEnvFromMapWith" do
  it "uses custom mapping toUpper" do
    let simulatedEnv = M.fromList [("PLAINHOST", "example.com"), ("PLAINPORT", "443")]
    validateEnvFromMapWith @PlainConfig (map toUpper) simulatedEnv
      `shouldBe` Right (PlainConfig "example.com" 443 Nothing)

--------------------------------------------------------------------------------
-- validateEnvW tests
--------------------------------------------------------------------------------

specValidateEnvW :: Spec
specValidateEnvW = describe "validateEnvW" do
  let schema :: SchemaConfig 'Dec
      schema = SchemaConfig
        { schemaHost = typeParser @String
        , schemaPort = typeParser @Int `orElse` 5432
        , schemaDebug = typeParser @Bool `orElse` False
        }

  it "reads valid env vars" do
    setEnv "SCHEMA_HOST" "localhost"
    setEnv "SCHEMA_PORT" "9090"
    setEnv "SCHEMA_DEBUG" "True"
    result <- validateEnvW schema
    unsetEnv "SCHEMA_HOST"
    unsetEnv "SCHEMA_PORT"
    unsetEnv "SCHEMA_DEBUG"
    result `shouldBe` Right (SchemaConfig "localhost" 9090 True)

  it "applies runtime defaults for missing vars" do
    setEnv "SCHEMA_HOST" "localhost"
    unsetEnv "SCHEMA_PORT"
    unsetEnv "SCHEMA_DEBUG"
    result <- validateEnvW schema
    unsetEnv "SCHEMA_HOST"
    result `shouldBe` Right (SchemaConfig "localhost" 5432 False)

  it "fails on invalid required field" do
    unsetEnv "SCHEMA_HOST"
    setEnv "SCHEMA_PORT" "8080"
    setEnv "SCHEMA_DEBUG" "True"
    result <- validateEnvW schema
    unsetEnv "SCHEMA_PORT"
    unsetEnv "SCHEMA_DEBUG"
    case result of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

  it "orElse does not swallow parse errors" do
    setEnv "SCHEMA_HOST" "localhost"
    setEnv "SCHEMA_PORT" "bad"
    setEnv "SCHEMA_DEBUG" "True"
    result <- validateEnvW schema
    unsetEnv "SCHEMA_HOST"
    unsetEnv "SCHEMA_PORT"
    unsetEnv "SCHEMA_DEBUG"
    case result of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

--------------------------------------------------------------------------------
-- validateEnvWFromMap / validateEnvWFromMapWith tests
--------------------------------------------------------------------------------

specValidateEnvWFromMap :: Spec
specValidateEnvWFromMap = describe "validateEnvWFromMap" do
  let schema :: SchemaConfig 'Dec
      schema = SchemaConfig
        { schemaHost = typeParser @String
        , schemaPort = typeParser @Int `orElse` 5432
        , schemaDebug = typeParser @Bool `orElse` False
        }

  it "reads valid values from a simulated environment" do
    let simulatedEnv = M.fromList
          [ ("SCHEMA_HOST", "localhost")
          , ("SCHEMA_PORT", "9090")
          , ("SCHEMA_DEBUG", "True")
          ]
    validateEnvWFromMap schema simulatedEnv `shouldBe` Right (SchemaConfig "localhost" 9090 True)

  it "applies runtime defaults for missing vars" do
    let simulatedEnv = M.fromList [("SCHEMA_HOST", "localhost")]
    validateEnvWFromMap schema simulatedEnv
      `shouldBe` Right (SchemaConfig "localhost" 5432 False)

  it "fails on invalid required field" do
    let simulatedEnv = M.fromList [("SCHEMA_PORT", "8080"), ("SCHEMA_DEBUG", "True")]
    case validateEnvWFromMap schema simulatedEnv of
      Left (ParseError errs) -> length errs `shouldBe` 1
      Right _ -> expectationFailure "expected Left"

specValidateEnvWFromMapWith :: Spec
specValidateEnvWFromMapWith = describe "validateEnvWFromMapWith" do
  it "uses custom mapping toUpper" do
    let schema :: SchemaConfig 'Dec
        schema = SchemaConfig
          { schemaHost = typeParser @String
          , schemaPort = typeParser @Int `orElse` 5432
          , schemaDebug = typeParser @Bool `orElse` False
          }
        simulatedEnv = M.fromList [("SCHEMAHOST", "example.com"), ("SCHEMAPORT", "443")]
    validateEnvWFromMapWith (map toUpper) schema simulatedEnv
      `shouldBe` Right (SchemaConfig "example.com" 443 False)

--------------------------------------------------------------------------------
-- validateEnvWDefault tests
--------------------------------------------------------------------------------

specValidateEnvWDefault :: Spec
specValidateEnvWDefault = describe "validateEnvWDefault" do
  it "auto-derives schema from TypeParser instances" do
    setEnv "SCHEMA_HOST" "auto"
    setEnv "SCHEMA_PORT" "7000"
    setEnv "SCHEMA_DEBUG" "True"
    result <- validateEnvWDefault @SchemaConfig
    unsetEnv "SCHEMA_HOST"
    unsetEnv "SCHEMA_PORT"
    unsetEnv "SCHEMA_DEBUG"
    result `shouldBe` Right (SchemaConfig "auto" 7000 True)

--------------------------------------------------------------------------------
-- validateEnvWDefaultFromMap tests
--------------------------------------------------------------------------------

specValidateEnvWDefaultFromMap :: Spec
specValidateEnvWDefaultFromMap = describe "validateEnvWDefaultFromMap" do
  it "auto-derives schema from TypeParser instances" do
    let simulatedEnv = M.fromList
          [ ("SCHEMA_HOST", "auto")
          , ("SCHEMA_PORT", "7000")
          , ("SCHEMA_DEBUG", "True")
          ]
    validateEnvWDefaultFromMap @SchemaConfig simulatedEnv
      `shouldBe` Right (SchemaConfig "auto" 7000 True)

--------------------------------------------------------------------------------
-- Top-level spec
--------------------------------------------------------------------------------

spec :: Spec
spec = do
  specCamelToUpperSnake
  specValidateEnv
  specValidateEnvWith
  specValidateEnvFromMap
  specValidateEnvFromMapWith
  specValidateEnvW
  specValidateEnvWFromMap
  specValidateEnvWFromMapWith
  specValidateEnvWDefault
  specValidateEnvWDefaultFromMap