From a53d9b5540d75b9e9e02b0144714d50836d48b61 Mon Sep 17 00:00:00 2001 From: Melad Raouf Date: Sun, 26 Jul 2026 21:24:28 +0100 Subject: [PATCH] [MS-1516] Force Room as default database after a fresh user login --- .../feature/logincheck/LoginCheckViewModel.kt | 25 +++++++++---- .../logincheck/LoginCheckViewModelTest.kt | 37 +++++++++++++++++++ .../RealmToRoomMigrationScheduler.kt | 10 +++++ .../migration/RealmToRoomMigrationWorker.kt | 5 +++ .../RealmToRoomMigrationSchedulerTest.kt | 18 +++++++++ .../RealmToRoomMigrationWorkerTest.kt | 30 +++++++++++++++ 6 files changed, 117 insertions(+), 8 deletions(-) diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt index 650474df68..1e06852ae6 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt @@ -91,7 +91,7 @@ class LoginCheckViewModel @Inject internal constructor( when (isUserSignedIn(actionRequest)) { MISMATCHED_PROJECT_ID -> _showAlert.send(LoginCheckError.DIFFERENT_PROJECT_ID) NOT_SIGNED_IN -> startSignInAttempt(actionRequest) - SIGNED_IN -> validateProjectAndProceed(actionRequest) + SIGNED_IN -> validateProjectAndProceed(actionRequest, isFreshLogin = false) } } @@ -115,7 +115,8 @@ class LoginCheckViewModel @Inject internal constructor( Simber.i("Log-in result: $result", tag = LOGIN) val requestAction = cachedRequest?.takeIf { result.isSuccess } if (requestAction != null) { - validateProjectAndProceed(requestAction) + // This path is only reached after a fresh login attempt. + validateProjectAndProceed(requestAction, isFreshLogin = true) } else { when (result.error) { null, LoginError.LoginNotCompleted -> { @@ -136,16 +137,22 @@ class LoginCheckViewModel @Inject internal constructor( } } - private suspend fun validateProjectAndProceed(actionRequest: ActionRequest) { + private suspend fun validateProjectAndProceed( + actionRequest: ActionRequest, + isFreshLogin: Boolean, + ) { when (configRepository.getProject()?.state) { null, ProjectState.PROJECT_ENDING -> _showAlert.send(LoginCheckError.PROJECT_ENDING) ProjectState.PROJECT_PAUSED -> _showAlert.send(LoginCheckError.PROJECT_PAUSED) ProjectState.PROJECT_ENDED -> startSignInAttempt(actionRequest) - ProjectState.RUNNING -> proceedWithAction(ensureActionFieldsTokenizedUseCase(actionRequest)) + ProjectState.RUNNING -> proceedWithAction(ensureActionFieldsTokenizedUseCase(actionRequest), isFreshLogin) } } - private fun proceedWithAction(actionRequest: ActionRequest) = viewModelScope.launch { + private fun proceedWithAction( + actionRequest: ActionRequest, + isFreshLogin: Boolean, + ) = viewModelScope.launch { updateProjectInCurrentSession() updateStoredUserId(actionRequest.userId) awaitAll( @@ -153,9 +160,11 @@ class LoginCheckViewModel @Inject internal constructor( async { addAuthorizationEvent(actionRequest, true) }, async { extractParametersForCrashReport(actionRequest) }, ) - // Schedule Realm-to-Room migration after successful login, if needed. - // This avoids down-syncing data into Realm then migrate to room instead set Room immediately the active db. - realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() + // After any fresh user login, make Room the default enrolment records database immediately, + // ignoring the migration flags. + if (isFreshLogin) { + realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() + } startBackgroundSync() _proceedWithAction.send(actionRequest) } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt index a4ca933d72..ea9d4c2e5e 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt @@ -275,6 +275,32 @@ internal class LoginCheckViewModelTest { coVerify { configRepository.getProject() } } + @Test + fun `Forces Room as default database after a fresh login`() = runTest { + coEvery { isUserSignedInUseCase.invoke(any()) } returns IsUserSignedInUseCase.SignedInState.NOT_SIGNED_IN + coEvery { configRepository.getProject()?.state } returns ProjectState.RUNNING + + viewModel.validateSignInAndProceed(ActionFactory.getIdentifyRequest()) + viewModel.handleLoginResult(LoginResult(true, null)) + + coVerify(exactly = 1) { realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() } + } + + @Test + fun `Awaits forceRoomAsDefaultDatabase before starting background sync on a fresh login`() = runTest { + coEvery { isUserSignedInUseCase.invoke(any()) } returns IsUserSignedInUseCase.SignedInState.NOT_SIGNED_IN + coEvery { configRepository.getProject()?.state } returns ProjectState.RUNNING + + viewModel.validateSignInAndProceed(ActionFactory.getIdentifyRequest()) + viewModel.handleLoginResult(LoginResult(true, null)) + + // The migration flag must be flipped to COMPLETED before background sync starts writing to the DB. + coVerifyOrder { + realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() + startBackgroundSync.invoke() + } + } + @Test fun `Correctly handles signed in users`() = runTest { coEvery { isUserSignedInUseCase.invoke(any()) } returns IsUserSignedInUseCase.SignedInState.SIGNED_IN @@ -284,6 +310,16 @@ internal class LoginCheckViewModelTest { coVerify { configRepository.getProject() } } + @Test + fun `Does nothing to migration flags when the user is already logged in`() = runTest { + coEvery { isUserSignedInUseCase.invoke(any()) } returns IsUserSignedInUseCase.SignedInState.SIGNED_IN + coEvery { configRepository.getProject()?.state } returns ProjectState.RUNNING + + viewModel.validateSignInAndProceed(ActionFactory.getIdentifyRequest()) + + coVerify(exactly = 0) { realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() } + } + @Test fun `Triggers alert if project is paused`() = runTest { coEvery { isUserSignedInUseCase.invoke(any()) } returns IsUserSignedInUseCase.SignedInState.SIGNED_IN @@ -349,6 +385,7 @@ internal class LoginCheckViewModelTest { startBackgroundSync.invoke() ensureActionFieldsTokenizedUseCase.invoke(any()) } + coVerify(exactly = 0) { realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() } viewModel.proceedWithAction .test() diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationScheduler.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationScheduler.kt index eaa9317bd8..a323b7f192 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationScheduler.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationScheduler.kt @@ -38,6 +38,16 @@ class RealmToRoomMigrationScheduler @Inject constructor( enqueueMigrationWork() } + /** + * Forces Room to become the default enrolment records database immediately, ignoring the migration flags + */ + suspend fun forceRoomAsDefaultDatabase() { + log("Forcing Room as default database after login, ignoring migration flags.") + workManager.cancelUniqueWork(RealmToRoomMigrationWorker.WORK_NAME) + realmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.COMPLETED) + realmToRoomMigrationFlagsStore.resetRetryCount() + } + private fun enqueueMigrationWork() { val constraints = Constraints .Builder() diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt index 3e5c8a1c3c..e4beaab466 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt @@ -14,6 +14,7 @@ import com.simprints.infra.logging.LoggingConstants.CrashReportTag.REALM_DB_MIGR import com.simprints.infra.logging.Simber import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlin.time.measureTime @@ -69,6 +70,10 @@ internal class RealmToRoomMigrationWorker @AssistedInject constructor( realmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.COMPLETED) realmToRoomMigrationFlagsStore.resetRetryCount() return@withContext success() + } catch (e: CancellationException) { + // The work may have been canceled deliberately (e.g. forceRoomAsDefaultDatabase()) + Simber.i("[RealmToRoomMigrationWorker] Migration cancelled: ${e.message}", tag = REALM_DB_MIGRATION) + throw e } catch (e: Exception) { Simber.e("[RealmToRoomMigrationWorker] Migration failed: ${e.message}", e) realmToRoomMigrationFlagsStore.incrementRetryCount() diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationSchedulerTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationSchedulerTest.kt index cdbb56c5a1..b3fd1c15a6 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationSchedulerTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationSchedulerTest.kt @@ -8,6 +8,7 @@ import androidx.work.WorkManager import com.google.common.truth.Truth.assertThat import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK @@ -195,4 +196,21 @@ class RealmToRoomMigrationSchedulerTest { val capturedRequest = workRequestSlot.captured assertThat(capturedRequest.workSpec.workerClassName).isEqualTo(RealmToRoomMigrationWorker::class.java.name) } + + @Test + fun `forceRoomAsDefaultDatabase should cancel worker and mark migration as completed`() = runTest { + // Given + coJustRun { mockRealmToRoomMigrationFlagsStore.updateStatus(any()) } + coJustRun { mockRealmToRoomMigrationFlagsStore.resetRetryCount() } + + // When + scheduler.forceRoomAsDefaultDatabase() + + // Then + coVerify(exactly = 1) { + mockWorkManager.cancelUniqueWork(RealmToRoomMigrationWorker.WORK_NAME) + mockRealmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.COMPLETED) + mockRealmToRoomMigrationFlagsStore.resetRetryCount() + } + } } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorkerTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorkerTest.kt index 242390921b..24848d6cf1 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorkerTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorkerTest.kt @@ -19,11 +19,13 @@ import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.Assert.fail import org.junit.Before import org.junit.Test @@ -195,4 +197,32 @@ class RealmToRoomMigrationWorkerTest { coVerify { realmToRoomMigrationFlagsStore.incrementRetryCount() } assertThat(result).isEqualTo(Result.failure()) } + + @Test + fun `doWork should rethrow CancellationException without touching flags or deleting Room data`() = runTest { + val mockSubjectsBatch1 = listOf(mockk(), mockk()) + + // Given + coEvery { realmToRoomMigrationFlagsStore.isDownSyncInProgress() } returns false + coEvery { realmDataSource.count(any()) } returns 3 + coEvery { + realmDataSource.loadAllSubjectsInBatches(any()) + } returns flowOf(mockSubjectsBatch1) + coEvery { roomDataSource.performActions(any(), any()) } throws CancellationException("Work cancelled") + + // When + try { + worker.doWork() + fail("Expected CancellationException to be thrown") + } catch (_: CancellationException) { + // expected + } + + // Then + coVerify(exactly = 0) { realmToRoomMigrationFlagsStore.incrementRetryCount() } + coVerify(exactly = 0) { realmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.FAILED) } + coVerify(exactly = 0) { realmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.COMPLETED) } + // deleteAll is called once before processing records, but not a second time as part of failure handling + coVerify(exactly = 1) { roomDataSource.deleteAll() } + } }