packages feed

acolyte-test-0.1.0.0: src/Acolyte/Test/Property.hs

-- | Property-based testing for acolyte APIs.
--
-- Generate random valid requests for any endpoint in an API type.
--
-- @
-- prop_noCrash :: Property
-- prop_noCrash = forAll (arbitraryApiRequest @MyAPI) $ \req -> ioProperty $ do
--   resp <- runService myServer req
--   pure (responseStatus' resp `elem` [200, 201, 400, 401, 404])
-- @
module Acolyte.Test.Property
  ( -- * Random request generation
    ArbitraryEndpoint (..)
  , ArbitraryAPI (..)
    -- * Helpers
  , randomCapture
  , generatePath
  , genJsonObject
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.IO.Unsafe (unsafePerformIO)

import Test.QuickCheck

import Http.Core
import Acolyte.Server.Handler (HasEndpointInfo (..))


-- | Generate a random valid request for a specific endpoint.
class ArbitraryEndpoint endpoint where
  arbitraryEndpointRequest :: Gen (Request ByteString)

instance HasEndpointInfo e => ArbitraryEndpoint e where
  arbitraryEndpointRequest = do
    let method  = endpointMethod @e
        pattern = endpointPattern @e
    pathSegments <- generatePath pattern
    let pathRaw = "/" <> BS8.intercalate "/" (map TE.encodeUtf8 pathSegments)
    body <- oneof [pure "", genJsonObject]
    let exts = unsafePerformIO emptyExtensions
    pure Request
      { requestMethod     = method
      , requestPathRaw    = pathRaw
      , requestPath       = pathSegments
      , requestQuery      = []
      , requestHeaders    = [("Content-Type", "application/json")]
      , requestBody       = body
      , requestExtensions = exts
      }


-- | Generate a random request for any endpoint in an API.
class ArbitraryAPI (api :: [Type]) where
  arbitraryApiRequest :: Gen (Request ByteString)

instance ArbitraryEndpoint e => ArbitraryAPI '[e] where
  arbitraryApiRequest = arbitraryEndpointRequest @e

instance (ArbitraryEndpoint e, ArbitraryAPI (e2 ': rest))
  => ArbitraryAPI (e ': e2 ': rest) where
    arbitraryApiRequest = oneof
      [ arbitraryEndpointRequest @e
      , arbitraryApiRequest @(e2 ': rest)
      ]


-- ===================================================================
-- Helpers
-- ===================================================================

-- | Generate a random capture value (int or short string).
randomCapture :: Gen Text
randomCapture = oneof
  [ T.pack . show <$> chooseInt (1, 9999)
  , T.pack <$> listOf1 (elements ['a'..'z'])
  ]


-- | Generate path segments from a pattern, replacing captures with random values.
generatePath :: Text -> Gen [Text]
generatePath pattern =
  let parts = filter (not . T.null) (T.splitOn "/" pattern)
  in mapM genSegment parts
  where
    genSegment seg
      | "{" `T.isPrefixOf` seg = randomCapture
      | otherwise = pure seg


-- | Generate a random small JSON object.
genJsonObject :: Gen ByteString
genJsonObject = do
  nFields <- chooseInt (0, 3)
  fields <- vectorOf nFields genField
  pure $ BS8.pack $ "{" ++ intercalateS "," fields ++ "}"
  where
    genField = do
      key <- listOf1 (elements ['a'..'z'])
      val <- oneof
        [ show <$> chooseInt (0 :: Int, 100)
        , (\s -> "\"" ++ s ++ "\"") <$> listOf1 (elements ['a'..'z'])
        ]
      pure $ "\"" ++ key ++ "\":" ++ val

    intercalateS _ [] = ""
    intercalateS _ [x] = x
    intercalateS sep (x:xs) = x ++ sep ++ intercalateS sep xs