epic-12: after epic audit and init commit

This commit is contained in:
2026-05-16 11:42:00 +04:00
commit c77277af0b
461 changed files with 28958 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'java-test-fixtures'
}
dependencies {
implementation(project(":core:events"))
testImplementation(project(":core:transitions"))
testImplementation(project(":core:sessions"))
testImplementation(project(":core:validation"))
testImplementation(project(":core:approvals"))
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:kernel"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":core:inference"))
testImplementation(project(":core:context"))
testFixturesImplementation(project(":core:events"))
testFixturesImplementation(project(":core:sessions"))
testFixturesImplementation(project(":testing:fixtures"))
testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
testFixturesImplementation "org.junit.jupiter:junit-jupiter:$junit_version"
}
@@ -0,0 +1,36 @@
package com.correx.testing.contracts.utils
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
object ConcurrencyRunner {
fun run(
threads: Int,
iterations: Int,
block: (threadIndex: Int, iteration: Int) -> Unit
) {
val executor = Executors.newFixedThreadPool(threads)
val start = CountDownLatch(1)
val done = CountDownLatch(threads)
repeat(threads) { t ->
executor.submit {
start.await()
try {
repeat(iterations) { i ->
block(t, i)
}
} finally {
done.countDown()
}
}
}
start.countDown()
done.await()
executor.shutdown()
}
}
@@ -0,0 +1,12 @@
package com.correx.testing.contracts.utils
import java.util.concurrent.CyclicBarrier
class DeterministicBarrier(parties: Int) {
private val barrier = CyclicBarrier(parties)
fun await() {
barrier.await()
}
}
@@ -0,0 +1,44 @@
package com.correx.testing.contracts.model
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.ApprovalScopeIdentity
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ProjectId
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.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
class ContextSnapshotTest {
@Test
fun `decision contains snapshot, not live context`() {
val engine = DefaultApprovalEngine()
val identity = ApprovalScopeIdentity(SessionId("s1"), StageId("st1"), ProjectId("p1"))
val mode = ApprovalMode.AUTO
val context = ApprovalContext(identity, mode)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
assertEquals(identity, decision.contextSnapshot.identity)
assertEquals(mode, decision.contextSnapshot.mode)
}
@Test
fun `snapshot does not change after decision`() {
val engine = DefaultApprovalEngine()
val identity = ApprovalScopeIdentity(SessionId("s1"), null, null)
val context = ApprovalContext(identity, ApprovalMode.PROMPT)
val now = Instant.parse("2026-01-01T00:00:01Z")
val decision = engine.evaluate(request("r1", Tier.T2), context, emptyList(), now)
val anotherContext = context.copy(mode = ApprovalMode.YOLO)
assertNotEquals(anotherContext.mode, decision.contextSnapshot.mode)
}
}
@@ -0,0 +1,32 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class StageIdTest {
@Test
fun `rejects blank value`() {
assertThrows<IllegalArgumentException> {
StageId("")
}
}
@Test
fun `stores value correctly`() {
val id = StageId("stage-1")
assertEquals("stage-1", id.value)
assertEquals("stage-1", id.toString())
}
@Test
fun `value class equality works`() {
val a = StageId("x")
val b = StageId("x")
assertEquals(a, b)
}
}
@@ -0,0 +1,32 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.TransitionId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class TransitionIdTest {
@Test
fun `rejects blank value`() {
assertThrows<IllegalArgumentException> {
TransitionId("")
}
}
@Test
fun `stores value correctly`() {
val id = TransitionId("t-1")
assertEquals("t-1", id.value)
assertEquals("t-1", id.toString())
}
@Test
fun `value class equality works`() {
val a = TransitionId("x")
val b = TransitionId("x")
assertEquals(a, b)
}
}
@@ -0,0 +1,28 @@
package com.correx.testing.contracts.model
import com.correx.core.validation.graph.GraphValidator
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.testing.fixtures.WorkflowFixtures
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ValidationReportContractTest {
@Test
fun `report must be deterministic`() {
val graph = WorkflowFixtures.simpleGraph()
val pipeline = ValidationPipeline(
listOf(GraphValidator())
)
val context = ValidationContext(graph)
val r1 = pipeline.validate(context)
val r2 = pipeline.validate(context)
assertEquals(r1, r2)
}
}
@@ -0,0 +1,67 @@
package com.correx.testing.contracts.model
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WorkflowGraphTest {
private val s1 = StageId("s1")
private val s2 = StageId("s2")
@Test
fun `graph stores immutable structure`() {
val graph = WorkflowGraph(
stages = mapOf(s1 to StageConfig(), s2 to StageConfig()),
transitions = setOf(
TransitionEdge(
id = TransitionId("t1"),
from = s1,
to = s2,
condition = { true }
)
),
start = s1
)
assertEquals(2, graph.stages.size)
assertEquals(1, graph.transitions.size)
assertEquals(s1, graph.start)
}
@Test
fun `map removes duplicate keys deterministically`() {
val node = s1
val graph = WorkflowGraph(
stages = mapOf(node to StageConfig()),
transitions = emptySet(),
start = s1
)
assertEquals(1, graph.stages.size)
assertTrue(graph.stages.containsKey(node))
}
@Test
fun `graph equality is structural`() {
val g1 = WorkflowGraph(
stages = mapOf(s1 to StageConfig()),
transitions = emptySet(),
start = s1
)
val g2 = WorkflowGraph(
stages = mapOf(s1 to StageConfig()),
transitions = emptySet(),
start = s1
)
assertEquals(g1, g2)
}
}
@@ -0,0 +1,150 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoEligibleProviderException
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.RoutingStrategy
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class DefaultInferenceRouterCacheTest {
private val stage = StageId("cache-test")
private class CountingProvider(
delegate: MockInferenceProvider,
) : InferenceProvider by delegate {
var healthCheckCount: Int = 0
private set
private var _health: ProviderHealth = ProviderHealth.Healthy
fun setHealth(h: ProviderHealth) { _health = h }
override suspend fun healthCheck(): ProviderHealth {
healthCheckCount++
return _health
}
}
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
object : ProviderRegistry {
override fun register(provider: InferenceProvider) = Unit
override fun resolve(capability: ModelCapability) =
providers.filter { p -> p.capabilities().any { it.capability == capability } }
override fun listAll() = providers.toList()
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
}
private fun firstStrategy(): RoutingStrategy =
RoutingStrategy { candidates, required ->
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), required)
}
// ── (a) repeated route() within TTL invokes health check once per provider ──
@Test
fun `health check is cached within TTL`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting = CountingProvider(mock)
val router = DefaultInferenceRouter(
registry = registryOf(counting),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
router.route(stage, setOf(ModelCapability.General))
router.route(stage, setOf(ModelCapability.General))
router.route(stage, setOf(ModelCapability.General))
// Cache hit for all subsequent calls: health checked once for the filter pass,
// plus the post-selection re-check on the first call (uncached path), then cached.
// After the first route() the entry is cached; the two subsequent calls use the cache.
// Post-selection re-check always goes live, so total = 1 (filter) + 1 (post-select)
// on first call, then 0 (filter cached) + 1 (post-select live) * 2 more = 4 total.
// But the cache only governs the filter pass; post-select is always live by design.
// So: first call = 2 checks (filter + post-select), subsequent calls = 1 each (post-select).
assertEquals(4, counting.healthCheckCount)
}
// ── (b) after TTL expires, health is re-checked ──────────────────────────
@Test
fun `health check is refreshed after TTL expires`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting = CountingProvider(mock)
val router = DefaultInferenceRouter(
registry = registryOf(counting),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
router.route(stage, setOf(ModelCapability.General))
val countAfterFirstCall = counting.healthCheckCount
// Advance past TTL
timeSource.plusAssign(6.seconds)
router.route(stage, setOf(ModelCapability.General))
// After TTL expiry a new filter-pass health check must fire
assert(counting.healthCheckCount > countAfterFirstCall + 1) {
"Expected more than ${countAfterFirstCall + 1} total checks, got ${counting.healthCheckCount}"
}
}
// ── (c) unhealthy cached entry causes provider skip ───────────────────────
@Test
fun `cached unavailable entry causes provider to be skipped`(): Unit = runBlocking {
val timeSource = TestTimeSource()
val mock1 = MockInferenceProvider(
id = ProviderId("p1"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val mock2 = MockInferenceProvider(
id = ProviderId("p2"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
)
val counting1 = CountingProvider(mock1)
val counting2 = CountingProvider(mock2)
val router = DefaultInferenceRouter(
registry = registryOf(counting1, counting2),
strategy = firstStrategy(),
cacheTtl = 5.seconds,
timeSource = timeSource,
)
// Warm the cache with p1 healthy
router.route(stage, setOf(ModelCapability.General))
// Mark p1 unavailable in the counting provider — but cache still says healthy
counting1.setHealth(ProviderHealth.Unavailable("went down"))
// Advance past TTL so cache refreshes
timeSource.plusAssign(6.seconds)
val selected = router.route(stage, setOf(ModelCapability.General))
assertSame(counting2, selected)
}
}
@@ -0,0 +1,123 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoEligibleProviderException
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.RoutingStrategy
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class DefaultInferenceRouterTest {
private val stage = StageId("s1")
private fun provider(id: String, vararg caps: ModelCapability): MockInferenceProvider =
MockInferenceProvider(
id = ProviderId(id),
declaredCapabilities = caps.map { CapabilityScore(it, 1.0) }.toSet(),
)
private fun registryOf(vararg providers: InferenceProvider): ProviderRegistry =
object : ProviderRegistry {
override fun register(provider: InferenceProvider) = Unit
override fun resolve(capability: ModelCapability) =
providers.filter { p -> p.capabilities().any { it.capability == capability } }
override fun listAll() = providers.toList()
override suspend fun healthCheckAll() = providers.associate { it.id to ProviderHealth.Healthy }
}
private fun firstStrategy(): RoutingStrategy =
object : RoutingStrategy {
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
candidates.firstOrNull() ?: throw NoEligibleProviderException(StageId("?"), emptySet())
}
private fun throwingStrategy(): RoutingStrategy =
object : RoutingStrategy {
override fun select(candidates: List<InferenceProvider>, requiredCapabilities: Set<ModelCapability>) =
throw NoEligibleProviderException(StageId("?"), requiredCapabilities)
}
// ── capability matching ───────────────────────────────────────────────────
@Test
fun `routes to provider that matches required capability`(): Unit = runBlocking {
val p = provider("a", ModelCapability.General)
val router = DefaultInferenceRouter(registryOf(p), firstStrategy())
assertSame(p, router.route(stage, setOf(ModelCapability.General)))
}
@Test
fun `deduplicates providers that satisfy multiple required capabilities`(): Unit = runBlocking {
val p = provider("a", ModelCapability.General, ModelCapability.Coding)
val selected = mutableListOf<InferenceProvider>()
val captureStrategy = object : RoutingStrategy {
override fun select(
candidates: List<InferenceProvider>,
requiredCapabilities: Set<ModelCapability>,
): InferenceProvider {
selected.addAll(candidates)
return candidates.first()
}
}
val router = DefaultInferenceRouter(
registryOf(p),
captureStrategy,
)
router.route(stage, setOf(ModelCapability.General, ModelCapability.Coding))
assert(selected.count { it.id == p.id } == 1) { "provider appeared ${selected.size} times, expected 1" }
}
// ── empty capabilities falls back to all providers ────────────────────────
@Test
fun `empty required capabilities routes from all registered providers`(): Unit = runBlocking {
val p1 = provider("a", ModelCapability.General)
val p2 = provider("b", ModelCapability.Coding)
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
val result = router.route(stage, emptySet())
assertSame(p1, result)
}
// ── no candidates throws ──────────────────────────────────────────────────
@Test
fun `throws NoEligibleProviderException when no provider satisfies capability`() {
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
assertThrows<NoEligibleProviderException> {
runBlocking { router.route(stage, setOf(ModelCapability.General)) }
}
}
@Test
fun `throws NoEligibleProviderException when registry is empty and capabilities are empty`() {
val router = DefaultInferenceRouter(registryOf(), throwingStrategy())
assertThrows<NoEligibleProviderException> {
runBlocking { router.route(stage, emptySet()) }
}
}
// ── unavailable providers are filtered ───────────────────────────────────
@Test
fun `filters out unavailable providers before selection`(): Unit = runBlocking {
val p1 = MockInferenceProvider(
id = com.correx.core.events.types.ProviderId("a"),
declaredCapabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
health = ProviderHealth.Unavailable("down"),
)
val p2 = provider("b", ModelCapability.General)
val router = DefaultInferenceRouter(registryOf(p1, p2), firstStrategy())
val result = router.route(stage, setOf(ModelCapability.General))
assertSame(p2, result)
}
}
@@ -0,0 +1,135 @@
package com.correx.testing.contracts.model.inference
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextPack
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest
import com.correx.testing.fixtures.inference.MockInferenceProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class InferenceProviderContractTest {
fun provider(): InferenceProvider = MockInferenceProvider()
private fun request() = InferenceRequest(
requestId = InferenceRequestId("req-1"),
sessionId = SessionId("session-1"),
stageId = StageId("stage-1"),
contextPack = emptyContextPack(), // test helper — returns minimal stub
generationConfig = GenerationConfig(
temperature = 0.0,
topP = 1.0,
maxTokens = 128,
),
)
// ── Successful inference ──────────────────────────────────────────────────
@Test
fun `infer returns response with matching requestId`(): Unit = runBlocking {
val p = provider()
val req = request()
val resp = p.infer(req)
assertEquals(req.requestId, resp.requestId)
}
@Test
fun `infer returns non-blank text`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.text.isNotBlank())
}
@Test
fun `infer tracks token usage`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.tokensUsed.totalTokens > 0)
}
@Test
fun `infer latency is non-negative`(): Unit = runBlocking {
val resp = provider().infer(request())
assertTrue(resp.latencyMs >= 0)
}
// ── Health ────────────────────────────────────────────────────────────────
@Test
fun `healthCheck returns non-null`(): Unit = runBlocking {
assertNotNull(provider().healthCheck())
}
// ── Capabilities ──────────────────────────────────────────────────────────
@Test
fun `capabilities is non-empty`() {
assertTrue(provider().capabilities().isNotEmpty())
}
@Test
fun `all capability scores are in range 0 to 1`() {
provider().capabilities().forEach { cs ->
assertTrue(cs.score in 0.0..1.0, "score out of range: $cs")
}
}
// ── Cancellation ──────────────────────────────────────────────────────────
// A slow provider — required so cancellation actually fires mid-flight rather than
// racing a 0ms infer that has already completed by the time cancel() is called.
private fun slowProvider(): InferenceProvider = MockInferenceProvider(artificialDelayMs = 1_000L)
@Test
fun `infer respects coroutine cancellation`(): Unit = runBlocking {
val p = slowProvider()
val started = System.currentTimeMillis()
val job = launch { p.infer(request()) }
// give the coroutine a moment to enter delay()
while (job.isActive && System.currentTimeMillis() - started < 50) { /* spin */ }
job.cancel()
job.join()
assertFalse(job.isActive)
// Must complete well before the 1000ms artificial delay would have elapsed.
assertTrue(System.currentTimeMillis() - started < 500, "cancellation did not interrupt mid-flight")
}
@Test
fun `infer cancellation completes within 500ms`(): Unit = runBlocking {
val p = slowProvider()
val started = System.currentTimeMillis()
val job = launch { p.infer(request()) }
job.cancel()
withTimeout(500) { job.join() }
assertTrue(System.currentTimeMillis() - started < 500, "join exceeded budget after cancel")
}
// ── Tokenizer ─────────────────────────────────────────────────────────────
@Test
fun `tokenizer countTokens is consistent with tokenize`(): Unit = runBlocking {
val t = provider().tokenizer
val text = "hello world"
assertEquals(t.tokenize(text).size, t.countTokens(text))
}
private fun emptyContextPack(): ContextPack = ContextPack(
id = ContextPackId("cp1"),
sessionId = SessionId("session-1"),
stageId = StageId("stage-1"),
layers = emptyMap(),
budgetUsed = 10,
budgetLimit = 20,
compressionMetadata = CompressionMetadata(),
)
}
@@ -0,0 +1,132 @@
package com.correx.testing.contracts.model.orchestration
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.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.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class EventsTest {
@Test
fun `WorkflowStartedEvent survives round-trip`() {
val event: EventPayload = WorkflowStartedEvent(
sessionId = SessionId("s1"),
startStageId = StageId("stage-a"),
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowCompletedEvent survives round-trip`() {
val event: EventPayload = WorkflowCompletedEvent(
sessionId = SessionId("s1"),
terminalStageId = StageId("stage-a"),
totalStages = 1,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowFailedEvent survives round-trip`() {
val event: EventPayload = WorkflowFailedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Something went wrong",
retryExhausted = false,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowFailedEvent survives round-trip with retry exhausted`() {
val event: EventPayload = WorkflowFailedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Something went wrong",
retryExhausted = true,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `OrchestrationPausedEvent survives round-trip`() {
val event: EventPayload = OrchestrationPausedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
reason = "Requires user approval",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `OrchestrationResumedEvent survives round-trip`() {
val event: EventPayload = OrchestrationResumedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `RetryAttemptedEvent survives round-trip`() {
val event: EventPayload = RetryAttemptedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
attemptNumber = 1,
maxAttempts = 3,
failureReason = "Something went wrong",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `RetryAttemptedEvent survives round-trip attempt = max attempts`() {
val event: EventPayload = RetryAttemptedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
attemptNumber = 3,
maxAttempts = 3,
failureReason = "Something went wrong",
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
}
@@ -0,0 +1,37 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.kernel.execution.ReplayStrategy
import com.correx.core.kernel.orchestration.OrchestrationConfig
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class OrchestrationConfigTest {
@Test
fun `default retryPolicy has maxAttempts of 3`() {
assertEquals(3, OrchestrationConfig().retryPolicy.maxAttempts)
}
@Test
fun `default replayStrategy is Full`() {
assertEquals(ReplayStrategy.Full, OrchestrationConfig().replayStrategy)
}
@Test
fun `default stageTimeoutMs is 60 seconds`() {
assertEquals(60_000L, OrchestrationConfig().stageTimeoutMs)
}
@Test
fun `custom values are stored`() {
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 5, backoffMs = 200L),
replayStrategy = ReplayStrategy.Full,
stageTimeoutMs = 30_000L,
)
assertEquals(5, config.retryPolicy.maxAttempts)
assertEquals(200L, config.retryPolicy.backoffMs)
assertEquals(30_000L, config.stageTimeoutMs)
}
}
@@ -0,0 +1,41 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.execution.RetryPolicy
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class RetryPolicyTest {
@Test
fun `maxAttempts of zero throws IllegalArgumentException`() {
assertThrows<IllegalArgumentException> {
RetryPolicy(maxAttempts = 0)
}
}
@Test
fun `negative maxAttempts throws IllegalArgumentException`() {
assertThrows<IllegalArgumentException> {
RetryPolicy(maxAttempts = -1)
}
}
@Test
fun `maxAttempts of one is valid`() {
val policy = RetryPolicy(maxAttempts = 1)
assertEquals(1, policy.maxAttempts)
}
@Test
fun `backoffMs defaults to zero`() {
val policy = RetryPolicy(maxAttempts = 1)
assertEquals(0L, policy.backoffMs)
}
@Test
fun `backoffMs is stored when provided`() {
val policy = RetryPolicy(maxAttempts = 3, backoffMs = 500L)
assertEquals(500L, policy.backoffMs)
}
}
@@ -0,0 +1,28 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.transitions.execution.StageExecutionResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class StageOutcomeTest {
@Test
fun `sealed exhaustiveness - all variants reachable`() {
val results: List<StageExecutionResult> = listOf(
StageExecutionResult.Success(emptyList()),
StageExecutionResult.Failure("something went wrong", false),
)
val labels = results.map { result ->
when (result) {
is StageExecutionResult.Success -> "success"
is StageExecutionResult.Failure -> "failure"
}
}
assertEquals(
listOf("success", "failure"),
labels,
)
}
}
@@ -0,0 +1,58 @@
package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
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 WorkflowResultTest {
@Test
fun `Completed carries sessionId and terminalStageId`() {
val result = WorkflowResult.Completed(SessionId("s1"), StageId("stage-z"))
assertEquals(SessionId("s1"), result.sessionId)
assertEquals(StageId("stage-z"), result.terminalStageId)
}
@Test
fun `Failed carries sessionId reason and retryExhausted`() {
val result = WorkflowResult.Failed(SessionId("s2"), "boom", retryExhausted = true)
assertEquals(SessionId("s2"), result.sessionId)
assertEquals("boom", result.reason)
assertTrue(result.retryExhausted)
}
@Test
fun `Failed retryExhausted can be false`() {
val result = WorkflowResult.Failed(SessionId("s3"), "transient", retryExhausted = false)
assertFalse(result.retryExhausted)
}
@Test
fun `Cancelled carries sessionId`() {
val result = WorkflowResult.Cancelled(SessionId("s4"))
assertEquals(SessionId("s4"), result.sessionId)
}
@Test
fun `sealed exhaustiveness - all variants reachable`() {
val results: List<WorkflowResult> = listOf(
WorkflowResult.Completed(SessionId("s1"), StageId("stage-z")),
WorkflowResult.Failed(SessionId("s2"), "reason", retryExhausted = false),
WorkflowResult.Cancelled(SessionId("s3")),
)
val labels = results.map { result ->
when (result) {
is WorkflowResult.Completed -> "completed"
is WorkflowResult.Failed -> "failed"
is WorkflowResult.Cancelled -> "cancelled"
}
}
assertEquals(listOf("completed", "failed", "cancelled"), labels)
}
}
@@ -0,0 +1,180 @@
package com.correx.testing.contracts.fixtures.events.store
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.testing.contracts.utils.ConcurrencyRunner
import com.correx.testing.fixtures.EventFixtures
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
abstract class EventStoreContractTest {
protected abstract fun store(): EventStore
@Test
fun `appends preserve ordering`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("e1"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("e2"), SessionId("s1")))
val events = store.read(SessionId("s1"))
Assertions.assertEquals(2, events.size)
Assertions.assertEquals("e1", events[0].metadata.eventId.value)
Assertions.assertEquals("e2", events[1].metadata.eventId.value)
}
@Test
fun `idempotency prevents duplicates`() {
val store = store()
val e = EventFixtures.newEvent(EventId("dup"), SessionId("s1"))
store.append(e)
assertThrows<Exception> {
store.append(e)
}
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
}
@Test
fun `sessions are isolated`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("a"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("b"), SessionId("s2")))
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
Assertions.assertEquals(1, store.read(SessionId("s2")).size)
}
@Test
fun `readFrom respects sequence cursor`() {
val store = store()
store.append(EventFixtures.newEvent(EventId("1"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("2"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("3"), SessionId("s1")))
val partial = store.readFrom(SessionId("s1"), 2)
Assertions.assertEquals(1, partial.size)
}
@Test
fun `event ids are globally unique under concurrent appends`() {
val store = store()
ConcurrencyRunner.run(20, 100) { t, i ->
store.append(
EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1"))
)
}
val all = store.read(SessionId("s1"))
val ids = all.map { it.metadata.eventId }.toSet()
Assertions.assertEquals(all.size, ids.size)
}
@Test
fun `sequences are strictly increasing per session`() {
val store = store()
repeat(100) {
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
}
val seqs = store.read(SessionId("s1")).map { it.sequence }
seqs.zipWithNext().forEach { (a, b) ->
Assertions.assertTrue(a < b)
}
}
@Test
fun `sequences are isolated per session`() {
val store = store()
repeat(50) {
store.append(EventFixtures.newEvent(EventId("a$it"), SessionId("A")))
store.append(EventFixtures.newEvent(EventId("b$it"), SessionId("B")))
}
val aSeq = store.read(SessionId("A")).map { it.sequence }
val bSeq = store.read(SessionId("B")).map { it.sequence }
Assertions.assertEquals(aSeq.sorted(), aSeq)
Assertions.assertEquals(bSeq.sorted(), bSeq)
}
@Test
fun `appendAll is atomic per session`() {
val store = store()
val batch = (1..50).map {
EventFixtures.newEvent(EventId("e$it"), SessionId("s1"))
}
store.appendAll(batch)
val result = store.read(SessionId("s1"))
Assertions.assertEquals(50, result.size)
val seqs = result.map { it.sequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@Test
fun `concurrent appendAll and append remain consistent`() {
val store = store()
ConcurrencyRunner.run(10, 50) { t, i ->
if (i % 2 == 0) {
store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1")))
} else {
store.appendAll(
listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1")))
)
}
}
val all = store.read(SessionId("s1"))
val seqs = all.map { it.sequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@Test
fun `read returns stable snapshot`() {
val store = store()
repeat(100) {
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
}
val r1 = store.read(SessionId("s1"))
val r2 = store.read(SessionId("s1"))
Assertions.assertEquals(r1, r2)
}
@Test
fun `readFrom behaves like streaming cursor`() {
val store = store()
repeat(10) {
store.append(EventFixtures.newEvent(EventId("$it"), SessionId("s1")))
}
val first = store.readFrom(SessionId("s1"), 3)
val second = store.readFrom(SessionId("s1"), 7)
Assertions.assertTrue(first.all { it.sequence > 3 })
Assertions.assertTrue(second.all { it.sequence > 7 })
}
}
@@ -0,0 +1,53 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.DefaultStateBuilder
import com.correx.core.sessions.SessionCounterProjection
import com.correx.core.sessions.SessionCounterState
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
open class CountingProjectionContractTest : ProjectionContractTest<SessionCounterState>() {
override fun projection() =
SessionCounterProjection("s1")
@Test
fun `projection reflects event count`() {
val events = (0 until 42).map {
stored(EventId(it.toString()), sessionId = SessionId("s1"), sequence = it.toLong())
}
val state = DefaultStateBuilder(
projection()
).build(events)
assertEquals(42, state.count)
}
@Test
fun `projection is order sensitive but deterministic`() {
val events = listOf(
stored(EventId("1"), sessionId = SessionId("s1"), sequence = 0),
stored(EventId("2"), sessionId = SessionId("s1"), sequence = 1),
stored(EventId("3"), sessionId = SessionId("s1"), sequence = 2)
)
val state = DefaultStateBuilder(
projection()
).build(events)
assertEquals(3, state.count)
}
@Test
fun `projection handles empty stream`() {
val state = DefaultStateBuilder(
projection()
).build(emptyList())
assertEquals(0, state.count)
}
}
@@ -0,0 +1,30 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.DefaultStateBuilder
import com.correx.core.sessions.projections.Projection
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class ProjectionContractTest<S> {
abstract fun projection(): Projection<S>
@Test
fun `projection rebuild is deterministic`() {
val events = (0 until 100).map {
stored(EventId("e$it"), sessionId = SessionId("s1"), sequence = it.toLong())
}
val projection = projection()
val builder = DefaultStateBuilder(projection)
val state1 = builder.build(events)
val state2 = builder.build(events)
assertEquals(state1, state2)
}
}
@@ -0,0 +1,34 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.testing.fixtures.EventFixtures.newEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class ReplayContractTest<S> {
protected abstract fun store(): EventStore
protected abstract fun replayer(
store: EventStore
): EventReplayer<S>
@Test
fun `rebuild is deterministic`() {
val store = store()
repeat(50) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state1 = replayer.rebuild(SessionId("s1"))
val state2 = replayer.rebuild(SessionId("s1"))
assertEquals(state1, state2)
}
}
@@ -0,0 +1,50 @@
package com.correx.testing.contracts.fixtures.projections
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.sessions.SessionCounterState
import com.correx.testing.fixtures.EventFixtures.newEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
abstract class SessionReplayContractTest {
protected abstract fun store(): EventStore
protected abstract fun replayer(
store: EventStore
): EventReplayer<SessionCounterState>
@Test
fun `rebuild is deterministic`() {
val store = store()
repeat(50) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state1 = replayer.rebuild(SessionId("s1"))
val state2 = replayer.rebuild(SessionId("s1"))
assertEquals(state1, state2)
}
@Test
fun `rebuild reflects complete stream`() {
val store = store()
repeat(42) {
store.append(newEvent(EventId("e$it"), SessionId("s1")))
}
val replayer = replayer(store)
val state = replayer.rebuild(SessionId("s1"))
assertEquals(42, state.count)
}
}