packages feed

wai-extra-3.1.14: Network/Wai/Test.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

module Network.Wai.Test (
    -- * Session
    Session,
    runSession,
    withSession,

    -- * Client Cookies
    ClientCookies,
    getClientCookies,
    modifyClientCookies,
    setClientCookie,
    deleteClientCookie,

    -- * Requests
    request,
    srequest,
    SRequest (..),
    SResponse (..),
    defaultRequest,
    setPath,
    setRawPathInfo,

    -- * Assertions
    assertStatus,
    assertContentType,
    assertBody,
    assertBodyContains,
    assertHeader,
    assertNoHeader,
    assertClientCookieExists,
    assertNoClientCookieExists,
    assertClientCookieValue,
) where

#if __GLASGOW_HASKELL__ < 710
import Control.Applicative ((<$>))
import Data.Monoid (mempty, mappend)
#endif

import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader (ask, runReaderT)
import qualified Control.Monad.Trans.State as ST
import Data.ByteString (ByteString)
import qualified Data.ByteString as S
import Data.ByteString.Builder (toLazyByteString)
import qualified Data.ByteString.Char8 as S8
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Lazy.Char8 as L8
import Data.CallStack (HasCallStack)
import Data.CaseInsensitive (CI)
import Data.IORef
import qualified Data.Map as Map
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (getCurrentTime)
import qualified Network.HTTP.Types as H
import Network.Wai
import Network.Wai.Internal (ResponseReceived (ResponseReceived))
import Network.Wai.Test.Internal
import qualified Test.HUnit as HUnit
import qualified Web.Cookie as Cookie

-- |
--
-- Since 3.0.6
getClientCookies :: Session ClientCookies
getClientCookies = clientCookies <$> lift ST.get

-- |
--
-- Since 3.0.6
modifyClientCookies :: (ClientCookies -> ClientCookies) -> Session ()
modifyClientCookies f =
    lift (ST.modify (\cs -> cs{clientCookies = f $ clientCookies cs}))

-- |
--
-- Since 3.0.6
setClientCookie :: Cookie.SetCookie -> Session ()
setClientCookie c =
    modifyClientCookies $
        Map.insert (Cookie.setCookieName c) c

-- |
--
-- Since 3.0.6
deleteClientCookie :: ByteString -> Session ()
deleteClientCookie =
    modifyClientCookies . Map.delete

-- | See also: 'runSessionWith'.
runSession :: Session a -> Application -> IO a
runSession session app = ST.evalStateT (runReaderT session app) initState

-- | Synonym for 'flip runSession'
withSession :: Application -> Session a -> IO a
withSession = flip runSession

data SRequest = SRequest
    { simpleRequest :: Request
    , simpleRequestBody :: L.ByteString
    -- ^ Request body that will override the one set in 'simpleRequest'.
    --
    -- This is usually simpler than setting the body as a stateful IO-action
    -- in 'simpleRequest'.
    }
data SResponse = SResponse
    { simpleStatus :: H.Status
    , simpleHeaders :: H.ResponseHeaders
    , simpleBody :: L.ByteString
    }
    deriving (Show, Eq)

request :: Request -> Session SResponse
request req = do
    app <- ask
    req' <- addCookiesToRequest req
    response <- liftIO $ do
        ref <- newIORef $ error "runResponse gave no result"
        ResponseReceived <- app req' (runResponse ref)
        readIORef ref
    extractSetCookieFromSResponse response

-- | Set whole path (request path + query string).
setPath :: Request -> S8.ByteString -> Request
setPath req path =
    req
        { pathInfo = segments
        , rawPathInfo = L8.toStrict . toLazyByteString $ H.encodePathSegments segments
        , queryString = query
        , rawQueryString = H.renderQuery True query
        }
  where
    (segments, query) = H.decodePath path

setRawPathInfo :: Request -> S8.ByteString -> Request
setRawPathInfo r rawPinfo =
    let pInfo = dropFrontSlash $ T.split (== '/') $ TE.decodeUtf8 rawPinfo
     in r{rawPathInfo = rawPinfo, pathInfo = pInfo}
  where
    dropFrontSlash ["", ""] = [] -- homepage, a single slash
    dropFrontSlash ("" : path) = path
    dropFrontSlash path = path

addCookiesToRequest :: Request -> Session Request
addCookiesToRequest req = do
    oldClientCookies <- getClientCookies
    let requestPath = "/" `T.append` T.intercalate "/" (pathInfo req)
    currentUTCTime <- liftIO getCurrentTime
    let cookiesForRequest =
            Map.filter
                ( \c ->
                    checkCookieTime currentUTCTime c
                        && checkCookiePath requestPath c
                )
                oldClientCookies
    let cookiePairs =
            [ (Cookie.setCookieName c, Cookie.setCookieValue c)
            | c <- map snd $ Map.toList cookiesForRequest
            ]
    let cookieValue = L8.toStrict . toLazyByteString $ Cookie.renderCookies cookiePairs
        addCookieHeader rest
            | null cookiePairs = rest
            | otherwise = ("Cookie", cookieValue) : rest
    return $ req{requestHeaders = addCookieHeader $ requestHeaders req}
  where
    checkCookieTime t c =
        case Cookie.setCookieExpires c of
            Nothing -> True
            Just t' -> t < t'
    checkCookiePath p c =
        case Cookie.setCookiePath c of
            Nothing -> True
            Just p' -> p' `S8.isPrefixOf` TE.encodeUtf8 p

extractSetCookieFromSResponse :: SResponse -> Session SResponse
extractSetCookieFromSResponse response = do
    let setCookieHeaders =
            filter (("Set-Cookie" ==) . fst) $ simpleHeaders response
    let newClientCookies = map (Cookie.parseSetCookie . snd) setCookieHeaders
    modifyClientCookies
        ( Map.union
            (Map.fromList [(Cookie.setCookieName c, c) | c <- newClientCookies])
        )
    return response

-- | Similar to 'request', but allows setting the request body as a plain
-- 'L.ByteString'.
srequest :: SRequest -> Session SResponse
srequest (SRequest req bod) = do
    refChunks <- liftIO $ newIORef $ L.toChunks bod
    let rbody = atomicModifyIORef refChunks $ \bss ->
            case bss of
                [] -> ([], S.empty)
                x : y -> (y, x)
    request $ setRequestBodyChunks rbody req

{- HLint ignore srequest "Use lambda-case" -}

runResponse :: IORef SResponse -> Response -> IO ResponseReceived
runResponse ref res = do
    refBuilder <- newIORef mempty
    let add y = atomicModifyIORef refBuilder $ \x -> (x `mappend` y, ())
    withBody $ \body -> body add (return ())
    builder <- readIORef refBuilder
    let lbs = toLazyByteString builder
        len = L.length lbs
    -- Force evaluation of the body to have exceptions thrown at the right
    -- time.
    seq len $ writeIORef ref $ SResponse s h $ toLazyByteString builder
    return ResponseReceived
  where
    (s, h, withBody) = responseToStream res

assertBool :: HasCallStack => String -> Bool -> Session ()
assertBool s b = unless b $ assertFailure s

assertString :: HasCallStack => String -> Session ()
assertString s = unless (null s) $ assertFailure s

assertFailure :: HasCallStack => String -> Session ()
assertFailure = liftIO . HUnit.assertFailure

assertContentType :: HasCallStack => ByteString -> SResponse -> Session ()
assertContentType ct SResponse{simpleHeaders = h} =
    case lookup "content-type" h of
        Nothing ->
            assertString $
                concat
                    [ "Expected content type "
                    , show ct
                    , ", but no content type provided"
                    ]
        Just ct' ->
            assertBool
                ( concat
                    [ "Expected content type "
                    , show ct
                    , ", but received "
                    , show ct'
                    ]
                )
                (go ct == go ct')
  where
    go = S8.takeWhile (/= ';')

assertStatus :: HasCallStack => Int -> SResponse -> Session ()
assertStatus i SResponse{simpleStatus = s} =
    assertBool
        ( concat
            [ "Expected status code "
            , show i
            , ", but received "
            , show sc
            ]
        )
        $ i == sc
  where
    sc = H.statusCode s

assertBody :: HasCallStack => L.ByteString -> SResponse -> Session ()
assertBody lbs SResponse{simpleBody = lbs'} =
    assertBool
        ( concat
            [ "Expected response body "
            , show $ L8.unpack lbs
            , ", but received "
            , show $ L8.unpack lbs'
            ]
        )
        $ lbs == lbs'

assertBodyContains :: HasCallStack => L.ByteString -> SResponse -> Session ()
assertBodyContains lbs SResponse{simpleBody = lbs'} =
    assertBool
        ( concat
            [ "Expected response body to contain "
            , show $ L8.unpack lbs
            , ", but received "
            , show $ L8.unpack lbs'
            ]
        )
        $ strict lbs `S.isInfixOf` strict lbs'
  where
    strict = S.concat . L.toChunks

assertHeader
    :: HasCallStack => CI ByteString -> ByteString -> SResponse -> Session ()
assertHeader header value SResponse{simpleHeaders = h} =
    case lookup header h of
        Nothing ->
            assertString $
                concat
                    [ "Expected header "
                    , show header
                    , " to be "
                    , show value
                    , ", but it was not present"
                    ]
        Just value' ->
            assertBool
                ( concat
                    [ "Expected header "
                    , show header
                    , " to be "
                    , show value
                    , ", but received "
                    , show value'
                    ]
                )
                (value == value')

assertNoHeader :: HasCallStack => CI ByteString -> SResponse -> Session ()
assertNoHeader header SResponse{simpleHeaders = h} =
    case lookup header h of
        Nothing -> return ()
        Just s ->
            assertString $
                concat
                    [ "Unexpected header "
                    , show header
                    , " containing "
                    , show s
                    ]

-- |
--
-- Since 3.0.6
assertClientCookieExists :: HasCallStack => String -> ByteString -> Session ()
assertClientCookieExists s cookieName = do
    cookies <- getClientCookies
    assertBool s $ Map.member cookieName cookies

-- |
--
-- Since 3.0.6
assertNoClientCookieExists :: HasCallStack => String -> ByteString -> Session ()
assertNoClientCookieExists s cookieName = do
    cookies <- getClientCookies
    assertBool s $ not $ Map.member cookieName cookies

-- |
--
-- Since 3.0.6
assertClientCookieValue
    :: HasCallStack => String -> ByteString -> ByteString -> Session ()
assertClientCookieValue s cookieName cookieValue = do
    cookies <- getClientCookies
    case Map.lookup cookieName cookies of
        Nothing ->
            assertFailure (s ++ " (cookie does not exist)")
        Just c ->
            assertBool
                ( concat
                    [ s
                    , " (actual value "
                    , show $ Cookie.setCookieValue c
                    , " expected value "
                    , show cookieValue
                    , ")"
                    ]
                )
                (Cookie.setCookieValue c == cookieValue)