Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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 -> {
Expand All @@ -136,26 +137,34 @@ 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(
async { updateDatabaseCountsInCurrentSession() },
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()
Comment thread
BurningAXE marked this conversation as resolved.
}
startBackgroundSync()
_proceedWithAction.send(actionRequest)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -349,6 +385,7 @@ internal class LoginCheckViewModelTest {
startBackgroundSync.invoke()
ensureActionFieldsTokenizedUseCase.invoke(any())
}
coVerify(exactly = 0) { realmToRoomMigrationScheduler.forceRoomAsDefaultDatabase() }

viewModel.proceedWithAction
.test()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment thread
meladRaouf marked this conversation as resolved.

private fun enqueueMigrationWork() {
val constraints = Constraints
.Builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
meladRaouf marked this conversation as resolved.
} catch (e: Exception) {
Simber.e("[RealmToRoomMigrationWorker] Migration failed: ${e.message}", e)
realmToRoomMigrationFlagsStore.incrementRetryCount()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Comment thread
meladRaouf marked this conversation as resolved.
val mockSubjectsBatch1 = listOf<EnrolmentRecord>(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() }
}
}