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(private val configs: List) : + Function, Mono> { + private var attempt = 0 + + override fun apply(mono: Mono): Mono = + ( + 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 { + protected fun setupTransferMocks(isFirst: (LunchmoneyInsertTransaction) -> Boolean): Pair = + setupTransferMocks(emptyList(), isFirst) + + protected fun setupTransferMocks( + fails: List, + isFirst: (LunchmoneyInsertTransaction) -> Boolean, + ): Pair { 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>()[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()), + ) } + val singleTransform = + FailTrackerTransformation( + fails.filterIsInstance(), + ) 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()) + .transform( + FailTrackerTransformation(fails.filterIsInstance()), + ) every { lunchmoneyMock.createTransactionGroup(any(), any(), any(), any(), any(), any()) - } returns Mono.just(trGroupId) + } returns + Mono.just(trGroupId) + .transform( + FailTrackerTransformation( + fails.filterIsInstance(), + ), + ) return Pair(newTransactionId, newTransactionId2) } @@ -178,4 +203,34 @@ abstract class IntegrationTestBase : TestBase(), CoroutineScope { } } } + + protected fun setupSingleTransactionMocks(fails: List = emptyList()): Long { + var insertTransaction: LunchmoneyInsertTransaction? = null + val newTransactionId = 1L + + every { lunchmoneyMock.insertTransactions(any(), any(), any(), any(), any(), any()) } answers { + insertTransaction = firstArg>()[0] + Mono.just(listOf(newTransactionId)) + .transform(FailTrackerTransformation(fails.filterIsInstance())) + } + 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()), + ) + } + 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>()[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: Розваги