From e68d4bf34c64b1826b2581569c486b61419a1eda Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 13:54:57 -0400 Subject: [PATCH] test(persistence): cover account switch with live readers on the outgoing db FlipcashDatabase.init closes the outgoing instance when the entropy changes, but neither existing test held a reader on that instance across the switch. Room 2.8.5 made suspending queries throw IllegalStateException once the database is closed, which is what makes that gap worth covering. Two cases: a suspending query against the outgoing DAO must fail rather than read through a closed pool, and a live Flow collector on the outgoing DAO must not be handed an exception. The first asserts the failure and not its type, because close() cancels Room's internal scope before the new IllegalStateException can fire, so the query surfaces a CancellationException instead. --- .../persistence/FlipcashDatabaseInitTest.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/FlipcashDatabaseInitTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/FlipcashDatabaseInitTest.kt index ff8804718c..d53f936cc4 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/FlipcashDatabaseInitTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/FlipcashDatabaseInitTest.kt @@ -2,10 +2,20 @@ package com.flipcash.app.persistence import android.content.Context import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertSame import kotlin.test.assertTrue @@ -50,8 +60,78 @@ class FlipcashDatabaseInitTest { assertTrue(second.isOpen) } + @Test + fun `suspending query against the outgoing database fails after an account switch`() { + runBlocking { + FlipcashDatabase.init(context, ENTROPY_A) + val outgoing = FlipcashDatabase.requireInstance() + val outgoingDao = outgoing.tokenDao() + // Proves the DAO and its connection pool are live before the switch. + assertTrue(outgoingDao.getAll().isEmpty()) + + FlipcashDatabase.init(context, ENTROPY_B) + + assertFalse(outgoing.isOpen) + // The incoming database is usable straight away. + assertTrue(FlipcashDatabase.requireInstance().tokenDao().getAll().isEmpty()) + + // Room 2.8.5 made suspending queries throw IllegalStateException once the + // database is closed; in practice close() cancels Room's internal scope + // first, so the query fails with a CancellationException instead. Either + // way it must fail rather than read through a closed pool, so this asserts + // the failure and not its type. + val error = runCatching { outgoingDao.getAll() }.exceptionOrNull() + assertNotNull(error, "query against the closed outgoing database should fail") + } + } + + @Test + fun `account switch does not fail a live collector on the outgoing database`() { + runBlocking { + FlipcashDatabase.init(context, ENTROPY_A) + val outgoingDao = FlipcashDatabase.requireInstance().tokenDao() + + val firstEmission = CountDownLatch(1) + val failed = CountDownLatch(1) + val failure = AtomicReference(null) + val collector = CoroutineScope(Dispatchers.IO).launch { + outgoingDao.observeAll() + .catch { + failure.set(it) + failed.countDown() + } + .collect { firstEmission.countDown() } + } + + try { + assertTrue( + firstEmission.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "collector never received an emission from the outgoing database", + ) + + // Switching accounts closes the outgoing database underneath the live + // collector. A screen still observing the old account must not be handed + // an exception, whatever Room does to the invalidation tracker. + FlipcashDatabase.init(context, ENTROPY_B) + + assertFalse( + failed.await(QUIET_SECONDS, TimeUnit.SECONDS), + "collector on the outgoing database failed with ${failure.get()}", + ) + } finally { + collector.cancel() + } + } + } + private companion object { const val ENTROPY_A = "aaaaaaaaaaaaaaaaaaaaaaaa" const val ENTROPY_B = "bbbbbbbbbbbbbbbbbbbbbbbb" + + /** Upper bound on waiting for something that should happen. */ + const val TIMEOUT_SECONDS = 10L + + /** How long to wait to be satisfied that a failure is not coming. */ + const val QUIET_SECONDS = 2L } }