test(review): Cover the probe quirks and the card latch

Adds timing, caching and boundary coverage for the review tool plus the
card's latch matrix. The existing both-actions card test is superseded:
its dismiss-then-review sequence is exactly what the latch blocks.
This commit is contained in:
darken
2026-08-06 09:55:09 +02:00
committed by Matthias Urhahn
parent 323b10224c
commit 19976c89f6
2 changed files with 475 additions and 28 deletions
@@ -11,10 +11,17 @@ import androidx.compose.ui.test.performSemanticsAction
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import org.junit.Assert.assertTrue
import io.kotest.matchers.shouldBe
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* The card only disappears once the next state emission arrives, so its two tap targets need a
* latch: a dismiss after a review would overwrite the completed-review bookkeeping with a snooze,
* a review after a dismiss would re-open what the user just closed. The latch is asymmetric —
* repeated review taps stay allowed, because a Play request can fail without persisting anything,
* which leaves the card on screen and in need of a retry.
*/
class ReviewCardTest : BaseComposeRobolectricTest() {
private val context: Context
@@ -27,6 +34,21 @@ class ReviewCardTest : BaseComposeRobolectricTest() {
// The card's title reuses the review label, so the action is matched by its click semantics.
private val reviewButton get() = hasText(reviewLabel) and hasClickAction()
private var reviews = 0
private var dismisses = 0
private fun setContent(withActivity: Boolean = true) {
composeRule.setContent {
PreviewWrapper {
ReviewCard(
// Null callback = no Activity to launch Play's review flow with.
onReview = if (withActivity) ({ reviews++ }) else null,
onDismiss = { dismisses++ },
)
}
}
}
@Test
fun `renders the body and both actions`() {
composeRule.setContent {
@@ -40,29 +62,6 @@ class ReviewCardTest : BaseComposeRobolectricTest() {
composeRule.onNode(reviewButton).assertIsEnabled()
}
@Test
fun `both actions invoke their callback`() {
var reviewed = false
var dismissed = false
composeRule.setContent {
PreviewWrapper {
ReviewCard(
onReview = { reviewed = true },
onDismiss = { dismissed = true },
)
}
}
composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick)
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(dismissed)
assertTrue(reviewed)
}
}
@Test
fun `the review action is disabled without a hosting activity`() {
composeRule.setContent {
@@ -76,4 +75,69 @@ class ReviewCardTest : BaseComposeRobolectricTest() {
// Dismissing has to stay possible, it doesn't need an Activity.
composeRule.onNodeWithText(dismissLabel).assertIsEnabled()
}
@Test
fun `a dismissed card ignores a later review tap`() {
setContent()
composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle { dismisses shouldBe 1 }
composeRule.onNode(reviewButton).assertIsNotEnabled()
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
reviews shouldBe 0
dismisses shouldBe 1
}
}
@Test
fun `a reviewed card ignores a later dismiss tap`() {
setContent()
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle { reviews shouldBe 1 }
composeRule.onNodeWithText(dismissLabel).assertIsNotEnabled()
composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
reviews shouldBe 1
dismisses shouldBe 0
}
}
@Test
fun `repeated review taps are not absorbed by the card`() {
setContent()
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle { reviews shouldBe 1 }
// A failed Play request persists nothing and leaves the card up, so the retry has to work.
// Duplicates are the tool's problem, it holds a single-flight lock for exactly this.
composeRule.onNode(reviewButton).assertIsEnabled()
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
reviews shouldBe 2
dismisses shouldBe 0
}
}
@Test
fun `a review tap without an activity consumes neither latch`() {
setContent(withActivity = false)
composeRule.onNode(reviewButton).assertIsNotEnabled()
composeRule.onNode(reviewButton).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle { reviews shouldBe 0 }
// Nothing was handed to the caller, so the card must still be dismissable
composeRule.onNodeWithText(dismissLabel).assertIsEnabled()
composeRule.onNodeWithText(dismissLabel).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle { dismisses shouldBe 1 }
}
}
@@ -17,13 +17,18 @@ import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerializationException
import org.junit.jupiter.api.Test
@@ -39,20 +44,33 @@ class GplayReviewToolTest : BaseTest() {
private val upgradeRepo = mockk<UpgradeRepo>()
private lateinit var lastDismissedMock: DataStoreValue<Instant?>
private lateinit var reviewedAtMock: DataStoreValue<Instant?>
private lateinit var lastDismissedFlow: MutableStateFlow<Instant?>
// Relaxed so the `.value(new)` writes (which go through `update`) succeed and can be verified.
private fun <T> rwSetting(initial: T): DataStoreValue<T> = mockk<DataStoreValue<T>>(relaxed = true).apply {
every { flow } returns flowOf(initial)
}
// Hot variant: a value written after the tool started collecting has to reach the pipeline,
// which a one-shot `flowOf` can't do.
private fun <T> hotSetting(source: Flow<T>): DataStoreValue<T> = mockk<DataStoreValue<T>>(relaxed = true).apply {
every { flow } returns source
}
// The tool's own scope has to run on the test scheduler, otherwise the probe backoff and the
// state throttle would burn real time.
private fun TestScope.tool(
upgradedAt: Instant? = Instant.now().minus(Duration.ofDays(30)),
lastDismissed: Instant? = null,
reviewedAt: Instant? = null,
minDuration: Duration? = null,
hotSettings: Boolean = false,
): GplayReviewTool {
lastDismissedMock = rwSetting(lastDismissed)
lastDismissedFlow = MutableStateFlow(lastDismissed)
lastDismissedMock = when {
hotSettings -> hotSetting(lastDismissedFlow)
else -> rwSetting(lastDismissed)
}
reviewedAtMock = rwSetting(reviewedAt)
every { settings.lastDismissed } returns lastDismissedMock
every { settings.reviewedAt } returns reviewedAtMock
@@ -66,7 +84,13 @@ class GplayReviewToolTest : BaseTest() {
settings = settings,
manager = manager,
upgradeRepo = upgradeRepo,
).apply { probeRetryDelay = Duration.ofSeconds(1) }
).apply {
probeRetryDelay = PROBE_RETRY_DELAY
probeFailureCooldown = PROBE_FAILURE_COOLDOWN
requestTimeout = REQUEST_TIMEOUT
launchTimeout = LAUNCH_TIMEOUT
minDuration?.let { reviewMinDuration = it }
}
}
private fun reviewInfo(canShow: Boolean = true): ReviewInfo {
@@ -86,9 +110,30 @@ class GplayReviewToolTest : BaseTest() {
private fun launchOk(): Task<Void?> = Tasks.forResult(null)
// A Play call that never comes back: the ktx wrapper suspends on the listeners it registers
// with the Task, and a relaxed mock never invokes them.
private fun <T> hangingTask(): Task<T> = mockk<Task<T>>(relaxed = true).apply {
every { isComplete } returns false
}
// The `onStart` seed is not a computed state, every assertion has to await the first real one.
private suspend fun GplayReviewTool.computedState() = state.drop(1).first()
// Live view of everything the tool emitted so far, for assertions that have to move the clock
// between emissions instead of awaiting a single one.
private fun TestScope.collectStates(tool: GplayReviewTool): List<ReviewTool.State> {
val states = mutableListOf<ReviewTool.State>()
backgroundScope.launch { tool.state.collect { states += it } }
return states
}
// The tool's flows all run on the background scope, and `advanceUntilIdle` only advances while
// there is foreground work, so every wait has to name the span it is waiting for.
private fun TestScope.advanceBy(duration: Duration) {
advanceTimeBy(duration.toMillis())
runCurrent()
}
@Test fun `an eligible user is asked for a review`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
@@ -117,6 +162,18 @@ class GplayReviewToolTest : BaseTest() {
verify(exactly = 0) { manager.requestReviewFlow() }
}
@Test fun `a user who already reviewed is never asked or probed again`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
tool(reviewedAt = Instant.now().minus(Duration.ofDays(200))).computedState().apply {
shouldAskForReview shouldBe false
// The card is gone for good, but the fact stays readable for anything else that asks.
hasReviewed shouldBe true
}
verify(exactly = 0) { manager.requestReviewFlow() }
}
@Test fun `reviewNow launches with a freshly requested ReviewInfo`() = runTest2 {
// ReviewInfo is short lived, the token used for the launch must not be the one the
// availability probe obtained (potentially hours) earlier.
@@ -173,6 +230,33 @@ class GplayReviewToolTest : BaseTest() {
coVerify(exactly = 0) { reviewedAtMock.update(any()) }
}
@Test fun `a launch the user dismissed instantly counts as a snooze`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
every { manager.launchReviewFlow(any(), any()) } returns launchOk()
val tool = tool()
tool.reviewNow(activity())
// Play returns immediately when it decides not to show anything, that is not a review.
coVerify(exactly = 1) { lastDismissedMock.update(any()) }
coVerify(exactly = 0) { reviewedAtMock.update(any()) }
}
@Test fun `a launch the user stayed in counts as a completed review`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
// Real sleep on purpose: the heuristic measures wall clock, virtual time cannot reach it.
every { manager.launchReviewFlow(any(), any()) } answers {
Thread.sleep(150)
launchOk()
}
val tool = tool(minDuration = Duration.ofMillis(50))
tool.reviewNow(activity())
coVerify(exactly = 1) { reviewedAtMock.update(any()) }
coVerify(exactly = 0) { lastDismissedMock.update(any()) }
}
@Test fun `a dead activity aborts the launch without persisting`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
every { manager.launchReviewFlow(any(), any()) } returns launchOk()
@@ -203,8 +287,10 @@ class GplayReviewToolTest : BaseTest() {
val tool = tool()
val activity = activity()
// runCurrent, not advanceUntilIdle: the first call has to be parked on the request when the
// second tap arrives, an unbounded time advance would trip the request timeout first.
val first = launch { tool.reviewNow(activity) }
advanceUntilIdle()
runCurrent()
tool.reviewNow(activity)
@@ -236,6 +322,15 @@ class GplayReviewToolTest : BaseTest() {
verify(exactly = 3) { manager.requestReviewFlow() }
}
@Test fun `an isNoOp probe answer is accepted instead of retried`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo(canShow = false))
tool().computedState().shouldAskForReview shouldBe false
// isNoOp is an answer, not a failure: burning the retry budget on it would just cost quota.
verify(exactly = 1) { manager.requestReviewFlow() }
}
@Test fun `corrupt review settings fall back to the default state`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
lastDismissedMock = rwSetting(null)
@@ -286,4 +381,292 @@ class GplayReviewToolTest : BaseTest() {
// The general failure path would have burned all 3 attempts and settled on "unavailable".
verify(exactly = 1) { manager.requestReviewFlow() }
}
@Test fun `a probe that times out abandons the whole round`() = runTest2 {
var hanging = true
every { manager.requestReviewFlow() } answers {
if (hanging) hangingTask() else Tasks.forResult(reviewInfo())
}
val tool = tool()
val states = collectStates(tool)
advanceBy(REQUEST_TIMEOUT.multipliedBy(2))
// Our timeout does not cancel the Play Task: an in-round retry would stack concurrent
// quota-consuming requests against a service that is already hung.
verify(exactly = 1) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe false
hanging = false
advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(2))
// Recovery comes from the next cooldown round, not from the abandoned one.
verify(exactly = 2) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe true
}
@Test fun `a probe exception is still retried inside the round`() = runTest2 {
// Only a timeout abandons the round, the exception path keeps its 3 attempt budget.
every { manager.requestReviewFlow() } returnsMany listOf(
Tasks.forException(RuntimeException("Play unavailable")),
Tasks.forResult(reviewInfo()),
)
val tool = tool()
val states = collectStates(tool)
advanceBy(PROBE_RETRY_DELAY.multipliedBy(4))
verify(exactly = 2) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe true
}
@Test fun `a hanging fresh request releases the lock without persisting`() = runTest2 {
var hanging = true
every { manager.requestReviewFlow() } answers {
if (hanging) hangingTask() else Tasks.forResult(reviewInfo())
}
every { manager.launchReviewFlow(any(), any()) } returns launchOk()
val tool = tool()
val activity = activity()
tool.reviewNow(activity)
// A hang is not user intent: no launch, no bookkeeping, the next tap has to be able to retry.
verify(exactly = 0) { manager.launchReviewFlow(any(), any()) }
coVerify(exactly = 0) { lastDismissedMock.update(any()) }
coVerify(exactly = 0) { reviewedAtMock.update(any()) }
hanging = false
tool.reviewNow(activity)
// Without the timeout the single-flight lock would still be held by the first tap.
verify(exactly = 1) { manager.launchReviewFlow(activity, any()) }
}
@Test fun `a hanging launch releases the lock without persisting`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
var hanging = true
every { manager.launchReviewFlow(any(), any()) } answers {
if (hanging) hangingTask() else launchOk()
}
val tool = tool()
val activity = activity()
tool.reviewNow(activity)
// The outcome is unknown, so neither the review nor the snooze may be recorded, and the
// duration heuristic must not treat the timeout as a completed review.
coVerify(exactly = 0) { reviewedAtMock.update(any()) }
coVerify(exactly = 0) { lastDismissedMock.update(any()) }
hanging = false
tool.reviewNow(activity)
verify(exactly = 2) { manager.launchReviewFlow(activity, any()) }
}
@Test fun `a dismiss racing the fresh request aborts the launch`() = runTest2 {
val tool = tool()
every { manager.requestReviewFlow() } answers {
// The user hits "maybe later" while Play is still answering the review tap.
runBlocking { tool.dismiss() }
Tasks.forResult(reviewInfo())
}
every { manager.launchReviewFlow(any(), any()) } returns launchOk()
tool.reviewNow(activity())
verify(exactly = 0) { manager.launchReviewFlow(any(), any()) }
// Only the dismiss' own write, reviewNow must not add bookkeeping on top of it.
coVerify(exactly = 1) { lastDismissedMock.update(any()) }
coVerify(exactly = 0) { reviewedAtMock.update(any()) }
}
@Test fun `a definitive probe answer is not requested twice`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
val tool = tool()
val first = mutableListOf<ReviewTool.State>()
val firstJob = backgroundScope.launch { tool.state.collect { first += it } }
advanceBy(Duration.ofSeconds(1))
first.last().shouldAskForReview shouldBe true
firstJob.cancelAndJoin()
val second = mutableListOf<ReviewTool.State>()
val secondJob = backgroundScope.launch { tool.state.collect { second += it } }
advanceBy(Duration.ofSeconds(1))
second.last().shouldAskForReview shouldBe true
secondJob.cancelAndJoin()
// Play's answer holds for the rest of the process, a re-subscription must not cost quota.
verify(exactly = 1) { manager.requestReviewFlow() }
}
@Test fun `an isNoOp answer is not requested twice either`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo(canShow = false))
val tool = tool()
val first = mutableListOf<ReviewTool.State>()
val firstJob = backgroundScope.launch { tool.state.collect { first += it } }
advanceBy(Duration.ofSeconds(1))
first.last().shouldAskForReview shouldBe false
firstJob.cancelAndJoin()
val second = mutableListOf<ReviewTool.State>()
val secondJob = backgroundScope.launch { tool.state.collect { second += it } }
advanceBy(Duration.ofSeconds(1))
second.last().shouldAskForReview shouldBe false
secondJob.cancelAndJoin()
// isNoOp is a verdict, not a failure: it is cached like any other answer.
verify(exactly = 1) { manager.requestReviewFlow() }
}
@Test fun `a transient probe failure is retried after the cooldown`() = runTest2 {
var healthy = false
every { manager.requestReviewFlow() } answers {
if (healthy) Tasks.forResult(reviewInfo()) else Tasks.forException(RuntimeException("Play unavailable"))
}
val tool = tool()
val states = collectStates(tool)
advanceBy(PROBE_FAILURE_COOLDOWN.dividedBy(2))
verify(exactly = 3) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe false
healthy = true
advanceBy(PROBE_FAILURE_COOLDOWN)
// A failure is not an answer, so Play gets asked again once the cooldown is over.
verify(exactly = 4) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe true
}
@Test fun `the probe retry rounds are bounded`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forException(RuntimeException("Play unavailable"))
val tool = tool(hotSettings = true)
val states = collectStates(tool)
advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(4))
// The initial round plus 3 cooldown rounds, 3 attempts each, then the process gives up.
verify(exactly = 12) { manager.requestReviewFlow() }
states.last().shouldAskForReview shouldBe false
advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(20))
verify(exactly = 12) { manager.requestReviewFlow() }
// The budget is spent for the process: an eligibility flicker restarts the probe branch,
// but it must not hand out a fresh set of rounds.
lastDismissedFlow.value = Instant.now()
advanceBy(Duration.ofSeconds(1))
lastDismissedFlow.value = null
advanceBy(PROBE_FAILURE_COOLDOWN.multipliedBy(10))
verify(exactly = 12) { manager.requestReviewFlow() }
}
@Test fun `a snooze running out flips the card on without a restart`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
var fakeNow = BASE_NOW
val tool = tool(
upgradedAt = BASE_NOW.minus(Duration.ofDays(60)),
lastDismissed = BASE_NOW.minus(Duration.ofDays(13)),
).apply { nowProvider = { fakeNow } }
val states = collectStates(tool)
advanceBy(Duration.ofSeconds(1))
states.last().shouldAskForReview shouldBe false
// The snooze ends a day later: the scheduled re-evaluation has to re-read the clock.
fakeNow = BASE_NOW.plus(Duration.ofDays(2))
advanceBy(Duration.ofDays(2))
states.last().shouldAskForReview shouldBe true
}
@Test fun `the pro grace period boundary is strict`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
var fakeNow = BASE_NOW
val upgradedAt = BASE_NOW.minus(Duration.ofDays(21))
val atBoundary = tool(upgradedAt = upgradedAt).apply { nowProvider = { fakeNow } }
val atBoundaryStates = collectStates(atBoundary)
advanceBy(Duration.ofSeconds(1))
// 21 days have to have passed, not just been reached
atBoundaryStates.last().shouldAskForReview shouldBe false
// A second instance because nothing is pending on the first one: the wake schedule only
// keeps boundaries strictly after "now", and this one is exactly "now".
fakeNow = BASE_NOW.plus(Duration.ofSeconds(1))
val pastBoundary = tool(upgradedAt = upgradedAt).apply { nowProvider = { fakeNow } }
val pastBoundaryStates = collectStates(pastBoundary)
advanceBy(Duration.ofSeconds(1))
pastBoundaryStates.last().shouldAskForReview shouldBe true
}
@Test fun `a new dismiss reschedules the boundary re-evaluation`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
var fakeNow = BASE_NOW
val tool = tool(
upgradedAt = BASE_NOW.minus(Duration.ofDays(60)),
lastDismissed = BASE_NOW.minus(Duration.ofDays(13)),
hotSettings = true,
).apply { nowProvider = { fakeNow } }
val states = collectStates(tool)
advanceBy(Duration.ofSeconds(1))
states.last().shouldAskForReview shouldBe false
// Dismissed again while the old snooze end was still scheduled
lastDismissedFlow.value = BASE_NOW
advanceBy(Duration.ofSeconds(1))
fakeNow = BASE_NOW.plus(Duration.ofDays(2))
advanceBy(Duration.ofDays(3))
// The stale boundary must not flip the card back on, the new snooze governs now
states.last().shouldAskForReview shouldBe false
fakeNow = BASE_NOW.plus(Duration.ofDays(15))
advanceBy(Duration.ofDays(15))
states.last().shouldAskForReview shouldBe true
}
@Test fun `a clock that moved backwards re-evaluates instead of flipping`() = runTest2 {
every { manager.requestReviewFlow() } returns Tasks.forResult(reviewInfo())
var fakeNow = BASE_NOW
val tool = tool(
upgradedAt = BASE_NOW.minus(Duration.ofDays(60)),
lastDismissed = BASE_NOW.minus(Duration.ofDays(13)),
).apply { nowProvider = { fakeNow } }
val states = collectStates(tool)
advanceBy(Duration.ofSeconds(1))
states.last().shouldAskForReview shouldBe false
// The device clock moved back: the wake fires on schedule, but the snooze is still running
fakeNow = BASE_NOW.minus(Duration.ofDays(5))
advanceBy(Duration.ofDays(30))
states.last().shouldAskForReview shouldBe false
verify(exactly = 0) { manager.requestReviewFlow() }
// Rescheduling is capped, so a backwards clock cannot keep the process waking forever
fakeNow = BASE_NOW.plus(Duration.ofDays(2))
advanceBy(Duration.ofDays(30))
states.last().shouldAskForReview shouldBe false
}
companion object {
private val BASE_NOW: Instant = Instant.parse("2024-06-01T12:00:00Z")
private val PROBE_RETRY_DELAY: Duration = Duration.ofSeconds(1)
private val PROBE_FAILURE_COOLDOWN: Duration = Duration.ofMinutes(5)
private val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(5)
private val LAUNCH_TIMEOUT: Duration = Duration.ofMinutes(1)
}
}