diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 4621022cd6..1984116c53 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -19,9 +19,9 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.events.EventRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncWorkerState -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -35,14 +35,11 @@ import javax.inject.Inject @AndroidEntryPoint internal class DebugFragment : Fragment(R.layout.fragment_debug) { @Inject - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @Inject lateinit var eventSyncManager: EventSyncManager - @Inject - lateinit var syncOrchestrator: SyncOrchestrator - @Inject lateinit var authStore: AuthStore @@ -67,7 +64,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + syncOrchestrator + .observeSyncState() .map { it.eventSyncState }.asLiveData() @@ -87,22 +85,18 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { ) binding.logs.append(ssb) - } + } binding.syncStart.setOnClickListener { - lifecycleScope.launch(dispatcher) { - syncOrchestrator.startEventSync() - } + syncOrchestrator.executeOneTime(OneTime.Events.start()) } binding.syncStop.setOnClickListener { - syncOrchestrator.stopEventSync() + syncOrchestrator.executeOneTime(OneTime.Events.stop()) } binding.syncSchedule.setOnClickListener { - lifecycleScope.launch(dispatcher) { - syncOrchestrator.rescheduleEventSync() - } + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule()) } binding.clearFirebaseToken.setOnClickListener { @@ -127,8 +121,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { binding.cleanAll.setOnClickListener { lifecycleScope.launch(dispatcher) { - syncOrchestrator.stopEventSync() - syncOrchestrator.cancelEventSync() + syncOrchestrator.executeOneTime(OneTime.Events.stop()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Events.unschedule()) eventRepository.deleteAll() eventSyncManager.resetDownSyncInfo() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt index ee2c674b95..ce2960fc9f 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt @@ -10,8 +10,7 @@ import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -22,7 +21,7 @@ import javax.inject.Inject @HiltViewModel internal class LogoutSyncViewModel @Inject constructor( private val configRepository: ConfigRepository, - sync: SyncUseCase, + syncOrchestrator: SyncOrchestrator, authStore: AuthStore, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { @@ -36,7 +35,8 @@ internal class LogoutSyncViewModel @Inject constructor( .asLiveData(viewModelScope.coroutineContext) val isLogoutWithoutSyncVisibleLiveData: LiveData = - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + syncOrchestrator + .observeSyncState() .map { syncStatus -> !syncStatus.eventSyncState.isSyncCompleted() || syncStatus.imageSyncStatus.isSyncing }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index b1bf6d0d65..ae2e0f094a 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -4,6 +4,7 @@ import com.simprints.core.DispatcherIO import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runBlocking @@ -19,7 +20,7 @@ internal class LogoutUseCase @Inject constructor( // To prevent a race between wiping data and navigation, this use case must block the executing thread operator fun invoke() = runBlocking(ioDispatcher) { // Cancel all background sync - syncOrchestrator.cancelBackgroundWork() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) syncOrchestrator.deleteEventSyncInfo() // sign out the user authManager.signOut() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index f083d2ffdd..da520eefac 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -16,9 +16,8 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -43,11 +42,10 @@ import javax.inject.Inject internal class SyncInfoViewModel @Inject constructor( private val configRepository: ConfigRepository, private val authStore: AuthStore, - private val syncOrchestrator: SyncOrchestrator, private val recentUserActivityManager: RecentUserActivityManager, private val timeHelper: TimeHelper, observeSyncInfo: ObserveSyncInfoUseCase, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val logoutUseCase: LogoutUseCase, @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -57,7 +55,7 @@ internal class SyncInfoViewModel @Inject constructor( get() = _loginNavigationEventLiveData private val _loginNavigationEventLiveData = MutableLiveData() - private val syncStatusFlow = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + private val syncStatusFlow = syncOrchestrator.observeSyncState() private val eventSyncStateFlow = syncStatusFlow.map { it.eventSyncState } private val imageSyncStatusFlow = @@ -146,10 +144,8 @@ internal class SyncInfoViewModel @Inject constructor( } } - syncOrchestrator.stopEventSync() - val isDownSyncAllowed = !isPreLogoutUpSync && configRepository.getProject()?.state == ProjectState.RUNNING - syncOrchestrator.startEventSync(isDownSyncAllowed) + syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed)) } } @@ -157,10 +153,10 @@ internal class SyncInfoViewModel @Inject constructor( viewModelScope.launch { val isImageSyncing = imageSyncStatusFlow.firstOrNull()?.isSyncing == true if (isImageSyncing) { - syncOrchestrator.stopImageSync() + syncOrchestrator.executeOneTime(OneTime.Images.stop()) } else { imageSyncButtonClickFlow.emit(Unit) - syncOrchestrator.startImageSync() + syncOrchestrator.executeOneTime(OneTime.Images.start()) } } } @@ -218,7 +214,7 @@ internal class SyncInfoViewModel @Inject constructor( .distinctUntilChanged() .collect { isEventSyncCompleted -> if (isEventSyncCompleted) { - syncOrchestrator.startImageSync() + syncOrchestrator.executeOneTime(OneTime.Images.start()) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt index b1b9475daf..da65c66be2 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt @@ -15,6 +15,7 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope @@ -114,8 +115,7 @@ internal class ModuleSelectionViewModel @Inject constructor( module.copy(name = encryptedName) } moduleRepository.saveModules(modules) - syncOrchestrator.stopEventSync() - syncOrchestrator.startEventSync() + syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt index 9cde07fe60..f4c2ec5559 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -23,9 +23,8 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.permission.CommCarePermissionChecker import com.simprints.infra.eventsync.status.models.DownSyncCounts import com.simprints.infra.network.ConnectivityTracker -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase -import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -45,7 +44,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private val commCarePermissionChecker: CommCarePermissionChecker, private val observeConfigurationFlow: ObserveConfigurationChangesUseCase, private val observeSyncableCounts: ObserveSyncableCountsUseCase, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, @param:DispatcherBG private val dispatcher: CoroutineDispatcher, ) { // Since we are not using distinctUntilChanged any emission from combined flows will trigger the main flow as well @@ -58,7 +57,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine( combinedRefreshSignals(), authStore.observeSignedInProjectId(), - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly), + syncOrchestrator.observeSyncState(), observeSyncableCounts(), observeConfigurationFlow(), ) { isOnline, projectId, (eventSyncState, imageSyncStatus), counts, (isRefreshing, isProjectRunning, moduleCounts, projectConfig) -> diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt index 731a976c6c..f49b73cfaa 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt @@ -9,7 +9,7 @@ import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.* @@ -31,7 +31,7 @@ internal class LogoutSyncViewModelTest { lateinit var configRepository: ConfigRepository @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var authStore: AuthStore @@ -140,12 +140,12 @@ internal class LogoutSyncViewModelTest { imageSyncStatus: ImageSyncStatus, ) { val statusFlow = MutableStateFlow(SyncStatus(eventSyncState = eventSyncState, imageSyncStatus = imageSyncStatus)) - every { sync.invoke(any(), any()) } returns statusFlow + every { syncOrchestrator.observeSyncState() } returns statusFlow } private fun createViewModel() = LogoutSyncViewModel( configRepository = configRepository, - sync = sync, + syncOrchestrator = syncOrchestrator, authStore = authStore, logoutUseCase = logoutUseCase, ) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt index 3a4f1c2ebc..78dee91485 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt @@ -4,11 +4,15 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -37,6 +41,7 @@ class LogoutUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = LogoutUseCase( syncOrchestrator = syncOrchestrator, @@ -51,8 +56,8 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } coVerify { - syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() authManager.signOut() flagsStore.clearMigrationFlags() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index 47b9e64965..03d1890e22 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -23,15 +23,16 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import com.simprints.testtools.common.livedata.getOrAwaitValues import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -57,9 +58,6 @@ class SyncInfoViewModelTest { @MockK private lateinit var authStore: AuthStore - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - @MockK private lateinit var recentUserActivityManager: RecentUserActivityManager @@ -70,7 +68,7 @@ class SyncInfoViewModelTest { private lateinit var observeSyncInfo: ObserveSyncInfoUseCase @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator @MockK private lateinit var logoutUseCase: LogoutUseCase @@ -147,11 +145,8 @@ class SyncInfoViewModelTest { syncStatusFlow = MutableStateFlow( SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus), ) - every { sync.invoke(any(), any()) } returns syncStatusFlow - coEvery { syncOrchestrator.startEventSync(any()) } returns Unit - coEvery { syncOrchestrator.stopEventSync() } returns Unit - coEvery { syncOrchestrator.startImageSync() } returns Unit - coEvery { syncOrchestrator.stopImageSync() } returns Unit + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow + every { syncOrchestrator.executeOneTime(any()) } returns Job().apply { complete() } every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L @@ -170,11 +165,10 @@ class SyncInfoViewModelTest { viewModel = SyncInfoViewModel( configRepository = configRepository, authStore = authStore, - syncOrchestrator = syncOrchestrator, recentUserActivityManager = recentUserActivityManager, timeHelper = timeHelper, observeSyncInfo = observeSyncInfo, - sync = sync, + syncOrchestrator = syncOrchestrator, logoutUseCase = logoutUseCase, ioDispatcher = testCoroutineRule.testCoroutineDispatcher, ) @@ -391,8 +385,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = true) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } } @Test @@ -401,8 +394,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -416,8 +408,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -431,8 +422,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -443,16 +433,14 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test fun `should stop current event sync before starting new one`() = runTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(any()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } // toggleImageSync() tests @@ -467,8 +455,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - coVerify { syncOrchestrator.startImageSync() } - coVerify(exactly = 0) { syncOrchestrator.stopImageSync() } + verify { syncOrchestrator.executeOneTime(OneTime.Images.start()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Images.stop()) } } @Test @@ -481,8 +469,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - coVerify { syncOrchestrator.stopImageSync() } - coVerify(exactly = 0) { syncOrchestrator.startImageSync() } + verify { syncOrchestrator.executeOneTime(OneTime.Images.stop()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Images.start()) } } // logout() tests @@ -536,7 +524,7 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(successResult) - coVerify { syncOrchestrator.startEventSync(any()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -547,7 +535,8 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(failureResult) - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } // Sync button responsiveness optimization @@ -683,7 +672,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify { syncOrchestrator.startEventSync(any()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -699,7 +688,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify { syncOrchestrator.startEventSync(any()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -715,7 +704,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -729,7 +719,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -746,7 +737,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(atLeast = 0) { syncOrchestrator.startEventSync(any()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -767,7 +758,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 1) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -788,7 +779,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } private companion object { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt index e80e6ed74a..7be2967611 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt @@ -15,6 +15,7 @@ import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue @@ -22,6 +23,7 @@ import com.simprints.testtools.common.syntax.assertThrows import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import org.junit.Before import org.junit.Rule import org.junit.Test @@ -55,6 +57,7 @@ class ModuleSelectionViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeOneTime(any()) } returns Job().apply { complete() } val modulesDefault = listOf( Module("a".asTokenizableEncrypted(), false), @@ -173,8 +176,7 @@ class ModuleSelectionViewModelTest { viewModel.saveModules() coVerify(exactly = 1) { repository.saveModules(updatedModules) } - coVerify(exactly = 1) { syncOrchestrator.stopEventSync() } - coVerify(exactly = 1) { syncOrchestrator.startEventSync() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt index 832c394ad9..25512c8d76 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -24,9 +24,9 @@ import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncStatus +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.SyncableCounts import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase -import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow @@ -48,7 +48,7 @@ internal class ObserveSyncInfoUseCaseTest { private val connectivityTracker = mockk() private val authStore = mockk() private val observeSyncableCounts = mockk() - private val sync = mockk() + private val syncOrchestrator = mockk() private val timeHelper = mockk() private val ticker = mockk() private val appForegroundStateTracker = mockk() @@ -131,7 +131,7 @@ internal class ObserveSyncInfoUseCaseTest { every { connectivityTracker.observeIsConnected() } returns flowOf(true) syncStatusFlow.value = SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus) - every { sync.invoke(any(), any()) } returns syncStatusFlow + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow every { mockEventSyncState.lastSyncTime } returns TEST_TIMESTAMP syncableCountsFlow.value = SyncableCounts( @@ -170,7 +170,7 @@ internal class ObserveSyncInfoUseCaseTest { commCarePermissionChecker = commCarePermissionChecker, observeConfigurationFlow = observeConfigurationFlow, observeSyncableCounts = observeSyncableCounts, - sync = sync, + syncOrchestrator = syncOrchestrator, dispatcher = testCoroutineRule.testCoroutineDispatcher, ) } 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 2318f3b666..8b78821165 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 @@ -30,6 +30,7 @@ import com.simprints.infra.logging.Simber import com.simprints.infra.orchestration.data.ActionRequest import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async @@ -105,7 +106,7 @@ class LoginCheckViewModel @Inject internal constructor( cachedRequest = actionRequest loginAlreadyTried.set(true) - syncOrchestrator.cancelBackgroundWork() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) _showLoginFlow.send(actionRequest) } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt index 10f3f88dc1..2b9f98072e 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt @@ -2,12 +2,14 @@ package com.simprints.feature.logincheck.usecases import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class StartBackgroundSyncUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, private val configRepository: ConfigRepository, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke() { val frequency = configRepository @@ -15,8 +17,7 @@ internal class StartBackgroundSyncUseCase @Inject constructor( .synchronization.down.simprints ?.frequency - syncOrchestrator.scheduleBackgroundWork( - withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START, - ) + val withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule(withDelay)).await() } } 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 cfb64d5713..4f450829b9 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 @@ -21,10 +21,12 @@ import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -84,6 +86,7 @@ internal class LoginCheckViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } viewModel = LoginCheckViewModel( rootManager = rootMatchers, @@ -169,8 +172,8 @@ internal class LoginCheckViewModelTest { coVerify { addAuthorizationEventUseCase.invoke(any(), eq(false)) - syncOrchestrator.cancelBackgroundWork() } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } viewModel.showLoginFlow .test() .assertValue { it.peekContent() == ActionFactory.getIdentifyRequest() } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt index 6d8fe722b1..727e87fa67 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt @@ -1,14 +1,22 @@ package com.simprints.feature.logincheck.usecases +import com.google.common.truth.Truth.assertThat import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class StartBackgroundSyncUseCaseTest { @MockK lateinit var syncOrchestrator: SyncOrchestrator @@ -21,10 +29,11 @@ class StartBackgroundSyncUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = StartBackgroundSyncUseCase( - syncOrchestrator, configRepository, + syncOrchestrator, ) } @@ -33,13 +42,12 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(any()) } + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) } @Test @@ -47,13 +55,12 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(false)) } + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = false)) } @Test @@ -61,25 +68,38 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(true)) } + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) } @Test fun `Does not start event sync on start if not Simprints sync`() = runTest { coEvery { configRepository - .getProjectConfiguration() - .synchronization.down.simprints + .getProjectConfiguration() + .synchronization.down.simprints } returns null - useCase.invoke() + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) + } + + private suspend fun TestScope.assertUseCaseAwaitsSync(expectedCommand: ScheduleCommand) { + val syncCommandJob = Job() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns syncCommandJob + + val useCaseJob = async { useCase.invoke() } + + runCurrent() + assertThat(useCaseJob.isCompleted).isFalse() + + syncCommandJob.complete() + runCurrent() + useCaseJob.await() - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(true)) } + verify { syncOrchestrator.executeSchedulingCommand(expectedCommand) } } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 890f9caeb3..1c0da834b7 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -1,26 +1,29 @@ package com.simprints.feature.validatepool.usecase -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.extensions.await import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import javax.inject.Inject internal class RunBlockingEventSyncUseCase @Inject constructor( - private val sync: SyncUseCase, private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke() { + val syncState = syncOrchestrator.observeSyncState() // First item in the flow (except uninitialized) is the state of last sync, - // so it can be used to as a filter out old sync states - val lastSyncId = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + // so it can be used to as a filter out old sync states. + // To guarantee it's not associated with the newly run sync, + // the value needs to be taken before it starts. + val lastSyncId = syncState .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId - - syncOrchestrator.startEventSync() - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + syncOrchestrator + .executeOneTime(OneTime.Events.start()) + .await() + syncState .map { it.eventSyncState } .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt index 19e538b1fe..aa954e5a89 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -2,8 +2,7 @@ package com.simprints.feature.validatepool.usecase import com.simprints.core.tools.time.TimeHelper import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -11,10 +10,11 @@ import kotlin.time.Duration internal class ShouldSuggestSyncUseCase @Inject constructor( private val timeHelper: TimeHelper, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val configRepository: ConfigRepository, ) { - suspend operator fun invoke(): Boolean = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + suspend operator fun invoke(): Boolean = syncOrchestrator + .observeSyncState() .map { it.eventSyncState } .firstOrNull() ?.lastSyncTime diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 8b32684f08..a906f7ac9b 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -5,14 +5,15 @@ import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.eventsync.status.models.EventSyncWorkerType import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Before @@ -26,9 +27,6 @@ class RunBlockingEventSyncUseCaseTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - @MockK - private lateinit var sync: SyncUseCase - @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -38,18 +36,13 @@ class RunBlockingEventSyncUseCaseTest { fun setUp() { MockKAnnotations.init(this) - coJustRun { syncOrchestrator.startEventSync(any()) } - - usecase = RunBlockingEventSyncUseCase( - sync, - syncOrchestrator, - ) + usecase = RunBlockingEventSyncUseCase(syncOrchestrator) } @Test fun `finishes execution when sync reporters are finished`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -57,14 +50,14 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test fun `finishes execution when sync reporters have failed`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -72,14 +65,14 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Failed()) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test fun `finishes execution when sync reporters have been cancelled`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -87,24 +80,24 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Cancelled) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test fun `does not start sync early when initial default state is emitted before last completed sync`() = runTest { val syncFlow = MutableStateFlow(createPlaceholderSyncStatus()) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) val job = launch { usecase.invoke() } testScheduler.advanceUntilIdle() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - coVerify(exactly = 1) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } job.cancel() } @@ -133,5 +126,10 @@ class RunBlockingEventSyncUseCaseTest { ) } + private fun setUpSync(syncFlow: StateFlow) { + every { syncOrchestrator.observeSyncState() } returns syncFlow + every { syncOrchestrator.executeOneTime(OneTime.Events.start()) } returns Job().apply { complete() } + } + private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) } diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt index 2f6a55a360..58827959f6 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt @@ -7,7 +7,7 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -22,7 +22,7 @@ class ShouldSuggestSyncUseCaseTest { lateinit var timeHelper: TimeHelper @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var configRepository: ConfigRepository @@ -35,9 +35,9 @@ class ShouldSuggestSyncUseCaseTest { MockKAnnotations.init(this) syncStatusFlow = MutableStateFlow(createSyncStatus(lastSyncTime = null)) - every { sync.invoke(any(), any()) } returns syncStatusFlow + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow - usecase = ShouldSuggestSyncUseCase(timeHelper, sync, configRepository) + usecase = ShouldSuggestSyncUseCase(timeHelper, syncOrchestrator, configRepository) } @Test diff --git a/id/src/main/java/com/simprints/id/Application.kt b/id/src/main/java/com/simprints/id/Application.kt index 48915db62c..97ab6836bd 100644 --- a/id/src/main/java/com/simprints/id/Application.kt +++ b/id/src/main/java/com/simprints/id/Application.kt @@ -16,6 +16,7 @@ import com.simprints.infra.logging.LoggingConstants.CrashReportingCustomKeys.VER import com.simprints.infra.logging.Simber import com.simprints.infra.logging.SimberBuilder import com.simprints.infra.logging.usecases.UpdateAndGetVersionHistoryUseCase +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -85,7 +86,7 @@ open class Application : appScope.launch { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() syncOrchestrator.cleanupWorkers() - syncOrchestrator.scheduleBackgroundWork() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()) } if (DB_ENCRYPTION) { System.loadLibrary("sqlcipher") diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index 11d7292e50..dc73691326 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -14,7 +14,9 @@ import com.simprints.core.ExternalScope import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC import com.simprints.infra.logging.Simber +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -49,7 +51,8 @@ class EventDownSyncResetService : Service() { // Reset current downsync state eventSyncManager.resetDownSyncInfo() // Trigger a new sync - syncOrchestrator.startEventSync() + // Execute in app scope to prevent a timeout cancellation leaving it in a stopped state + syncOrchestrator.executeOneTime(OneTime.Events.start()).await() } resetJob?.invokeOnCompletion { stopSelf() } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt index 8c563bbdbb..901b54d3fa 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt @@ -1,6 +1,5 @@ package com.simprints.infra.eventsync -// todo MS-1300 disband into usecases interface EventSyncManager { fun getPeriodicWorkTags(): List diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt new file mode 100644 index 0000000000..ea65a71d28 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt @@ -0,0 +1,39 @@ +package com.simprints.infra.sync + +/** + * One-time (immediate) sync control commands. + * + * Intended to be executed via [SyncOrchestrator.executeOneTime]. + */ +sealed class OneTime { + internal enum class Action { + START, + STOP, + RESTART, + } + + internal data class EventsCommand( + val action: Action, + val isDownSyncAllowed: Boolean = true, + ) : OneTime() + + internal data class ImagesCommand( + val action: Action, + ) : OneTime() + + object Events { + fun start(isDownSyncAllowed: Boolean = true): OneTime = EventsCommand(Action.START, isDownSyncAllowed) + + fun stop(): OneTime = EventsCommand(Action.STOP) + + fun restart(isDownSyncAllowed: Boolean = true): OneTime = EventsCommand(Action.RESTART, isDownSyncAllowed) + } + + object Images { + fun start(): OneTime = ImagesCommand(Action.START) + + fun stop(): OneTime = ImagesCommand(Action.STOP) + + fun restart(): OneTime = ImagesCommand(Action.RESTART) + } +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt new file mode 100644 index 0000000000..3e06396f9b --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.sync + +/** + * Periodic/background scheduling commands. + * + * Intended to be executed via [SyncOrchestrator.executeSchedulingCommand]. + */ +sealed class ScheduleCommand { + internal enum class Action { + RESCHEDULE, + UNSCHEDULE, + } + + internal data class EverythingCommand( + val action: Action, + val withDelay: Boolean = false, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + internal data class EventsCommand( + val action: Action, + val withDelay: Boolean = false, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + internal data class ImagesCommand( + val action: Action, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + object Everything { + fun reschedule(withDelay: Boolean = false): ScheduleCommand = EverythingCommand(action = Action.RESCHEDULE, withDelay = withDelay) + + fun unschedule(): ScheduleCommand = EverythingCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter( + withDelay: Boolean = false, + block: suspend () -> Unit, + ): ScheduleCommand = EverythingCommand(action = Action.RESCHEDULE, withDelay = withDelay, blockWhileUnscheduled = block) + } + + object Events { + fun reschedule(withDelay: Boolean = false): ScheduleCommand = EventsCommand(action = Action.RESCHEDULE, withDelay = withDelay) + + fun unschedule(): ScheduleCommand = EventsCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter( + withDelay: Boolean = false, + block: suspend () -> Unit, + ): ScheduleCommand = EventsCommand(action = Action.RESCHEDULE, withDelay = withDelay, blockWhileUnscheduled = block) + } + + object Images { + fun reschedule(): ScheduleCommand = ImagesCommand(action = Action.RESCHEDULE) + + fun unschedule(): ScheduleCommand = ImagesCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter(block: suspend () -> Unit): ScheduleCommand = + ImagesCommand(action = Action.RESCHEDULE, blockWhileUnscheduled = block) + } +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt deleted file mode 100644 index 0ba182e13d..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.simprints.infra.sync - -enum class SyncCommand { ObserveOnly } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt index 7616a935ad..a0a132c773 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt @@ -1,12 +1,26 @@ package com.simprints.infra.sync +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.Flow -// todo MS-1299, MS-1300 move sync controls into SyncUseCase & its helper usecases, disband the rest into new other usecases interface SyncOrchestrator { - suspend fun scheduleBackgroundWork(withDelay: Boolean = false) + /** + * A combined reactive stream of sync state for all syncable entities. + */ + fun observeSyncState(): StateFlow + + /** + * Executes an immediate (one-time) sync control command. + * Returns a job of the ongoing command execution. + */ + fun executeOneTime(command: OneTime): Job - suspend fun cancelBackgroundWork() + /** + * Executes a periodic/background scheduling command. + * Returns a job of the ongoing command execution. + */ + fun executeSchedulingCommand(command: ScheduleCommand): Job fun startConfigSync() @@ -16,24 +30,6 @@ interface SyncOrchestrator { */ fun refreshConfiguration(): Flow - suspend fun rescheduleEventSync(withDelay: Boolean = false) - - fun cancelEventSync() - - suspend fun startEventSync(isDownSyncAllowed: Boolean = true) - - fun stopEventSync() - - fun startImageSync() - - fun stopImageSync() - - /** - * Fully reschedule the background worker. - * Should be used in when the configuration that affects scheduling has changed. - */ - suspend fun rescheduleImageUpSync() - /** * Schedule a worker to upload subjects with IDs in the provided list. */ diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt index d534f3e6db..9fd119a16c 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt @@ -8,11 +8,14 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.workDataOf import com.simprints.core.AppScope +import com.simprints.core.DispatcherIO import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker @@ -21,14 +24,23 @@ import com.simprints.infra.sync.extensions.anyRunning import com.simprints.infra.sync.extensions.cancelWorkers import com.simprints.infra.sync.extensions.schedulePeriodicWorker import com.simprints.infra.sync.extensions.startWorker +import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase import com.simprints.infra.sync.files.FileUpSyncWorker import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase -import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -39,27 +51,194 @@ internal class SyncOrchestratorImpl @Inject constructor( private val authStore: AuthStore, private val configRepository: ConfigRepository, private val eventSyncManager: EventSyncManager, + private val eventSyncStateProcessor: EventSyncStateProcessor, + private val observeImageSyncStatus: ObserveImageSyncStatusUseCase, private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, private val cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase, private val imageSyncTimestampProvider: ImageSyncTimestampProvider, @param:AppScope private val appScope: CoroutineScope, + @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) : SyncOrchestrator { + private val defaultEventSyncState = EventSyncState( + syncId = "", + progress = null, + total = null, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ) + private val defaultImageSyncStatus = ImageSyncStatus( + isSyncing = false, + progress = null, + lastUpdateTimeMillis = -1L, + ) + private val defaultSyncStatus = SyncStatus(defaultEventSyncState, defaultImageSyncStatus) + + private val sharedSyncState: StateFlow by lazy { + combine( + eventSyncStateProcessor.getLastSyncState().onStart { emit(defaultEventSyncState) }, + observeImageSyncStatus().onStart { emit(defaultImageSyncStatus) }, + ) { eventSyncState, imageSyncStatus -> + SyncStatus(eventSyncState, imageSyncStatus) + }.stateIn( + appScope, + SharingStarted.Eagerly, + defaultSyncStatus, + ) + } + init { - appScope.launch { - // Stop image upload when event sync starts + // Automatically conditioned scheduling rule: + // stop image upload when event sync starts. + appScope.launch(ioDispatcher) { workManager .getWorkInfosFlow( WorkQuery.fromUniqueWorkNames( SyncConstants.EVENT_SYNC_WORK_NAME, SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, ), - ).collect { workInfoList -> - if (workInfoList.anyRunning()) rescheduleImageUpSync() + ).map { workInfoList -> + workInfoList.anyRunning() + }.distinctUntilChanged() + .filter { it } // only when event sync becomes running + .collect { + rescheduleImageUpSync() } } } - override suspend fun scheduleBackgroundWork(withDelay: Boolean) { + override fun observeSyncState(): StateFlow = sharedSyncState + + override fun executeOneTime(command: OneTime): Job { + return when (command) { + is OneTime.EventsCommand -> executeOneTimeAction( + action = command.action, + stop = ::stopEventSync, + start = { startEventSync(isDownSyncAllowed = command.isDownSyncAllowed) }, + ) + + is OneTime.ImagesCommand -> executeOneTimeAction( + action = command.action, + stop = ::stopImageSync, + start = { startImageSync() }, + ) + } + } + + override fun executeSchedulingCommand(command: ScheduleCommand): Job { + return when (command) { + is ScheduleCommand.EverythingCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::cancelBackgroundWork, + reschedule = { scheduleBackgroundWork(withDelay = command.withDelay) }, + ) + + is ScheduleCommand.EventsCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::cancelEventSync, + reschedule = { rescheduleEventSync(withDelay = command.withDelay) }, + ) + + is ScheduleCommand.ImagesCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::stopImageSync, + reschedule = { rescheduleImageUpSync() }, + ) + } + } + + override fun startConfigSync() { + workManager.startWorker(SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME) + workManager.startWorker(SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME) + } + + override fun refreshConfiguration(): Flow { + startConfigSync() + return workManager + .getWorkInfosFlow( + WorkQuery.fromUniqueWorkNames( + SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME, + SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME, + ), + ).filter { workInfoList -> + workInfoList.none { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING } + }.map { } // Converts flow emissions to Unit value as we only care about when it happens, not the value + } + + override fun uploadEnrolmentRecords( + id: String, + subjectIds: List, + ) { + workManager.startWorker( + SyncConstants.RECORD_UPLOAD_WORK_NAME, + inputData = workDataOf( + SyncConstants.RECORD_UPLOAD_INPUT_ID_NAME to id, + SyncConstants.RECORD_UPLOAD_INPUT_SUBJECT_IDS_NAME to subjectIds.toTypedArray(), + ), + ) + } + + override suspend fun deleteEventSyncInfo() { + eventSyncManager.deleteSyncInfo() + workManager.pruneWork() + imageSyncTimestampProvider.clearTimestamp() + } + + override fun cleanupWorkers() { + cleanupDeprecatedWorkers() + } + + private fun executeOneTimeAction( + action: OneTime.Action, + stop: () -> Unit, + start: suspend () -> Unit, + ): Job { + val shouldStop = + action == OneTime.Action.STOP || action == OneTime.Action.RESTART + if (shouldStop) { + stop() + } + + val shouldStart = + action == OneTime.Action.START || action == OneTime.Action.RESTART + if (!shouldStart) { + return Job().apply { complete() } + } + + return appScope.launch(ioDispatcher) { + start() + } + } + + private fun executeSchedulingAction( + action: ScheduleCommand.Action, + blockWhileUnscheduled: (suspend () -> Unit)?, + unschedule: () -> Unit, + reschedule: suspend () -> Unit, + ): Job { + val shouldUnschedule = + action == ScheduleCommand.Action.UNSCHEDULE || blockWhileUnscheduled != null + if (shouldUnschedule) { + unschedule() + } + + val shouldSchedule = + action == ScheduleCommand.Action.RESCHEDULE + if (!shouldSchedule) { + return Job().apply { complete() } + } + + return appScope.launch(ioDispatcher) { + blockWhileUnscheduled?.invoke() + reschedule() + } + } + + private suspend fun scheduleBackgroundWork(withDelay: Boolean) { if (authStore.signedInProjectId.isNotEmpty()) { workManager.schedulePeriodicWorker( SyncConstants.PROJECT_SYNC_WORK_NAME, @@ -74,7 +253,7 @@ internal class SyncOrchestratorImpl @Inject constructor( SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, constraints = getImageUploadConstraints(), ) - rescheduleEventSync(withDelay) + rescheduleEventSync(withDelay = withDelay) if (shouldScheduleFirmwareUpdate()) { workManager.schedulePeriodicWorker( SyncConstants.FIRMWARE_UPDATE_WORK_NAME, @@ -86,7 +265,7 @@ internal class SyncOrchestratorImpl @Inject constructor( } } - override suspend fun cancelBackgroundWork() { + private fun cancelBackgroundWork() { workManager.cancelWorkers( SyncConstants.PROJECT_SYNC_WORK_NAME, SyncConstants.DEVICE_SYNC_WORK_NAME, @@ -97,25 +276,7 @@ internal class SyncOrchestratorImpl @Inject constructor( stopEventSync() } - override fun startConfigSync() { - workManager.startWorker(SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME) - workManager.startWorker(SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME) - } - - override fun refreshConfiguration(): Flow { - startConfigSync() - return workManager - .getWorkInfosFlow( - WorkQuery.fromUniqueWorkNames( - SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME, - SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME, - ), - ).filter { workInfoList -> - workInfoList.none { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING } - }.map { } // Converts flow emissions to Unit value as we only care about when it happens, not the value - } - - override suspend fun rescheduleEventSync(withDelay: Boolean) { + private suspend fun rescheduleEventSync(withDelay: Boolean) { workManager.schedulePeriodicWorker( workName = SyncConstants.EVENT_SYNC_WORK_NAME, repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, @@ -125,12 +286,12 @@ internal class SyncOrchestratorImpl @Inject constructor( ) } - override fun cancelEventSync() { + private fun cancelEventSync() { workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME) stopEventSync() } - override suspend fun startEventSync(isDownSyncAllowed: Boolean) { + private suspend fun startEventSync(isDownSyncAllowed: Boolean) { workManager.startWorker( workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, constraints = getEventSyncConstraints(), @@ -139,22 +300,26 @@ internal class SyncOrchestratorImpl @Inject constructor( ) } - override fun stopEventSync() { + private fun stopEventSync() { workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME) - // Event sync consists of multiple workers, so we cancel them all by tag + // Event sync consists of multiple workers, so we cancel them all by tag. workManager.cancelAllWorkByTag(eventSyncManager.getAllWorkerTag()) } - override fun startImageSync() { + private fun startImageSync() { stopImageSync() workManager.startWorker(SyncConstants.FILE_UP_SYNC_WORK_NAME) } - override fun stopImageSync() { + private fun stopImageSync() { workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) } - override suspend fun rescheduleImageUpSync() { + /** + * Fully reschedule the background worker. + * Should be used when the configuration that affects scheduling has changed. + */ + private suspend fun rescheduleImageUpSync() { workManager.schedulePeriodicWorker( SyncConstants.FILE_UP_SYNC_WORK_NAME, SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, @@ -164,29 +329,6 @@ internal class SyncOrchestratorImpl @Inject constructor( ) } - override fun uploadEnrolmentRecords( - id: String, - subjectIds: List, - ) { - workManager.startWorker( - SyncConstants.RECORD_UPLOAD_WORK_NAME, - inputData = workDataOf( - SyncConstants.RECORD_UPLOAD_INPUT_ID_NAME to id, - SyncConstants.RECORD_UPLOAD_INPUT_SUBJECT_IDS_NAME to subjectIds.toTypedArray(), - ), - ) - } - - override suspend fun deleteEventSyncInfo() { - eventSyncManager.deleteSyncInfo() - workManager.pruneWork() - imageSyncTimestampProvider.clearTimestamp() - } - - override fun cleanupWorkers() { - cleanupDeprecatedWorkers() - } - private suspend fun getImageUploadConstraints(): Constraints { val networkType = configRepository .getProjectConfiguration() @@ -196,7 +338,7 @@ internal class SyncOrchestratorImpl @Inject constructor( } private suspend fun getEventSyncConstraints(): Constraints { - // CommCare doesn't require network connection + // CommCare doesn't require network connection. val networkType = configRepository .getProjectConfiguration() .isCommCareEventDownSyncAllowed() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt index df12c9e505..99898067be 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt @@ -2,6 +2,7 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.ScheduleCommand import javax.inject.Inject internal class LogoutUseCase @Inject constructor( @@ -9,7 +10,7 @@ internal class LogoutUseCase @Inject constructor( private val authManager: AuthManager, ) { suspend operator fun invoke() { - syncOrchestrator.cancelBackgroundWork() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt index fd97a02e0a..b3cc402e10 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt @@ -2,7 +2,9 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class RescheduleWorkersIfConfigChangedUseCase @Inject constructor( @@ -13,7 +15,7 @@ internal class RescheduleWorkersIfConfigChangedUseCase @Inject constructor( newConfig: ProjectConfiguration, ) { if (shouldRescheduleImageUpload(oldConfig, newConfig)) { - syncOrchestrator.rescheduleImageUpSync() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()).await() } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt index cabc592266..7fc682c26a 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt @@ -3,23 +3,27 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, private val eventSyncManager: EventSyncManager, private val enrolmentRecordRepository: EnrolmentRecordRepository, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke( oldConfig: ProjectConfiguration, newConfig: ProjectConfiguration, ) { if (hasPartitionTypeChanged(oldConfig, newConfig)) { - syncOrchestrator.cancelEventSync() - eventSyncManager.resetDownSyncInfo() - enrolmentRecordRepository.deleteAll() - syncOrchestrator.rescheduleEventSync() + syncOrchestrator.executeSchedulingCommand( + ScheduleCommand.Events.rescheduleAfter { + eventSyncManager.resetDownSyncInfo() + enrolmentRecordRepository.deleteAll() + }, + ).await() } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt b/infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt new file mode 100644 index 0000000000..0c14de2d81 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt @@ -0,0 +1,21 @@ +package com.simprints.infra.sync.extensions + +import kotlinx.coroutines.Job +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Waits for a Job to complete and rethrows failures (including cancellations). + */ +suspend fun Job.await() { + suspendCancellableCoroutine { continuation -> + val handle = invokeOnCompletion { cause -> + when (cause) { + null -> continuation.resume(Unit) + else -> continuation.resumeWithException(cause) + } + } + continuation.invokeOnCancellation { handle.dispose() } + } +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt deleted file mode 100644 index 5148274799..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.simprints.infra.sync.usecase - -import com.simprints.core.AppScope -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Combines statuses of syncable entities together in a reactive way. - * - * Because sync state is extensively used throughout the project, including synchronously, - * it is an app-scoped StateFlow. An up-to-date sync state value can be accessed synchronously. - */ -@Singleton -class SyncUseCase @Inject internal constructor( - eventSyncStateProcessor: EventSyncStateProcessor, - imageSync: ObserveImageSyncStatusUseCase, - @param:AppScope private val appScope: CoroutineScope, -) { - private val defaultEventSyncState = EventSyncState( - syncId = "", - progress = null, - total = null, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - private val defaultImageSyncStatus = ImageSyncStatus( - isSyncing = false, - progress = null, - lastUpdateTimeMillis = -1L, - ) - private val defaultSyncStatus = SyncStatus(defaultEventSyncState, defaultImageSyncStatus) - - private val sharedSyncStatus: StateFlow by lazy { - combine( - eventSyncStateProcessor.getLastSyncState().onStart { emit(defaultEventSyncState) }, - imageSync().onStart { emit(defaultImageSyncStatus) }, - ) { eventSyncState, imageSyncStatus -> - SyncStatus(eventSyncState, imageSyncStatus) - }.stateIn( - appScope, - SharingStarted.Eagerly, - defaultSyncStatus, - ) - } - - /** - * Takes sync control commands (incl. no action) for syncable entities, and returns their combined sync status, - * with a .value also available to the callers synchronously. - * - * Sync commands intentionally do not have default values, - * to prevent a `sync()` usage from being interpreted as a command to start syncing. - */ - operator fun invoke( - eventSync: SyncCommand, // todo MS-1299 finalize the signature of sync controls - imageSync: SyncCommand, // todo MS-1299 finalize the signature of sync controls - ): StateFlow = sharedSyncStatus - // todo MS-1299 move sync commands here from SyncOrchestrator (use helper usecases if needed), add to SyncCommand, and implement them -} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt new file mode 100644 index 0000000000..70f5190222 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt @@ -0,0 +1,42 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class OneTimeSyncCommandTest { + @Test + fun `events start builds expected command`() { + assertThat(OneTime.Events.start()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.START, isDownSyncAllowed = true)) + + assertThat(OneTime.Events.start(isDownSyncAllowed = false)) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.START, isDownSyncAllowed = false)) + } + + @Test + fun `events stop builds expected command`() { + assertThat(OneTime.Events.stop()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.STOP, isDownSyncAllowed = true)) + } + + @Test + fun `events restart builds expected command`() { + assertThat(OneTime.Events.restart()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.RESTART, isDownSyncAllowed = true)) + + assertThat(OneTime.Events.restart(isDownSyncAllowed = false)) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.RESTART, isDownSyncAllowed = false)) + } + + @Test + fun `images commands build expected commands`() { + assertThat(OneTime.Images.start()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.START)) + + assertThat(OneTime.Images.stop()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.STOP)) + + assertThat(OneTime.Images.restart()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.RESTART)) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt new file mode 100644 index 0000000000..533d314b25 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt @@ -0,0 +1,56 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ScheduleSyncCommandTest { + @Test + fun `everything commands build expected command`() { + assertThat(ScheduleCommand.Everything.reschedule()) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = false)) + + assertThat(ScheduleCommand.Everything.reschedule(withDelay = true)) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = true)) + + assertThat(ScheduleCommand.Everything.unschedule()) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Everything.rescheduleAfter(withDelay = true, block = block) as ScheduleCommand.EverythingCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isTrue() + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } + + @Test + fun `events commands build expected command`() { + assertThat(ScheduleCommand.Events.reschedule()) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = false)) + + assertThat(ScheduleCommand.Events.reschedule(withDelay = true)) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = true)) + + assertThat(ScheduleCommand.Events.unschedule()) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Events.rescheduleAfter(withDelay = false, block = block) as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isFalse() + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } + + @Test + fun `images commands build expected command`() { + assertThat(ScheduleCommand.Images.reschedule()) + .isEqualTo(ScheduleCommand.ImagesCommand(action = ScheduleCommand.Action.RESCHEDULE)) + + assertThat(ScheduleCommand.Images.unschedule()) + .isEqualTo(ScheduleCommand.ImagesCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Images.rescheduleAfter(block) as ScheduleCommand.ImagesCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt new file mode 100644 index 0000000000..80059ad8ad --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt @@ -0,0 +1,436 @@ +package com.simprints.infra.sync + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker +import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME +import com.simprints.infra.sync.SyncConstants.FILE_UP_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.FIRMWARE_UPDATE_WORK_NAME +import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class SyncOrchestratorCommandExecutionTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var workManager: WorkManager + + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configRepository: ConfigRepository + + @MockK + private lateinit var eventSyncManager: EventSyncManager + + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + + @MockK + private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase + + @MockK + private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase + + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + + private lateinit var orchestrator: SyncOrchestratorImpl + + @Before + fun setup() { + MockKAnnotations.init(this, relaxed = true) + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) + orchestrator = createOrchestrator() + } + + @Test + fun `does not schedule any workers if not logged in`() = runTest { + every { authStore.signedInProjectId } returns "" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() + + verify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } + + @Test + fun `schedules all necessary background workers if logged in`() = runTest { + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() + + verify { + workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(DEVICE_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(FILE_UP_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(EVENT_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(FIRMWARE_UPDATE_WORK_NAME, any(), any()) + } + } + + @Test + fun `schedules images with any connection if not specified`() = runTest { + coEvery { + configRepository + .getProjectConfiguration() + .synchronization.up.simprints.imagesRequireUnmeteredConnection + } returns false + every { authStore.signedInProjectId } returns "projectId" + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + any(), + match { it.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED }, + ) + } + } + + @Test + fun `schedules images with unmetered constraint if requested`() = runTest { + coEvery { + configRepository + .getProjectConfiguration() + .synchronization.up.simprints.imagesRequireUnmeteredConnection + } returns true + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + any(), + match { it.workSpec.constraints.requiredNetworkType == NetworkType.UNMETERED }, + ) + } + } + + @Test + fun `cancels firmware update worker if firmware update should not be scheduled`() = runTest { + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() + + verify { workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) } + } + + @Test + fun `unschedule cancels all necessary background workers`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) + + verify { + workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(DEVICE_SYNC_WORK_NAME) + workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `reschedules event sync worker with correct tags`() = runTest { + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule()).join() + + verify { + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { it.tags.containsAll(setOf("tag1", "tag2")) }, + ) + } + } + + @Test + fun `reschedules event sync worker with correct delay`() = runTest { + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule(withDelay = true)).join() + + verify { + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { it.workSpec.initialDelay > 0 }, + ) + } + } + + @Test + fun `rescheduleAfter for schedule events routes to unschedule and reschedule with delay`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + orchestrator + .executeSchedulingCommand( + ScheduleCommand.Events.rescheduleAfter(withDelay = true) { }, + ).join() + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { + it.workSpec.initialDelay > 0 && + it.tags.containsAll(setOf("tag1", "tag2")) + }, + ) + } + } + + @Test + fun `unschedule events cancels correct workers`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.unschedule()) + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `start one-time event sync uses correct tags`() = runTest { + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + orchestrator.executeOneTime(OneTime.Events.start()).join() + + verify { + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { it.tags.containsAll(setOf("tag1", "tag2")) }, + ) + } + } + + @Test + fun `start one-time event sync uses correct input data`() = runTest { + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + orchestrator.executeOneTime(OneTime.Events.start(isDownSyncAllowed = false)).join() + + verify { + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { + !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) + }, + ) + } + } + + @Test + fun `restart one-time event sync routes to stop and start with expected input param`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + orchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)).join() + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { + it.tags.containsAll(setOf("tag1", "tag2")) && + !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) + }, + ) + } + } + + @Test + fun `stop one-time event sync cancels correct workers`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + orchestrator.executeOneTime(OneTime.Events.stop()) + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `reschedules image worker when requested`() = runTest { + orchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `start one-time image sync re-starts image worker`() = runTest { + orchestrator.executeOneTime(OneTime.Images.start()).join() + + verify { + workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) + workManager.enqueueUniqueWork( + FILE_UP_SYNC_WORK_NAME, + any(), + any(), + ) + } + } + + @Test + fun `stop one-time image sync cancels image worker`() = runTest { + orchestrator.executeOneTime(OneTime.Images.stop()) + + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } + } + + @Test + fun `unschedule images returns completed job and routes to stop logic`() = runTest { + val job = orchestrator.executeSchedulingCommand(ScheduleCommand.Images.unschedule()) + + assertThat(job.isCompleted).isTrue() + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } + } + + @Test + fun `rescheduleAfter runs block before rescheduling images`() = runTest { + val blockStarted = Channel(Channel.UNLIMITED) + val unblock = Channel(Channel.UNLIMITED) + val block: suspend () -> Unit = { + blockStarted.trySend(Unit) + unblock.receive() + } + + val job = orchestrator.executeSchedulingCommand(ScheduleCommand.Images.rescheduleAfter(block)) + + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } + + blockStarted.receive() + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + + unblock.trySend(Unit) + job.join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `reschedules image worker when event sync starts`() = runTest { + val eventStartFlow = MutableSharedFlow>() + every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow + + orchestrator = createOrchestrator() + + eventStartFlow.emit(createWorkInfo(WorkInfo.State.RUNNING)) + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `does not reschedule image worker when event sync is not running`() = runTest { + val eventStartFlow = MutableSharedFlow>() + every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow + + orchestrator = createOrchestrator() + + eventStartFlow.emit(createWorkInfo(WorkInfo.State.CANCELLED)) + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + private fun createOrchestrator() = SyncOrchestratorImpl( + workManager = workManager, + authStore = authStore, + configRepository = configRepository, + eventSyncManager = eventSyncManager, + eventSyncStateProcessor = eventSyncStateProcessor, + observeImageSyncStatus = observeImageSyncStatus, + shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, + cleanupDeprecatedWorkers = cleanupDeprecatedWorkers, + imageSyncTimestampProvider = imageSyncTimestampProvider, + appScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + ioDispatcher = testCoroutineRule.testCoroutineDispatcher, + ) + + private fun createWorkInfo(state: WorkInfo.State) = listOf( + WorkInfo(UUID.randomUUID(), state, emptySet()), + ) +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt index 15fa765193..8c7594725a 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt @@ -1,38 +1,27 @@ package com.simprints.infra.sync -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.truth.Truth.assertThat -import com.google.common.util.concurrent.ListenableFuture import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME -import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME -import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME -import com.simprints.infra.sync.SyncConstants.FILE_UP_SYNC_WORK_NAME -import com.simprints.infra.sync.SyncConstants.FIRMWARE_UPDATE_WORK_NAME -import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_ID_NAME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_SUBJECT_IDS_NAME import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations -import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.count import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -57,6 +46,12 @@ class SyncOrchestratorImplTest { @MockK private lateinit var eventSyncManager: EventSyncManager + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + @MockK private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase @@ -71,110 +66,11 @@ class SyncOrchestratorImplTest { @Before fun setup() { MockKAnnotations.init(this, relaxed = true) + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) syncOrchestrator = createSyncOrchestrator() } - @Test - fun `does not schedules any workers if not logged in`() = runTest { - every { authStore.signedInProjectId } returns "" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify(exactly = 0) { - workManager.enqueueUniquePeriodicWork(any(), any(), any()) - } - } - - @Test - fun `schedules all necessary background workers if logged in`() = runTest { - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(DEVICE_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(FILE_UP_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(EVENT_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(FIRMWARE_UPDATE_WORK_NAME, any(), any()) - } - } - - @Test - fun `schedules images with any connection if not specified`() = runTest { - coEvery { - configRepository - .getProjectConfiguration() - .synchronization.up.simprints.imagesRequireUnmeteredConnection - } returns false - every { authStore.signedInProjectId } returns "projectId" - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - any(), - match { it.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED }, - ) - } - } - - @Test - fun `schedules images with unmetered constraint if requested`() = runTest { - coEvery { - configRepository - .getProjectConfiguration() - .synchronization.up.simprints.imagesRequireUnmeteredConnection - } returns true - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - any(), - match { it.workSpec.constraints.requiredNetworkType == NetworkType.UNMETERED }, - ) - } - } - - @Test - fun `schedules cancel firmware update worker if no support for vero 2`() = runTest { - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) - } - } - - @Test - fun `cancels all necessary background workers`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelBackgroundWork() - - verify { - workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(DEVICE_SYNC_WORK_NAME) - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) - - // Explicitly cancel event sync sub-workers - workManager.cancelAllWorkByTag("syncWorkers") - } - } - @Test fun `schedules device worker when refresh requested`() = runTest { syncOrchestrator.refreshConfiguration() @@ -208,130 +104,6 @@ class SyncOrchestratorImplTest { assertThat(syncOrchestrator.refreshConfiguration().count()).isEqualTo(1) } - @Test - fun `reschedules event sync worker with correct tags`() = runTest { - every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.rescheduleEventSync() - - verify { - workManager.enqueueUniquePeriodicWork( - EVENT_SYNC_WORK_NAME, - any(), - match { it.tags.containsAll(setOf("tag1", "tag2")) }, - ) - } - } - - @Test - fun `reschedules event sync worker with correct delay`() = runTest { - every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.rescheduleEventSync(true) - - verify { - workManager.enqueueUniquePeriodicWork( - EVENT_SYNC_WORK_NAME, - any(), - match { it.workSpec.initialDelay > 0 }, - ) - } - } - - @Test - fun `cancel event sync worker cancels correct worker`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelEventSync() - - verify { - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelAllWorkByTag("syncWorkers") - } - } - - @Test - fun `start event sync worker with correct tags`() = runTest { - every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.startEventSync() - - verify { - workManager.enqueueUniqueWork( - EVENT_SYNC_WORK_NAME_ONE_TIME, - any(), - match { it.tags.containsAll(setOf("tag1", "tag2")) }, - ) - } - } - - @Test - fun `start event sync worker with correct input data`() = runTest { - every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.startEventSync(isDownSyncAllowed = false) - - verify { - workManager.enqueueUniqueWork( - EVENT_SYNC_WORK_NAME_ONE_TIME, - any(), - match { - !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) - }, - ) - } - } - - @Test - fun `stop event sync worker cancels correct worker`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelEventSync() - - verify { - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelAllWorkByTag("syncWorkers") - } - } - - @Test - fun `reschedules image worker when requested`() = runTest { - syncOrchestrator.rescheduleImageUpSync() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - any(), - ) - } - } - - @Test - fun `start image sync re-starts image worker`() = runTest { - syncOrchestrator.startImageSync() - - verify { - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.enqueueUniqueWork( - FILE_UP_SYNC_WORK_NAME, - any(), - any(), - ) - } - } - - @Test - fun `stop image sync cancels image worker`() = runTest { - syncOrchestrator.stopImageSync() - - verify { - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - } - } - @Test fun `schedules record upload`() = runTest { syncOrchestrator.uploadEnrolmentRecords(INSTRUCTION_ID, listOf(SUBJECT_ID)) @@ -368,55 +140,20 @@ class SyncOrchestratorImplTest { verify { cleanupDeprecatedWorkers.invoke() } } - @Test - fun `stops image worker when event sync starts`() = runTest { - val eventStartFlow = MutableSharedFlow>() - every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - every { - workManager.getWorkInfosForUniqueWork(FILE_UP_SYNC_WORK_NAME) - } returns mockFuture(createWorkInfo(WorkInfo.State.RUNNING)) - - // Recreating orchestrator with new mocks since the subscription is done in init - syncOrchestrator = createSyncOrchestrator() - eventStartFlow.emit(createWorkInfo(WorkInfo.State.RUNNING)) - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - any(), - ) - } - } - - @Test - fun `does not stop image worker when event sync is not running`() = runTest { - val eventStartFlow = MutableSharedFlow>() - every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - - // Recreating orchestrator with new mocks since the subscription is done in init - syncOrchestrator = createSyncOrchestrator() - eventStartFlow.emit(createWorkInfo(WorkInfo.State.CANCELLED)) - - verify(exactly = 0) { - workManager.getWorkInfosForUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.cancelWorkById(any()) - } - } - private fun createSyncOrchestrator() = SyncOrchestratorImpl( workManager, authStore, configRepository, eventSyncManager, + eventSyncStateProcessor, + observeImageSyncStatus, shouldScheduleFirmwareUpdate, cleanupDeprecatedWorkers, imageSyncTimestampProvider, CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + testCoroutineRule.testCoroutineDispatcher, ) - private fun mockFuture(workInfo: List) = mockk>> { every { get() } returns workInfo } - private fun createWorkInfo(state: WorkInfo.State) = listOf( WorkInfo(UUID.randomUUID(), state, emptySet()), ) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt new file mode 100644 index 0000000000..7afc9c8f9c --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt @@ -0,0 +1,157 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncOrchestratorObserveSyncStateTest { + @MockK + private lateinit var workManager: androidx.work.WorkManager + + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configRepository: ConfigRepository + + @MockK + private lateinit var eventSyncManager: EventSyncManager + + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + + @MockK + private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase + + @MockK + private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase + + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + + private val eventSyncStatusFlow = MutableSharedFlow() + private val imageSyncStatusFlow = MutableSharedFlow() + + @Before + fun setup() { + MockKAnnotations.init(this, relaxed = true) + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) + every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow + every { observeImageSyncStatus.invoke() } returns imageSyncStatusFlow + } + + @Test + fun `returns default SyncStatus before upstream flows emit`() = runTest { + val expected = SyncStatus( + eventSyncState = EventSyncState( + syncId = "", + progress = null, + total = null, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ), + imageSyncStatus = ImageSyncStatus( + isSyncing = false, + progress = null, + lastUpdateTimeMillis = -1L, + ), + ) + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val resultFlow = orchestrator.observeSyncState() + + assertThat(resultFlow.value).isEqualTo(expected) + } + + @Test + fun `combines latest event and image states into SyncStatus`() = runTest { + val event = EventSyncState( + syncId = "sync-1", + progress = 1, + total = 10, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ) + val image = ImageSyncStatus( + isSyncing = true, + progress = 2 to 5, + lastUpdateTimeMillis = 123L, + ) + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val resultFlow = orchestrator.observeSyncState() + + runCurrent() // ensure upstream flows are collected before emitting + eventSyncStatusFlow.emit(event) + imageSyncStatusFlow.emit(image) + runCurrent() + + assertThat(resultFlow.value).isEqualTo(SyncStatus(event, image)) + } + + @Test + fun `returns the same shared StateFlow across invocations`() = runTest { + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val flow1 = orchestrator.observeSyncState() + val flow2 = orchestrator.observeSyncState() + + assertThat(flow1).isSameInstanceAs(flow2) + verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } + verify(exactly = 1) { observeImageSyncStatus.invoke() } + } + + private fun createOrchestrator( + appScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + ) = SyncOrchestratorImpl( + workManager = workManager, + authStore = authStore, + configRepository = configRepository, + eventSyncManager = eventSyncManager, + eventSyncStateProcessor = eventSyncStateProcessor, + observeImageSyncStatus = observeImageSyncStatus, + shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, + cleanupDeprecatedWorkers = cleanupDeprecatedWorkers, + imageSyncTimestampProvider = imageSyncTimestampProvider, + appScope = appScope, + ioDispatcher = dispatcher, + ) +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index 2159a9cfd4..9fe80e8541 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -1,10 +1,14 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.authlogic.AuthManager +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import io.mockk.MockKAnnotations import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -21,6 +25,7 @@ class LogoutUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = LogoutUseCase( syncOrchestrator = syncOrchestrator, @@ -32,8 +37,8 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } coVerify { - syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt index 067a2c0f64..b3893e661f 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt @@ -1,16 +1,24 @@ package com.simprints.infra.sync.config.usecase +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.simprintsUpSyncConfigurationConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration import io.mockk.MockKAnnotations -import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class RescheduleWorkersIfConfigChangedUseCaseTest { @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -20,6 +28,7 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = RescheduleWorkersIfConfigChangedUseCase(syncOrchestrator) } @@ -47,32 +56,45 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { ), ) - coVerify(exactly = 0) { syncOrchestrator.rescheduleImageUpSync() } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } } @Test fun `should reschedule image upload when unmetered connection flag changes`() = runTest { - useCase( - projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - up = synchronizationConfiguration.up.copy( - simprints = simprintsUpSyncConfigurationConfiguration.copy( - imagesRequireUnmeteredConnection = false, + val syncCommandJob = Job() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns syncCommandJob + + val useCaseJob = async { + useCase( + projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + imagesRequireUnmeteredConnection = false, + ), ), ), ), - ), - projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - up = synchronizationConfiguration.up.copy( - simprints = simprintsUpSyncConfigurationConfiguration.copy( - imagesRequireUnmeteredConnection = true, + projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + imagesRequireUnmeteredConnection = true, + ), ), ), ), - ), - ) + ) + } + + runCurrent() + assertThat(useCaseJob.isCompleted).isFalse() + + syncCommandJob.complete() + runCurrent() + useCaseJob.await() - coVerify { syncOrchestrator.rescheduleImageUpSync() } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()) } + assertThat(useCaseJob.isCompleted).isTrue() } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index 3390fa1b06..13cf221500 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -1,19 +1,25 @@ package com.simprints.infra.sync.config.usecase +import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.Frequency import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ResetLocalRecordsIfConfigChangedUseCaseTest { @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -25,15 +31,17 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository private lateinit var useCase: ResetLocalRecordsIfConfigChangedUseCase + private val scheduleCommandSlot = slot() @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { syncOrchestrator.executeSchedulingCommand(capture(scheduleCommandSlot)) } returns noopJob() useCase = ResetLocalRecordsIfConfigChangedUseCase( - syncOrchestrator = syncOrchestrator, eventSyncManager = eventSyncManager, enrolmentRecordRepository = enrolmentRecordRepository, + syncOrchestrator = syncOrchestrator, ) } @@ -60,9 +68,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -91,9 +98,15 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isFalse() + assertThat(command.blockWhileUnscheduled).isNotNull() + + command.blockWhileUnscheduled?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -122,9 +135,14 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() + + command.blockWhileUnscheduled?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -153,9 +171,14 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() + + command.blockWhileUnscheduled?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -184,9 +207,14 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() + + command.blockWhileUnscheduled?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -215,9 +243,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -246,9 +273,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -277,9 +303,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -308,11 +333,12 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } } + + private fun noopJob(): Job = Job().apply { complete() } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt new file mode 100644 index 0000000000..804284fcd7 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.sync.extensions + +import com.google.common.truth.Truth +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class JobExtTest { + @Test + fun `await completes when job completes normally`() = runTest { + val job = Job() + val awaitDeferred = backgroundScope.async { job.await() } + + runCurrent() + Truth.assertThat(awaitDeferred.isCompleted).isFalse() + + job.complete() + runCurrent() + awaitDeferred.await() + Truth.assertThat(awaitDeferred.isCompleted).isTrue() + } + + @Test + fun `await rethrows failure from job`() = runTest { + val job = Job() + val expected = IllegalStateException("ExceptionMessage") + + launch { job.completeExceptionally(expected) } + + val thrown = try { + job.await() + null + } catch (throwable: Throwable) { + throwable + } + Truth.assertThat(thrown).isNotNull() + Truth.assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(thrown!!.message).isEqualTo("ExceptionMessage") + Truth.assertThat(thrown === expected || thrown.cause === expected).isTrue() + } + + @Test + fun `await throws CancellationException when job is cancelled`() = runTest { + val job = Job() + launch { job.cancel() } + + val thrown = try { + job.await() + null + } catch (throwable: Throwable) { + throwable + } + Truth.assertThat(thrown).isInstanceOf(CancellationException::class.java) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt deleted file mode 100644 index 890d85c58d..0000000000 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.simprints.infra.sync.usecase - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.common.truth.Truth.assertThat -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class SyncUseCaseTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @MockK - private lateinit var eventSyncStateProcessor: EventSyncStateProcessor - - @MockK - private lateinit var imageSync: ObserveImageSyncStatusUseCase - - private val eventSyncStatusFlow = MutableSharedFlow() - private val imageSyncStatusFlow = MutableSharedFlow() - - @Before - fun setup() { - MockKAnnotations.init(this, relaxed = true) - every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow - every { imageSync.invoke() } returns imageSyncStatusFlow - } - - @Test - fun `returns default SyncStatus before upstream flows emit`() = runTest { - val expected = SyncStatus( - eventSyncState = EventSyncState( - syncId = "", - progress = null, - total = null, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ), - imageSyncStatus = ImageSyncStatus( - isSyncing = false, - progress = null, - lastUpdateTimeMillis = -1L, - ), - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `combines latest event and image states into SyncStatus`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val expected = SyncStatus(event, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() // ensure upstream flows are collected before emitting - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when event emits even if image never emits`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() - val expected = with(resultFlow.value) { - copy(eventSyncState = event) - } - eventSyncStatusFlow.emit(event) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when image emits even if event never emits`() = runTest { - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() - val expected = with(resultFlow.value) { - copy(imageSyncStatus = image) - } - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when event sync state changes`() = runTest { - val event1 = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val event2 = event1.copy( - progress = 5, - ) - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val expected1 = SyncStatus(event1, image) - val expected2 = SyncStatus(event2, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() - eventSyncStatusFlow.emit(event1) - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected1) - - eventSyncStatusFlow.emit(event2) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected2) - } - - @Test - fun `updates SyncStatus when image sync status changes`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image1 = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val image2 = image1.copy( - isSyncing = false, - ) - val expected1 = SyncStatus(event, image1) - val expected2 = SyncStatus(event, image2) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image1) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected1) - - imageSyncStatusFlow.emit(image2) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected2) - } - - @Test - fun `returns the same shared StateFlow across invocations`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image1 = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val image2 = image1.copy( - isSyncing = false, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - - val resultFlow1 = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - runCurrent() - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image1) - runCurrent() - - imageSyncStatusFlow.emit(image2) - runCurrent() - - val resultFlow2 = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) - - assertThat(resultFlow1).isSameInstanceAs(resultFlow2) - verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } - verify(exactly = 1) { imageSync() } - } -}