kioku-core-0.1.0.0: test/Kioku/SessionLineageSpec.hs
module Kioku.SessionLineageSpec (tests) where
import Control.Monad (void)
import Data.List (sort)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Time (UTCTime, addUTCTime, getCurrentTime)
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error)
import Hasql.Transaction qualified as Tx
import Kioku.Api.Scope (MemoryScope (..), Namespace (..))
import Kioku.App (runAppIO, withNoopAppEnv)
import Kioku.Id (SessionId, genSessionId, idText)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Session qualified as Session
import Kioku.Session.Domain (StartSessionData (..))
import Kioku.Session.ReadModel (SessionRow (..))
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Test.Tasty (TestTree, localOption, mkTimeout, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)
tests :: TestTree
tests =
testGroup
"Session.Lineage"
[ testCase "child sessions record parent id and delegation depth" testDelegationLineage,
testGroup
"start rejects malformed lineage"
[ testCase "a session that is its own predecessor" (assertInvalidLineage selfPrevious),
testCase "a session that is its own parent" (assertInvalidLineage selfParent),
testCase "a negative delegation depth" (assertInvalidLineage negativeDepth),
testCase "a delegation depth past the cap" (assertInvalidLineage depthPastCap),
testCase "a delegated session at depth 0" (assertInvalidLineage parentAtDepthZero),
testCase "a root session at depth 1" (assertInvalidLineage rootAtDepthOne)
],
-- A cycle cannot be built through the API any more, so this one manufactures corrupt
-- data directly. Before the CTE carried a path array, getChain looped until the
-- timeout fired; it now returns in milliseconds.
localOption (mkTimeout 10_000_000) $
testCase "getChain terminates on a cyclic chain" testChainTerminatesOnCycle
]
data LineageResult = LineageResult
{ children :: ![SessionRow],
childChain :: ![SessionRow]
}
testDelegationLineage :: IO ()
testDelegationLineage =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
parent <- genSessionId
child1 <- genSessionId
child2 <- genSessionId
now <- getCurrentTime
let child2Started = addUTCTime 2 now
result <-
runAppIO env do
startFixture parent "parent" Nothing Nothing 0 now
startFixture child1 "child-1" Nothing (Just parent) 1 (addUTCTime 1 now)
startFixture child2 "child-2" (Just child1) (Just parent) 1 child2Started
children <- Session.getDelegationChildren parent >>= liftEither "getDelegationChildren"
childChain <- Session.getChain child2 >>= liftEither "getChain"
pure LineageResult {children, childChain}
case result of
Left storeErr -> assertFailure ("store error: " <> show storeErr)
Right LineageResult {children, childChain} -> do
assertEqual "parent has exactly the two direct delegation children" [idText child1, idText child2] (rowSessionId <$> children)
assertEqual "children carry parentSessionId" [Just (idText parent), Just (idText parent)] (rowParentSessionId <$> children)
assertEqual "children carry depth 1" [1, 1] (rowDelegationDepth <$> children)
assertEqual "previous-session chain follows previousSessionId, not parentSessionId" [idText child1, idText child2] (rowSessionId <$> childChain)
-- | A malformed-lineage case, built from the session's own id (so the self-reference cases
-- can point a field at it) and an unrelated id (so the depth cases can name a parent
-- without tripping the self-parent rule first).
type LineageCase = SessionId -> SessionId -> UTCTime -> StartSessionData
baseStart :: LineageCase
baseStart sid _other startedAt =
StartSessionData
{ sessionId = sid,
agentId = "test-agent",
focus = "delegation lineage",
scope = ScopeGlobal (Namespace "kioku-test"),
subjectRef = Nothing,
previousSessionId = Nothing,
parentSessionId = Nothing,
delegationDepth = 0,
startedAt
}
selfPrevious, selfParent, negativeDepth, depthPastCap, parentAtDepthZero, rootAtDepthOne :: LineageCase
selfPrevious sid o t = (baseStart sid o t) {previousSessionId = Just sid}
selfParent sid o t = (baseStart sid o t) {parentSessionId = Just sid, delegationDepth = 1}
negativeDepth sid o t = (baseStart sid o t) {delegationDepth = -1}
depthPastCap sid o t = (baseStart sid o t) {parentSessionId = Just o, delegationDepth = 65}
parentAtDepthZero sid o t = (baseStart sid o t) {parentSessionId = Just o, delegationDepth = 0}
rootAtDepthOne sid o t = (baseStart sid o t) {delegationDepth = 1}
assertInvalidLineage :: LineageCase -> IO ()
assertInvalidLineage mkCommand =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
sid <- genSessionId
other <- genSessionId
now <- getCurrentTime
result <- runAppIO env (Session.start (mkCommand sid other now))
case result of
Left storeErr -> assertFailure ("store error: " <> show storeErr)
Right (Left (Session.SessionInvalidLineage _)) -> pure ()
Right other' ->
assertFailure ("expected SessionInvalidLineage, got " <> show other')
startFixture ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
SessionId ->
Text ->
Maybe SessionId ->
Maybe SessionId ->
Int ->
UTCTime ->
Eff es ()
startFixture sid agent previous parent depth startedAt = do
result <-
Session.start
StartSessionData
{ sessionId = sid,
agentId = agent,
focus = "delegation lineage",
scope = ScopeGlobal (Namespace "kioku-test"),
subjectRef = Nothing,
previousSessionId = previous,
parentSessionId = parent,
delegationDepth = depth,
startedAt
}
void (liftEither "Session.start" result)
-- | Insert two sessions that name each other as predecessor, bypassing 'Session.start'
-- (which now refuses to create a cycle), then walk the chain. The assertion is simply that
-- the query returns at all — and returns each session exactly once, rather than looping.
testChainTerminatesOnCycle :: IO ()
testChainTerminatesOnCycle =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
a <- genSessionId
b <- genSessionId
result <-
runAppIO env do
insertCyclicPair a b
Session.getChain a >>= liftEither "getChain"
case result of
Left storeErr -> assertFailure ("store error: " <> show storeErr)
Right chain -> do
assertEqual "the cyclic chain returns both sessions" 2 (length chain)
assertEqual
"each session appears exactly once"
(sort [idText a, idText b])
(sort (rowSessionId <$> chain))
insertCyclicPair ::
(IOE :> es, Store :> es) =>
SessionId ->
SessionId ->
Eff es ()
insertCyclicPair a b =
runTransaction . Tx.sql . encodeUtf8 $
"INSERT INTO kioku_sessions (session_id, agent_id, focus, namespace, delegation_depth, status, started_at, previous_session_id) VALUES "
<> "('"
<> idText a
<> "','t','f','kioku-test',0,'completed',NOW(),'"
<> idText b
<> "'),('"
<> idText b
<> "','t','f','kioku-test',0,'completed',NOW() - interval '1 second','"
<> idText a
<> "')"
liftEither :: (Show e, IOE :> es) => String -> Either e a -> Eff es a
liftEither label = \case
Left err -> liftIO (assertFailure (label <> ": " <> show err))
Right value -> pure value
rowSessionId :: SessionRow -> Text
rowSessionId row = row.sessionId
rowParentSessionId :: SessionRow -> Maybe Text
rowParentSessionId row = row.parentSessionId
rowDelegationDepth :: SessionRow -> Int
rowDelegationDepth row = row.delegationDepth