Skip to content

Commit 020d68e

Browse files
authored
Use awaitable state machine in reversal info collection controller (#105)
* Use awaitable state machine in reversal info collection controller * Address review comments
1 parent 0538464 commit 020d68e

File tree

6 files changed

+83
-32
lines changed

6 files changed

+83
-32
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ domainApiKfsmv2 = "0.1.0"
77
guice = "7.0.0"
88
jodaMoney = "1.0.7"
99
kfsm = "0.12.0"
10-
kfsmv2 = "2.0.0"
10+
kfsmv2 = "2.1.0"
1111
kotest = "5.9.1"
1212
kotlinxCoroutines = "1.10.2"
1313
kotestArrow = "2.0.0"

innie/src/main/kotlin/xyz/block/bittycity/innie/controllers/ReversalInfoCollectionController.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import app.cash.kfsm.v2.StateMachine
55
import app.cash.quiver.extensions.catch
66
import arrow.core.raise.result
77
import jakarta.inject.Inject
8+
import jakarta.inject.Named
9+
import kotlin.time.Duration.Companion.milliseconds
810
import xyz.block.bittycity.common.client.CurrencyDisplayPreferenceClient
911
import xyz.block.bittycity.common.utils.WalletAddressParser
1012
import xyz.block.bittycity.innie.fsm.DepositEffect
@@ -29,7 +31,8 @@ class ReversalInfoCollectionController @Inject constructor(
2931
awaitableStateMachine: AwaitableStateMachine<DepositToken, Deposit, DepositState, DepositEffect>,
3032
depositStore: DepositStore,
3133
private val currencyDisplayPreferenceClient: CurrencyDisplayPreferenceClient,
32-
private val walletAddressParser: WalletAddressParser
34+
private val walletAddressParser: WalletAddressParser,
35+
@param:Named("deposit.stateMachineTimeoutInMilliSeconds") private val stateMachineTimeoutInMilliSeconds: Long,
3336
) : DepositInfoCollectionController(
3437
pendingCollectionState = CollectingReversalInfo,
3538
stateMachine = stateMachine,
@@ -74,7 +77,11 @@ class ReversalInfoCollectionController @Inject constructor(
7477

7578
override fun transition(value: Deposit): Result<Deposit> = result {
7679
when (value.state) {
77-
is CollectingReversalInfo -> stateMachine.transition(value, ReversalInfoCollectionComplete()).bind()
80+
is CollectingReversalInfo -> awaitableStateMachine.transitionAndAwait(
81+
value,
82+
ReversalInfoCollectionComplete(),
83+
stateMachineTimeoutInMilliSeconds.milliseconds
84+
).bind()
7885
else -> raise(IllegalStateException("Unexpected state ${value.state}"))
7986
}
8087
}

innie/src/test/kotlin/xyz/block/bittycity/innie/controllers/ReversalInfoCollectionControllerTest.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import io.kotest.matchers.types.shouldBeInstanceOf
55
import io.kotest.property.arbitrary.next
66
import jakarta.inject.Inject
77
import org.junit.jupiter.api.Test
8-
import xyz.block.bittycity.innie.models.CheckingReversalSanctions
98
import xyz.block.bittycity.innie.models.CollectingReversalInfo
109
import xyz.block.bittycity.innie.models.DepositFailureReason.RISK_BLOCKED
1110
import xyz.block.bittycity.innie.models.DepositReversal
1211
import xyz.block.bittycity.innie.models.DepositReversalHurdle
12+
import xyz.block.bittycity.innie.models.WaitingForReversalPendingConfirmationStatus
1313
import xyz.block.bittycity.innie.testing.Arbitrary
1414
import xyz.block.bittycity.innie.testing.Arbitrary.amount
1515
import xyz.block.bittycity.innie.testing.Arbitrary.balanceId
@@ -82,14 +82,19 @@ class ReversalInfoCollectionControllerTest : BittyCityTestCase() {
8282
it.copy(failureReason = RISK_BLOCKED)
8383
}
8484

85-
val result = subject.processInputs(
86-
deposit,
87-
emptyList(),
88-
Operation.EXECUTE
89-
).getOrThrow()
85+
try {
86+
startProcessingEffects()
87+
val result = subject.processInputs(
88+
deposit,
89+
emptyList(),
90+
Operation.EXECUTE
91+
).getOrThrow()
9092

91-
result.shouldBeInstanceOf<ProcessingState.Complete<*, *>>()
93+
result.shouldBeInstanceOf<ProcessingState.Complete<*, *>>()
9294

93-
depositWithToken(deposit.id).state shouldBe CheckingReversalSanctions
95+
depositWithToken(deposit.id).state shouldBe WaitingForReversalPendingConfirmationStatus
96+
} finally {
97+
stopProcessingEffects()
98+
}
9499
}
95100
}

innie/src/test/kotlin/xyz/block/bittycity/innie/controllers/ReversalSanctionsControllerTest.kt

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package xyz.block.bittycity.innie.controllers
22

3+
import app.cash.kfsm.v2.WorkflowFailedException
34
import app.cash.quiver.extensions.failure
45
import app.cash.quiver.extensions.success
6+
import io.kotest.assertions.throwables.shouldThrow
57
import io.kotest.matchers.nulls.shouldNotBeNull
68
import io.kotest.matchers.should
79
import io.kotest.matchers.shouldBe
@@ -10,13 +12,12 @@ import jakarta.inject.Inject
1012
import org.junit.jupiter.api.Test
1113
import xyz.block.bittycity.common.client.Evaluation
1214
import xyz.block.bittycity.innie.api.DepositDomainController
13-
import xyz.block.bittycity.innie.models.CheckingReversalRisk
14-
import xyz.block.bittycity.innie.models.CheckingReversalSanctions
1515
import xyz.block.bittycity.innie.models.CollectingReversalInfo
1616
import xyz.block.bittycity.innie.models.CollectingReversalSanctionsInfo
1717
import xyz.block.bittycity.innie.models.DepositFailureReason.RISK_BLOCKED
1818
import xyz.block.bittycity.innie.models.DepositReversal
1919
import xyz.block.bittycity.innie.models.DepositReversalFailureReason
20+
import xyz.block.bittycity.innie.models.WaitingForReversalPendingConfirmationStatus
2021
import xyz.block.bittycity.innie.testing.Arbitrary
2122
import xyz.block.bittycity.innie.testing.Arbitrary.amount
2223
import xyz.block.bittycity.innie.testing.Arbitrary.balanceId
@@ -60,10 +61,13 @@ class ReversalSanctionsControllerTest : BittyCityTestCase() {
6061

6162
sanctionsClient.nextEvaluation = Evaluation.APPROVE.success()
6263

63-
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
64-
app.effectProcessor.processAll()
65-
66-
depositWithToken(deposit.id).state shouldBe CheckingReversalRisk
64+
try {
65+
startProcessingEffects()
66+
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
67+
depositWithToken(deposit.id).state shouldBe WaitingForReversalPendingConfirmationStatus
68+
} finally {
69+
stopProcessingEffects()
70+
}
6771
}
6872

6973
@Test
@@ -94,13 +98,16 @@ class ReversalSanctionsControllerTest : BittyCityTestCase() {
9498

9599
sanctionsClient.nextEvaluation = Evaluation.FAIL.success()
96100

97-
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
98-
app.processAllEffects()
99-
100-
depositWithToken(deposit.id) should {
101-
it.state shouldBe CollectingReversalInfo
102-
it.currentReversal.shouldNotBeNull()
103-
it.currentReversal?.failureReason shouldBe DepositReversalFailureReason.SANCTIONS_FAILED
101+
try {
102+
startProcessingEffects()
103+
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
104+
depositWithToken(deposit.id) should {
105+
it.state shouldBe CollectingReversalInfo
106+
it.currentReversal.shouldNotBeNull()
107+
it.currentReversal?.failureReason shouldBe DepositReversalFailureReason.SANCTIONS_FAILED
108+
}
109+
} finally {
110+
stopProcessingEffects()
104111
}
105112
}
106113

@@ -132,10 +139,13 @@ class ReversalSanctionsControllerTest : BittyCityTestCase() {
132139

133140
sanctionsClient.nextEvaluation = RuntimeException("Something went wrong").failure()
134141

135-
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
136-
app.processAllEffects()
137-
138-
depositWithToken(deposit.id).state shouldBe CheckingReversalSanctions
142+
try {
143+
startProcessingEffects()
144+
shouldThrow<WorkflowFailedException> { subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow() }
145+
depositWithToken(deposit.id).state shouldBe CollectingReversalInfo
146+
} finally {
147+
stopProcessingEffects()
148+
}
139149
}
140150

141151
@Test
@@ -166,9 +176,12 @@ class ReversalSanctionsControllerTest : BittyCityTestCase() {
166176

167177
sanctionsClient.nextEvaluation = Evaluation.HOLD.success()
168178

169-
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
170-
app.processAllEffects()
171-
172-
depositWithToken(deposit.id).state shouldBe CollectingReversalSanctionsInfo
179+
try {
180+
startProcessingEffects()
181+
subject.execute(deposit, emptyList(), Operation.EXECUTE).getOrThrow()
182+
depositWithToken(deposit.id).state shouldBe CollectingReversalSanctionsInfo
183+
} finally {
184+
stopProcessingEffects()
185+
}
173186
}
174187
}

innie/src/test/kotlin/xyz/block/bittycity/innie/testing/TestApp.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import xyz.block.bittycity.innie.models.DepositState
1717
import xyz.block.bittycity.innie.models.DepositToken
1818
import xyz.block.bittycity.innie.store.DepositStore
1919
import java.time.Instant
20+
import java.util.concurrent.Executors
21+
import java.util.concurrent.ScheduledExecutorService
22+
import java.util.concurrent.TimeUnit
2023

2124
class TestApp {
2225

@@ -49,6 +52,8 @@ class TestApp {
4952
depositOperations.clear()
5053
}
5154

55+
private var effectProcessingExecutor: ScheduledExecutorService? = null
56+
5257
/**
5358
* Process all pending effects until no more remain.
5459
* This is useful for tests that need to wait for multi-step workflows to complete.
@@ -59,6 +64,24 @@ class TestApp {
5964
}
6065
}
6166

67+
/**
68+
* Start continuously processing effects on a background thread.
69+
* This is required for tests that use [AwaitableStateMachine.transitionAndAwait],
70+
* since it blocks the calling thread while polling for a settled state.
71+
*/
72+
fun startProcessingEffects() {
73+
val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
74+
Thread(runnable, "effect-processor").apply { isDaemon = true }
75+
}
76+
executor.scheduleWithFixedDelay({ effectProcessor.processAll() }, 0, 10, TimeUnit.MILLISECONDS)
77+
effectProcessingExecutor = executor
78+
}
79+
80+
fun stopProcessingEffects() {
81+
effectProcessingExecutor?.shutdownNow()
82+
effectProcessingExecutor = null
83+
}
84+
6285
fun TestRunData.seedDeposit(
6386
id: DepositToken = Arbitrary.depositToken.next(),
6487
state: DepositState = newDeposit.state,

innie/src/test/kotlin/xyz/block/bittycity/innie/testing/TestModule.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.google.inject.AbstractModule
44
import com.google.inject.Provides
55
import com.google.inject.Scopes
66
import com.google.inject.TypeLiteral
7+
import com.google.inject.name.Names
78
import com.squareup.moshi.Moshi
89
import io.kotest.property.arbitrary.next
910
import jakarta.inject.Singleton
@@ -31,6 +32,8 @@ class TestModule : AbstractModule() {
3132
bind(TestApp::class.java).`in`(Scopes.SINGLETON)
3233
bind(TestRunData::class.java).toInstance(Arbitrary.testRunData.next())
3334

35+
bind(Long::class.java).annotatedWith(Names.named("deposit.stateMachineTimeoutInMilliSeconds")).toInstance(10000L)
36+
3437
// Bind the concrete generic type that Guice expects
3538
bind(object : TypeLiteral<IdempotencyOperations<DepositToken, RequirementId>>() {})
3639
.to(FakeResponseOperations::class.java)

0 commit comments

Comments
 (0)