packages feed

linnet-0.1.0.0: test/EndpointSpec.hs

{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications    #-}

module EndpointSpec
  ( spec
  ) where

import           Control.Applicative       ((<|>))
import           Control.Exception         (SomeException, fromException,
                                            toException)
import           Control.Monad.Catch       (throwM)
import           Control.Monad.IO.Class    (liftIO)
import qualified Data.ByteString           as B
import           Data.Either               (isLeft, lefts)
import           Data.Function             ((&))
import           Data.Functor.Identity
import           Data.List                 (uncons)
import           Data.List.NonEmpty        (NonEmpty (..), toList)
import           Data.Maybe                (isNothing, maybeToList)
import qualified Data.Text                 as T
import           Debug.Trace               (trace)
import           ExtractPathLaws
import           Instances
import           Linnet
import           Linnet.Endpoint
import           Linnet.Endpoints.Entity
import           Linnet.Errors
import           Linnet.Input
import           Linnet.Internal.Coproduct (Coproduct (..))
import           Linnet.Internal.HList
import           Linnet.Output             (withHeader)
import           Network.HTTP.Types        (methodConnect, methodDelete,
                                            methodGet, methodHead,
                                            methodOptions, methodPatch,
                                            methodPost, methodPut, methodTrace)
import           Network.Wai               (requestMethod)
import           Test.Hspec
import           Test.QuickCheck           (conjoin, property)
import           Test.QuickCheck.Monadic   (assert, monadicIO, run)
import           Util

spec :: Spec
spec = do
  checkLaws "Text" $ extractPathLaws @T.Text
  checkLaws "Int" $ extractPathLaws @Int
  it "supports simple fmap" $
    property $ \(i :: Input) ->
      resultValueUnsafe (runEndpoint (T.toUpper <$> path @T.Text) i) ==
      Identity (fmap T.toUpper . headOption $ reminder i)
  it "supports transformOutput" $
    property $ \(e :: Endpoint (Either SomeException) String) ->
      transformOutput ((fmap . fmap) length) e == fmap length e
  it "supports transform" $
    property $ \(e :: Endpoint (Either SomeException) String) -> transform (fmap length) e == fmap length e
  it "propagates default (Ok) output" $
    property $ \(i :: Input) ->
      resultOutputUnsafe (runEndpoint (path @T.Text) i) == Identity (fmap ok . headOption $ reminder i)
  it "propagates default (Ok) output through fmap, mapM'" $
    property $ \(i :: Input) ->
      let expected = ok . T.length <$> (headOption . reminder) i
          endpoint = path @T.Text
       in resultOutputUnsafe (runEndpoint (fmap T.length endpoint) i) == Identity expected &&
          resultOutputUnsafe (runEndpoint (mapM' (pure . T.length) endpoint) i) == Identity expected
  it "propagates output through mapOutputM" $
    property $ \(i :: Input) ->
      let expected i = withHeader ("Foo", "Bar") $ created i
       in resultOutputUnsafe (runEndpoint (mapOutputM (pure . expected . T.length) $ path @T.Text) i) ==
          Identity (fmap (expected . T.length) (headOption $ reminder i))
  it "matches one path segment" $
    property $ \(i :: Input) ->
      let v = (maybeToList . headOption . reminder $ i) >>= (\s -> [matchedReminder $ runEndpoint (p' @Identity s) i])
       in null v || v == [i {reminder = tail . reminder $ i}]
  it "matches the entire input with pathAny" $
    property $ \(i :: Input) -> matchedReminder (runEndpoint (pathAny @Identity) i) == i {reminder = []}
  it "matches empty path" $
    property $ \(i :: Input) ->
      let result = runEndpoint (pathEmpty @Identity) i
       in (null (reminder i) && isMatched result) || (reminder i /= [] && not (isMatched result))
  it "matches the HTTP method" $
    let matchMethod :: B.ByteString -> (Endpoint Identity (HList '[]) -> Endpoint Identity (HList '[])) -> Input -> Bool
        matchMethod method f input =
          let result = runEndpoint (f zero) input
           in ((requestMethod . request) input == method && maybeReminder result == Just input) ||
              ((requestMethod . request) input /= method && isNothing (maybeReminder result))
     in conjoin
          [ property $ matchMethod methodGet get
          , property $ matchMethod methodPost post
          , property $ matchMethod methodPut put
          , property $ matchMethod methodPatch patch
          , property $ matchMethod methodDelete delete
          , property $ matchMethod methodHead head'
          , property $ matchMethod methodOptions options
          , property $ matchMethod methodConnect connect
          , property $ matchMethod methodTrace trace'
          ]
  it "always matches with identity instance" $
    property $ \(i :: Input) -> maybeReminder (runEndpoint (zero @Identity) i) == Just i
  it "matches & consumes the entire input" $
    property $ \(i :: Input) ->
      let e = foldl (//) (zero @(Either SomeException)) $ map p' (reminder i)
       in maybeReminder (runEndpoint e i) == Just (i {reminder = []})
  it "shouldn't match if one of the endpoints has failed in (//) composition" $
    property $ \(i :: Input, s :: T.Text) ->
      isNothing (maybeReminder (runEndpoint (pathAny @(Either SomeException) // p' s) i))
  it "matches if at least one of the endpoints succeed in alternative <|>" $
    let matchOneOfTwo :: (T.Text -> Endpoint IO (HList '[])) -> Input -> Bool
        matchOneOfTwo f input = isNothing v || v == Just input {reminder = tail $ reminder input}
          where
            v = f <$> (headOption . reminder) input >>= (\e -> maybeReminder (runEndpoint e input))
     in conjoin
          [ property $ matchOneOfTwo (\s -> p' s <|> p' (T.reverse s))
          , property $ matchOneOfTwo (\s -> p' (T.reverse s) <|> p' s)
          ]
  it "always responds with the same output if it's constant" $
    conjoin
      [ property $ \(t :: T.Text) ->
          monadicIO $ do
            p <- liftIO $ resultValueUnsafe (runEndpoint (pure t) (inputGet "/" []))
            l <- liftIO $ resultValueUnsafe (runEndpoint (lift $ pure t) (inputGet "/" []))
            assert (p == Just t)
            assert (l == Just t)
      , property $ \(out :: Output T.Text) ->
          resultOutputUnsafe (runEndpoint (liftOutputM $ pure out) (inputGet "/" [])) == Identity (Just out)
      ]
  it "handles the exception raised in a monad" $
    property $ \(i :: Input, s :: T.Text) ->
      monadicIO $ do
        let e = lift (throwM @IO (TestException "test")) & handle (\(e :: TestException) -> return $ created s)
        result <- liftIO $ runEndpoint e i & resultOutputEither
        assert (result == Right (Just (created s)))
  it "re-raises the exception if it wasn't handled" $
    property $ \(i :: Input, s :: T.Text) ->
      monadicIO $ do
        let e :: Endpoint IO T.Text = lift (throwM @IO (TestException "test"))
        result <- liftIO $ runEndpoint e i & resultOutputEither
        assert (result == Left (toException $ TestException "test"))
  it "throws MissingEntity if an item wasn't found" $ do
    let i = inputGet "/" []
    let name = "test"
    let (endpoints, exceptions) =
          unzip
            [ (param @T.Text name, MissingEntity $ Param name)
            , (T.intercalate ";" . toList <$> paramsNel @T.Text name, MissingEntity $ Param name)
            , (header @T.Text name, MissingEntity $ Header name)
            , (cookie @T.Text name, MissingEntity $ Cookie name)
            , (textBody @T.Text, MissingEntity Body)
            ]
    results <- lefts <$> mapM (\e -> runEndpoint e i & resultOutputEither) endpoints
    map fromException results `shouldBe` Just <$> exceptions
  it "accumulates Linnet errors in product" $
    property $ \(i :: Input, e :: LinnetError, e' :: LinnetError) ->
      monadicIO $ do
        let ea = lift @IO @Int (throwM e)
        let eb = lift @IO @Int (throwM e')
        result <- liftIO $ resultOutputEither (runEndpoint (ea // eb) i)
        result' <- liftIO $ resultOutputEither (runEndpoint (eb // ea) i)
        assert (result == (Left $ toException (e <> e')))
        assert (result' == (Left $ toException (e' <> e)))
  it "fails fast once non-Linnet error is observed" $
    property $ \(i :: Input, e :: LinnetError) ->
      monadicIO $ do
        let exception = TestException "foo"
        let ea = lift @IO @Int (throwM e)
        let eb = lift @IO @Int (throwM exception)
        result <- liftIO $ resultOutputEither (runEndpoint (ea // eb) i)
        result' <- liftIO $ resultOutputEither (runEndpoint (eb // ea) i)
        assert (result == (Left $ toException exception))
        assert (result' == (Left $ toException exception))
  it "selects endpoint with the least reminder" $
    property $ \(i :: Input, s :: T.Text) ->
      monadicIO $ do
        let e1 = foldl (\acc segment -> acc // p' segment) zero (reminder i) ~>> return (ok True)
        let e2 = e1 // p' s ~>> (\t -> return $ ok (5 :: Int))
        let composition = e1 |+| e2
        let composition' = e2 |+| e1
        let newInput = i {reminder = reminder i ++ [s]}
        let endpointResult1 = runEndpoint e1 newInput
        let endpointResult2 = runEndpoint e2 newInput
        assert $ isMatched endpointResult1
        assert $ isMatched endpointResult2
        result <- liftIO $ resultValueUnsafe (runEndpoint composition newInput)
        result' <- liftIO $ resultValueUnsafe (runEndpoint composition' newInput)
        assert (result == Just (Inr (Inl 5)))
        assert (result' == Just (Inl 5))
  it "returns request in root endpoint" $
    property $ \(i :: Input) -> resultValueUnsafe (runEndpoint root i) == Identity (Just $ request i)
  it "adjoins endpoints together in //" $ do
    let i = inputGet "foo/bar/zoo" []
    let endpoint = (path @T.Text // path @T.Text // path @T.Text) ~>> (\p1 p2 p3 -> return $ ok (p1, p2, p3))
    result <- liftIO $ resultValueUnsafe $ runEndpoint endpoint i
    result `shouldBe` Just ("foo", "bar", "zoo")
  it "catches exception with try" $
    property $ \(i :: Input) ->
      monadicIO $ do
        let exception = TestException "foo"
        let endpoint = (lift $ throwM exception) :: Endpoint IO Int
        let handled = try endpoint
        result <- liftIO $ resultValueUnsafe (runEndpoint handled i)
        assert $ result == Just (Left exception)