packages feed

musicbrainz-email-1.0.0.0: test/Tests.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE StandaloneDeriving #-}
module Main (main) where

--------------------------------------------------------------------------------
import Control.Applicative ((<$>), (<*>))
import Control.Exception (bracket)
import Control.Monad (replicateM)
import Control.Monad.IO.Class (liftIO)
import qualified Control.Concurrent.STM.TChan as TChan
import Data.Monoid (mempty)
import System.IO.Error (catchIOError, isDoesNotExistError)

import Database.PostgreSQL.Simple.SqlQQ (sql)


--------------------------------------------------------------------------------
import qualified Control.Error as Error
import qualified Control.Concurrent.STM as STM
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Configurator as Configurator
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Encoding
import qualified Data.Time as Time
import qualified Database.PostgreSQL.Simple as PG
import qualified Heist
import qualified Network.AMQP as AMQP
import qualified Network.HTTP as HTTP
import qualified Network.Mail.Mime as Mail
import qualified Test.SmallCheck.Series as SmallCheck
import qualified Test.Framework as Tests
import qualified Test.Framework.Providers.HUnit as Tests
import qualified Test.Framework.Providers.SmallCheck as Tests

import Data.Aeson ((.=))
import Test.HUnit ((@?=), (@?))

--------------------------------------------------------------------------------
import qualified Enqueue
import qualified Mailer
import qualified RateLimit

import qualified MusicBrainz.Email as Email
import qualified MusicBrainz.Messaging as Messaging

--------------------------------------------------------------------------------
main :: IO ()
main = do
  testConfig <- Configurator.load [ Configurator.Required "test.cfg" ]
                 `catchIOError`
                  (\e -> if isDoesNotExistError e
                           then error "test.cfg not found. Please add a test.cfg file, see test.cfg.example for more information."
                           else ioError e)

  let opt key def = Configurator.lookupDefault (def PG.defaultConnectInfo) testConfig key
  pgConf <- PG.ConnectInfo
    <$> opt "db.host" PG.connectHost
    <*> opt "db.port" PG.connectPort
    <*> opt "db.user" PG.connectUser
    <*> opt "db.password" PG.connectPassword
    <*> opt "db.database" PG.connectDatabase

  rabbitMqConf <- Messaging.RabbitMQConnection
    <$> Configurator.require testConfig "rabbitmq.host"
    <*> Configurator.require testConfig "rabbitmq.vhost"
    <*> Configurator.require testConfig "rabbitmq.user"
    <*> Configurator.require testConfig "rabbitmq.password"

  Tests.defaultMain $ map withTimeOut $
    [ enqueuePasswordResets pgConf rabbitMqConf
    , expandTemplates
    , messagesAreSent rabbitMqConf
    , invalidMessageRouting rabbitMqConf
    , sendMailFailureRouting rabbitMqConf
    , heistFailureRouting rabbitMqConf
    , rateLimitTests
    ]


--------------------------------------------------------------------------------
enqueuePasswordResets :: PG.ConnectInfo -> Messaging.RabbitMQConnection -> Tests.Test
enqueuePasswordResets pgConf rabbitConf = withTimeOut $
  Tests.testGroup "Enqueueing password reset emails"
    [ Tests.testCase "Will send password reset emails to editors with old login date and confirmed email address" $
        withRabbitMq rabbitConf $ \(rabbitMq, _) -> do
          pg <- emptyPg

          PG.execute pg
            [sql| INSERT INTO editor (name, password, email, email_confirm_date, last_login_date)
                  VALUES (?, 'ignored', ?, '2010-01-01', '2010-01-01') |]
            ( Email.passwordResetEditor $ Email.emailTemplate expected
            , Mail.addressEmail $ Email.emailTo expected
            )

          sentMessages <- spyQueue rabbitMq Email.outboxQueue

          liftIO (Enqueue.run (Enqueue.Options (Enqueue.PasswordReset pgConf) rabbitConf))

          sentMessage <- STM.atomically $ TChan.readTChan sentMessages
          (Aeson.decode (AMQP.msgBody sentMessage)) @?= Just expected

    , Tests.testCase "Will not send to editors with unconfirmed email address" $
        withRabbitMq rabbitConf $ \(rabbitMq, _) -> do
          pg <- emptyPg

          PG.execute pg
            [sql| INSERT INTO editor (name, password, email, email_confirm_date, last_login_date)
                  VALUES (?, 'ignored', ?, null, '2010-01-01') |]
            ( Email.passwordResetEditor $ Email.emailTemplate expected
            , Mail.addressEmail $ Email.emailTo expected
            )

          expectNoSentMessages rabbitMq

    , Tests.testCase "Will not send to editors who logged in recently" $
        withRabbitMq rabbitConf $ \(rabbitMq, _) -> do
          pg <- emptyPg

          PG.execute pg
            [sql| INSERT INTO editor (name, password, email, email_confirm_date, last_login_date)
                  VALUES (?, 'ignored', ?, now(), '2013-04-29') |]
            ( Email.passwordResetEditor $ Email.emailTemplate expected
            , Mail.addressEmail $ Email.emailTo expected
            )

          expectNoSentMessages rabbitMq
    ]

 where

  expectNoSentMessages rabbitMq = do
    sentMessages <- spyQueue rabbitMq Email.outboxQueue

    liftIO (Enqueue.run (Enqueue.Options (Enqueue.PasswordReset pgConf) rabbitConf))

    (STM.atomically $ TChan.isEmptyTChan sentMessages)
      @? "No emails should have been sent"

  emptyPg = do
    pg <- PG.connect pgConf
    PG.execute_ pg "TRUNCATE editor CASCADE"
    return pg

  expected = Email.Email
    { Email.emailTo = Mail.Address { Mail.addressEmail = "ollie@ocharles.org.uk"
                                   , Mail.addressName = Just "ocharles"
                                   }
    , Email.emailFrom = Mail.Address { Mail.addressEmail = "noreply@musicbrainz.org"
                                     , Mail.addressName = Just "MusicBrainz"
                                     }
    , Email.emailTemplate =
        Email.PasswordReset { Email.passwordResetEditor = "ocharles" }
    }


--------------------------------------------------------------------------------
instance Monad m => SmallCheck.Serial m Text.Text where
  series = SmallCheck.cons1 Text.pack

instance Monad m => SmallCheck.Serial m Mail.Address

expandTemplates :: Tests.Test
expandTemplates = Tests.buildTest $ do
  heist <- Mailer.loadTemplates
  return $ Tests.testGroup "Can expand templates into real emails"
    [ Tests.withDepth 4 $ Tests.testProperty "Password reset emails" $
        \editor emailAddress emailFrom ->
           let emailTo = Mail.Address { Mail.addressEmail = emailAddress
                                      , Mail.addressName = Just editor
                                      }
               Just mail = Mailer.emailToMail
                 Email.Email { Email.emailTemplate = Email.PasswordReset editor
                             , Email.emailTo = emailTo
                             , Email.emailFrom = emailFrom
                             }
                 heist
               emailBody = Encoding.decodeUtf8 . BS.concat . LBS.toChunks .
                 Mail.partContent . head . head . Mail.mailParts $ mail
           in and $ [ Mail.mailTo mail == [ emailTo ]
                    , Mail.mailFrom mail == emailFrom
                    ] ++
                    map (flip Text.isInfixOf emailBody)
                      [ changePasswordUrl editor
                      , greeting editor
                      , "https://musicbrainz.org/lost-password"
                      ]
    ]

 where

  changePasswordUrl =
    Text.append "https://musicbrainz.org/account/change-password?mandatory=1&username=" .
      Text.pack . HTTP.urlEncode . Text.unpack

  greeting = Text.append "Dear "


--------------------------------------------------------------------------------
deriving instance Eq Mail.Encoding
deriving instance Show Mail.Encoding

deriving instance Eq Mail.Mail
deriving instance Show Mail.Mail

deriving instance Eq Mail.Part
deriving instance Show Mail.Part

messagesAreSent :: Messaging.RabbitMQConnection -> Tests.Test
messagesAreSent rabbitConf = withTimeOut $
  Tests.testCase "Emails in outbox are sent by outbox consumer" $ do
    withRabbitMq rabbitConf $ \(rabbitMq, rabbitMqConn) -> do
      heist <- Mailer.loadTemplates

      sentEmails <- STM.atomically $ TChan.newTChan
      Mailer.consumeOutbox rabbitMqConn heist $
        STM.atomically . TChan.writeTChan sentEmails

      AMQP.publishMsg rabbitMq Email.outboxExchange ""
        AMQP.newMsg { AMQP.msgBody = Aeson.encode testEmail }

      sentEmail <- STM.atomically $ TChan.readTChan sentEmails
      Just sentEmail @?= Mailer.emailToMail testEmail heist


--------------------------------------------------------------------------------
testEmail :: Email.Email
testEmail = Email.Email
    { Email.emailTemplate = Email.PasswordReset "ocharles"
    , Email.emailTo =
        Mail.Address { Mail.addressName = Nothing
                     , Mail.addressEmail = "foo@example.com"
                     }
    , Email.emailFrom =
        Mail.Address { Mail.addressName = Just "MusicBrainz"
                     , Mail.addressEmail = "noreply@musicbrainz.org"
                     }
    }


--------------------------------------------------------------------------------
invalidMessageRouting :: Messaging.RabbitMQConnection -> Tests.Test
invalidMessageRouting rabbitConf = withTimeOut $
  Tests.testCase "Unparsable emails are forwarded to outbox.invalid" $ do
    withRabbitMq rabbitConf $ \(rabbitMq, rabbitMqConn) -> do
      invalidMessages <- spyQueue rabbitMq Email.invalidQueue

      heist <- Mailer.loadTemplates
      Mailer.consumeOutbox rabbitMqConn heist (const $ return ())

      AMQP.publishMsg rabbitMq Email.outboxExchange ""
        AMQP.newMsg { AMQP.msgBody = invalidRequest }

      invalidMessage <- STM.atomically $ TChan.readTChan invalidMessages
      Aeson.decode (AMQP.msgBody invalidMessage) @?=
        Just (Aeson.object [ "error" .= ("Could not decode JSON" :: String)
                           , "json" .= invalidRequest
                           ])

 where

  invalidRequest = LBS.fromChunks [Encoding.encodeUtf8 "Ceci n'est pas une JSON-request"]


--------------------------------------------------------------------------------
sendMailFailureRouting :: Messaging.RabbitMQConnection -> Tests.Test
sendMailFailureRouting rabbitConf = withTimeOut $
  Tests.testCase "If sendmail doesn't exit cleanly, messages are forwarded to outbox.unroutable" $ do
    withRabbitMq rabbitConf $ \(rabbitMq, rabbitMqConn) -> do
      unroutableMessages <- spyQueue rabbitMq Email.unroutableQueue

      heist <- Mailer.loadTemplates
      Mailer.consumeOutbox rabbitMqConn heist (const $ error errorMessage)

      AMQP.publishMsg rabbitMq Email.outboxExchange ""
        AMQP.newMsg { AMQP.msgBody = Aeson.encode testEmail }

      unroutableMessage <- STM.atomically $ TChan.readTChan unroutableMessages
      Aeson.decode (AMQP.msgBody unroutableMessage)
        @?= Just (Aeson.object [ "error" .= errorMessage
                               , "email" .= testEmail
                               ])

 where

  errorMessage = "Kaboom!"


--------------------------------------------------------------------------------
heistFailureRouting :: Messaging.RabbitMQConnection -> Tests.Test
heistFailureRouting rabbitConf = withTimeOut $
  Tests.testCase "If Heist can't expand the template, messages are forwarded to outbox.unroutable" $ do
    withRabbitMq rabbitConf $ \(rabbitMq, rabbitMqConn) -> do
      unroutableMessages <- spyQueue rabbitMq Email.unroutableQueue

      -- A 'Heist' that doesn't know about any of the templates
      Right emptyHeist <- Error.runEitherT (Heist.initHeist mempty)
      Mailer.consumeOutbox rabbitMqConn emptyHeist (const $ return ())

      AMQP.publishMsg rabbitMq Email.outboxExchange ""
        AMQP.newMsg { AMQP.msgBody = Aeson.encode testEmail }

      unroutableMessage <- STM.atomically $ TChan.readTChan unroutableMessages
      Aeson.decode (AMQP.msgBody unroutableMessage)
        @?= Just (Aeson.object
                    [ "email" .= testEmail
                    , "error" .= ("Couldn't render template" :: String)
                    ])


--------------------------------------------------------------------------------
withRabbitMq :: Messaging.RabbitMQConnection -> ((AMQP.Channel, AMQP.Connection) -> IO a) -> IO a
withRabbitMq rabbitConf = bracket acquire release
 where

  acquire = do
    rabbitMqConn <- Messaging.connect rabbitConf
    rabbitMq <- AMQP.openChannel rabbitMqConn
    Email.establishRabbitMqConfiguration rabbitMq

    AMQP.purgeQueue rabbitMq Email.outboxQueue

    return (rabbitMq, rabbitMqConn)

  release (_, conn) = AMQP.closeConnection conn


--------------------------------------------------------------------------------
withTimeOut :: Tests.Test -> Tests.Test
withTimeOut =
  Tests.plusTestOptions mempty { Tests.topt_timeout = Just (Just 50000000) }


--------------------------------------------------------------------------------
spyQueue :: AMQP.Channel
         -> String
         -> IO (TChan.TChan AMQP.Message)
spyQueue rabbitMq queue = do
  sentMessages <- STM.atomically $ TChan.newTChan

  AMQP.consumeMsgs rabbitMq queue AMQP.NoAck $ \(message, _) ->
    STM.atomically $ TChan.writeTChan sentMessages message

  return sentMessages


--------------------------------------------------------------------------------
rateLimitTests :: Tests.Test
rateLimitTests = Tests.testCase "Fast requests are rate limited" $ do
  let limit = 50
      requests = 10
      expected = (fromIntegral requests) / limit

  limitedFunction <- RateLimit.rateLimit limit (const $ return ())

  startTime <- Time.getCurrentTime

  replicateM requests (limitedFunction ())

  duration <- (`Time.diffUTCTime` startTime) <$> Time.getCurrentTime
  duration >= expected @?
    (show requests ++ " requests should take at least 1/5 second, took " ++
     show duration ++ " expected " ++ show expected)