packages feed

kiroku-store-0.13.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 Data.Vector qualified as V
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 Test.CompactionHelpers qualified as CompactionHelpers
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)

            Right allEvents <- runStoreIO store (readAllForward (GlobalPosition 0) 10)
            input <- CompactionHelpers.inputFor store [V.head allEvents]
            Right (Right (CompactionAppliedNow _)) <- runStoreIO store (applyCompaction (CompactionHelpers.manifest input))

            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 ()