packages feed

kiroku-store-0.11.0.1: test/Test/ScriptSessionErrors.hs

module Test.ScriptSessionErrors (spec) where

import Control.Exception (bracket)
import Control.Lens ((&), (.~))
import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Hasql.Connection.Settings qualified as Conn
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Errors qualified as Errors
import Hasql.Pool qualified as Pool
import Hasql.Pool.Config qualified as Config
import Hasql.Session qualified as Session
import Hasql.Statement (preparable)
import Hasql.Transaction qualified as Tx
import Kiroku.Store
import Kiroku.Test.Postgres (withMigratedTestDatabase)
import System.Timeout (timeout)
import Test.Helpers (countEvents, makeEvent, withTestStore)
import Test.Hspec

spec :: Spec
spec = describe "structured script session errors" $ do
    around withTestStore $ do
        it "preserves missing-relation SQLSTATE through runTransaction" $ \store -> do
            result <- runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 missingRelation)))
            shouldBeMissingRelation result
        it "preserves missing-relation SQLSTATE without retry" $ \store -> do
            result <- runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 missingRelation)))
            shouldBeMissingRelation result
        forM_ ["40001", "40P01"] $ \code -> do
            it ("preserves script SQLSTATE and message " <> show code) $ \store -> do
                result <- timeout 5_000_000 $ runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 (raiseCode code))))
                result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))
            it ("preserves prepared-statement SQLSTATE and message " <> show code) $ \store -> do
                result <-
                    timeout 5_000_000 $
                        runStoreIO store $
                            runTransactionNoRetry $
                                Tx.statement () (preparable (raiseCode code) E.noParams D.noResult)
                result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))
        it "rolls back application writes before a script failure" $ \store -> do
            createSideTable store
            result <- runStoreIO store $ runTransactionNoRetry $ do
                Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"
                Tx.sql (TE.encodeUtf8 missingRelation)
            shouldBeMissingRelation result
            countSideRows store `shouldReturn` Right 0
        it "rolls back composed appends and application writes on script failure" $ \store -> do
            createSideTable store
            result <- runStoreIO store $
                runTransactionAppendingNoRetry (StreamName "script-rollback") NoStream [makeEvent "Created" (Aeson.object [])] $ \_ -> do
                    Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"
                    Tx.sql (TE.encodeUtf8 missingRelation)
            shouldBeMissingRelation result
            countSideRows store `shouldReturn` Right 0
            runStoreIO store (getStream (StreamName "script-rollback")) `shouldReturn` Right Nothing
            countEvents store `shouldReturn` 0

    it "preserves missing-relation SQLSTATE through the public pool-backed read" $
        withMigratedTestDatabase $ \connStr ->
            withStore (defaultConnectionSettings connStr) $ \store ->
                withPool connStr [Config.initSession (Session.script "SET search_path TO pg_catalog")] $ \pool -> do
                    result <- runStoreIO (store & #pool .~ pool) (getStream (StreamName "missing"))
                    shouldBeMissingRelation result

    it "keeps genuine connection establishment failures typed for transactions and reads" $
        withTestStore $ \store ->
            withPool "host=/nonexistent/kiroku-script-errors connect_timeout=1" [] $ \pool -> do
                let broken = store & #pool .~ pool
                tx <- timeout 5_000_000 $ runStoreIO broken (runTransactionNoRetry (Tx.sql "SELECT 1"))
                tx `shouldSatisfy` isConnectionLost
                readResult <- timeout 5_000_000 $ runStoreIO broken (getStream (StreamName "missing"))
                readResult `shouldSatisfy` isConnectionLost

    it "routes script server errors through every contextual mapper" $ do
        forM_ ["40001", "40P01", "42P01", "40003", "23503"] $ \code -> do
            let usage = scriptUsage code fixtureMessage Nothing
                expected = if code `elem` ["40001", "40P01"] then TransientTransactionFailure code fixtureMessage else UnexpectedServerError code fixtureMessage
            mapGenericUsageError usage `shouldBe` expected
            mapTransactionUsageError usage `shouldBe` expected
            mapLinkUsageError (StreamName "target") usage `shouldBe` expected
            isTransientSerializationError usage `shouldBe` (code `elem` ["40001", "40P01"])
            if code == "23503"
                then mapUsageError "target" AnyVersion usage `shouldBe` StreamNotFound (StreamName "target")
                else do
                    mapUsageError "target" AnyVersion usage `shouldBe` expected
                    attributeMultiStreamError [(StreamName "target", AnyVersion)] usage `shouldBe` expected

    it "retains script append and link constraint attribution" $ do
        let usage = scriptUsage "23505" "duplicate key value violates unique constraint \"stream_events_pkey\"" Nothing
        mapTransactionUsageError usage `shouldBe` DuplicateEvent Nothing
        mapUsageError "target" AnyVersion usage `shouldBe` DuplicateEvent Nothing
        mapLinkUsageError (StreamName "target") usage `shouldBe` EventAlreadyLinked (StreamName "target") Nothing
        let named = scriptUsage "23505" "duplicate key value violates unique constraint \"ix_streams_stream_name\"" (Just "Key (stream_name)=(second) already exists.")
        attributeMultiStreamError [(StreamName "first", AnyVersion), (StreamName "second", NoStream)] named
            `shouldBe` StreamAlreadyExists (StreamName "second")

    it "distinguishes session connection loss, acquisition timeout and unsupported errors" $ do
        let lost = Pool.SessionUsageError (Errors.ConnectionSessionError "connection detail")
            fallback = Pool.SessionUsageError (Errors.DriverSessionError "driver detail")
        forM_ [mapGenericUsageError, mapTransactionUsageError, mapUsageError "target" AnyVersion, mapLinkUsageError (StreamName "target")] $ \mapper -> do
            mapper lost `shouldBe` ConnectionLost "connection detail"
            mapper Pool.AcquisitionTimeoutUsageError `shouldBe` PoolAcquisitionTimeout
            mapper fallback `shouldSatisfy` (\case ConnectionError _ -> True; _ -> False)
        isTransientSerializationError lost `shouldBe` False

missingRelation :: Text
missingRelation = "INSERT INTO public.kiroku_script_missing_relation VALUES (1)"

fixtureMessage :: Text
fixtureMessage = "script error fixture"

raiseCode :: Text -> Text
raiseCode code = "DO $$ BEGIN RAISE EXCEPTION USING ERRCODE = '" <> code <> "', MESSAGE = '" <> fixtureMessage <> "'; END $$"

scriptUsage :: Text -> Text -> Maybe Text -> Pool.UsageError
scriptUsage code message detail = Pool.SessionUsageError (Errors.ScriptSessionError "fixture SQL" (Errors.ServerError code message detail Nothing Nothing))

shouldBeMissingRelation :: (Show a) => Either StoreError a -> Expectation
shouldBeMissingRelation result = case result of
    Left (UnexpectedServerError "42P01" _) -> pure ()
    other -> expectationFailure ("Expected structured 42P01, got " <> show other)

isConnectionLost :: Maybe (Either StoreError a) -> Bool
isConnectionLost (Just (Left (ConnectionLost _))) = True
isConnectionLost _ = False

withPool :: Text -> [Config.Setting] -> (Pool.Pool -> IO a) -> IO a
withPool connStr extra = bracket (Pool.acquire (Config.settings ([Config.staticConnectionSettings (Conn.connectionString connStr), Config.size 1, Config.acquisitionTimeout 1] <> extra))) Pool.release

createSideTable :: KirokuStore -> IO ()
createSideTable store = runStoreIO store (runTransactionNoRetry (Tx.sql "CREATE TABLE public.script_error_rows (id integer)")) `shouldReturn` Right ()

countSideRows :: KirokuStore -> IO (Either StoreError Int64)
countSideRows store =
    runStoreIO store $
        runTransactionNoRetry $
            Tx.statement () (preparable "SELECT count(*) FROM public.script_error_rows" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))