diff --git a/testing/fixtures/build.gradle b/testing/fixtures/build.gradle index 57f8ed25..5dc6c86e 100644 --- a/testing/fixtures/build.gradle +++ b/testing/fixtures/build.gradle @@ -14,5 +14,6 @@ dependencies { implementation(project(":core:context")) implementation(project(":core:kernel")) implementation(project(":core:validation")) + implementation(project(":infrastructure:persistence")) } tasks.named("koverVerify").configure { enabled = false } diff --git a/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/DeterministicHarness.kt b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/DeterministicHarness.kt new file mode 100644 index 00000000..0b5ea673 --- /dev/null +++ b/testing/fixtures/src/main/kotlin/com/correx/testing/fixtures/DeterministicHarness.kt @@ -0,0 +1,41 @@ +package com.correx.testing.fixtures + +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.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.core.sessions.projections.Projection +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import kotlinx.datetime.Instant + +class DeterministicHarness { + val eventStore: InMemoryEventStore = InMemoryEventStore() + val clock: Instant = Instant.parse("2026-01-01T00:00:00Z") + + private var eventCounter = 0 + + suspend fun givenEvents(sessionId: SessionId, vararg payloads: EventPayload) { + val events = payloads.map { payload -> + val eventId = EventId("event-${eventCounter++}") + NewEvent( + metadata = EventMetadata( + eventId = eventId, + sessionId = sessionId, + timestamp = clock, + schemaVersion = 1, + causationId = null, + correlationId = null + ), + payload = payload + ) + } + eventStore.appendAll(events) + } + + fun rebuild(sessionId: SessionId, projection: Projection): S { + val replayer = DefaultEventReplayer(eventStore, projection) + return replayer.rebuild(sessionId) + } +} diff --git a/testing/replay/src/test/kotlin/DeterministicHarnessTest.kt b/testing/replay/src/test/kotlin/DeterministicHarnessTest.kt new file mode 100644 index 00000000..58cfeb38 --- /dev/null +++ b/testing/replay/src/test/kotlin/DeterministicHarnessTest.kt @@ -0,0 +1,77 @@ +import com.correx.core.events.events.ChatSessionStartedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolInvokedEvent +import com.correx.core.events.serialization.eventJson +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.Projection +import com.correx.testing.fixtures.DeterministicHarness +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DeterministicHarnessTest { + + private data class SimpleState(val eventCount: Int = 0) + + private val simpleProjection = object : Projection { + override fun initial() = SimpleState() + override fun apply(state: SimpleState, event: StoredEvent) = + state.copy(eventCount = state.eventCount + 1) + } + + @Test + fun `two harnesses with same payloads yield identical events`() = runBlocking { + val harness1 = DeterministicHarness() + val harness2 = DeterministicHarness() + + val sessionId = SessionId("test-session") + val payload1 = ChatSessionStartedEvent(sessionId) + val payload2 = ToolInvokedEvent("read_file") + + harness1.givenEvents(sessionId, payload1, payload2) + harness2.givenEvents(sessionId, payload1, payload2) + + val events1 = harness1.eventStore.read(sessionId) + val events2 = harness2.eventStore.read(sessionId) + + assertEquals(events1.size, events2.size) + events1.zip(events2).forEach { (e1, e2) -> + assertEquals(e1.metadata.eventId, e2.metadata.eventId, "eventIds must match") + assertEquals(e1.metadata.timestamp, e2.metadata.timestamp, "timestamps must match") + assertEquals(e1.sequence, e2.sequence, "sequences must match") + assertEquals(e1.sessionSequence, e2.sessionSequence, "sessionSequences must match") + assertEquals( + eventJson.encodeToString(StoredEvent.serializer(), e1), + eventJson.encodeToString(StoredEvent.serializer(), e2), + "stored event JSON must be byte-identical", + ) + } + } + + @Test + fun `rebuild applies events deterministically`() = runBlocking { + val harness = DeterministicHarness() + val sessionId = SessionId("rebuild-test") + + harness.givenEvents( + sessionId, + ChatSessionStartedEvent(sessionId), + ToolInvokedEvent("read_file"), + ToolInvokedEvent("write_file") + ) + + val state = harness.rebuild(sessionId, simpleProjection) + assertEquals(3, state.eventCount) + } + + @Test + fun `clock is fixed for deterministic timestamps`(): Unit = runBlocking { + val harness = DeterministicHarness() + val sessionId = SessionId("clock-test") + + harness.givenEvents(sessionId, ChatSessionStartedEvent(sessionId)) + + val event = harness.eventStore.read(sessionId).first() + assertEquals("2026-01-01T00:00:00Z", event.metadata.timestamp.toString()) + } +}