epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:sessions"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalState
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
|
||||
class ApprovalProjector(private val reducer: ApprovalReducer) : Projection<ApprovalState> {
|
||||
override fun initial(): ApprovalState = ApprovalState()
|
||||
override fun apply(state: ApprovalState, event: StoredEvent): ApprovalState = reducer.reduce(state, event)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalState
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
|
||||
interface ApprovalReducer {
|
||||
fun reduce(state: ApprovalState, event: StoredEvent): ApprovalState
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.ApprovalState
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantExpiredEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
|
||||
class DefaultApprovalReducer : ApprovalReducer {
|
||||
|
||||
override fun reduce(state: ApprovalState, event: StoredEvent): ApprovalState {
|
||||
return when (val payload = event.payload) {
|
||||
is ApprovalRequestedEvent -> {
|
||||
val request = DomainApprovalRequest(
|
||||
id = payload.requestId,
|
||||
tier = payload.tier,
|
||||
validationReportId = payload.validationReportId,
|
||||
riskSummaryId = payload.riskSummaryId,
|
||||
timestamp = event.metadata.timestamp,
|
||||
causationId = event.metadata.causationId,
|
||||
correlationId = event.metadata.correlationId,
|
||||
userSteering = payload.userSteering
|
||||
)
|
||||
state.copy(requests = state.requests + (request.id to request))
|
||||
}
|
||||
|
||||
is ApprovalDecisionResolvedEvent -> {
|
||||
val decisionId = payload.decisionId
|
||||
// The event does not carry context info, so we reconstruct a partial identity
|
||||
// with a default mode; stageId and projectId are unavailable at this point.
|
||||
val contextSnapshot = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(
|
||||
sessionId = event.metadata.sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
causationId = event.metadata.causationId,
|
||||
correlationId = event.metadata.correlationId
|
||||
)
|
||||
val decision = ApprovalDecision(
|
||||
id = decisionId,
|
||||
requestId = payload.requestId,
|
||||
outcome = payload.outcome,
|
||||
state = payload.status,
|
||||
tier = payload.tier,
|
||||
contextSnapshot = contextSnapshot,
|
||||
resolutionTimestamp = payload.resolutionTimestamp,
|
||||
reason = payload.reason,
|
||||
userSteering = payload.userSteering
|
||||
)
|
||||
state.copy(decisions = state.decisions + (decisionId to decision))
|
||||
}
|
||||
|
||||
is ApprovalGrantCreatedEvent -> {
|
||||
val grant = ApprovalGrant(
|
||||
id = payload.grantId,
|
||||
scope = payload.scope,
|
||||
permittedTiers = payload.permittedTiers,
|
||||
reason = payload.reason,
|
||||
timestamp = event.metadata.timestamp,
|
||||
expiresAt = payload.expiresAt
|
||||
)
|
||||
state.copy(grants = state.grants + (grant.id to grant))
|
||||
}
|
||||
|
||||
is ApprovalGrantExpiredEvent -> {
|
||||
state.copy(grants = state.grants - payload.grantId)
|
||||
}
|
||||
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalState
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
|
||||
class DefaultApprovalRepository(
|
||||
private val replayer: EventReplayer<ApprovalState>
|
||||
) {
|
||||
fun getApprovalState(sessionId: SessionId): ApprovalState =
|
||||
replayer.rebuild(sessionId)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.approvals.domain
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
interface ApprovalEngine {
|
||||
fun evaluate(
|
||||
request: DomainApprovalRequest,
|
||||
context: ApprovalContext,
|
||||
grants: List<ApprovalGrant>,
|
||||
now: Instant
|
||||
): ApprovalDecision
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.correx.core.approvals.domain
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.isAtMost
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
class DefaultApprovalEngine : ApprovalEngine {
|
||||
|
||||
override fun evaluate(
|
||||
request: DomainApprovalRequest,
|
||||
context: ApprovalContext,
|
||||
grants: List<ApprovalGrant>,
|
||||
now: Instant
|
||||
): ApprovalDecision {
|
||||
val decisionId = ApprovalDecisionId("decision:${request.id.value}")
|
||||
|
||||
val matchingGrant = grants.firstOrNull { grant ->
|
||||
!isExpired(grant, now)
|
||||
&& scopeMatches(grant.scope, context)
|
||||
&& request.tier in grant.permittedTiers
|
||||
}
|
||||
|
||||
if (matchingGrant != null) {
|
||||
return approved(decisionId, request, context, now, reason = "grant:${matchingGrant.id.value}")
|
||||
}
|
||||
|
||||
if (context.mode == ApprovalMode.YOLO) {
|
||||
return approved(decisionId, request, context, now, reason = null)
|
||||
}
|
||||
|
||||
val threshold = when (context.mode) {
|
||||
ApprovalMode.DENY -> Tier.T0
|
||||
ApprovalMode.PROMPT -> Tier.T1
|
||||
ApprovalMode.AUTO -> Tier.T2
|
||||
ApprovalMode.YOLO -> error("unreachable")
|
||||
}
|
||||
|
||||
return if (request.tier.isAtMost(threshold)) {
|
||||
approved(decisionId, request, context, now, reason = null)
|
||||
} else {
|
||||
ApprovalDecision(
|
||||
id = decisionId,
|
||||
requestId = request.id,
|
||||
outcome = null,
|
||||
state = ApprovalStatus.PENDING,
|
||||
tier = request.tier,
|
||||
contextSnapshot = context,
|
||||
resolutionTimestamp = null,
|
||||
reason = null,
|
||||
userSteering = request.userSteering
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun approved(
|
||||
id: ApprovalDecisionId,
|
||||
request: DomainApprovalRequest,
|
||||
context: ApprovalContext,
|
||||
now: Instant,
|
||||
reason: String?
|
||||
) = ApprovalDecision(
|
||||
id = id,
|
||||
requestId = request.id,
|
||||
outcome = ApprovalOutcome.AUTO_APPROVED,
|
||||
state = ApprovalStatus.COMPLETED,
|
||||
tier = request.tier,
|
||||
contextSnapshot = context,
|
||||
resolutionTimestamp = now,
|
||||
reason = reason,
|
||||
userSteering = request.userSteering
|
||||
)
|
||||
|
||||
private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean =
|
||||
grant.expiresAt != null && grant.expiresAt <= now
|
||||
|
||||
private fun scopeMatches(scope: GrantScope, context: ApprovalContext): Boolean = when (scope) {
|
||||
is GrantScope.SESSION -> true
|
||||
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
||||
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.approvals.domain
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* No-op implementation of [ApprovalEngine] used during deterministic replay.
|
||||
* Replay reconstructs approval decisions from the event log — live approval
|
||||
* evaluation must never run on the replay path.
|
||||
*/
|
||||
class NoOpApprovalEngine : ApprovalEngine {
|
||||
override fun evaluate(
|
||||
request: DomainApprovalRequest,
|
||||
context: ApprovalContext,
|
||||
grants: List<ApprovalGrant>,
|
||||
now: Instant,
|
||||
): ApprovalDecision = ApprovalDecision(
|
||||
id = null,
|
||||
requestId = request.id,
|
||||
outcome = ApprovalOutcome.AUTO_APPROVED,
|
||||
state = ApprovalStatus.COMPLETED,
|
||||
tier = Tier.T0,
|
||||
contextSnapshot = context,
|
||||
resolutionTimestamp = now,
|
||||
reason = "replay — reconstructed from event log",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.events.types.CausationId
|
||||
import com.correx.core.events.types.CorrelationId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalContext(
|
||||
val identity: ApprovalScopeIdentity,
|
||||
val mode: ApprovalMode,
|
||||
val causationId: CausationId? = null,
|
||||
val correlationId: CorrelationId? = null
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.UserSteering
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalDecision(
|
||||
val id: ApprovalDecisionId?,
|
||||
val requestId: ApprovalRequestId,
|
||||
val outcome: ApprovalOutcome?,
|
||||
val state: ApprovalStatus,
|
||||
val tier: Tier,
|
||||
val contextSnapshot: ApprovalContext,
|
||||
val resolutionTimestamp: Instant?,
|
||||
val reason: String?,
|
||||
val userSteering: UserSteering? = null
|
||||
) {
|
||||
val isFinal: Boolean get() = state != ApprovalStatus.PENDING
|
||||
val isApproved: Boolean get() = outcome == ApprovalOutcome.APPROVED || outcome == ApprovalOutcome.AUTO_APPROVED
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.approvals.GrantScope
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.types.GrantId
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalGrant(
|
||||
val id: GrantId,
|
||||
val scope: GrantScope,
|
||||
val permittedTiers: Set<Tier>,
|
||||
val reason: String,
|
||||
val timestamp: Instant,
|
||||
val expiresAt: Instant? = null
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalScopeIdentity(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId?,
|
||||
val projectId: ProjectId?
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.GrantId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalState(
|
||||
val requests: Map<ApprovalRequestId, DomainApprovalRequest> = emptyMap(),
|
||||
val decisions: Map<ApprovalDecisionId, ApprovalDecision> = emptyMap(),
|
||||
val grants: Map<GrantId, ApprovalGrant> = emptyMap()
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.core.approvals.model
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.UserSteering
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.CausationId
|
||||
import com.correx.core.events.types.CorrelationId
|
||||
import com.correx.core.events.types.RiskSummaryId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DomainApprovalRequest(
|
||||
val id: ApprovalRequestId,
|
||||
val tier: Tier,
|
||||
val validationReportId: ValidationReportId,
|
||||
val riskSummaryId: RiskSummaryId?,
|
||||
val timestamp: Instant,
|
||||
val causationId: CausationId? = null,
|
||||
val correlationId: CorrelationId? = null,
|
||||
val userSteering: UserSteering? = null
|
||||
)
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalState
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantExpiredEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolInvokedEvent
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
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 DefaultApprovalReducerTest {
|
||||
|
||||
private val reducer = DefaultApprovalReducer()
|
||||
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val sessionId = SessionId("session-1")
|
||||
|
||||
private fun storedEvent(payload: com.correx.core.events.events.EventPayload, sequence: Long = 1L): StoredEvent {
|
||||
return StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("event-$sequence"),
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null
|
||||
),
|
||||
sequence = sequence,
|
||||
payload = payload
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalRequestedEvent adds request to state`() {
|
||||
val requestId = ApprovalRequestId("req-1")
|
||||
val payload = ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = ValidationReportId("report-1"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
)
|
||||
|
||||
val state = reducer.reduce(ApprovalState(), storedEvent(payload))
|
||||
|
||||
assertTrue(state.requests.containsKey(requestId))
|
||||
assertEquals(requestId, state.requests[requestId]?.id)
|
||||
assertEquals(Tier.T2, state.requests[requestId]?.tier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalGrantCreatedEvent adds grant to state`() {
|
||||
val grantId = GrantId("grant-1")
|
||||
val payload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||
reason = "user approved",
|
||||
expiresAt = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
)
|
||||
|
||||
val state = reducer.reduce(ApprovalState(), storedEvent(payload))
|
||||
|
||||
assertTrue(state.grants.containsKey(grantId))
|
||||
assertEquals(grantId, state.grants[grantId]?.id)
|
||||
assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalGrantExpiredEvent removes grant from state`() {
|
||||
val grantId = GrantId("grant-2")
|
||||
|
||||
val addPayload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T1),
|
||||
reason = "granted",
|
||||
expiresAt = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
)
|
||||
val removePayload = ApprovalGrantExpiredEvent(grantId = grantId)
|
||||
|
||||
val stateAfterAdd = reducer.reduce(ApprovalState(), storedEvent(addPayload, sequence = 1L))
|
||||
assertTrue(stateAfterAdd.grants.containsKey(grantId))
|
||||
|
||||
val stateAfterRemove = reducer.reduce(stateAfterAdd, storedEvent(removePayload, sequence = 2L))
|
||||
assertFalse(stateAfterRemove.grants.containsKey(grantId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalDecisionResolvedEvent adds decision to state`() {
|
||||
val decisionId = ApprovalDecisionId("decision-1")
|
||||
val requestId = ApprovalRequestId("req-1")
|
||||
val payload = ApprovalDecisionResolvedEvent(
|
||||
decisionId = decisionId,
|
||||
requestId = requestId,
|
||||
outcome = ApprovalOutcome.APPROVED,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = Tier.T2,
|
||||
resolutionTimestamp = timestamp,
|
||||
reason = null
|
||||
)
|
||||
|
||||
val state = reducer.reduce(ApprovalState(), storedEvent(payload))
|
||||
|
||||
assertTrue(state.decisions.containsKey(decisionId))
|
||||
assertEquals(requestId, state.decisions[decisionId]?.requestId)
|
||||
assertEquals(ApprovalOutcome.APPROVED, state.decisions[decisionId]?.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown events pass state through unchanged`() {
|
||||
val initial = ApprovalState()
|
||||
val state = reducer.reduce(initial, storedEvent(ToolInvokedEvent("tool")))
|
||||
assertEquals(initial, state)
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantExpiredEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.GrantId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultApprovalRepositoryTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val replayer = DefaultEventReplayer(store, ApprovalProjector(DefaultApprovalReducer()))
|
||||
private val repository = DefaultApprovalRepository(replayer)
|
||||
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
|
||||
private fun metadata(sessionId: SessionId, eventId: String): EventMetadata = EventMetadata(
|
||||
eventId = EventId(eventId),
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `unknown session returns empty ApprovalState`() {
|
||||
val state = repository.getApprovalState(SessionId("unknown-session"))
|
||||
assertEquals(com.correx.core.approvals.model.ApprovalState(), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after ApprovalRequestedEvent state contains the request`() {
|
||||
val sessionId = SessionId("session-requested")
|
||||
val requestId = ApprovalRequestId("req-1")
|
||||
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = metadata(sessionId, "event-1"),
|
||||
payload = ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = ValidationReportId("report-1"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val state = repository.getApprovalState(sessionId)
|
||||
assertEquals(1, state.requests.size)
|
||||
assertTrue(state.requests.containsKey(requestId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after ApprovalGrantCreatedEvent then ApprovalGrantExpiredEvent grants list is empty`() {
|
||||
val sessionId = SessionId("session-grant-expired")
|
||||
val grantId = GrantId("grant-1")
|
||||
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = metadata(sessionId, "event-1"),
|
||||
payload = ApprovalGrantCreatedEvent(
|
||||
grantId = grantId,
|
||||
scope = GrantScope.SESSION,
|
||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||
reason = "approved",
|
||||
expiresAt = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = metadata(sessionId, "event-2"),
|
||||
payload = ApprovalGrantExpiredEvent(grantId = grantId)
|
||||
)
|
||||
)
|
||||
|
||||
val state = repository.getApprovalState(sessionId)
|
||||
assertTrue(state.grants.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user