packages feed

eventsourcing-0.9.0: test/Database/CQRS/TabularDataTest.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}

module Database.CQRS.TabularDataTest
  ( tests
  ) where

import Control.DeepSeq (NFData)
import Control.Concurrent (ThreadId, forkIO, killThread, threadDelay)
import Control.Monad
import Control.Monad.Trans (MonadIO(..))
import Data.List (nub)
import GHC.Generics (Generic)
import Hedgehog
import Test.Tasty
import Test.Tasty.Hedgehog

import qualified Control.Concurrent.STM     as STM
import qualified Control.Monad.Except       as Exc
import qualified Control.Monad.State.Strict as St
import qualified Data.HashMap.Strict        as HM
import qualified Hedgehog.Gen               as Gen
import qualified Hedgehog.Range             as Range

import Database.CQRS.TabularData (pattern (:~), empty)
import Helpers (interleaveSeqs)

import qualified Database.CQRS             as CQRS
import qualified Database.CQRS.InMemory    as CQRS.InMem
import qualified Database.CQRS.TabularData as CQRS.Tab

tests :: TestTree
tests = testGroup "Database.CQRS.TabularData"
  [ testProperty "Projection using tabular data behaves like aggregator" $
      property tabularDataVsAggregator
  , testProperty "Projection can be restarted" $ property restartingProjection
  ]

tabularDataVsAggregator :: PropertyT IO ()
tabularDataVsAggregator = do
  streamIds <- forAll $
    nub <$> Gen.list (Range.linear 1 10) (Gen.int (Range.linear 1 1000))

  events <- forAll $
    foldM interleaveSeqs [] <=< forM streamIds $ \streamId ->
      Gen.list (Range.linear 1 1000) ((streamId,) <$> testEventGen)

  n <- forAll $ Gen.int (Range.linear 0 (length events))
  let (beforeEvents, afterEvents) = splitAt n events

  streamFamily <- liftIO CQRS.InMem.emptyStreamFamily

  trackingTable <- CQRS.createInMemoryTrackingTable
  table <- liftIO $ STM.newTVarIO []

  liftIO . forM_ beforeEvents $ \(streamId, event) -> do
    stream <- CQRS.getStream streamFamily streamId
    void . Exc.runExceptT $ CQRS.writeEvent stream event

  threadId <- liftIO . forkIO . void .  Exc.runExceptT $ do
    CQRS.runProjection streamFamily id goodProjection trackingTable $
      CQRS.executeInMemoryActions table CQRS.Tab.applyTabularDataAction

  liftIO . forM_ afterEvents $ \(streamId, event) -> do
    stream <- CQRS.getStream streamFamily streamId
    void . Exc.runExceptT $ CQRS.writeEvent stream event

  forM_ streamIds $
    checkConsistency True streamFamily trackingTable table threadId

  liftIO . killThread $ threadId

checkConsistency
  :: Bool
  -> CQRS.InMem.StreamFamily Int () TestEvent
  -> CQRS.InMemoryTrackingTable Int Integer Int
  -> STM.TVar (CQRS.Tab.Table ProjectedCols)
  -> ThreadId
  -> Int
  -> PropertyT IO ()
checkConsistency
    shouldRetry streamFamily trackingTable table threadId streamId = do
  projected <- liftIO $ STM.readTVarIO table

  stream <- CQRS.getStream streamFamily streamId
  eExpected <- Exc.runExceptT $
    CQRS.runAggregator aggregator stream mempty (streamId, NoRow)
  let mResult = HM.lookup (streamId :~ empty) projected

  trackedState <- liftIO $ CQRS.getTrackedState trackingTable streamId
  annotateShow trackedState

  case (mResult, eExpected) of
    (Nothing, Right ((_, NoRow), _, _)) -> pure ()
    ( Just (counter :~ CQRS.Tab.Nil),
      Right ((_, SuccessState counter'), _, _) )
      | counter == counter' -> pure ()
    ( Just (counter :~ CQRS.Tab.Nil),
      Right ((_, FailedState counter'), _, _) )
      | counter == counter' -> pure ()
    _ | shouldRetry -> do
          liftIO . threadDelay $ 10 * 1000 -- Wait 10ms for the projection.
          checkConsistency
            False streamFamily trackingTable table threadId streamId
      | otherwise -> do
          liftIO . killThread $ threadId
          annotateShow mResult
          annotateShow eExpected
          failure

restartingProjection :: PropertyT IO ()
restartingProjection = do
  events <- forAll $ Gen.list (Range.linear 1 100) testEventGen
  n <- forAll $ Gen.int (Range.linear 0 (length events))
  let (beforeEvents, afterEvents) = splitAt n events

  streamFamily <- liftIO CQRS.InMem.emptyStreamFamily

  trackingTable <- CQRS.createInMemoryTrackingTable
  -- Set a dummy row in the table to make the bad projection crash.
  table <- liftIO $ STM.newTVarIO [(0 :~ empty, 0 :~ empty)]

  liftIO . forM_ beforeEvents $ \event -> do
    stream <- CQRS.getStream streamFamily 1
    void . Exc.runExceptT $ CQRS.writeEvent stream event

  threadId <- liftIO . forkIO . void . Exc.runExceptT $
    CQRS.runProjection streamFamily id badProjection trackingTable $
      CQRS.executeInMemoryActions table CQRS.Tab.applyTabularDataAction

  liftIO . forM_ afterEvents $ \event -> do
    stream <- CQRS.getStream streamFamily 1
    void . Exc.runExceptT $ CQRS.writeEvent stream event

  liftIO . killThread $ threadId
  threadId' <- liftIO . forkIO . void . Exc.runExceptT $
    CQRS.runProjection streamFamily id goodProjection trackingTable $
      CQRS.executeInMemoryActions table CQRS.Tab.applyTabularDataAction

  checkConsistency True streamFamily trackingTable table threadId' 1

  liftIO . killThread $ threadId'

data TestEvent
  = CreatedEvent
  | IncrementedEvent
  | ResetEvent
  | DeletedEvent
  deriving (Bounded, Enum, Eq, Generic, NFData, Show)

testEventGen :: Gen TestEvent
testEventGen = Gen.enumBounded

type ProjectedCols =
  'CQRS.Tab.WithUniqueKey
    '[ '("stream_id", Int)]
    '[ '("counter", Int)]

goodProjection
  :: CQRS.Projection
      (CQRS.EventWithContext Integer () TestEvent)
      Int
      (CQRS.Tab.TabularDataAction ProjectedCols)
goodProjection CQRS.EventWithContext{..} = do
  streamId <- St.get

  pure $ case event of
    CreatedEvent ->
      [ CQRS.Tab.Insert $ streamId :~ 0 :~ empty
      ]

    IncrementedEvent ->
      [ CQRS.Tab.Update
          (CQRS.Tab.ffield @ProjectedCols @"counter" (CQRS.Tab.plus 1))
          (CQRS.Tab.field @ProjectedCols @"stream_id" streamId)
      ]

    ResetEvent ->
      [ CQRS.Tab.Upsert $ streamId :~ 0 :~ empty
      ]

    DeletedEvent ->
      [ CQRS.Tab.Delete $
          CQRS.Tab.ffield @ProjectedCols @"stream_id" (CQRS.Tab.equal streamId)
      ]

badProjection
  :: CQRS.Projection
      (CQRS.EventWithContext Integer () TestEvent)
      Int
      (CQRS.Tab.TabularDataAction ProjectedCols)
badProjection CQRS.EventWithContext{..} = do
  streamId <- St.get

  pure $ case event of
    CreatedEvent ->
      [ CQRS.Tab.Insert $ streamId :~ 0 :~ empty
      ]

    IncrementedEvent ->
      [ CQRS.Tab.Insert $ 0 :~ 0 :~ empty -- Make it fail.
      ]

    ResetEvent ->
      [ CQRS.Tab.Upsert $ streamId :~ 0 :~ empty
      ]

    DeletedEvent ->
      [ CQRS.Tab.Delete $
          CQRS.Tab.ffield @ProjectedCols @"stream_id" (CQRS.Tab.equal streamId)
      ]

data ExpectedResult
  = NoRow
  | FailedState Int
  | SuccessState Int
  deriving Show

aggregator
  :: CQRS.Aggregator
      (CQRS.EventWithContext Integer () TestEvent)
      (Int, ExpectedResult)
aggregator CQRS.EventWithContext{..} =
  St.modify' $ \(streamId, current) ->
    (streamId,) $ case (event, current) of
      (_, FailedState _) -> current
      (CreatedEvent, NoRow) -> SuccessState 0
      (CreatedEvent, SuccessState c) -> FailedState c
      (IncrementedEvent, NoRow) -> NoRow
      (IncrementedEvent, SuccessState c) -> SuccessState (c + 1)
      (ResetEvent, _) -> SuccessState 0
      (DeletedEvent, _) -> NoRow