epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":core:events"))
|
||||
testImplementation(project(":core:sessions"))
|
||||
testImplementation(project(":core:transitions"))
|
||||
testImplementation(project(":core:validation"))
|
||||
testImplementation(project(":core:approvals"))
|
||||
testImplementation(project(":testing:fixtures"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.testing.replay
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,59 @@
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.testing.fixtures.request
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalReplayTest {
|
||||
@Test
|
||||
fun `replaying same request with same inputs yields identical decision`() {
|
||||
val engine1 = DefaultApprovalEngine()
|
||||
val engine2 = DefaultApprovalEngine()
|
||||
val req = request("req1", Tier.T2)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||
ApprovalMode.AUTO
|
||||
)
|
||||
val grants = listOf(
|
||||
ApprovalGrant(
|
||||
GrantId("g1"),
|
||||
GrantScope.SESSION,
|
||||
setOf(Tier.T2),
|
||||
"reason",
|
||||
Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine1.evaluate(req, ctx, grants, now)
|
||||
val d2 = engine2.evaluate(req, ctx, grants, now)
|
||||
|
||||
assertEquals(d1, d2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decision ID determinism holds over multiple replays`() {
|
||||
val engine = DefaultApprovalEngine()
|
||||
val req = request("fixed", Tier.T3)
|
||||
val ctx = ApprovalContext(
|
||||
ApprovalScopeIdentity(SessionId("s"), null, null),
|
||||
ApprovalMode.PROMPT
|
||||
)
|
||||
val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||
|
||||
val d1 = engine.evaluate(req, ctx, emptyList(), now)
|
||||
val d2 = engine.evaluate(req, ctx, emptyList(), now)
|
||||
|
||||
assertEquals(d1.id, d2.id)
|
||||
assertEquals(ApprovalDecisionId("decision:fixed"), d1.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionEmptyReplayTest {
|
||||
|
||||
@Test
|
||||
fun `empty session returns initial state`() {
|
||||
val store = InMemoryEventStore()
|
||||
val replayer = DefaultEventReplayer(
|
||||
store,
|
||||
SessionProjector(DefaultSessionReducer())
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(SessionId("missing"))
|
||||
|
||||
assertEquals(SessionStatus.CREATED, state.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionPausedEvent
|
||||
import com.correx.core.events.events.SessionResumedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.datetime.Clock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionReplayTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val projector = SessionProjector(DefaultSessionReducer())
|
||||
private val replayer = DefaultEventReplayer(store, projector)
|
||||
|
||||
@Test
|
||||
fun `rebuild session from event stream`() {
|
||||
val sessionId = SessionId("s1")
|
||||
|
||||
val metadataToPayload = mapOf<EventMetadata, EventPayload>(
|
||||
EventMetadata(EventId("start"), sessionId, Clock.System.now(), 1, null, null) to SessionStartedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("paused"), sessionId, Clock.System.now(), 1, null, null) to SessionPausedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(EventId("resumed"), sessionId, Clock.System.now(), 1, null, null) to SessionResumedEvent(
|
||||
sessionId
|
||||
),
|
||||
EventMetadata(
|
||||
EventId("completed"),
|
||||
sessionId,
|
||||
Clock.System.now(),
|
||||
1,
|
||||
null,
|
||||
null
|
||||
) to SessionCompletedEvent(sessionId),
|
||||
)
|
||||
|
||||
metadataToPayload.map { (meta, payload) ->
|
||||
store.append(NewEvent(meta, payload))
|
||||
}
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(SessionStatus.COMPLETED, state.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import com.correx.core.events.events.SessionCompletedEvent
|
||||
import com.correx.core.events.events.SessionStartedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StageStartedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.SessionStatus
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.testing.fixtures.EventFixtures.newEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TransitionReplayIntegrationTest {
|
||||
|
||||
private val sessionId = SessionId("session-1")
|
||||
|
||||
@Test
|
||||
fun `workflow replay reconstructs COMPLETED session`() {
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
|
||||
store.appendAll(
|
||||
listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-5"),
|
||||
payload = SessionCompletedEvent(sessionId)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val replayer = DefaultEventReplayer(
|
||||
store = store,
|
||||
projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(
|
||||
SessionStatus.COMPLETED,
|
||||
state.status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workflow replay reconstructs FAILED session`() {
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
|
||||
store.appendAll(
|
||||
listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1"),
|
||||
reason = "validation failed"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val replayer = DefaultEventReplayer(
|
||||
store = store,
|
||||
projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
)
|
||||
|
||||
val state = replayer.rebuild(sessionId)
|
||||
|
||||
assertEquals(
|
||||
SessionStatus.FAILED,
|
||||
state.status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay is deterministic for identical event stream`() {
|
||||
val events = listOf(
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-1"),
|
||||
payload = SessionStartedEvent(sessionId)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-2"),
|
||||
payload = TransitionExecutedEvent(
|
||||
sessionId = sessionId,
|
||||
from = StageId("draft"),
|
||||
to = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-3"),
|
||||
payload = StageStartedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
),
|
||||
newEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId("event-4"),
|
||||
payload = StageCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("review"),
|
||||
transitionId = TransitionId("transition-1")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val store1 = InMemoryEventStore()
|
||||
val store2 = InMemoryEventStore()
|
||||
|
||||
store1.appendAll(events)
|
||||
store2.appendAll(events)
|
||||
|
||||
val projection = SessionProjector(
|
||||
reducer = DefaultSessionReducer()
|
||||
)
|
||||
|
||||
val replayer1 = DefaultEventReplayer(
|
||||
store = store1,
|
||||
projection = projection
|
||||
)
|
||||
|
||||
val replayer2 = DefaultEventReplayer(
|
||||
store = store2,
|
||||
projection = projection
|
||||
)
|
||||
|
||||
val state1 = replayer1.rebuild(sessionId)
|
||||
val state2 = replayer2.rebuild(sessionId)
|
||||
|
||||
assertEquals(state1, state2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import com.correx.core.transitions.resolution.TransitionOrdering
|
||||
import com.correx.core.validation.graph.GraphValidator
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.core.validation.transition.TransitionValidator
|
||||
import com.correx.testing.fixtures.WorkflowFixtures
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ValidationReplayTest {
|
||||
|
||||
@Test
|
||||
fun `validation must be replay deterministic`() {
|
||||
|
||||
val graph = WorkflowFixtures.simpleGraph()
|
||||
|
||||
val context = ValidationContext(graph)
|
||||
|
||||
val pipeline = ValidationPipeline(
|
||||
listOf(
|
||||
GraphValidator(),
|
||||
TransitionValidator(TransitionOrdering.comparator)
|
||||
)
|
||||
)
|
||||
|
||||
val results = (1..10).map {
|
||||
pipeline.validate(context)
|
||||
}
|
||||
|
||||
assertTrue(results.distinct().size == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user