packages feed

kiroku-store-0.3.0.0: test/Test/NotifyGuard.hs

module Test.NotifyGuard (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM (TVar, atomically, check, modifyTVar', newTVarIO, readTVar)
import Control.Exception (bracket, throwIO)
import Control.Lens ((^.))
import Data.Aeson qualified as Aeson
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Conn
import Hasql.Notifications qualified as Notifications
import Kiroku.Store
import Kiroku.Test.Postgres (withMigratedTestDatabase)
import System.IO.Error (userError)
import Test.Helpers (makeEvent)
import Test.Hspec

spec :: Spec
spec =
    describe "NOTIFY trigger guard" $
        around withNotifyStore $ do
            it "emits one unchanged append payload and no lifecycle or $all payloads" $ \(store, listenerConn) -> do
                payloads <- newTVarIO []

                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")
                Async.withAsync
                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)
                    $ \_listener -> do
                        let firstStream = StreamName "notify-guard-1"
                            secondStream = StreamName "notify-guard-2"

                        Right first <-
                            runStoreIO store $
                                appendToStream firstStream NoStream [makeEvent "NotifyGuardFirst" (Aeson.object [])]
                        waitForPayload payloads (expectedPayload firstStream first)

                        Right _ <- runStoreIO store $ softDeleteStream firstStream
                        Right _ <- runStoreIO store $ undeleteStream firstStream

                        Right second <-
                            runStoreIO store $
                                appendToStream secondStream NoStream [makeEvent "NotifyGuardSecond" (Aeson.object [])]
                        waitForPayload payloads (expectedPayload secondStream second)

                        actual <- atomically (readTVar payloads)
                        actual
                            `shouldBe` [ expectedPayload firstStream first
                                       , expectedPayload secondStream second
                                       ]
                        map payloadFieldCount actual `shouldBe` [3, 3]
                        actual `shouldSatisfy` all (not . BS.isPrefixOf "$all,")

            it "emits the unchanged payload for a 512-byte stream name" $ \(store, listenerConn) -> do
                payloads <- newTVarIO []

                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")
                Async.withAsync
                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)
                    $ \_listener -> do
                        let stream = StreamName (T.replicate 512 "n")

                        Right result <-
                            runStoreIO store $
                                appendToStream stream NoStream [makeEvent "NotifyGuardMaxName" (Aeson.object [])]
                        let expected = expectedPayload stream result
                        waitForPayload payloads expected

                        actual <- atomically (readTVar payloads)
                        actual `shouldBe` [expected]
                        payloadFieldCount expected `shouldBe` 3

withNotifyStore :: ((KirokuStore, Connection.Connection) -> IO ()) -> IO ()
withNotifyStore action =
    withMigratedTestDatabase $ \connStr ->
        withStore (defaultConnectionSettings connStr) $ \store ->
            withConnection connStr $ \listenerConn ->
                action (store, listenerConn)

withConnection :: T.Text -> (Connection.Connection -> IO a) -> IO a
withConnection connStr =
    bracket acquire Connection.release
  where
    acquire = do
        result <- Connection.acquire (Conn.connectionString connStr)
        case result of
            Left err -> throwIO (userError ("failed to acquire LISTEN connection: " <> show err))
            Right conn -> pure conn

expectedPayload :: StreamName -> AppendResult -> ByteString
expectedPayload (StreamName name) result =
    let StreamId sid = result ^. #streamId
        StreamVersion version = result ^. #streamVersion
     in T.encodeUtf8 $
            T.intercalate
                ","
                [ name
                , T.pack (show sid)
                , T.pack (show version)
                ]

payloadFieldCount :: ByteString -> Int
payloadFieldCount = length . BS.split 44

waitForPayload :: TVar [ByteString] -> ByteString -> IO ()
waitForPayload payloads expected = do
    result <-
        Async.race
            (threadDelay 5_000_000)
            ( atomically $ do
                seen <- readTVar payloads
                check (expected `elem` seen)
            )
    case result of
        Left () -> do
            seen <- atomically (readTVar payloads)
            expectationFailure ("timed out waiting for notification " <> show expected <> "; saw " <> show seen)
        Right () -> pure ()