keiki-0.2.0.0: test/Keiki/RoundTripSpec.hs
{-# LANGUAGE BlockArguments #-}
module Keiki.RoundTripSpec (spec) where
import Data.Text (Text)
import Data.Text qualified as Text
import Keiki.Core
import Keiki.Fixtures.BrokenTailCoverage qualified as Broken
import Keiki.Fixtures.CounterPipeline qualified as Counter
import Keiki.Fixtures.EmailDelivery qualified as Email
import Keiki.Fixtures.RegisterEmission qualified as Register
import Keiki.Fixtures.SplitCoverage qualified as Split
import Keiki.Fixtures.UserRegistration qualified as User
import Keiki.RoundTrip
import Test.Hspec (Spec, describe, it, shouldSatisfy)
import Test.QuickCheck (Gen, elements, frequency)
spec :: Spec
spec = do
mapM_ roundTripSpec allFixtures
teethSpec stateChangingEpsilonFixture
teethSpec brokenTailCoverageFixture
teethSpec counterPipelineFixture
describe "validator/teeth agreement" do
it "classifies the silent transition as StateChangingEpsilon" $
validateTransducer defaultValidationOptions stateChangingEpsilon
`shouldSatisfy` any isStateChangingEpsilon
it "classifies BrokenTailCoverage as HeadUnrecoverable" $
validateTransducer defaultValidationOptions Broken.brokenTailCoverage
`shouldSatisfy` any isHeadUnrecoverable
allFixtures :: [RoundTripFixture]
allFixtures =
[ emailDeliveryFixture,
userRegistrationFixture,
registerEmissionFixture,
splitCoverageFixture
]
emailDeliveryFixture :: RoundTripFixture
emailDeliveryFixture =
RoundTripFixture
{ rtName = "EmailDelivery",
rtTransducer = Email.emailDelivery,
rtGenCommand = \_ _ -> Email.SendEmail <$> genSendEmailData,
rtObserve = observeEmail,
rtTamperCases = emailTamperCases
}
genSendEmailData :: Gen Email.SendEmailData
genSendEmailData =
Email.SendEmailData
<$> genShortText
<*> genShortText
<*> genUTCTime
observeEmail :: Email.EmailVertex -> RegFile Email.EmailRegs -> Text
observeEmail Email.EmailPending _ = "(no slots)"
observeEmail Email.EmailSentVertex regs =
Text.pack $
"recipient="
<> show (regs ! #emailRecipient)
<> " subject="
<> show (regs ! #emailSubject)
<> " sentAt="
<> show (regs ! #emailSentAt)
emailTamperCases :: [TamperCase Email.EmailEvent]
emailTamperCases =
[ TamperCase
{ tcName = "drop only event",
tcMutate = \case
[_] -> Just []
_ -> Nothing,
tcExpect = MustNotSilentlyMatch
},
TamperCase
{ tcName = "duplicate event",
tcMutate = \case
[event] -> Just [event, event]
_ -> Nothing,
tcExpect = MustFailReplay
}
]
userRegistrationFixture :: RoundTripFixture
userRegistrationFixture =
RoundTripFixture
{ rtName = "UserRegistration",
rtTransducer = User.userReg,
rtGenCommand = genUserCommand,
rtObserve = observeUser,
rtTamperCases = userTamperCases
}
genUserCommand :: User.Vertex -> RegFile User.UserRegRegs -> Gen User.UserCmd
genUserCommand User.PotentialCustomer _ =
frequency
[ (8, User.StartRegistration <$> genStartRegistrationData),
(1, User.ConfirmAccount <$> genConfirmAccountData "wrong"),
(1, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime)
]
genUserCommand User.RequiresConfirmation regs =
frequency
[ (5, User.ConfirmAccount <$> genConfirmAccountData (regs ! #confirmCode)),
(2, User.ConfirmAccount <$> genConfirmAccountData "wrong"),
(4, User.ResendConfirmation <$> genResendConfirmationData),
(2, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime)
]
genUserCommand User.Confirmed _ =
frequency
[ (8, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime),
(1, User.StartRegistration <$> genStartRegistrationData)
]
genUserCommand User.Deleted _ = arbitraryUserCommand
genStartRegistrationData :: Gen User.StartRegistrationData
genStartRegistrationData =
User.StartRegistrationData
<$> genShortText
<*> genFromPool ["alpha", "beta", "gamma"]
<*> genUTCTime
genConfirmAccountData :: Text -> Gen User.ConfirmAccountData
genConfirmAccountData code = User.ConfirmAccountData code <$> genUTCTime
genResendConfirmationData :: Gen User.ResendConfirmationData
genResendConfirmationData =
User.ResendConfirmationData
<$> genFromPool ["alpha", "beta", "gamma"]
<*> genUTCTime
arbitraryUserCommand :: Gen User.UserCmd
arbitraryUserCommand =
elements [User.FulfillGDPRRequest (User.FulfillGDPRRequestData epoch)]
where
epoch = read "1970-01-01 00:00:00 UTC"
observeUser :: User.Vertex -> RegFile User.UserRegRegs -> Text
observeUser User.PotentialCustomer _ = "(no slots)"
observeUser User.RequiresConfirmation regs =
Text.pack $
"email="
<> show (regs ! #email)
<> " confirmCode="
<> show (regs ! #confirmCode)
<> " registeredAt="
<> show (regs ! #registeredAt)
observeUser User.Confirmed regs =
observeUser User.RequiresConfirmation regs
<> Text.pack (" confirmedAt=" <> show (regs ! #confirmedAt))
observeUser User.Deleted regs =
Text.pack $
"email="
<> show (regs ! #email)
<> " confirmCode="
<> show (regs ! #confirmCode)
<> " registeredAt="
<> show (regs ! #registeredAt)
<> " deletedAt="
<> show (regs ! #deletedAt)
userTamperCases :: [TamperCase User.UserEvent]
userTamperCases =
[ TamperCase
{ tcName = "drop chain tail",
tcMutate = removeFirstConfirmationEmail,
tcExpect = MustFailReplay
},
TamperCase
{ tcName = "swap chain events",
tcMutate = swapRegistrationChain,
tcExpect = MustFailReplay
},
TamperCase
{ tcName = "truncate mid-chain",
tcMutate = truncateAfterRegistrationStarted,
tcExpect = MustFailReplay
},
TamperCase
{ tcName = "duplicate chain head",
tcMutate = duplicateRegistrationStarted,
tcExpect = MustFailReplay
},
TamperCase
{ tcName = "foreign splice",
tcMutate = \events -> Just (foreignAccountConfirmed : events),
tcExpect = MustFailReplay
}
]
removeFirstConfirmationEmail :: [User.UserEvent] -> Maybe [User.UserEvent]
removeFirstConfirmationEmail = \case
[] -> Nothing
User.ConfirmationEmailSent _ : rest -> Just rest
event : rest -> (event :) <$> removeFirstConfirmationEmail rest
swapRegistrationChain :: [User.UserEvent] -> Maybe [User.UserEvent]
swapRegistrationChain = \case
first@(User.RegistrationStarted _) : second@(User.ConfirmationEmailSent _) : rest ->
Just (second : first : rest)
event : rest -> (event :) <$> swapRegistrationChain rest
[] -> Nothing
truncateAfterRegistrationStarted :: [User.UserEvent] -> Maybe [User.UserEvent]
truncateAfterRegistrationStarted = go []
where
go _ [] = Nothing
go prefix (event@(User.RegistrationStarted _) : _) =
Just (reverse (event : prefix))
go prefix (event : rest) = go (event : prefix) rest
duplicateRegistrationStarted :: [User.UserEvent] -> Maybe [User.UserEvent]
duplicateRegistrationStarted = \case
first@(User.RegistrationStarted _) : second@(User.ConfirmationEmailSent _) : rest ->
Just (first : second : first : rest)
event : rest -> (event :) <$> duplicateRegistrationStarted rest
[] -> Nothing
foreignAccountConfirmed :: User.UserEvent
foreignAccountConfirmed =
User.AccountConfirmed
User.AccountConfirmedData
{ email = "foreign@example.test",
confirmCode = "foreign-code",
at = read "1970-01-01 00:00:00 UTC"
}
brokenTailCoverageFixture :: RoundTripFixture
brokenTailCoverageFixture =
RoundTripFixture
{ rtName = "BrokenTailCoverage",
rtTransducer = Broken.brokenTailCoverage,
rtGenCommand = \_ _ ->
Broken.Provision
<$> (Broken.ProvisionData <$> genShortText <*> elements [0 .. 20]),
rtObserve = observeBrokenTailCoverage,
rtTamperCases = []
}
observeBrokenTailCoverage :: Broken.BrokenVertex -> RegFile Broken.BrokenRegs -> Text
observeBrokenTailCoverage Broken.BtcIdle _ = "(no slots)"
observeBrokenTailCoverage Broken.BtcProvisioned regs =
Text.pack $
"owner="
<> show (regs ! #owner)
<> " quota="
<> show (regs ! #quota)
registerEmissionFixture :: RoundTripFixture
registerEmissionFixture =
RoundTripFixture
{ rtName = "RegisterEmission",
rtTransducer = Register.registerEmission,
rtGenCommand = genRegisterCommand,
rtObserve = observeRegister,
rtTamperCases =
[ TamperCase
{ tcName = "truncate mid-chain",
tcMutate = truncateAfterClosed,
tcExpect = MustFailReplay
}
]
}
genRegisterCommand :: Register.RegisterVertex -> RegFile Register.RegisterEmissionRegs -> Gen Register.RegisterCmd
genRegisterCommand Register.Fresh _ =
frequency
[ (8, Register.Open <$> genShortText),
(1, Register.Add <$> elements [-10 .. 10]),
(1, pure Register.Close)
]
genRegisterCommand Register.Active _ =
frequency
[ (5, Register.Add <$> elements [-10 .. 10]),
(5, pure Register.Close),
(1, Register.Open <$> genShortText)
]
genRegisterCommand Register.Finished _ = pure Register.Close
observeRegister :: Register.RegisterVertex -> RegFile Register.RegisterEmissionRegs -> Text
observeRegister Register.Fresh _ = "(initial)"
observeRegister state regs =
Text.pack $
show state
<> " owner="
<> show (regs ! #owner)
<> " total="
<> show (regs ! #total)
truncateAfterClosed :: [Register.RegisterEvent] -> Maybe [Register.RegisterEvent]
truncateAfterClosed = go []
where
go _ [] = Nothing
go prefix (event@(Register.Closed _) : _) = Just (reverse (event : prefix))
go prefix (event : rest) = go (event : prefix) rest
splitCoverageFixture :: RoundTripFixture
splitCoverageFixture =
RoundTripFixture
{ rtName = "SplitCoverage.fixed",
rtTransducer = Split.splitCoverageFixed,
rtGenCommand = \_ _ ->
Split.Begin
<$> elements [-10 .. 10]
<*> elements [-10 .. 10]
<*> elements [-10 .. 10],
rtObserve = \state _ -> Text.pack (show state),
rtTamperCases =
[ TamperCase
{ tcName = "truncate mid-chain",
tcMutate = \case
Split.OutABC a b c : _ -> Just [Split.OutABC a b c]
_ -> Nothing,
tcExpect = MustFailReplay
}
]
}
counterPipelineFixture :: RoundTripFixture
counterPipelineFixture =
RoundTripFixture
{ rtName = "CounterPipeline.stageA (derived-only output)",
rtTransducer = Counter.stageA,
rtGenCommand = \_ _ -> Counter.MsgA <$> elements [0 .. 20],
rtObserve = \Counter.StageVertex regs ->
Text.pack ("regA=" <> show (regs ! #regA)),
rtTamperCases = []
}
isStateChangingEpsilon :: TransducerValidationWarning s -> Bool
isStateChangingEpsilon StateChangingEpsilon {} = True
isStateChangingEpsilon _ = False
isHeadUnrecoverable :: TransducerValidationWarning s -> Bool
isHeadUnrecoverable HeadUnrecoverable {} = True
isHeadUnrecoverable _ = False
data EpsilonVertex = EpsilonStart | EpsilonDone
deriving (Eq, Ord, Show, Enum, Bounded)
data EpsilonCommand = EpsilonAdvance
deriving (Eq, Show)
stateChangingEpsilonFixture :: RoundTripFixture
stateChangingEpsilonFixture =
RoundTripFixture
{ rtName = "StateChangingEpsilon",
rtTransducer = stateChangingEpsilon,
rtGenCommand = \_ _ -> pure EpsilonAdvance,
rtObserve = \state _ -> Text.pack (show state),
rtTamperCases = []
}
stateChangingEpsilon :: SymTransducer (HsPred '[] EpsilonCommand) '[] EpsilonVertex EpsilonCommand ()
stateChangingEpsilon =
SymTransducer
{ initial = EpsilonStart,
initialRegs = RNil,
isFinal = (== EpsilonDone),
edgesOut = \case
EpsilonStart ->
[ Edge
{ guard = PTop,
update = UKeep,
output = [],
target = EpsilonDone
}
]
EpsilonDone -> []
}