packages feed

mangopay-1.2: test/Web/MangoPay/TestUtils.hs

{-# LANGUAGE RankNTypes,OverloadedStrings,DeriveDataTypeable #-}
{-# OPTIONS_GHC -F -pgmF htfpp #-}
module Web.MangoPay.TestUtils where

import Web.MangoPay

import Data.ByteString.Lazy as BS hiding (map,any,null)
import Network.HTTP.Conduit as H
import Data.Maybe
import Test.Framework

import Network.Wai as W
import Network.Wai.Handler.Warp
import Network.HTTP.Types (status200)
import Blaze.ByteString.Builder (copyByteString)
import Data.Aeson as A
import Control.Concurrent (forkIO, ThreadId, threadDelay)
import Control.Concurrent.MVar (MVar, newMVar, putMVar, takeMVar)
import Control.Monad (when, void, liftM)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Resource (ResourceT, runResourceT)

import Data.Text (Text)
import Data.List
import Data.Typeable
import Control.Applicative
import Test.HUnit (Assertion)
import Data.Default (def)
import Data.IORef
import System.IO.Unsafe (unsafePerformIO)
import Control.Monad.Logger

-- | a test card
testCardInfo1 :: CardInfo
testCardInfo1 = CardInfo "4970100000000154" "1220" "123"

-- | test MangoPay API call, logging in with the client credentials
-- expects a file called client.test.conf containing the JSON of client credentials
-- in the current directory
testMP :: forall b.
            (AccessToken -> MangoPayT (LoggingT (ResourceT IO)) b)
            -> IO b
testMP f= do
        ior<-readIORef testState
        let mgr=tsManager ior
        let at=tsAccessToken ior
        let cred=tsCredentials ior
        runResourceT $ runStdoutLoggingT $ runMangoPayT cred mgr Sandbox $ f at

-- | the test state as a top level global variable
-- <http://www.haskell.org/haskellwiki/Top_level_mutable_state>
-- since HTF does not let us thread a parameter through tests
testState :: IORef TestState
{-# NOINLINE testState #-}
testState = unsafePerformIO (newIORef $ TestState zero zero zero zero zero)
  where zero=error "testState has not been initialized yet"


-- | the test state we keep over all tests
data TestState=TestState {
    tsAccessToken :: AccessToken -- ^ the access token if we're logged in
    ,tsCredentials :: Credentials -- ^ the credentials
    ,tsManager :: H.Manager -- ^ the http manager
    ,tsHookEndPoint :: HookEndPoint -- ^ the end point for notifications
    ,tsReceivedEvents :: ReceivedEvents -- ^ the received events
  }


-- | read end point information from hook.test.conf in current folder
getHookEndPoint :: IO HookEndPoint
getHookEndPoint = do
      js<-BS.readFile "hook.test.conf"
      let mhook=decode js
      assertBool (isJust mhook)
      return $ fromJust mhook

-- | simple configuration to tell the tests what endpoint to use for notifications
data HookEndPoint = HookEndPoint{
        hepUrl :: Text
        ,hepPort :: Int
        } deriving (Show,Read,Eq,Ord,Typeable)

-- | to json
instance ToJSON HookEndPoint where
        toJSON h=object ["Url"  .= hepUrl h,"Port" .= hepPort h]

-- | from json
instance FromJSON HookEndPoint where
        parseJSON (Object v) =HookEndPoint <$>
                         v .: "Url" <*>
                         v .: "Port"
        parseJSON _=fail "HookEndPoint"


-- | the events received via the notification hook
-- uses a MVar for storing events
data ReceivedEvents=ReceivedEvents{
        events::MVar [Either EventResult Event]
        }

-- | creates the new ReceivedEvents
newReceivedEvents :: IO ReceivedEvents
newReceivedEvents=do
        mv<-newMVar []
        return $ ReceivedEvents mv

-- | test an event, checking the resource id and the event type
testEvent :: Maybe Text -> EventType -> Event -> Bool
testEvent tid et evt= tid == (Just $ eResourceId evt)
        && et == eEventType evt

-- | check that we're receiving events of the given type with the resource id returned by the passed test
testEventTypes :: [EventType]
  -> IO (Maybe Text)
  -> Assertion
testEventTypes evtTs =void . testEventTypes' evtTs

-- | check that we're receiving events of the given type with the resource id returned by the passed test
-- returns the result of the inner test
testEventTypes' :: [EventType]
  -> IO (Maybe Text)
  -> IO (Maybe Text)
testEventTypes' evtTs ops=do
    res<-liftM tsReceivedEvents $ readIORef testState
    a<-ops
    mapM_ (testSearchEvent a) evtTs
    er<-waitForEvent res (map (testEvent a) evtTs) 5
    assertEqual EventsOK er
    return a

-- | assert that we find an event for the given resource id and event type
testSearchEvent :: Maybe Text -> EventType -> Assertion
testSearchEvent tid evtT=do
  es<-testMP $ searchEvents (def{espEventType=Just evtT})
  assertBool (not $ null es)
  assertBool (any ((tid ==) . Just . eResourceId) es)

-- | create a hook for a given event type
createHook :: EventType -> Assertion
createHook evtT= do
    hook<-liftM tsHookEndPoint $ readIORef testState
    h<-testMP $ storeHook (Hook Nothing Nothing Nothing (hepUrl hook) Enabled Nothing evtT)
    assertBool (isJust $ hId h)
    h2<-testMP $ fetchHook (fromJust $ hId h)
    assertEqual (hId h) (hId h2)
    assertEqual (Just Valid) (hValidity h)

-- | run a test with the notification server running
testEvents :: IO a -- ^ the test, returning a value
  -> [a -> Event -> Bool] -- ^ the test on the events, taking into account the returned value
  -> Assertion
testEvents ops tests=do
    res<-liftM tsReceivedEvents $ readIORef testState
    a<-ops
    er<-waitForEvent res (map ($ a) tests) 30
    assertEqual EventsOK er

-- | result of waiting for event
data EventResult = Timeout -- ^ didn't receive all expected events
  | EventsOK -- ^ OK: everything expected received, nothing unexpected
  | ExtraEvent Event -- ^ unexpected
  | UnhandledNotification String -- ^ notification we couldn't parse
  deriving (Show,Eq,Ord,Typeable)

-- | wait till we receive all the expected events, and none other, for a maximum number of seconds
waitForEvent :: ReceivedEvents
  -> [Event -> Bool] -- ^ function on the expected event
  -> Integer -- ^ delay in seconds
  -> IO EventResult
waitForEvent _ _ del | del<=0=return Timeout
waitForEvent rc fs del=do
        mevt<-popReceivedEvent rc
        case (mevt,fs) of
          (Nothing,[])->return EventsOK -- nothing left to process
          (Just (Left er),_)->return er -- some notification we didn't understand
          (Just (Right evt),[])->return $ ExtraEvent evt -- an event that doesn't match
          (Nothing,_)->do -- no event yet
             threadDelay 1000000
             waitForEvent rc fs (del-1)
          (Just (Right evt),_)-> -- an event, does it match
                case Data.List.foldl' (match1 evt) ([],False) fs of
                  (_,False)->return $ ExtraEvent evt -- doesn't match
                  (fs2,_)-> waitForEvent rc fs2 del -- matched, either we have more to do or we need to check no unexpected event was found

  where
    -- | match the first event function and return all the non matching function, and a flag indicating if we matched
    match1 :: Event -> ([Event -> Bool],Bool) -> (Event -> Bool) -> ([Event -> Bool],Bool)
    match1 evt (nfs,False) f
      | f evt=(nfs,True)
      | otherwise=(f:nfs,False)
    match1 _ (nfs,True) f=(f:nfs,True)

-- | get one received event (and remove it from the underlying storage)
popReceivedEvent :: ReceivedEvents -> IO (Maybe (Either EventResult Event))
popReceivedEvent (ReceivedEvents mv)=do
        evts<-takeMVar mv
        case evts of
          []->do
                putMVar mv []
                return Nothing
          (e:es)->do
                putMVar mv es
                return $ Just e

-- | get all received events (and remove them from the underlying storage)
popReceivedEvents :: ReceivedEvents -> IO [Either EventResult Event]
popReceivedEvents (ReceivedEvents mv)=do
        evts<-takeMVar mv
        putMVar mv []
        return evts

-- | add a new event
pushReceivedEvent :: ReceivedEvents -> Either EventResult Event -> IO ()
pushReceivedEvent (ReceivedEvents mv) evt=do
        evts' <-takeMVar mv
        -- we're getting events in duplicate ???
        let ns = if evt `Prelude.elem` evts' then evts' else evt:evts'
        putMVar mv ns

-- | start a HTTP server listening on the given port
-- if the path info is "mphook", then we'll push the received event
startHTTPServer :: Port -> ReceivedEvents -> IO ThreadId
startHTTPServer p revts=
  forkIO $ run p app
  where
    app req = do
                when (pathInfo req == ["mphook"]) $ do
                        let mevt=eventFromQueryString $ W.queryString req
                        liftIO $ case mevt of
                            Just evt->do
                                pushReceivedEvent revts $ Right evt
                                print evt
                            Nothing->pushReceivedEvent revts $ Left $ UnhandledNotification $ show $ W.queryString req
                return $ W.responseBuilder status200 [("Content-Type", "text/plain")] $ copyByteString "noop"