packages feed

shikumi-0.3.0.0: test/ConstraintSpec.hs

{-# LANGUAGE DataKinds #-}

-- | EP-26 M2: declarative field constraints. A 'Constrained' field carries a
-- type-level constraint list that (a) emits the matching JSON-Schema keywords in
-- the derived schema and (b) is enforced after decode, turning a violation into a
-- located 'ValidationFailure'. The contrast record 'BioUnchecked' (same fields, no
-- constraint) shows the constraint is what rejects an out-of-bounds value.
module ConstraintSpec (tests) where

import Data.Aeson (Value (..))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Shikumi.Error (ShikumiError (..))
import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema, fromModel)
import Shikumi.Schema.Types (Constrained, Constraint (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))

-- A small constrained record: a tagline of at least 10 characters and a score in
-- the inclusive range [0, 100].
data Bio = Bio
  { tagline :: !(Constrained '[ 'MinLen 10] Text),
    score :: !(Constrained '[ 'MinVal "0", 'MaxVal "100"] Int)
  }
  deriving stock (Generic, Show, Eq)

instance ToSchema Bio

instance FromModel Bio

instance Validatable Bio

-- The same shape with no constraints, to prove the constraint does the rejecting.
data BioUnchecked = BioUnchecked
  { tagline :: !Text,
    score :: !Int
  }
  deriving stock (Generic, Show, Eq)

instance ToSchema BioUnchecked

instance FromModel BioUnchecked

instance Validatable BioUnchecked

-- A record with a /malformed/ numeric bound symbol (EP-35): @"abc"@ is not a
-- number. Deriving its schema must not crash, and decoding must fail cleanly.
newtype BadBound = BadBound
  {n :: Constrained '[ 'MinVal "abc"] Int}
  deriving stock (Generic, Show, Eq)

instance ToSchema BadBound

instance FromModel BadBound

instance Validatable BadBound

-- | Drill into @properties.<field>@ of a record schema 'Value'.
propOf :: Text -> Value -> Maybe Value
propOf name (Object o) = do
  Object props <- KM.lookup "properties" o
  KM.lookup (Key.fromText name) props
propOf _ _ = Nothing

-- | Look a keyword up inside a property schema object.
keyword :: Text -> Value -> Maybe Value
keyword k (Object o) = KM.lookup (Key.fromText k) o
keyword _ _ = Nothing

tests :: TestTree
tests =
  testGroup
    "ConstraintSpec"
    [ testCase "schema: tagline carries minLength 10" $
        (propOf "tagline" (deriveSchema @Bio) >>= keyword "minLength") @?= Just (Number 10),
      testCase "schema: score carries minimum 0 and maximum 100" $ do
        (propOf "score" (deriveSchema @Bio) >>= keyword "minimum") @?= Just (Number 0)
        (propOf "score" (deriveSchema @Bio) >>= keyword "maximum") @?= Just (Number 100),
      testCase "decode: short tagline -> ValidationFailure" $
        fromModel @Bio (obj "short" 50)
          @?= Left (ValidationFailure "tagline: minLength 10 violated"),
      testCase "decode: conforming value -> Right" $
        case fromModel @Bio (obj "long enough tagline" 50) of
          Right _ -> pure ()
          Left e -> assertFailure ("expected Right, got " <> show e),
      testCase "decode: out-of-range score -> ValidationFailure" $
        fromModel @Bio (obj "long enough tagline" 150)
          @?= Left (ValidationFailure "score: maximum 100 violated"),
      testCase "contrast: the unconstrained record accepts the short value" $
        fromModel @BioUnchecked (obj "short" 50)
          @?= Right (BioUnchecked {tagline = "short", score = 50}),
      testCase "EP-35: a malformed numeric bound omits the schema keyword (no crash)" $
        (propOf "n" (deriveSchema @BadBound) >>= keyword "minimum") @?= Nothing,
      testCase "EP-35: a malformed numeric bound fails decode with a ValidationFailure naming it" $
        case fromModel @BadBound (Object (KM.fromList [("n", Number 5)])) of
          Left (ValidationFailure m) | "abc" `T.isInfixOf` m -> pure ()
          other -> assertFailure ("expected ValidationFailure mentioning abc, got " <> show other)
    ]
  where
    obj t s =
      Object
        ( KM.fromList
            [ ("tagline", String t),
              ("score", Number (fromIntegral (s :: Int)))
            ]
        )