Further yesod-test cleanup
This commit is contained in:
parent
ba8706429a
commit
ad817275e8
@ -24,7 +24,7 @@ backend pre-conditions, or to assert that your session is having the desired eff
|
|||||||
|
|
||||||
module Yesod.Test (
|
module Yesod.Test (
|
||||||
-- * Declaring and running your test suite
|
-- * Declaring and running your test suite
|
||||||
yesodSpec, YesodSpec, YesodSpecM, YesodSpecTree (..), ydescribe, yit,
|
yesodSpec, YesodSpec, YesodSpecTree (..), ydescribe, yit,
|
||||||
|
|
||||||
-- * Making requests
|
-- * Making requests
|
||||||
-- | To make a request you need to point to an url and pass in some parameters.
|
-- | To make a request you need to point to an url and pass in some parameters.
|
||||||
@ -53,6 +53,7 @@ module Yesod.Test (
|
|||||||
|
|
||||||
-- * Grab information
|
-- * Grab information
|
||||||
getTestYesod,
|
getTestYesod,
|
||||||
|
getResponse,
|
||||||
|
|
||||||
-- * Utils for debugging tests
|
-- * Utils for debugging tests
|
||||||
printBody, printMatches,
|
printBody, printMatches,
|
||||||
@ -84,7 +85,6 @@ import Control.Monad.IO.Class
|
|||||||
import System.IO
|
import System.IO
|
||||||
import Yesod.Test.TransversingCSS
|
import Yesod.Test.TransversingCSS
|
||||||
import Yesod.Core (toWaiAppPlain, YesodDispatch)
|
import Yesod.Core (toWaiAppPlain, YesodDispatch)
|
||||||
import Data.Monoid (mappend)
|
|
||||||
import qualified Data.Text.Lazy as TL
|
import qualified Data.Text.Lazy as TL
|
||||||
import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8)
|
import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8)
|
||||||
import Text.XML.Cursor hiding (element)
|
import Text.XML.Cursor hiding (element)
|
||||||
@ -96,21 +96,54 @@ import qualified Web.Cookie as Cookie
|
|||||||
import qualified Blaze.ByteString.Builder as Builder
|
import qualified Blaze.ByteString.Builder as Builder
|
||||||
import Data.Time.Clock (getCurrentTime)
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
|
||||||
-- | The state used in a single test case defined using 'it'
|
-- | The state used in a single test case defined using 'yit'
|
||||||
data OneSpecData site = OneSpecData Application site Cookies (Maybe SResponse)
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
data YesodExampleData site = YesodExampleData
|
||||||
|
{ yedApp :: !Application
|
||||||
|
, yedSite :: !site
|
||||||
|
, yedCookies :: !Cookies
|
||||||
|
, yedResponse :: !(Maybe SResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | A single test case, to be run with 'yit'.
|
||||||
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
type YesodExample site = ST.StateT (YesodExampleData site) IO
|
||||||
|
|
||||||
|
-- | Mapping from cookie name to value.
|
||||||
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
type Cookies = M.Map ByteString Cookie.SetCookie
|
||||||
|
|
||||||
|
-- | Corresponds to hspec\'s 'Spec'.
|
||||||
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
type YesodSpec site = Writer [YesodSpecTree site] ()
|
||||||
|
|
||||||
|
-- | Internal data structure, corresponding to hspec\'s 'YesodSpecTree'.
|
||||||
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
data YesodSpecTree site
|
||||||
|
= YesodSpecGroup String [YesodSpecTree site]
|
||||||
|
| YesodSpecItem String (YesodExample site ())
|
||||||
|
|
||||||
-- | Get the foundation value used for the current test.
|
-- | Get the foundation value used for the current test.
|
||||||
--
|
--
|
||||||
-- Since 1.2.0
|
-- Since 1.2.0
|
||||||
getTestYesod :: YesodExample site site
|
getTestYesod :: YesodExample site site
|
||||||
getTestYesod = do
|
getTestYesod = fmap yedSite ST.get
|
||||||
OneSpecData _ site _ _ <- ST.get
|
|
||||||
return site
|
|
||||||
|
|
||||||
-- | The OneSpec state monad is where 'yit' runs.
|
-- | Get the most recently provided response value, if available.
|
||||||
type YesodExample site = ST.StateT (OneSpecData site) IO
|
--
|
||||||
|
-- Since 1.2.0
|
||||||
|
getResponse :: YesodExample site (Maybe SResponse)
|
||||||
|
getResponse = fmap yedResponse ST.get
|
||||||
|
|
||||||
data RequestBuilderData = RequestBuilderData [RequestPart] (Maybe SResponse)
|
data RequestBuilderData = RequestBuilderData
|
||||||
|
{ rbdPosts :: [RequestPart]
|
||||||
|
, rbdResponse :: (Maybe SResponse)
|
||||||
|
}
|
||||||
|
|
||||||
-- | Request parts let us discern regular key/values from files sent in the request.
|
-- | Request parts let us discern regular key/values from files sent in the request.
|
||||||
data RequestPart
|
data RequestPart
|
||||||
@ -122,29 +155,11 @@ data RequestPart
|
|||||||
-- response to analize the forms that the server is expecting to receive.
|
-- response to analize the forms that the server is expecting to receive.
|
||||||
type RequestBuilder = ST.StateT RequestBuilderData IO
|
type RequestBuilder = ST.StateT RequestBuilderData IO
|
||||||
|
|
||||||
-- | Both the OneSpec and RequestBuilder monads hold a response that can be analized,
|
|
||||||
-- by making them instances of this class we can have general methods that work on
|
|
||||||
-- the last received response.
|
|
||||||
class HoldsResponse a where
|
|
||||||
readResponse :: a -> Maybe SResponse
|
|
||||||
instance HoldsResponse (OneSpecData conn) where
|
|
||||||
readResponse (OneSpecData _ _ _ x) = x
|
|
||||||
instance HoldsResponse RequestBuilderData where
|
|
||||||
readResponse (RequestBuilderData _ x) = x
|
|
||||||
|
|
||||||
type Cookies = M.Map ByteString Cookie.SetCookie
|
|
||||||
|
|
||||||
-- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
|
-- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
|
||||||
-- and 'ConnectionPool'
|
-- and 'ConnectionPool'
|
||||||
ydescribe :: String -> YesodSpec site -> YesodSpec site
|
ydescribe :: String -> YesodSpec site -> YesodSpec site
|
||||||
ydescribe label yspecs = tell [YesodSpecGroup label $ execWriter yspecs]
|
ydescribe label yspecs = tell [YesodSpecGroup label $ execWriter yspecs]
|
||||||
|
|
||||||
type YesodSpec site = YesodSpecM site ()
|
|
||||||
type YesodSpecM site = Writer [YesodSpecTree site]
|
|
||||||
data YesodSpecTree site
|
|
||||||
= YesodSpecGroup String [YesodSpecTree site]
|
|
||||||
| YesodSpecItem String (YesodExample site ())
|
|
||||||
|
|
||||||
yesodSpec :: YesodDispatch site
|
yesodSpec :: YesodDispatch site
|
||||||
=> site
|
=> site
|
||||||
-> YesodSpec site
|
-> YesodSpec site
|
||||||
@ -155,7 +170,12 @@ yesodSpec site yspecs =
|
|||||||
unYesod (YesodSpecGroup x y) = Core.SpecGroup x $ map unYesod y
|
unYesod (YesodSpecGroup x y) = Core.SpecGroup x $ map unYesod y
|
||||||
unYesod (YesodSpecItem x y) = Core.it x $ do
|
unYesod (YesodSpecItem x y) = Core.it x $ do
|
||||||
app <- toWaiAppPlain site
|
app <- toWaiAppPlain site
|
||||||
ST.evalStateT y $ OneSpecData app site M.empty Nothing
|
ST.evalStateT y YesodExampleData
|
||||||
|
{ yedApp = app
|
||||||
|
, yedSite = site
|
||||||
|
, yedCookies = M.empty
|
||||||
|
, yedResponse = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
-- | Describe a single test that keeps cookies, and a reference to the last response.
|
-- | Describe a single test that keeps cookies, and a reference to the last response.
|
||||||
yit :: String -> YesodExample site () -> YesodSpec site
|
yit :: String -> YesodExample site () -> YesodSpec site
|
||||||
@ -163,28 +183,43 @@ yit label example = tell [YesodSpecItem label example]
|
|||||||
|
|
||||||
-- Performs a given action using the last response. Use this to create
|
-- Performs a given action using the last response. Use this to create
|
||||||
-- response-level assertions
|
-- response-level assertions
|
||||||
withResponse :: HoldsResponse a => (SResponse -> ST.StateT a IO b) -> ST.StateT a IO b
|
withResponse' :: MonadIO m
|
||||||
withResponse f = maybe err f =<< fmap readResponse ST.get
|
=> (state -> Maybe SResponse)
|
||||||
|
-> (SResponse -> ST.StateT state m a)
|
||||||
|
-> ST.StateT state m a
|
||||||
|
withResponse' getter f = maybe err f . getter =<< ST.get
|
||||||
where err = failure "There was no response, you should make a request"
|
where err = failure "There was no response, you should make a request"
|
||||||
|
|
||||||
|
-- | Performs a given action using the last response. Use this to create
|
||||||
|
-- response-level assertions
|
||||||
|
withResponse :: (SResponse -> YesodExample site a) -> YesodExample site a
|
||||||
|
withResponse = withResponse' yedResponse
|
||||||
|
|
||||||
-- | Use HXT to parse a value from an html tag.
|
-- | Use HXT to parse a value from an html tag.
|
||||||
-- Check for usage examples in this module's source.
|
-- Check for usage examples in this module's source.
|
||||||
parseHTML :: HtmlLBS -> Cursor
|
parseHTML :: HtmlLBS -> Cursor
|
||||||
parseHTML html = fromDocument $ HD.parseLBS html
|
parseHTML html = fromDocument $ HD.parseLBS html
|
||||||
|
|
||||||
-- | Query the last response using css selectors, returns a list of matched fragments
|
-- | Query the last response using css selectors, returns a list of matched fragments
|
||||||
htmlQuery :: HoldsResponse a => Query -> ST.StateT a IO [HtmlLBS]
|
htmlQuery' :: MonadIO m
|
||||||
htmlQuery query = withResponse $ \ res ->
|
=> (state -> Maybe SResponse)
|
||||||
|
-> Query
|
||||||
|
-> ST.StateT state m [HtmlLBS]
|
||||||
|
htmlQuery' getter query = withResponse' getter $ \ res ->
|
||||||
case findBySelector (simpleBody res) query of
|
case findBySelector (simpleBody res) query of
|
||||||
Left err -> failure $ query <> " did not parse: " <> T.pack (show err)
|
Left err -> failure $ query <> " did not parse: " <> T.pack (show err)
|
||||||
Right matches -> return $ map (encodeUtf8 . TL.pack) matches
|
Right matches -> return $ map (encodeUtf8 . TL.pack) matches
|
||||||
|
|
||||||
|
-- | Query the last response using css selectors, returns a list of matched fragments
|
||||||
|
htmlQuery :: Query -> YesodExample site [HtmlLBS]
|
||||||
|
htmlQuery = htmlQuery' yedResponse
|
||||||
|
|
||||||
-- | Asserts that the two given values are equal.
|
-- | Asserts that the two given values are equal.
|
||||||
assertEqual :: (Eq a) => String -> a -> a -> YesodExample site ()
|
assertEqual :: (Eq a) => String -> a -> a -> YesodExample site ()
|
||||||
assertEqual msg a b = liftIO $ HUnit.assertBool msg (a == b)
|
assertEqual msg a b = liftIO $ HUnit.assertBool msg (a == b)
|
||||||
|
|
||||||
-- | Assert the last response status is as expected.
|
-- | Assert the last response status is as expected.
|
||||||
statusIs :: HoldsResponse a => Int -> ST.StateT a IO ()
|
statusIs :: Int -> YesodExample site ()
|
||||||
statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
|
statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
|
||||||
liftIO $ flip HUnit.assertBool (H.statusCode s == number) $ concat
|
liftIO $ flip HUnit.assertBool (H.statusCode s == number) $ concat
|
||||||
[ "Expected status was ", show number
|
[ "Expected status was ", show number
|
||||||
@ -192,7 +227,7 @@ statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
|
|||||||
]
|
]
|
||||||
|
|
||||||
-- | Assert the given header key/value pair was returned.
|
-- | Assert the given header key/value pair was returned.
|
||||||
assertHeader :: HoldsResponse a => CI BS8.ByteString -> BS8.ByteString -> ST.StateT a IO ()
|
assertHeader :: CI BS8.ByteString -> BS8.ByteString -> YesodExample site ()
|
||||||
assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
|
assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
|
||||||
case lookup header h of
|
case lookup header h of
|
||||||
Nothing -> failure $ T.pack $ concat
|
Nothing -> failure $ T.pack $ concat
|
||||||
@ -212,7 +247,7 @@ assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
|
|||||||
]
|
]
|
||||||
|
|
||||||
-- | Assert the given header was not included in the response.
|
-- | Assert the given header was not included in the response.
|
||||||
assertNoHeader :: HoldsResponse a => CI BS8.ByteString -> ST.StateT a IO ()
|
assertNoHeader :: CI BS8.ByteString -> YesodExample site ()
|
||||||
assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
|
assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
|
||||||
case lookup header h of
|
case lookup header h of
|
||||||
Nothing -> return ()
|
Nothing -> return ()
|
||||||
@ -225,14 +260,14 @@ assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
|
|||||||
|
|
||||||
-- | Assert the last response is exactly equal to the given text. This is
|
-- | Assert the last response is exactly equal to the given text. This is
|
||||||
-- useful for testing API responses.
|
-- useful for testing API responses.
|
||||||
bodyEquals :: HoldsResponse a => String -> ST.StateT a IO ()
|
bodyEquals :: String -> YesodExample site ()
|
||||||
bodyEquals text = withResponse $ \ res ->
|
bodyEquals text = withResponse $ \ res ->
|
||||||
liftIO $ HUnit.assertBool ("Expected body to equal " ++ text) $
|
liftIO $ HUnit.assertBool ("Expected body to equal " ++ text) $
|
||||||
(simpleBody res) == BSL8.pack text
|
(simpleBody res) == BSL8.pack text
|
||||||
|
|
||||||
-- | Assert the last response has the given text. The check is performed using the response
|
-- | Assert the last response has the given text. The check is performed using the response
|
||||||
-- body in full text form.
|
-- body in full text form.
|
||||||
bodyContains :: HoldsResponse a => String -> ST.StateT a IO ()
|
bodyContains :: String -> YesodExample site ()
|
||||||
bodyContains text = withResponse $ \ res ->
|
bodyContains text = withResponse $ \ res ->
|
||||||
liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $
|
liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $
|
||||||
(simpleBody res) `contains` text
|
(simpleBody res) `contains` text
|
||||||
@ -242,7 +277,7 @@ contains a b = DL.isInfixOf b (BSL8.unpack a)
|
|||||||
|
|
||||||
-- | Queries the html using a css selector, and all matched elements must contain
|
-- | Queries the html using a css selector, and all matched elements must contain
|
||||||
-- the given string.
|
-- the given string.
|
||||||
htmlAllContain :: HoldsResponse a => Query -> String -> ST.StateT a IO ()
|
htmlAllContain :: Query -> String -> YesodExample site ()
|
||||||
htmlAllContain query search = do
|
htmlAllContain query search = do
|
||||||
matches <- htmlQuery query
|
matches <- htmlQuery query
|
||||||
case matches of
|
case matches of
|
||||||
@ -254,7 +289,7 @@ htmlAllContain query search = do
|
|||||||
-- element contains the given string.
|
-- element contains the given string.
|
||||||
--
|
--
|
||||||
-- Since 0.3.5
|
-- Since 0.3.5
|
||||||
htmlAnyContain :: HoldsResponse a => Query -> String -> ST.StateT a IO ()
|
htmlAnyContain :: Query -> String -> YesodExample site ()
|
||||||
htmlAnyContain query search = do
|
htmlAnyContain query search = do
|
||||||
matches <- htmlQuery query
|
matches <- htmlQuery query
|
||||||
case matches of
|
case matches of
|
||||||
@ -264,19 +299,19 @@ htmlAnyContain query search = do
|
|||||||
|
|
||||||
-- | Performs a css query on the last response and asserts the matched elements
|
-- | Performs a css query on the last response and asserts the matched elements
|
||||||
-- are as many as expected.
|
-- are as many as expected.
|
||||||
htmlCount :: HoldsResponse a => Query -> Int -> ST.StateT a IO ()
|
htmlCount :: Query -> Int -> YesodExample site ()
|
||||||
htmlCount query count = do
|
htmlCount query count = do
|
||||||
matches <- fmap DL.length $ htmlQuery query
|
matches <- fmap DL.length $ htmlQuery query
|
||||||
liftIO $ flip HUnit.assertBool (matches == count)
|
liftIO $ flip HUnit.assertBool (matches == count)
|
||||||
("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches))
|
("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches))
|
||||||
|
|
||||||
-- | Outputs the last response body to stderr (So it doesn't get captured by HSpec)
|
-- | Outputs the last response body to stderr (So it doesn't get captured by HSpec)
|
||||||
printBody :: HoldsResponse a => ST.StateT a IO ()
|
printBody :: YesodExample site ()
|
||||||
printBody = withResponse $ \ SResponse { simpleBody = b } ->
|
printBody = withResponse $ \ SResponse { simpleBody = b } ->
|
||||||
liftIO $ hPutStrLn stderr $ BSL8.unpack b
|
liftIO $ hPutStrLn stderr $ BSL8.unpack b
|
||||||
|
|
||||||
-- | Performs a CSS query and print the matches to stderr.
|
-- | Performs a CSS query and print the matches to stderr.
|
||||||
printMatches :: HoldsResponse a => Query -> ST.StateT a IO ()
|
printMatches :: Query -> YesodExample site ()
|
||||||
printMatches query = do
|
printMatches query = do
|
||||||
matches <- htmlQuery query
|
matches <- htmlQuery query
|
||||||
liftIO $ hPutStrLn stderr $ show matches
|
liftIO $ hPutStrLn stderr $ show matches
|
||||||
@ -298,7 +333,12 @@ fileByName name path mimetype = do
|
|||||||
|
|
||||||
-- This looks up the name of a field based on the contents of the label pointing to it.
|
-- This looks up the name of a field based on the contents of the label pointing to it.
|
||||||
nameFromLabel :: T.Text -> RequestBuilder T.Text
|
nameFromLabel :: T.Text -> RequestBuilder T.Text
|
||||||
nameFromLabel label = withResponse $ \ res -> do
|
nameFromLabel label = do
|
||||||
|
mres <- fmap rbdResponse ST.get
|
||||||
|
res <-
|
||||||
|
case mres of
|
||||||
|
Nothing -> failure "nameFromLabel: No response available"
|
||||||
|
Just res -> return res
|
||||||
let
|
let
|
||||||
body = simpleBody res
|
body = simpleBody res
|
||||||
mfor = parseHTML body
|
mfor = parseHTML body
|
||||||
@ -345,7 +385,7 @@ fileByLabel label path mime = do
|
|||||||
-- Receives a CSS selector that should resolve to the form element containing the nonce.
|
-- Receives a CSS selector that should resolve to the form element containing the nonce.
|
||||||
addNonce_ :: Query -> RequestBuilder ()
|
addNonce_ :: Query -> RequestBuilder ()
|
||||||
addNonce_ scope = do
|
addNonce_ scope = do
|
||||||
matches <- htmlQuery $ scope `mappend` "input[name=_token][type=hidden][value]"
|
matches <- htmlQuery' rbdResponse $ scope <> "input[name=_token][type=hidden][value]"
|
||||||
case matches of
|
case matches of
|
||||||
[] -> failure $ "No nonce found in the current page"
|
[] -> failure $ "No nonce found in the current page"
|
||||||
element:[] -> byName "_token" $ head $ attribute "value" $ parseHTML element
|
element:[] -> byName "_token" $ head $ attribute "value" $ parseHTML element
|
||||||
@ -380,22 +420,26 @@ doRequest method url paramsBuild = doRequestHeaders method url [] paramsBuild
|
|||||||
-- headers as well as letting you specify the request method.
|
-- headers as well as letting you specify the request method.
|
||||||
doRequestHeaders :: H.Method -> BS8.ByteString -> [H.Header] -> RequestBuilder a -> YesodExample site ()
|
doRequestHeaders :: H.Method -> BS8.ByteString -> [H.Header] -> RequestBuilder a -> YesodExample site ()
|
||||||
doRequestHeaders method url extrahead paramsBuild = do
|
doRequestHeaders method url extrahead paramsBuild = do
|
||||||
OneSpecData app conn oldCookies mRes <- ST.get
|
YesodExampleData app conn oldCookies mRes <- ST.get
|
||||||
|
|
||||||
-- expire cookies and filter them for the current path. TODO: support max age
|
-- expire cookies and filter them for the current path. TODO: support max age
|
||||||
currentUtc <- liftIO getCurrentTime
|
currentUtc <- liftIO getCurrentTime
|
||||||
let cookies = M.filter (checkCookieTime currentUtc) oldCookies
|
let cookies = M.filter (checkCookieTime currentUtc) oldCookies
|
||||||
cookiesForPath = M.filter checkCookiePath cookies
|
cookiesForPath = M.filter checkCookiePath cookies
|
||||||
|
|
||||||
RequestBuilderData parts _ <- liftIO $ ST.execStateT paramsBuild $ RequestBuilderData [] mRes
|
RequestBuilderData parts _ <- liftIO $ ST.execStateT paramsBuild RequestBuilderData
|
||||||
let req = if DL.any isFile parts
|
{ rbdPosts = []
|
||||||
then makeMultipart cookiesForPath parts
|
, rbdResponse = mRes
|
||||||
else makeSinglepart cookiesForPath parts
|
}
|
||||||
|
let maker
|
||||||
|
| DL.any isFile parts = makeMultipart
|
||||||
|
| otherwise = makeSinglepart
|
||||||
|
req = maker cookiesForPath parts
|
||||||
|
|
||||||
response <- liftIO $ runSession (srequest req) app
|
response <- liftIO $ runSession (srequest req) app
|
||||||
let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response
|
let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response
|
||||||
cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
|
cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
|
||||||
ST.put $ OneSpecData app conn cookies' (Just response)
|
ST.put $ YesodExampleData app conn cookies' (Just response)
|
||||||
where
|
where
|
||||||
isFile (ReqFilePart _ _ _ _) = True
|
isFile (ReqFilePart _ _ _ _) = True
|
||||||
isFile _ = False
|
isFile _ = False
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user