packages feed

kiroku-store-0.1.0.0: test/Test/Properties.hs

{- | Property-based tests for invariants identified by EP-1 through EP-5.

Each property generates a random sequence of operations and asserts that
some invariant holds after the sequence executes. The properties target
the cross-cutting invariants enumerated in EP-6 M1's Invariant List
(F2–F8 of @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@):

  * F2 — global position contiguity across appends, multi-stream appends,
    and links.
  * F3 — no orphan event payloads after lifecycle operations.
  * F4 — soft-delete write barrier under arbitrary append constructors.
  * F5 — caller-supplied event-id idempotence.

These properties run inside hspec via 'hspec-hedgehog'. Each property
acquires a fresh ephemeral PostgreSQL via 'withTestStore' inside the
hedgehog test action; the operation count per property is small (10-20
ops) to keep total runtime bounded.
-}
module Test.Properties (spec) where

import Control.Lens ((^.))
import Control.Monad (forM_, void)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.List (sort)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.UUID.V4 qualified as UUIDv4
import Data.Vector qualified as V
import Hedgehog
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Kiroku.Store
import Test.Helpers (makeEvent, withTestStore)
import Test.Hspec
import Test.Hspec.Hedgehog (PropertyT, hedgehog, modifyMaxSuccess)

-- | Operations the property generators can produce.
data Op
    = OpAppend !StreamName ![Text]
    | OpAppendMulti ![(StreamName, [Text])]
    | OpSoftDelete !StreamName
    | OpUndelete !StreamName
    | OpHardDelete !StreamName
    deriving stock (Show)

genStreamName :: Gen StreamName
genStreamName = do
    -- Bounded universe (5 streams) so generated sequences exercise reuse.
    n <- Gen.int (Range.linear 0 4)
    pure (StreamName ("prop-" <> T.pack (show n)))

genEventTypes :: Gen [Text]
genEventTypes = do
    n <- Gen.int (Range.linear 1 3)
    pure (map (\i -> "Type" <> T.pack (show i)) [1 .. n])

genOp :: Gen Op
genOp =
    Gen.frequency
        [ (60, OpAppend <$> genStreamName <*> genEventTypes)
        ,
            ( 10
            , OpAppendMulti
                <$> Gen.list
                    (Range.linear 2 3)
                    ((,) <$> genStreamName <*> genEventTypes)
            )
        , (10, OpSoftDelete <$> genStreamName)
        , (10, OpUndelete <$> genStreamName)
        , (10, OpHardDelete <$> genStreamName)
        ]

genOps :: Gen [Op]
genOps = Gen.list (Range.linear 1 12) genOp

{- | Run a generated operation, ignoring any 'StoreError' (the property
is about the system's invariants surviving any sequence of attempts,
including ones that fail).
-}
runOp :: KirokuStore -> Op -> IO ()
runOp store = \case
    OpAppend sn types -> do
        let evts = map (\t -> makeEvent t (Aeson.object [])) types
        void $ runStoreIO store $ appendToStream sn AnyVersion evts
    OpAppendMulti ops -> do
        let triples = map (\(sn, types) -> (sn, AnyVersion, map (\t -> makeEvent t (Aeson.object [])) types)) ops
        void $ runStoreIO store $ appendMultiStream triples
    OpSoftDelete sn -> void $ runStoreIO store $ softDeleteStream sn
    OpUndelete sn -> void $ runStoreIO store $ undeleteStream sn
    OpHardDelete sn -> void $ runStoreIO store $ hardDeleteStream sn

-- | Read the global position cursor by counting events on `$all`.
readAllCount :: KirokuStore -> IO Int
readAllCount store = do
    Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000
    pure (V.length v)

{- | Read all events on `$all` and return their global positions in
the order returned.
-}
readAllPositions :: KirokuStore -> IO [Int]
readAllPositions store = do
    Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000
    pure (V.toList (V.map (\e -> case e ^. #globalPosition of GlobalPosition n -> fromIntegral n) v))

{- | Each property creates a fresh ephemeral PostgreSQL per test case,
which dominates wall-clock time. Cap iterations at 15 per property —
enough to exercise generator coverage without blowing the suite past
two minutes.
-}
spec :: Spec
spec = modifyMaxSuccess (const 15) $ do
    describe "kiroku-store invariants (property-based, hedgehog)" $ do
        -- F2 — Global position contiguity. After any sequence of
        -- appends, multi-appends, soft/undelete/hard-delete operations,
        -- the global positions on $all (excluding hard-deleted events)
        -- form a strictly ascending sequence with no duplicates and no
        -- gaps relative to the events that actually exist on $all.
        it "$all global positions are strictly ascending and unique (F2)" $
            hedgehog $
                propAllPositionsAscending

        -- F4 — Soft-delete write barrier. After softDeleteStream, no
        -- subsequent appendToStream of any ExpectedVersion succeeds
        -- (until undeleteStream). Generates a random append, soft-deletes,
        -- attempts further appends with each ExpectedVersion constructor,
        -- asserts each one fails.
        it "soft-deleted streams reject appends of every ExpectedVersion (F4)" $
            hedgehog $
                propSoftDeleteBarrier

        -- F5 — Idempotent caller-supplied event ids. A batch of appends
        -- with all-unique event ids succeeds; a batch where any id is a
        -- repeat of a previously committed id fails with DuplicateEvent.
        it "duplicate caller-supplied event ids are rejected (F5)" $
            hedgehog $
                propDuplicateEventIds

propAllPositionsAscending :: PropertyT IO ()
propAllPositionsAscending = do
    ops <- forAll genOps
    positions <- evalIO $ withTestStoreReturn $ \store -> do
        forM_ ops (runOp store)
        readAllPositions store
    annotateShow positions
    -- Strictly ascending: each position greater than the previous.
    sort positions === positions
    -- Unique: no duplicates.
    length positions === length (Set.fromList positions)
    -- The count returned by $all matches the position list length.
    cnt <- evalIO $ withTestStoreReturn $ \store -> do
        forM_ ops (runOp store)
        readAllCount store
    cnt === length positions

propSoftDeleteBarrier :: PropertyT IO ()
propSoftDeleteBarrier = do
    streamSeed <- forAll (Gen.int (Range.linear 0 9))
    nEvents <- forAll (Gen.int (Range.linear 1 4))
    evalIO $ withTestStoreReturn $ \store -> do
        let sn = StreamName ("barrier-" <> T.pack (show streamSeed))
        let evts = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. nEvents]
        Right _ <- runStoreIO store $ appendToStream sn NoStream evts
        Right _ <- runStoreIO store $ softDeleteStream sn
        -- Every subsequent append constructor must be rejected.
        rNo <- runStoreIO store $ appendToStream sn NoStream [makeEvent "X" (Aeson.object [])]
        case rNo of
            Left (StreamAlreadyExists _) -> pure ()
            other -> error ("F4 violated: NoStream against soft-deleted should be StreamAlreadyExists, got: " <> show other)
        rExact <- runStoreIO store $ appendToStream sn (ExactVersion (StreamVersion (fromIntegral nEvents))) [makeEvent "X" (Aeson.object [])]
        case rExact of
            Left (WrongExpectedVersion _ _ _) -> pure ()
            Left (StreamNotFound _) -> pure ()
            other -> error ("F4 violated: ExactVersion against soft-deleted should fail, got: " <> show other)
        rExists <- runStoreIO store $ appendToStream sn StreamExists [makeEvent "X" (Aeson.object [])]
        case rExists of
            Left (StreamNotFound _) -> pure ()
            other -> error ("F4 violated: StreamExists against soft-deleted should be StreamNotFound, got: " <> show other)
        rAny <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "X" (Aeson.object [])]
        case rAny of
            Left (StreamNotFound _) -> pure ()
            other -> error ("F4 violated: AnyVersion against soft-deleted should be StreamNotFound, got: " <> show other)

propDuplicateEventIds :: PropertyT IO ()
propDuplicateEventIds = do
    nFirst <- forAll (Gen.int (Range.linear 1 4))
    evalIO $ withTestStoreReturn $ \store -> do
        -- Generate a batch of unique caller-supplied event ids.
        ids <- mapM (const UUIDv4.nextRandom) [1 .. nFirst]
        let mkEvent uid =
                EventData
                    { eventId = Just (EventId uid)
                    , eventType = EventType "Dup"
                    , payload = Aeson.object []
                    , metadata = Nothing
                    , causationId = Nothing
                    , correlationId = Nothing
                    }
        let evts1 = map mkEvent ids
        r1 <- runStoreIO store $ appendToStream (StreamName "dup-prop-1") NoStream evts1
        case r1 of
            Right _ -> pure ()
            other -> error ("Initial append with unique ids should succeed: " <> show other)
        -- Re-issue any one of those ids in a fresh stream — must be rejected.
        let dupId = case ids of
                (i : _) -> i
                [] -> error "propDuplicateEventIds: nFirst >= 1 by Range, ids must be non-empty"
        let evts2 = [mkEvent dupId]
        r2 <- runStoreIO store $ appendToStream (StreamName "dup-prop-2") NoStream evts2
        case r2 of
            Left (DuplicateEvent _) -> pure ()
            other -> error ("F5 violated: duplicate event id should be rejected, got: " <> show other)

-- | Variant of 'withTestStore' that returns the action's result.
withTestStoreReturn :: (KirokuStore -> IO a) -> IO a
withTestStoreReturn action = do
    ref <- newIORef (Nothing :: Maybe a)
    withTestStore $ \store -> do
        r <- action store
        atomicModifyIORef' ref (\_ -> (Just r, ()))
    readIORef ref >>= \case
        Just r -> pure r
        Nothing -> error "withTestStoreReturn: action did not produce a result"