From ac358e9b458bf2e9c7abd164152aebe9949af04b Mon Sep 17 00:00:00 2001
From: Dmitry Marchuk <code@dmarchuk.com>
Date: Sun, 26 Nov 2023 13:53:42 +0200
Subject: [PATCH] Retry tests

---
 .../common/retry/RetryStatementSource.kt      |   4 +
 .../TelegramErrorHandlerEventListener.kt      |   4 +-
 .../integration/FailTrackerTransformation.kt  |  21 ++
 .../integration/IntegrationFailConfig.kt      |  15 ++
 .../integration/IntegrationTestBase.kt        |  83 ++++++--
 .../monobudget/integration/RetriesTest.kt     | 188 ++++++++++++++++++
 ...IntegrationTest.kt => TransactionsTest.kt} |  38 +---
 src/test/resources/test-settings.yml          |   2 +-
 8 files changed, 306 insertions(+), 49 deletions(-)
 create mode 100644 src/test/kotlin/io/github/smaugfm/monobudget/integration/FailTrackerTransformation.kt
 create mode 100644 src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationFailConfig.kt
 create mode 100644 src/test/kotlin/io/github/smaugfm/monobudget/integration/RetriesTest.kt
 rename src/test/kotlin/io/github/smaugfm/monobudget/integration/{IntegrationTest.kt => TransactionsTest.kt} (87%)

diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/RetryStatementSource.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/RetryStatementSource.kt
index 925f7f6..e7e61ef 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/RetryStatementSource.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/RetryStatementSource.kt
@@ -1,5 +1,6 @@
 package io.github.smaugfm.monobudget.common.retry
 
+import io.github.oshai.kotlinlogging.KotlinLogging
 import io.github.smaugfm.monobudget.common.exception.BudgetBackendError
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingEventListener
@@ -13,6 +14,8 @@ import org.koin.core.annotation.Single
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 
+private val log = KotlinLogging.logger {}
+
 @Single
 class RetryStatementSource(
     private val scope: CoroutineScope,
@@ -35,6 +38,7 @@ class RetryStatementSource(
                 ctx,
                 retrySettings.interval,
             )
+        log.warn(e) { "Error processing transaction. Will retry in ${retrySettings.interval}..." }
         scheduleRetry(request)
     }
 
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramErrorHandlerEventListener.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramErrorHandlerEventListener.kt
index beb2477..46012fb 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramErrorHandlerEventListener.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramErrorHandlerEventListener.kt
@@ -46,8 +46,8 @@ class TelegramErrorHandlerEventListener(
         ctx: StatementProcessingContext,
         e: BudgetBackendError,
     ) {
-        // Send retry message only on first retry
-        if (ctx.attempt != 1) {
+        // Send retry message only on first attempt
+        if (ctx.attempt != 0) {
             return
         }
 
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/FailTrackerTransformation.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/FailTrackerTransformation.kt
new file mode 100644
index 0000000..53a4a89
--- /dev/null
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/integration/FailTrackerTransformation.kt
@@ -0,0 +1,21 @@
+package io.github.smaugfm.monobudget.integration
+
+import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException
+import io.ktor.http.HttpStatusCode
+import reactor.core.publisher.Mono
+import java.util.function.Function
+
+class FailTrackerTransformation<T>(private val configs: List<IntegrationFailConfig>) :
+    Function<Mono<T>, Mono<T>> {
+    private var attempt = 0
+
+    override fun apply(mono: Mono<T>): Mono<T> =
+        (
+            if (configs.any { it.attemptFailRange.contains(attempt) }) {
+                Mono.error(LunchmoneyApiResponseException(HttpStatusCode.BadRequest.value))
+            } else {
+                mono
+            }
+        )
+            .also { attempt++ }
+}
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationFailConfig.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationFailConfig.kt
new file mode 100644
index 0000000..b753f88
--- /dev/null
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationFailConfig.kt
@@ -0,0 +1,15 @@
+package io.github.smaugfm.monobudget.integration
+
+sealed class IntegrationFailConfig(val attemptFailRange: IntRange) {
+    class Update(attemptFailRange: IntRange) :
+        IntegrationFailConfig(attemptFailRange)
+
+    class Insert(attemptFailRange: IntRange) :
+        IntegrationFailConfig(attemptFailRange)
+
+    class GetSingle(attemptFailRange: IntRange) :
+        IntegrationFailConfig(attemptFailRange)
+
+    class CreateTransactionGroup(attemptFailRange: IntRange) :
+        IntegrationFailConfig(attemptFailRange)
+}
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTestBase.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTestBase.kt
index 94fe747..3746275 100644
--- a/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTestBase.kt
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTestBase.kt
@@ -5,6 +5,7 @@ import io.github.smaugfm.lunchmoney.api.LunchmoneyApi
 import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryMultiple
 import io.github.smaugfm.lunchmoney.model.LunchmoneyInsertTransaction
 import io.github.smaugfm.lunchmoney.model.LunchmoneyTransaction
+import io.github.smaugfm.lunchmoney.response.LunchmoneyUpdateTransactionResponse
 import io.github.smaugfm.monobudget.Application
 import io.github.smaugfm.monobudget.TestBase
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
@@ -41,7 +42,7 @@ import kotlin.coroutines.cancellation.CancellationException
 
 private val log = KotlinLogging.logger {}
 
-@Suppress("MagicNumber")
+@Suppress("MagicNumber", "LongMethod")
 abstract class IntegrationTestBase : TestBase(), CoroutineScope {
     override val coroutineContext = Dispatchers.Default
 
@@ -90,7 +91,7 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
             this@IntegrationTestBase,
             Settings.load(
                 Paths.get(
-                    IntegrationTest::class.java.classLoader.getResource("test-settings.yml")!!.path,
+                    TransactionsTest::class.java.classLoader.getResource("test-settings.yml")!!.path,
                 ),
             ),
             MonoWebhookSettings(false, URI.create(""), 0),
@@ -106,7 +107,13 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
         )
     }
 
-    protected fun setupTransferMocks(isFirst: (LunchmoneyInsertTransaction) -> Boolean): Pair<Long, Long> {
+    protected fun setupTransferMocks(isFirst: (LunchmoneyInsertTransaction) -> Boolean): Pair<Long, Long> =
+        setupTransferMocks(emptyList(), isFirst)
+
+    protected fun setupTransferMocks(
+        fails: List<IntegrationFailConfig>,
+        isFirst: (LunchmoneyInsertTransaction) -> Boolean,
+    ): Pair<Long, Long> {
         var insertTransaction: LunchmoneyInsertTransaction? = null
         var insertTransaction2: LunchmoneyInsertTransaction? = null
         val newTransactionId = 1L
@@ -114,14 +121,22 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
         val trGroupId = 3L
         every { lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any()) } answers {
             val i = firstArg<List<LunchmoneyInsertTransaction>>()[0]
-            if (isFirst(i)) {
-                insertTransaction = i
-                Mono.just(listOf(newTransactionId))
-            } else {
-                insertTransaction2 = i
-                Mono.just(listOf(newTransactionId2))
-            }
+            val mono =
+                if (isFirst(i)) {
+                    insertTransaction = i
+                    Mono.just(listOf(newTransactionId))
+                } else {
+                    insertTransaction2 = i
+                    Mono.just(listOf(newTransactionId2))
+                }
+            mono.transform(
+                FailTrackerTransformation(fails.filterIsInstance<IntegrationFailConfig.Insert>()),
+            )
         }
+        val singleTransform =
+            FailTrackerTransformation<LunchmoneyTransaction>(
+                fails.filterIsInstance<IntegrationFailConfig.GetSingle>(),
+            )
         every { lunchmoneyMock.getSingleTransaction(newTransactionId, any()) } answers {
             Mono.just(
                 LunchmoneyTransaction(
@@ -136,7 +151,7 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
                     categoryId = insertTransaction?.categoryId,
                     status = insertTransaction!!.status!!,
                 ),
-            )
+            ).transform(singleTransform)
         }
         every { lunchmoneyMock.getSingleTransaction(newTransactionId2, any()) } answers {
             Mono.just(
@@ -152,14 +167,24 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
                     categoryId = insertTransaction2?.categoryId,
                     status = insertTransaction2!!.status!!,
                 ),
-            )
+            ).transform(singleTransform)
         }
         every {
             lunchmoneyMock.updateTransaction(any(), any(), any(), any(), any())
-        } returns Mono.just(mockk())
+        } returns
+            Mono.just(mockk<LunchmoneyUpdateTransactionResponse>())
+                .transform(
+                    FailTrackerTransformation(fails.filterIsInstance<IntegrationFailConfig.Update>()),
+                )
         every {
             lunchmoneyMock.createTransactionGroup(any(), any(), any(), any(), any(), any())
-        } returns Mono.just(trGroupId)
+        } returns
+            Mono.just(trGroupId)
+                .transform(
+                    FailTrackerTransformation(
+                        fails.filterIsInstance<IntegrationFailConfig.CreateTransactionGroup>(),
+                    ),
+                )
 
         return Pair(newTransactionId, newTransactionId2)
     }
@@ -178,4 +203,34 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope {
             }
         }
     }
+
+    protected fun setupSingleTransactionMocks(fails: List<IntegrationFailConfig> = emptyList()): Long {
+        var insertTransaction: LunchmoneyInsertTransaction? = null
+        val newTransactionId = 1L
+
+        every { lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any()) } answers {
+            insertTransaction = firstArg<List<LunchmoneyInsertTransaction>>()[0]
+            Mono.just(listOf(newTransactionId))
+                .transform(FailTrackerTransformation(fails.filterIsInstance<IntegrationFailConfig.Insert>()))
+        }
+        every { lunchmoneyMock.getSingleTransaction(newTransactionId, any()) } answers {
+            Mono.just(
+                LunchmoneyTransaction(
+                    id = newTransactionId,
+                    isGroup = false,
+                    date = insertTransaction!!.date,
+                    payee = insertTransaction!!.payee!!,
+                    amount = insertTransaction!!.amount,
+                    currency = insertTransaction!!.currency!!,
+                    toBase = 1.0,
+                    notes = insertTransaction?.notes,
+                    categoryId = insertTransaction?.categoryId,
+                    status = insertTransaction!!.status!!,
+                ),
+            ).transform(
+                FailTrackerTransformation(fails.filterIsInstance<IntegrationFailConfig.GetSingle>()),
+            )
+        }
+        return newTransactionId
+    }
 }
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/RetriesTest.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/RetriesTest.kt
new file mode 100644
index 0000000..4ea8476
--- /dev/null
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/integration/RetriesTest.kt
@@ -0,0 +1,188 @@
+package io.github.smaugfm.monobudget.integration
+
+import com.elbekd.bot.model.ChatId
+import io.github.smaugfm.monobank.model.MonoStatementItem
+import io.github.smaugfm.monobank.model.MonoWebhookResponseData
+import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
+import io.github.smaugfm.monobudget.integration.TestData.UAH
+import io.github.smaugfm.monobudget.mono.MonobankWebhookResponseStatementItem
+import io.mockk.coVerify
+import io.mockk.confirmVerified
+import io.mockk.verifySequence
+import kotlinx.datetime.Clock
+import org.junit.jupiter.api.Test
+import java.math.BigDecimal
+import java.util.UUID
+
+@Suppress("LongMethod")
+class RetriesTest : IntegrationTestBase() {
+    @Test
+    fun `When lunchmoney fails and then recovers transfer transaction is processed correctly`() {
+        val (newTransactionId, newTransactionId2) =
+            setupTransferMocks(
+                listOf(
+                    IntegrationFailConfig.CreateTransactionGroup(0..0),
+                ),
+            ) { it.amount < BigDecimal.ZERO }
+
+        runTestApplication {
+            webhookStatementsFlow.emit(
+                StatementProcessingContext(
+                    MonobankWebhookResponseStatementItem(
+                        d =
+                            MonoWebhookResponseData(
+                                account = "MONO-EXAMPLE-UAH2",
+                                statementItem =
+                                    MonoStatementItem(
+                                        id = UUID.randomUUID().toString(),
+                                        time = Clock.System.now(),
+                                        description = "test send",
+                                        mcc = 4829,
+                                        originalMcc = 4829,
+                                        hold = true,
+                                        amount = -5600,
+                                        operationAmount = -5600,
+                                        currencyCode = UAH,
+                                        commissionRate = 0,
+                                        cashbackAmount = 0,
+                                        balance = 0,
+                                    ),
+                            ),
+                        accountCurrency = UAH,
+                    ),
+                ),
+            )
+            webhookStatementsFlow.emit(
+                StatementProcessingContext(
+                    MonobankWebhookResponseStatementItem(
+                        d =
+                            MonoWebhookResponseData(
+                                account = "MONO-EXAMPLE-UAH",
+                                statementItem =
+                                    MonoStatementItem(
+                                        id = UUID.randomUUID().toString(),
+                                        time = Clock.System.now(),
+                                        description = "test receive",
+                                        mcc = 4829,
+                                        originalMcc = 4829,
+                                        hold = true,
+                                        amount = 5600,
+                                        operationAmount = 5600,
+                                        currencyCode = UAH,
+                                        commissionRate = 0,
+                                        cashbackAmount = 0,
+                                        balance = 0,
+                                    ),
+                            ),
+                        accountCurrency = UAH,
+                    ),
+                ),
+            )
+
+            coVerify(timeout = 1000, exactly = 2) {
+                tgMock.sendMessage(
+                    match {
+                        it is ChatId.IntegerId && it.id == 55555555L
+                    },
+                    any(),
+                    any(),
+                    any(),
+                    any(),
+                )
+            }
+            coVerify(timeout = 1000, exactly = 1) {
+                tgMock.sendMessage(
+                    match {
+                        it is ChatId.IntegerId && it.id == 55555556L
+                    },
+                    any(),
+                    any(),
+                    any(),
+                    any(),
+                )
+            }
+            verifySequence {
+                lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any())
+                lunchmoneyMock.getSingleTransaction(eq(newTransactionId), any())
+                lunchmoneyMock.updateTransaction(eq(newTransactionId), any(), any(), any(), any())
+                lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any())
+                lunchmoneyMock.getSingleTransaction(eq(newTransactionId2), any())
+                lunchmoneyMock.createTransactionGroup(
+                    any(),
+                    "Transfer",
+                    listOf(newTransactionId, newTransactionId2),
+                    444444L,
+                    any(),
+                    any(),
+                )
+                lunchmoneyMock.createTransactionGroup(
+                    any(),
+                    "Transfer",
+                    listOf(newTransactionId, newTransactionId2),
+                    444444L,
+                    any(),
+                    any(),
+                )
+            }
+            confirmVerified(lunchmoneyMock)
+        }
+    }
+
+    @Test
+    fun `When lunchmoney fails and then recovers successful method calls are not retried`() {
+        val newTransactionId =
+            setupSingleTransactionMocks(
+                listOf(
+                    IntegrationFailConfig.GetSingle(0..0),
+                ),
+            )
+
+        runTestApplication {
+            webhookStatementsFlow.emit(
+                StatementProcessingContext(
+                    MonobankWebhookResponseStatementItem(
+                        d =
+                            MonoWebhookResponseData(
+                                account = "MONO-EXAMPLE-UAH",
+                                statementItem =
+                                    MonoStatementItem(
+                                        id = UUID.randomUUID().toString(),
+                                        time = Clock.System.now(),
+                                        description = "test",
+                                        mcc = 4829,
+                                        originalMcc = 4829,
+                                        hold = true,
+                                        amount = -5600,
+                                        operationAmount = -5600,
+                                        currencyCode = UAH,
+                                        commissionRate = 0,
+                                        cashbackAmount = 0,
+                                        balance = 0,
+                                    ),
+                            ),
+                        accountCurrency = UAH,
+                    ),
+                ),
+            )
+
+            coVerify(timeout = 1000, exactly = 2) {
+                tgMock.sendMessage(
+                    match {
+                        it is ChatId.IntegerId && it.id == 55555555L
+                    },
+                    any(),
+                    any(),
+                    any(),
+                    any(),
+                )
+            }
+
+            verifySequence {
+                lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any())
+                lunchmoneyMock.getSingleTransaction(eq(newTransactionId), any())
+                lunchmoneyMock.getSingleTransaction(eq(newTransactionId), any())
+            }
+            confirmVerified(lunchmoneyMock)
+        }
+    }
+}
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTest.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/TransactionsTest.kt
similarity index 87%
rename from src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTest.kt
rename to src/test/kotlin/io/github/smaugfm/monobudget/integration/TransactionsTest.kt
index b831814..f35b4f5 100644
--- a/src/test/kotlin/io/github/smaugfm/monobudget/integration/IntegrationTest.kt
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/integration/TransactionsTest.kt
@@ -1,28 +1,23 @@
 package io.github.smaugfm.monobudget.integration
 
 import com.elbekd.bot.model.ChatId
-import io.github.smaugfm.lunchmoney.model.LunchmoneyInsertTransaction
-import io.github.smaugfm.lunchmoney.model.LunchmoneyTransaction
 import io.github.smaugfm.monobank.model.MonoStatementItem
 import io.github.smaugfm.monobank.model.MonoWebhookResponseData
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
 import io.github.smaugfm.monobudget.integration.TestData.UAH
 import io.github.smaugfm.monobudget.mono.MonobankWebhookResponseStatementItem
-import io.mockk.InternalPlatformDsl.toStr
 import io.mockk.coVerify
 import io.mockk.confirmVerified
-import io.mockk.every
 import io.mockk.verifySequence
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.datetime.Clock
 import org.junit.jupiter.api.Test
-import reactor.core.publisher.Mono
 import java.math.BigDecimal
 import java.util.UUID
 
 @Suppress("LongMethod")
-class IntegrationTest : IntegrationTestBase(), CoroutineScope {
+class TransactionsTest : IntegrationTestBase(), CoroutineScope {
     @Test
     fun `When nothing happens finishes normally`() {
         runTestApplication {
@@ -43,7 +38,7 @@ class IntegrationTest : IntegrationTestBase(), CoroutineScope {
                                 account = "MONO-EXAMPLE-UAH",
                                 statementItem =
                                     MonoStatementItem(
-                                        id = UUID.randomUUID().toStr(),
+                                        id = UUID.randomUUID().toString(),
                                         time = Clock.System.now(),
                                         description = "Від: 777777****1234",
                                         mcc = 4829,
@@ -105,7 +100,7 @@ class IntegrationTest : IntegrationTestBase(), CoroutineScope {
                                 account = "MONO-EXAMPLE-UAH2",
                                 statementItem =
                                     MonoStatementItem(
-                                        id = UUID.randomUUID().toStr(),
+                                        id = UUID.randomUUID().toString(),
                                         time = Clock.System.now(),
                                         description = "test send",
                                         mcc = 4829,
@@ -131,7 +126,7 @@ class IntegrationTest : IntegrationTestBase(), CoroutineScope {
                                 account = "MONO-EXAMPLE-UAH",
                                 statementItem =
                                     MonoStatementItem(
-                                        id = UUID.randomUUID().toStr(),
+                                        id = UUID.randomUUID().toString(),
                                         time = Clock.System.now(),
                                         description = "test receive",
                                         mcc = 4829,
@@ -192,28 +187,7 @@ class IntegrationTest : IntegrationTestBase(), CoroutineScope {
 
     @Test
     fun `Mono webhook triggers new transaction creation`() {
-        var insertTransaction: LunchmoneyInsertTransaction? = null
-        val newTransactionId = 1L
-        every { lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any()) } answers {
-            insertTransaction = firstArg<List<LunchmoneyInsertTransaction>>()[0]
-            Mono.just(listOf(newTransactionId))
-        }
-        every { lunchmoneyMock.getSingleTransaction(newTransactionId, any()) } answers {
-            Mono.just(
-                LunchmoneyTransaction(
-                    id = newTransactionId,
-                    isGroup = false,
-                    date = insertTransaction!!.date,
-                    payee = insertTransaction!!.payee!!,
-                    amount = insertTransaction!!.amount,
-                    currency = insertTransaction!!.currency!!,
-                    toBase = 1.0,
-                    notes = insertTransaction?.notes,
-                    categoryId = insertTransaction?.categoryId,
-                    status = insertTransaction!!.status!!,
-                ),
-            )
-        }
+        val newTransactionId = setupSingleTransactionMocks()
 
         runTestApplication {
             webhookStatementsFlow.emit(
@@ -224,7 +198,7 @@ class IntegrationTest : IntegrationTestBase(), CoroutineScope {
                                 account = "MONO-EXAMPLE-UAH",
                                 statementItem =
                                     MonoStatementItem(
-                                        id = UUID.randomUUID().toStr(),
+                                        id = UUID.randomUUID().toString(),
                                         time = Clock.System.now(),
                                         description = "test",
                                         mcc = 4829,
diff --git a/src/test/resources/test-settings.yml b/src/test/resources/test-settings.yml
index 5991a5e..edd0a70 100644
--- a/src/test/resources/test-settings.yml
+++ b/src/test/resources/test-settings.yml
@@ -40,7 +40,7 @@ accounts:
       telegramChatId: 55555556
       currency: UAH
 retry:
-  interval: 1s
+  interval: 500ms
 mcc:
   mccGroupToCategoryName:
     HR: Розваги