keiki-0.2.0.0: test/Keiki/ReplayEitherSpec.hs
module Keiki.ReplayEitherSpec (spec) where
import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
import Keiki.Core
import Keiki.Fixtures.UserRegistration
import Test.Hspec
t :: Integer -> UTCTime
t seconds = UTCTime (fromGregorian 2026 5 1) (secondsToDiffTime seconds)
headEvent :: UserEvent
headEvent =
RegistrationStarted
(RegistrationStartedData "alice@x" "Z9F4" (t 0))
tailEvent :: UserEvent
tailEvent = ConfirmationEmailSent (ConfirmationEmailSentData "alice@x")
canonicalLog :: [UserEvent]
canonicalLog =
[ headEvent,
tailEvent,
ConfirmationResent (ConfirmationResentData "alice@x" "K2P7" (t 100)),
AccountConfirmed (AccountConfirmedData "alice@x" "K2P7" (t 200)),
AccountDeleted (AccountDeletedData "alice@x" (t 300))
]
type Snapshot = (Email, ConfirmationCode, UTCTime, UTCTime, UTCTime)
snapshot :: RegFile UserRegRegs -> Snapshot
snapshot regs =
( regs ! #email,
regs ! #confirmCode,
regs ! #registeredAt,
regs ! #confirmedAt,
regs ! #deletedAt
)
expectedSnapshot :: Snapshot
expectedSnapshot = ("alice@x", "K2P7", t 100, t 200, t 300)
duplicateEntrance :: [Edge (HsPred UserRegRegs UserCmd) UserRegRegs UserCmd UserEvent Vertex]
duplicateEntrance = case edgesOut userRegAST PotentialCustomer of
[edge] -> [edge, edge {target = Confirmed}]
_ -> error "userRegAST must have exactly one PotentialCustomer edge"
ambiguousUserReg ::
SymTransducer
(HsPred UserRegRegs UserCmd)
UserRegRegs
Vertex
UserCmd
UserEvent
ambiguousUserReg =
userRegAST
{ edgesOut = \source ->
if source == PotentialCustomer
then duplicateEntrance
else edgesOut userRegAST source
}
singleStepSpec :: Spec
singleStepSpec = describe "applyEventStreamingEither" $ do
it "reports every rejected outgoing edge when no head output inverts" $
case applyEventStreamingEither
userReg
(Settled PotentialCustomer)
(initialRegs userReg)
(AccountConfirmed (AccountConfirmedData "alice@x" "Z9F4" (t 0))) of
Left failure ->
failure
`shouldBe` ReplayNoInvertingEdge
PotentialCustomer
[ RejectedEdgeSummary
{ rejectedEdge =
EdgeRef
{ edgeSource = PotentialCustomer,
edgeIndex = 0
},
rejectedTarget = RequiresConfirmation,
rejectedGuard = False
}
]
Right _ -> expectationFailure "expected ReplayNoInvertingEdge"
it "reports the observed event and full expected queue on a mismatch" $
case applyEventStreamingEither
userReg
(InFlight RequiresConfirmation [tailEvent])
(initialRegs userReg)
(AccountDeleted (AccountDeletedData "alice@x" (t 999))) of
Left failure ->
failure
`shouldBe` ReplayQueueMismatch
RequiresConfirmation
(AccountDeleted (AccountDeletedData "alice@x" (t 999)))
[tailEvent]
Right _ -> expectationFailure "expected ReplayQueueMismatch"
it "reports every edge whose head output inverts ambiguously" $
case applyEventStreamingEither
ambiguousUserReg
(Settled PotentialCustomer)
(initialRegs ambiguousUserReg)
headEvent of
Left failure ->
failure
`shouldBe` ReplayAmbiguousInversions
PotentialCustomer
[ MatchedEdgeSummary
{ matchedEdge =
EdgeRef
{ edgeSource = PotentialCustomer,
edgeIndex = 0
},
matchedTarget = RequiresConfirmation
},
MatchedEdgeSummary
{ matchedEdge =
EdgeRef
{ edgeSource = PotentialCustomer,
edgeIndex = 1
},
matchedTarget = Confirmed
}
]
Right _ -> expectationFailure "expected ReplayAmbiguousInversions"
it "keeps applyEventStreaming as a Nothing-returning failure wrapper" $
case applyEventStreaming
ambiguousUserReg
(Settled PotentialCustomer)
(initialRegs ambiguousUserReg)
headEvent of
Nothing -> pure ()
Just _ -> expectationFailure "expected Nothing from compatibility wrapper"
spec :: Spec
spec = do
singleStepSpec
describe "reconstituteEither" $ do
it "replays the canonical log to Deleted with the expected snapshot" $
case reconstituteEither userReg canonicalLog of
Right (finalVertex, finalRegs) ->
(finalVertex, snapshot finalRegs)
`shouldBe` (Deleted, expectedSnapshot)
Left failure -> expectationFailure ("unexpected replay failure: " <> show failure)
it "names the exact corrupted event index and queue mismatch" $
let observed = AccountDeleted (AccountDeletedData "alice@x" (t 999))
corrupted = headEvent : observed : drop 2 canonicalLog
in case reconstituteEither userReg corrupted of
Left failure ->
failure
`shouldBe` ReplayFailure
{ replayFailedIndex = 1,
replayFailedState = InFlight RequiresConfirmation [tailEvent],
replayFailureReason =
ReplayEventFailed
( ReplayQueueMismatch
RequiresConfirmation
observed
[tailEvent]
)
}
Right _ -> expectationFailure "expected corrupted log to fail"
it "reports a foreign first event at index zero" $
let foreignEvent = AccountConfirmed (AccountConfirmedData "alice@x" "Z9F4" (t 0))
in case reconstituteEither userReg [foreignEvent] of
Left failure ->
failure
`shouldBe` ReplayFailure
{ replayFailedIndex = 0,
replayFailedState = Settled PotentialCustomer,
replayFailureReason =
ReplayEventFailed
( ReplayNoInvertingEdge
PotentialCustomer
[ RejectedEdgeSummary
{ rejectedEdge =
EdgeRef
{ edgeSource = PotentialCustomer,
edgeIndex = 0
},
rejectedTarget = RequiresConfirmation,
rejectedGuard = False
}
]
)
}
Right _ -> expectationFailure "expected foreign event to fail"
it "reports a truncated multi-event chain at the input length" $
case reconstituteEither userReg [headEvent] of
Left failure ->
failure
`shouldBe` ReplayFailure
{ replayFailedIndex = 1,
replayFailedState = InFlight RequiresConfirmation [tailEvent],
replayFailureReason = ReplayLogTruncated [tailEvent]
}
Right _ -> expectationFailure "expected truncated chain to fail"
describe "replayEvents" $ do
it "resumes from a caller-supplied mid-chain seed" $
case applyEventStreamingEither
userReg
(Settled PotentialCustomer)
(initialRegs userReg)
headEvent of
Left failure -> expectationFailure ("could not build seed: " <> show failure)
Right (wrapper, regsAfterHead) ->
case replayEvents userReg (wrapper, regsAfterHead) [tailEvent] of
Right (Settled RequiresConfirmation, regsAfterTail) -> do
regsAfterTail ! #email `shouldBe` "alice@x"
regsAfterTail ! #confirmCode `shouldBe` "Z9F4"
regsAfterTail ! #registeredAt `shouldBe` t 0
Right (other, _) ->
expectationFailure ("expected settled state, got " <> show other)
Left failure ->
expectationFailure ("mid-chain resume failed: " <> show failure)
it "returns a final InFlight wrapper without treating it as truncation" $
case replayEvents
userReg
(Settled PotentialCustomer, initialRegs userReg)
[headEvent] of
Right (wrapper, _) ->
wrapper `shouldBe` InFlight RequiresConfirmation [tailEvent]
Left failure -> expectationFailure ("unexpected fold failure: " <> show failure)
describe "Maybe compatibility wrappers" $
it "return Nothing exactly where the strict variants return Left" $ do
let observed = AccountDeleted (AccountDeletedData "alice@x" (t 999))
corrupted = headEvent : observed : drop 2 canonicalLog
case reconstitute userReg corrupted of
Nothing -> pure ()
Just _ -> expectationFailure "reconstitute accepted corrupted log"
case applyEvents userReg (initial userReg, initialRegs userReg) corrupted of
Nothing -> pure ()
Just _ -> expectationFailure "applyEvents accepted corrupted log"
case reconstituteEither userReg corrupted of
Left _ -> pure ()
Right _ -> expectationFailure "reconstituteEither accepted corrupted log"
case applyEventsEither userReg (initial userReg, initialRegs userReg) corrupted of
Left _ -> pure ()
Right _ -> expectationFailure "applyEventsEither accepted corrupted log"
describe "former Decider behavioral coverage" $ do
it "replays the complete multi-event output of one forward step" $ do
let command = StartRegistration (StartRegistrationData "alice@x" "Z9F4" (t 0))
case stepEither userReg (initial userReg, initialRegs userReg) command of
Left failure -> expectationFailure ("forward step failed: " <> show failure)
Right (_, _, emitted) -> do
emitted `shouldBe` [headEvent, tailEvent]
case applyEventsEither userReg (initial userReg, initialRegs userReg) emitted of
Right (vertex, _) -> vertex `shouldBe` RequiresConfirmation
Left failure -> expectationFailure ("multi-event replay failed: " <> show failure)
it "preserves the durable pre-confirmation deletion path" $ do
let startCommand = StartRegistration (StartRegistrationData "bob@x" "S0E1" (t 0))
deleteCommand = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))
case stepEither userReg (initial userReg, initialRegs userReg) startCommand of
Left failure -> expectationFailure ("registration failed: " <> show failure)
Right (_, _, startEvents) ->
case applyEventsEither userReg (initial userReg, initialRegs userReg) startEvents of
Left failure -> expectationFailure ("registration replay failed: " <> show failure)
Right preDeletion@(RequiresConfirmation, _) ->
case stepEither userReg preDeletion deleteCommand of
Left failure -> expectationFailure ("deletion failed: " <> show failure)
Right (_, _, deletionEvents) -> do
deletionEvents
`shouldBe` [AccountDeleted (AccountDeletedData "bob@x" (t 999))]
case applyEventsEither userReg preDeletion deletionEvents of
Right (vertex, _) -> vertex `shouldBe` Deleted
Left failure -> expectationFailure ("deletion replay failed: " <> show failure)
Right (other, _) ->
expectationFailure ("expected RequiresConfirmation, got " <> show other)