packages feed

figment-0.2.0.0: test/Figment/Test/Decode.hs

{-# LANGUAGE DerivingVia #-}
-- Sum types with record payloads are under test here, deliberately.
{-# OPTIONS_GHC -Wno-partial-fields #-}

-- | The native decoder.
module Figment.Test.Decode (tests) where

import qualified Data.Aeson as Aeson
import Data.Function ((&))
import Data.Int (Int8)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time (Day, fromGregorian)
import Data.Word (Word16)
import GHC.Generics (Generic)
import Test.Tasty
import Test.Tasty.HUnit

import Figment
import qualified Figment.Jail as Jail
import qualified Figment.Provider.Env as Env

data Server = Server {host :: Text, port :: Word16, tags :: [Text]}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

data Level = Debug | Info | Warn
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

data Shape = Circle {radius :: Double} | Square {side :: Double} | Dot
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

newtype Port = Port Int
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

data Pair = Pair Int Text
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

data Strict = Strict {maxConnections :: Int, timeoutSecs :: Int}
  deriving stock (Eq, Show, Generic)

instance FromConfig Strict where
  fromConfig = genericDecoder defaultOptions {fieldLabelModifier = snakeCase, rejectUnknownFields = True}

data Opt = Opt {required' :: Int, optional' :: Maybe Int, withDefault :: Int}
  deriving stock (Eq, Show, Generic)

instance FromConfig Opt where
  fromConfig = Opt <$> field "required" <*> optionalField "optional" <*> fieldOr "with_default" 42

newtype Legacy = Legacy (Map.Map Text Int)
  deriving stock (Eq, Show)
  deriving newtype (Aeson.FromJSON)
  deriving (FromConfig) via (ViaAeson Legacy)

data WithTagged = WithTagged {secret :: Tagged Text, dir :: RelativePath}
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromConfig)

loadText :: Data -> IO Loaded
loadText d = load (from d) >>= either (assertFailure . Text.unpack . renderErrors) pure

decodeJ :: FromConfig a => Text -> IO (Either ConfigErrors a)
decodeJ t = extract <$> loadText (fromText json t)

paths :: ConfigErrors -> [Text]
paths = map (renderPath . errorPath) . errorList

tests :: TestTree
tests =
  testGroup
    "decode"
    [ testCase "records, lists, bounded ints" $ do
        r <- decodeJ "{\"host\":\"h\",\"port\":8080,\"tags\":[\"a\",\"b\"]}"
        r @?= Right (Server "h" 8080 ["a", "b"])
    , testCase "REGRESSION C3: the error points at the failing field, not the first key" $ do
        r <- decodeJ "{\"host\":\"h\",\"port\":\"bad\",\"tags\":[]}" :: IO (Either ConfigErrors Server)
        either paths (const []) r @?= ["port"]
    , testCase "all errors are reported at once, each with its path" $ do
        r <- decodeJ "{\"port\":70000,\"tags\":[\"ok\", 5]}" :: IO (Either ConfigErrors Server)
        case r of
          Left es -> do
            paths es @?= ["host", "port", "tags[1]"]
            map errorKind (errorList es)
              @?= [ MissingField "host"
                  , InvalidValue "integer 70000" "an integer in the range 0..65535"
                  , InvalidType "integer 5" "a string"
                  ]
          Right v -> assertFailure (show v)
    , testCase "errors carry the source, line and column" $ do
        l <- loadText (fromText toml "host = \"h\"\nport = \"x\"\ntags = []\n")
        case extract l :: Either ConfigErrors Server of
          Left es -> case errorList es of
            [e] -> do
              errorPos e @?= Just (Pos 2 8)
              fmap metaName (errorMetadata e) @?= Just "TOML source string"
            other -> assertFailure (show other)
          Right v -> assertFailure (show v)
    , testCase "out of range never wraps" $ do
        r <- decodeJ "{\"n\":300}" :: IO (Either ConfigErrors (Map.Map Text Int8))
        either (map errorKind . errorList) (const []) r @?= [InvalidValue "integer 300" "an integer in the range -128..127"]
    , testCase "non-integers are not truncated" $ do
        r <- decodeJ "{\"n\":1.5}" :: IO (Either ConfigErrors (Map.Map Text Int))
        either (map errorKind . errorList) (const []) r @?= [InvalidValue "number 1.5" "an integer"]
    , testCase "enums, case-insensitively; unknown variants list the options" $ do
        r <- decodeJ "{\"a\":\"Debug\",\"b\":\"warn\"}"
        r @?= Right (Map.fromList [("a" :: Text, Debug), ("b", Warn)])
        bad <- decodeJ "{\"a\":\"verbose\"}" :: IO (Either ConfigErrors (Map.Map Text Level))
        either (map errorKind . errorList) (const []) bad @?= [UnknownVariant "verbose" ["Debug", "Info", "Warn"]]
    , testCase "sum types are externally tagged" $ do
        r <- decodeJ "{\"xs\":[{\"Circle\":{\"radius\":1}}, {\"Square\":{\"side\":2}}, \"Dot\"]}"
        r @?= Right (Map.fromList [("xs" :: Text, [Circle 1, Square 2, Dot])])
    , testCase "newtypes unwrap, positional constructors are arrays" $ do
        p <- decodeJ "{\"p\":80}"
        p @?= Right (Map.fromList [("p" :: Text, Port 80)])
        q <- decodeJ "{\"q\":[1,\"x\"]}"
        q @?= Right (Map.fromList [("q" :: Text, Pair 1 "x")])
    , testCase "snake_case options and unknown-field suggestions" $ do
        ok <- decodeJ "{\"max_connections\":1,\"timeout_secs\":2}"
        ok @?= Right (Strict 1 2)
        bad <- decodeJ "{\"max_conections\":1,\"timeout_secs\":2}" :: IO (Either ConfigErrors Strict)
        case bad of
          Left es -> map errorKind (errorList es) @?= [UnknownField "max_conections" ["max_connections"], MissingField "max_connections"]
          Right v -> assertFailure (show v)
    , testCase "hand-written decoders: optional and defaulted fields" $ do
        r <- decodeJ "{\"required\":1}"
        r @?= Right (Opt 1 Nothing 42)
    , testCase "missing-field hints name every way to set it" $ do
        l <- load (new & merge (fromText json "{}") & merge (Env.fromList [] (prefixed "APP_") & Env.split "__") & merge (cli []))
               >>= either (assertFailure . show) pure
        case extract l :: Either ConfigErrors Server of
          Left es -> case filter ((== MissingField "port") . errorKind) (errorList es) of
            [e] -> do
              let h = Text.unlines (errorHints e)
              assertBool (Text.unpack h) ("`APP_PORT`" `Text.isInfixOf` h && "`--port`" `Text.isInfixOf` h)
            other -> assertFailure (show other)
          Right v -> assertFailure (show v)
    , testCase "lossy extraction" $ do
        l <- loadText (fromText json "{\"host\":5,\"port\":\"8080\",\"tags\":[]}")
        case extract l :: Either ConfigErrors Server of
          Left es -> length (errorList es) @?= 2
          Right v -> assertFailure (show v)
        extractLossy l @?= Right (Server "5" 8080 [])
    , testCase "Tagged and RelativePath know their source" $ withTmpToml $ \path -> do
        l <- load (from (fileExact toml path)) >>= either (assertFailure . show) pure
        case extract l of
          Right (WithTagged s d) -> do
            taggedValue s @?= "x"
            taggedPath s @?= keyPath "secret"
            fmap metaSource (taggedMetadata s) @?= Just (Just (FileSource path))
            relative d @?= dirOf path <> "/data"
          Left e -> assertFailure (Text.unpack (renderErrors e))
    , testCase "ISO dates, NonEmpty, tuples" $ do
        r <- decodeJ "{\"d\":\"2024-02-29\"}"
        r @?= Right (Map.fromList [("d" :: Text, fromGregorian 2024 2 29 :: Day)])
        ne <- decodeJ "{\"xs\":[]}" :: IO (Either ConfigErrors (Map.Map Text (NonEmpty Int)))
        either (map errorKind . errorList) (const []) ne @?= [InvalidLength 0 "a non-empty array"]
        t <- decodeJ "{\"t\":[1,\"a\"]}"
        t @?= Right (Map.fromList [("t" :: Text, (1 :: Int, "a" :: Text))])
    , testCase "ViaAeson bridge keeps the path of aeson's error" $ do
        ok <- decodeJ "{\"legacy\":{\"a\":1}}"
        ok @?= Right (Map.fromList [("legacy" :: Text, Legacy (Map.fromList [("a", 1)]))])
        bad <- decodeJ "{\"legacy\":{\"a\":\"x\"}}" :: IO (Either ConfigErrors (Map.Map Text Legacy))
        either paths (const []) bad @?= ["legacy.a"]
    , testCase "validate attaches the location of the value" $ do
        l <- loadText (fromText toml "port = 0\n")
        let positive p = if (p :: Int) > 0 then Right p else Left "port must be positive"
            d = fieldWith "port" (validate positive fromConfig) Nothing
        case extractWith d l of
          Left es -> map (\e -> (errorKind e, errorPos e)) (errorList es) @?= [(Message "port must be positive", Just (Pos 1 8))]
          Right v -> assertFailure (show v)
    ]
  where
    dirOf = reverse . drop 1 . dropWhile (/= '/') . reverse

withTmpToml :: (FilePath -> IO a) -> IO a
withTmpToml k = Jail.withJail $ \j ->
  Jail.createFile j "conf/app.toml" "secret = \"x\"\ndir = \"data\"\n" >>= k