feat(server,cli): session replay inspection — timeline + determinism digest
This commit is contained in:
@@ -3,6 +3,7 @@ package com.correx.apps.cli
|
|||||||
import com.correx.apps.cli.commands.ApproveCommand
|
import com.correx.apps.cli.commands.ApproveCommand
|
||||||
import com.correx.apps.cli.commands.EventsCommand
|
import com.correx.apps.cli.commands.EventsCommand
|
||||||
import com.correx.apps.cli.commands.ProviderCommand
|
import com.correx.apps.cli.commands.ProviderCommand
|
||||||
|
import com.correx.apps.cli.commands.ReplayCommand
|
||||||
import com.correx.apps.cli.commands.RunCommand
|
import com.correx.apps.cli.commands.RunCommand
|
||||||
import com.correx.apps.cli.commands.SessionCommand
|
import com.correx.apps.cli.commands.SessionCommand
|
||||||
import com.correx.apps.cli.commands.StatusCommand
|
import com.correx.apps.cli.commands.StatusCommand
|
||||||
@@ -27,4 +28,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
|
|||||||
ProviderCommand(),
|
ProviderCommand(),
|
||||||
UndoCommand(),
|
UndoCommand(),
|
||||||
EventsCommand(),
|
EventsCommand(),
|
||||||
|
ReplayCommand(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.correx.apps.cli.commands
|
||||||
|
|
||||||
|
import com.correx.apps.cli.CorrexCli
|
||||||
|
import com.correx.apps.cli.DEFAULT_PORT
|
||||||
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
|
import com.github.ajalt.clikt.core.ProgramResult
|
||||||
|
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||||
|
import com.github.ajalt.clikt.parameters.options.default
|
||||||
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TimelineEntryDto(
|
||||||
|
val sequence: Long,
|
||||||
|
val type: String,
|
||||||
|
val stageId: String?,
|
||||||
|
val summary: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReplayReportDto(
|
||||||
|
val sessionId: String,
|
||||||
|
val eventCount: Long,
|
||||||
|
val timeline: List<TimelineEntryDto>,
|
||||||
|
val digest: String,
|
||||||
|
val deterministic: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val SEPARATOR_WIDTH = 110
|
||||||
|
|
||||||
|
private fun renderReport(report: ReplayReportDto): String {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
lines += "Session: ${report.sessionId}"
|
||||||
|
lines += "Events: ${report.eventCount}"
|
||||||
|
lines += "Digest: ${report.digest}"
|
||||||
|
lines += "Deterministic: ${report.deterministic}"
|
||||||
|
lines += ""
|
||||||
|
lines += "%-6s %-35s %-20s %s".format("seq", "type", "stageId", "summary")
|
||||||
|
lines += "-".repeat(SEPARATOR_WIDTH)
|
||||||
|
for (entry in report.timeline) {
|
||||||
|
lines += "%-6d %-35s %-20s %s".format(
|
||||||
|
entry.sequence,
|
||||||
|
entry.type,
|
||||||
|
entry.stageId ?: "-",
|
||||||
|
entry.summary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReplayCommand : CliktCommand(name = "replay") {
|
||||||
|
private val sessionId by argument("sessionId")
|
||||||
|
private val host by option("--host").default("localhost")
|
||||||
|
private val port by option("--port").default("$DEFAULT_PORT")
|
||||||
|
|
||||||
|
private val replayJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override fun run(): Unit = runBlocking {
|
||||||
|
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
|
||||||
|
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
|
||||||
|
val url = "http://$host:$portInt/sessions/$sessionId/replay"
|
||||||
|
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(replayJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val nonDeterministic = runCatching {
|
||||||
|
if (outputJson) {
|
||||||
|
val body = client.get(url).bodyAsText()
|
||||||
|
println(body)
|
||||||
|
val parsed = replayJson.decodeFromString<ReplayReportDto>(body)
|
||||||
|
!parsed.deterministic
|
||||||
|
} else {
|
||||||
|
val report = client.get(url).body<ReplayReportDto>()
|
||||||
|
println(renderReport(report))
|
||||||
|
!report.deterministic
|
||||||
|
}
|
||||||
|
}.getOrElse { e ->
|
||||||
|
System.err.println("Error fetching replay: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
if (nonDeterministic) throw ProgramResult(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.correx.apps.server.replay
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||||
|
import com.correx.core.events.events.RetryAttemptedEvent
|
||||||
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||||
|
import com.correx.core.events.events.TransitionExecutedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
|
import com.correx.core.events.serialization.eventJson
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TimelineEntry(
|
||||||
|
val sequence: Long,
|
||||||
|
val type: String,
|
||||||
|
val stageId: String?,
|
||||||
|
val summary: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReplayReport(
|
||||||
|
val sessionId: String,
|
||||||
|
val eventCount: Long,
|
||||||
|
val timeline: List<TimelineEntry>,
|
||||||
|
val digest: String,
|
||||||
|
val deterministic: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ReplayInspectionService(private val eventStore: EventStore) {
|
||||||
|
|
||||||
|
fun inspect(sessionId: SessionId): ReplayReport {
|
||||||
|
val events = eventStore.read(sessionId)
|
||||||
|
val timeline = events.map { stored ->
|
||||||
|
val element = eventJson.encodeToJsonElement(EventPayload.serializer(), stored.payload)
|
||||||
|
val type = element.jsonObject["type"]?.jsonPrimitive?.content ?: "unknown"
|
||||||
|
toTimelineEntry(stored.sequence, stored.payload, type)
|
||||||
|
}
|
||||||
|
val digest = computeDigest(sessionId)
|
||||||
|
val digest2 = computeDigest(sessionId)
|
||||||
|
return ReplayReport(
|
||||||
|
sessionId = sessionId.value,
|
||||||
|
eventCount = events.size.toLong(),
|
||||||
|
timeline = timeline,
|
||||||
|
digest = digest,
|
||||||
|
deterministic = digest == digest2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry =
|
||||||
|
when (payload) {
|
||||||
|
is WorkflowStartedEvent -> TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
|
||||||
|
is StageCompletedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}")
|
||||||
|
is StageFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value} — ${payload.reason}")
|
||||||
|
is ToolExecutionCompletedEvent -> TimelineEntry(sequence, type, null, "Tool executed: ${payload.toolName}")
|
||||||
|
is ToolExecutionFailedEvent -> TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName} — ${payload.reason}")
|
||||||
|
is ToolExecutionRejectedEvent -> TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName} — ${payload.reason}")
|
||||||
|
is ApprovalRequestedEvent -> TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
|
||||||
|
is ApprovalDecisionResolvedEvent -> TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
|
||||||
|
is OrchestrationPausedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration paused at ${payload.stageId.value}: ${payload.reason}")
|
||||||
|
is OrchestrationResumedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}")
|
||||||
|
is RetryAttemptedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at ${payload.stageId.value}: ${payload.failureReason}")
|
||||||
|
is TransitionExecutedEvent -> TimelineEntry(sequence, type, payload.from.value, "Transition ${payload.from.value} → ${payload.to.value}")
|
||||||
|
is WorkflowCompletedEvent -> TimelineEntry(sequence, type, payload.terminalStageId.value, "Workflow completed after ${payload.totalStages} stages")
|
||||||
|
is WorkflowFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}")
|
||||||
|
else -> TimelineEntry(sequence, type, null, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeDigest(sessionId: SessionId): String {
|
||||||
|
val events = eventStore.read(sessionId)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
for (event in events) {
|
||||||
|
val element = eventJson.encodeToJsonElement(EventPayload.serializer(), event.payload)
|
||||||
|
val type = element.jsonObject["type"]?.jsonPrimitive?.content ?: "unknown"
|
||||||
|
val payloadJsonString = eventJson.encodeToString(EventPayload.serializer(), event.payload)
|
||||||
|
val line = "${event.sequence}|$type|$payloadJsonString"
|
||||||
|
digest.update(line.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.correx.apps.server.routes
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.protocol.SessionConfigDto
|
import com.correx.apps.server.protocol.SessionConfigDto
|
||||||
|
import com.correx.apps.server.replay.ReplayInspectionService
|
||||||
import com.correx.apps.server.ws.SessionStreamHandler
|
import com.correx.apps.server.ws.SessionStreamHandler
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
import com.correx.core.events.serialization.eventJson
|
import com.correx.core.events.serialization.eventJson
|
||||||
@@ -81,6 +82,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
|||||||
undoSessionRoute(module)
|
undoSessionRoute(module)
|
||||||
resumeSessionRoute(module)
|
resumeSessionRoute(module)
|
||||||
getEventsRoute(module)
|
getEventsRoute(module)
|
||||||
|
getReplayRoute(module)
|
||||||
webSocket("/stream") {
|
webSocket("/stream") {
|
||||||
val id = call.parameters["id"] ?: return@webSocket
|
val id = call.parameters["id"] ?: return@webSocket
|
||||||
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
||||||
@@ -179,3 +181,13 @@ private fun Route.getEventsRoute(module: ServerModule) {
|
|||||||
call.respond(rows)
|
call.respond(rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Route.getReplayRoute(module: ServerModule) {
|
||||||
|
val service = ReplayInspectionService(module.eventStore)
|
||||||
|
get("/replay") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val report = service.inspect(TypeId(id))
|
||||||
|
call.respond(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
package com.correx.apps.server.replay
|
||||||
|
|
||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
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.StoredEvent
|
||||||
|
import com.correx.core.events.events.TransitionExecutedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.ApprovalDecisionId
|
||||||
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
|
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.utils.TypeId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ReplayInspectionServiceTest {
|
||||||
|
|
||||||
|
private val sessionId: SessionId = TypeId("test-session-1")
|
||||||
|
private val stageId: StageId = TypeId("stage-1")
|
||||||
|
private val toStageId: StageId = TypeId("stage-2")
|
||||||
|
private val transitionId: TransitionId = TypeId("t-1")
|
||||||
|
private val requestId: ApprovalRequestId = TypeId("req-1")
|
||||||
|
private val decisionId: ApprovalDecisionId = TypeId("dec-1")
|
||||||
|
|
||||||
|
private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent =
|
||||||
|
StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("e$seq"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val seedEvents = listOf(
|
||||||
|
storedEvent(WorkflowStartedEvent(sessionId = sessionId, workflowId = "wf-1", startStageId = stageId), 1L),
|
||||||
|
storedEvent(StageCompletedEvent(sessionId = sessionId, stageId = stageId, transitionId = transitionId), 2L),
|
||||||
|
storedEvent(ApprovalRequestedEvent(
|
||||||
|
requestId = requestId,
|
||||||
|
tier = Tier.T2,
|
||||||
|
validationReportId = TypeId("vr-1"),
|
||||||
|
riskSummaryId = null,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
projectId = null,
|
||||||
|
), 3L),
|
||||||
|
storedEvent(ApprovalDecisionResolvedEvent(
|
||||||
|
decisionId = decisionId,
|
||||||
|
requestId = requestId,
|
||||||
|
outcome = ApprovalOutcome.APPROVED,
|
||||||
|
status = ApprovalStatus.COMPLETED,
|
||||||
|
tier = Tier.T2,
|
||||||
|
resolutionTimestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
reason = null,
|
||||||
|
), 4L),
|
||||||
|
storedEvent(WorkflowCompletedEvent(sessionId = sessionId, terminalStageId = stageId, totalStages = 1), 5L),
|
||||||
|
storedEvent(TransitionExecutedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
from = stageId,
|
||||||
|
to = toStageId,
|
||||||
|
transitionId = transitionId,
|
||||||
|
), 6L),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
|
||||||
|
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||||
|
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||||
|
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
|
||||||
|
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||||
|
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
|
||||||
|
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||||
|
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||||
|
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||||
|
override suspend fun lastGlobalSequence(): Long = 0L
|
||||||
|
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
|
||||||
|
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inspect returns correct event count and timeline`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertEquals(sessionId.value, report.sessionId)
|
||||||
|
assertEquals(6L, report.eventCount)
|
||||||
|
assertEquals(6, report.timeline.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeline type uses SerialName discriminator not simpleName`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertEquals("WorkflowStarted", report.timeline[0].type)
|
||||||
|
assertEquals("StageCompleted", report.timeline[1].type)
|
||||||
|
assertEquals("WorkflowCompleted", report.timeline[4].type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeline entries carry stageId where applicable`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
val workflowEntry = report.timeline[0]
|
||||||
|
assertEquals(stageId.value, workflowEntry.stageId)
|
||||||
|
|
||||||
|
val stageEntry = report.timeline[1]
|
||||||
|
assertEquals(stageId.value, stageEntry.stageId)
|
||||||
|
|
||||||
|
val approvalEntry = report.timeline[2]
|
||||||
|
assertEquals(stageId.value, approvalEntry.stageId)
|
||||||
|
|
||||||
|
val decisionEntry = report.timeline[3]
|
||||||
|
assertEquals(null, decisionEntry.stageId)
|
||||||
|
|
||||||
|
val completedEntry = report.timeline[4]
|
||||||
|
assertEquals(stageId.value, completedEntry.stageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `TransitionExecutedEvent summary includes from and to stages`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
val transitionEntry = report.timeline[5]
|
||||||
|
assertEquals("TransitionExecuted", transitionEntry.type)
|
||||||
|
assertTrue(transitionEntry.summary.contains(stageId.value), "summary should mention from-stage")
|
||||||
|
assertTrue(transitionEntry.summary.contains(toStageId.value), "summary should mention to-stage")
|
||||||
|
assertFalse(transitionEntry.summary == "TransitionExecuted", "summary should not be the bare catch-all type")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `digest is 64 hex characters`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertEquals(64, report.digest.length)
|
||||||
|
assertTrue(report.digest.matches(Regex("[0-9a-f]+")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `digest is stable across two inspect calls`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report1 = service.inspect(sessionId)
|
||||||
|
val report2 = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertEquals(report1.digest, report2.digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deterministic is true for stable store`() {
|
||||||
|
val service = ReplayInspectionService(SeededEventStore(seedEvents))
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertTrue(report.deterministic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deterministic is false when store returns different events per read`() {
|
||||||
|
val unstableStore = object : EventStore by SeededEventStore(seedEvents) {
|
||||||
|
private var reads = 0
|
||||||
|
override fun read(sessionId: SessionId): List<StoredEvent> {
|
||||||
|
reads += 1
|
||||||
|
return if (reads % 2 == 0) seedEvents.dropLast(1) else seedEvents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val service = ReplayInspectionService(unstableStore)
|
||||||
|
val report = service.inspect(sessionId)
|
||||||
|
|
||||||
|
assertFalse(report.deterministic)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user