From 10b79f480b5fedb3bed70fea5b0c74d5feb222ca Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 28 May 2024 10:42:15 +0300 Subject: [PATCH 1/5] SM-219 Add blank id validation module --- .github/workflows/pr-checks.yml | 1 + feature/validate-subject-pool/.gitignore | 1 + feature/validate-subject-pool/build.gradle.kts | 11 +++++++++++ .../src/main/AndroidManifest.xml | 2 ++ settings.gradle.kts | 1 + 5 files changed, 16 insertions(+) create mode 100644 feature/validate-subject-pool/.gitignore create mode 100644 feature/validate-subject-pool/build.gradle.kts create mode 100644 feature/validate-subject-pool/src/main/AndroidManifest.xml diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c5b2b689d2..519b5ce60b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -76,6 +76,7 @@ jobs: feature:setup feature:enrol-last-biometric feature:matcher + feature:validate-subject-pool reportsId: feature2 feature-dashboard-unit-tests: diff --git a/feature/validate-subject-pool/.gitignore b/feature/validate-subject-pool/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/feature/validate-subject-pool/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/validate-subject-pool/build.gradle.kts b/feature/validate-subject-pool/build.gradle.kts new file mode 100644 index 0000000000..df9c3780f1 --- /dev/null +++ b/feature/validate-subject-pool/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("simprints.feature") + id("kotlin-parcelize") +} + +android { + namespace = "com.simprints.feature.validatepool" +} + +dependencies { +} diff --git a/feature/validate-subject-pool/src/main/AndroidManifest.xml b/feature/validate-subject-pool/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8072ee00db --- /dev/null +++ b/feature/validate-subject-pool/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 19ffd7b56b..66a97ae9c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -110,6 +110,7 @@ include( ":feature:consent", ":feature:setup", ":feature:matcher", + ":feature:validate-subject-pool" ) // Infra modules From 56ad5609a06d6bd120e2a2ebf5675b613cd21e40 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 28 May 2024 11:32:16 +0300 Subject: [PATCH 2/5] MS-219 Add boilerplate for ID pool validation step in orchestration --- feature/orchestrator/build.gradle.kts | 1 + .../orchestrator/OrchestratorFragment.kt | 2 + .../orchestrator/OrchestratorViewModel.kt | 2 + .../feature/orchestrator/steps/Step.kt | 2 + .../feature/orchestrator/steps/StepId.kt | 1 + .../usecases/steps/BuildStepsUseCase.kt | 38 ++++++++++++------- .../res/navigation/graph_orchestration.xml | 6 +++ .../validate-subject-pool/build.gradle.kts | 2 + .../ValidateSubjectPoolContract.kt | 13 +++++++ .../validatepool/ValidateSubjectPoolResult.kt | 9 +++++ .../screen/ValidateSubjectPoolFragment.kt | 24 ++++++++++++ .../screen/ValidateSubjectPoolViewModel.kt | 10 +++++ .../layout/fragment_validate_subject_pool.xml | 7 ++++ .../graph_validate_subject_pool.xml | 20 ++++++++++ 14 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolContract.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolResult.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt create mode 100644 feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml create mode 100644 feature/validate-subject-pool/src/main/res/navigation/graph_validate_subject_pool.xml diff --git a/feature/orchestrator/build.gradle.kts b/feature/orchestrator/build.gradle.kts index d0d00a7817..8143a2ff92 100644 --- a/feature/orchestrator/build.gradle.kts +++ b/feature/orchestrator/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":feature:select-subject")) implementation(project(":feature:exit-form")) implementation(project(":feature:matcher")) + implementation(project(":feature:validate-subject-pool")) implementation(project(":face:capture")) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorFragment.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorFragment.kt index fe2dafb098..f993bc145f 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorFragment.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorFragment.kt @@ -26,6 +26,7 @@ import com.simprints.feature.logincheck.LoginCheckViewModel import com.simprints.feature.orchestrator.cache.OrchestratorCache import com.simprints.feature.selectsubject.SelectSubjectContract import com.simprints.feature.setup.SetupContract +import com.simprints.feature.validatepool.ValidateSubjectPoolContract import com.simprints.fingerprint.capture.FingerprintCaptureContract import com.simprints.infra.orchestration.data.responses.AppConfirmationResponse import com.simprints.infra.orchestration.data.responses.AppEnrolResponse @@ -114,6 +115,7 @@ internal class OrchestratorFragment : Fragment(R.layout.fragment_orchestrator) { handleResult(FaceCaptureContract.DESTINATION, orchestratorVm::handleResult) handleResult(FingerprintCaptureContract.DESTINATION, orchestratorVm::handleResult) handleResult(FetchSubjectContract.DESTINATION, orchestratorVm::handleResult) + handleResult(ValidateSubjectPoolContract.DESTINATION, orchestratorVm::handleResult) } private fun handleResult(destination: Int, block: (T) -> Unit) { diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt index a26c48924d..c8f97a0307 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt @@ -79,6 +79,8 @@ internal class OrchestratorViewModel @Inject constructor( fun handleResult(result: Serializable) = viewModelScope.launch { Simber.d(result.toString()) + // TODO handle subject pool validation result + val errorResponse = mapRefusalOrErrorResult(result) if (errorResponse != null) { // Shortcut the flow execution if any refusal or error result is found diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt index 3b0fa75b3b..e16465a9c1 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt @@ -14,6 +14,7 @@ import com.simprints.feature.fetchsubject.FetchSubjectResult import com.simprints.feature.login.LoginResult import com.simprints.feature.selectsubject.SelectSubjectResult import com.simprints.feature.setup.SetupResult +import com.simprints.feature.validatepool.ValidateSubjectPoolResult import com.simprints.fingerprint.capture.FingerprintCaptureResult import com.simprints.fingerprint.connect.FingerprintConnectResult import com.simprints.matcher.FaceMatchResult @@ -54,6 +55,7 @@ import java.io.Serializable JsonSubTypes.Type(value = SelectSubjectResult::class, name = "SelectSubjectResult"), JsonSubTypes.Type(value = AlertResult::class, name = "AlertResult"), JsonSubTypes.Type(value = ExitFormResult::class, name = "ExitFormResult"), + JsonSubTypes.Type(value = ValidateSubjectPoolResult::class, name = "ValidateSubjectPoolResult"), ) abstract class SerializableMixin : Serializable diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/StepId.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/StepId.kt index 9f4dab4b88..835cd3b969 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/StepId.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/StepId.kt @@ -15,6 +15,7 @@ internal object StepId { const val CONSENT = STEP_BASE_CORE + 3 const val ENROL_LAST_BIOMETRIC = STEP_BASE_CORE + 4 const val CONFIRM_IDENTITY = STEP_BASE_CORE + 5 + const val VALIDATE_ID_POOL = STEP_BASE_CORE + 6 // Face step ids private const val STEP_BASE_FINGERPRINT = 300 diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt index 447a1ca7e1..1d937fd3ec 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt @@ -16,6 +16,7 @@ import com.simprints.feature.orchestrator.steps.StepId import com.simprints.feature.orchestrator.usecases.MapStepsForLastBiometricEnrolUseCase import com.simprints.feature.selectsubject.SelectSubjectContract import com.simprints.feature.setup.SetupContract +import com.simprints.feature.validatepool.ValidateSubjectPoolContract import com.simprints.fingerprint.capture.FingerprintCaptureContract import com.simprints.infra.config.store.models.GeneralConfiguration.Modality import com.simprints.infra.config.store.models.ProjectConfiguration @@ -51,20 +52,25 @@ internal class BuildStepsUseCase @Inject constructor( } else emptyList(), ) - is ActionRequest.IdentifyActionRequest -> listOf( - buildSetupStep(), - buildConsentStep(ConsentType.IDENTIFY), - buildModalityCaptureSteps( - projectConfiguration, - FlowType.IDENTIFY, - ), - buildModalityMatcherSteps( - projectConfiguration, - FlowType.IDENTIFY, - buildMatcherSubjectQuery(projectConfiguration, action), - BiometricDataSource.fromString(action.biometricDataSource), + is ActionRequest.IdentifyActionRequest -> { + val subjectQuery = buildMatcherSubjectQuery(projectConfiguration, action) + + listOf( + buildSetupStep(), + buildValidateIdPoolStep(subjectQuery), + buildConsentStep(ConsentType.IDENTIFY), + buildModalityCaptureSteps( + projectConfiguration, + FlowType.IDENTIFY, + ), + buildModalityMatcherSteps( + projectConfiguration, + FlowType.IDENTIFY, + subjectQuery, + BiometricDataSource.fromString(action.biometricDataSource), + ) ) - ) + } is ActionRequest.VerifyActionRequest -> listOf( buildSetupStep(), @@ -112,6 +118,12 @@ internal class BuildStepsUseCase @Inject constructor( payload = ConsentContract.getArgs(consentType), )) + private fun buildValidateIdPoolStep(subjectQuery: SubjectQuery) = listOf(Step( + id = StepId.VALIDATE_ID_POOL, + navigationActionId = R.id.action_orchestratorFragment_to_validateSubjectPool, + destinationId = ValidateSubjectPoolContract.DESTINATION, + payload = ValidateSubjectPoolContract.getArgs(subjectQuery), + )) private fun buildModalityCaptureSteps( projectConfiguration: ProjectConfiguration, diff --git a/feature/orchestrator/src/main/res/navigation/graph_orchestration.xml b/feature/orchestrator/src/main/res/navigation/graph_orchestration.xml index f9f0cb4cc4..f98da93764 100644 --- a/feature/orchestrator/src/main/res/navigation/graph_orchestration.xml +++ b/feature/orchestrator/src/main/res/navigation/graph_orchestration.xml @@ -69,4 +69,10 @@ + + + diff --git a/feature/validate-subject-pool/build.gradle.kts b/feature/validate-subject-pool/build.gradle.kts index df9c3780f1..d958cfb8e5 100644 --- a/feature/validate-subject-pool/build.gradle.kts +++ b/feature/validate-subject-pool/build.gradle.kts @@ -8,4 +8,6 @@ android { } dependencies { + + implementation(project(":infra:enrolment-records-store")) } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolContract.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolContract.kt new file mode 100644 index 0000000000..132458d793 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolContract.kt @@ -0,0 +1,13 @@ +package com.simprints.feature.validatepool + +import android.os.Bundle +import com.simprints.feature.validatepool.screen.ValidateSubjectPoolFragmentArgs +import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery + +object ValidateSubjectPoolContract { + + fun getArgs(subjectQuery: SubjectQuery): Bundle = + ValidateSubjectPoolFragmentArgs(subjectQuery).toBundle() + + val DESTINATION = R.id.validateSubjectPoolFragment +} diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolResult.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolResult.kt new file mode 100644 index 0000000000..1fdaf151d8 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/ValidateSubjectPoolResult.kt @@ -0,0 +1,9 @@ +package com.simprints.feature.validatepool + +import androidx.annotation.Keep +import java.io.Serializable + +@Keep +data class ValidateSubjectPoolResult( + val isValid: Boolean, +) : Serializable diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt new file mode 100644 index 0000000000..34e684ad6a --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt @@ -0,0 +1,24 @@ +package com.simprints.feature.validatepool.screen + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import com.simprints.feature.validatepool.R +import com.simprints.feature.validatepool.databinding.FragmentValidateSubjectPoolBinding +import com.simprints.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class ValidateSubjectPoolFragment : Fragment(R.layout.fragment_validate_subject_pool) { + + private val viewModel: ValidateSubjectPoolViewModel by viewModels() + private val binding by viewBinding(FragmentValidateSubjectPoolBinding::bind) + private val args: ValidateSubjectPoolFragmentArgs by navArgs() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } + +} diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt new file mode 100644 index 0000000000..a896193d7b --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -0,0 +1,10 @@ +package com.simprints.feature.validatepool.screen + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class ValidateSubjectPoolViewModel @Inject constructor( + +) : ViewModel() diff --git a/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml b/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml new file mode 100644 index 0000000000..ef6f3cfc98 --- /dev/null +++ b/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml @@ -0,0 +1,7 @@ + + + + diff --git a/feature/validate-subject-pool/src/main/res/navigation/graph_validate_subject_pool.xml b/feature/validate-subject-pool/src/main/res/navigation/graph_validate_subject_pool.xml new file mode 100644 index 0000000000..d3f23dbe36 --- /dev/null +++ b/feature/validate-subject-pool/src/main/res/navigation/graph_validate_subject_pool.xml @@ -0,0 +1,20 @@ + + + + + + + + + + From f665ce7ed810098858e4e13139f2f95ebfad264f Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 29 May 2024 10:57:13 +0300 Subject: [PATCH 3/5] MS-219 Implement pool validation logic and UI states --- .../orchestrator/OrchestratorViewModel.kt | 1 - .../MapRefusalOrErrorResultUseCase.kt | 12 +- .../orchestrator/OrchestratorViewModelTest.kt | 12 +- .../MapRefusalOrErrorResultUseCaseTest.kt | 28 ++- .../validate-subject-pool/build.gradle.kts | 3 + .../screen/ValidateSubjectPoolFragment.kt | 71 ++++++++ .../screen/ValidateSubjectPoolState.kt | 16 ++ .../screen/ValidateSubjectPoolViewModel.kt | 34 +++- .../validatepool/usecase/HasRecordsUseCase.kt | 12 ++ .../usecase/IsModuleIdNotSyncedUseCase.kt | 14 ++ .../usecase/ShouldSuggestSyncUseCase.kt | 22 +++ .../layout/fragment_validate_subject_pool.xml | 101 ++++++++++- .../ValidateSubjectPoolViewModelTest.kt | 161 ++++++++++++++++++ .../usecase/HasRecordsUseCaseTest.kt | 39 +++++ .../usecase/IsModuleIdNotSyncedUseCaseTest.kt | 44 +++++ .../usecase/ShouldSuggestSyncUseCaseTest.kt | 59 +++++++ .../resources/src/main/res/values/strings.xml | 11 ++ 17 files changed, 623 insertions(+), 17 deletions(-) create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolState.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCase.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCase.kt create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt create mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt create mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCaseTest.kt create mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCaseTest.kt create mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt index c8f97a0307..448d4d6df9 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt @@ -79,7 +79,6 @@ internal class OrchestratorViewModel @Inject constructor( fun handleResult(result: Serializable) = viewModelScope.launch { Simber.d(result.toString()) - // TODO handle subject pool validation result val errorResponse = mapRefusalOrErrorResult(result) if (errorResponse != null) { diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt index 58bbdce3a0..ededf2d62b 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt @@ -5,16 +5,21 @@ import com.simprints.feature.alert.AlertResult import com.simprints.feature.exitform.ExitFormResult import com.simprints.feature.fetchsubject.FetchSubjectResult import com.simprints.feature.setup.SetupResult +import com.simprints.feature.validatepool.ValidateSubjectPoolResult import com.simprints.fingerprint.connect.FingerprintConnectResult +import com.simprints.infra.events.SessionEventRepository import com.simprints.infra.orchestration.data.responses.AppErrorResponse +import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse import com.simprints.infra.orchestration.data.responses.AppRefusalResponse import com.simprints.infra.orchestration.data.responses.AppResponse import java.io.Serializable import javax.inject.Inject -internal class MapRefusalOrErrorResultUseCase @Inject constructor() { +internal class MapRefusalOrErrorResultUseCase @Inject constructor( + private val eventRepository: SessionEventRepository, +) { - operator fun invoke(result: Serializable): AppResponse? = when (result) { + suspend operator fun invoke(result: Serializable): AppResponse? = when (result) { is ExitFormResult -> AppRefusalResponse.fromResult(result) is FetchSubjectResult -> result.takeUnless { it.found }?.let { AppErrorResponse( @@ -31,6 +36,9 @@ internal class MapRefusalOrErrorResultUseCase @Inject constructor() { is AlertResult -> AppErrorResponse(result.appErrorReason ?: AppErrorReason.UNEXPECTED_ERROR) + is ValidateSubjectPoolResult -> result.takeUnless { it.isValid } + ?.let { AppIdentifyResponse(emptyList(), eventRepository.getCurrentSessionScope().id) } + else -> null } } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt index 19e6f94154..efc6ae5161 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt @@ -124,7 +124,7 @@ internal class OrchestratorViewModelTest { createMockStep(StepId.SETUP), createMockStep(StepId.CONSENT), ) - every { mapRefusalOrErrorResult(any()) } returns null + coEvery { mapRefusalOrErrorResult(any()) } returns null every { shouldCreatePerson(any(), any(), any()) } returns false val stepsObserver = viewModel.currentStep.test() @@ -139,7 +139,7 @@ internal class OrchestratorViewModelTest { @Test fun `Creates person if required after step result`() = runTest { every { stepsBuilder.build(any(), any()) } returns emptyList() - every { mapRefusalOrErrorResult(any()) } returns null + coEvery { mapRefusalOrErrorResult(any()) } returns null every { shouldCreatePerson(any(), any(), any()) } returns true coJustRun { createPersonEvent(any()) } @@ -155,7 +155,7 @@ internal class OrchestratorViewModelTest { createMockStep(StepId.SETUP), createMockStep(StepId.CONSENT), ) - every { mapRefusalOrErrorResult(any()) } returns null + coEvery { mapRefusalOrErrorResult(any()) } returns null every { shouldCreatePerson(any(), any(), any()) } returns false coEvery { appResponseBuilder(any(), any(), any()) } returns mockk() coJustRun { dailyActivityUseCase(any()) } @@ -174,7 +174,7 @@ internal class OrchestratorViewModelTest { createMockStep(StepId.SETUP), createMockStep(StepId.CONSENT), ) - every { mapRefusalOrErrorResult(any()) } returns AppErrorResponse(AppErrorReason.UNEXPECTED_ERROR) + coEvery { mapRefusalOrErrorResult(any()) } returns AppErrorResponse(AppErrorReason.UNEXPECTED_ERROR) viewModel.handleAction(mockk()) viewModel.handleResult(SetupResult(true)) @@ -191,7 +191,7 @@ internal class OrchestratorViewModelTest { SubjectQuery(), BiometricDataSource.SIMPRINTS)), ) - every { mapRefusalOrErrorResult(any()) } returns null + coEvery { mapRefusalOrErrorResult(any()) } returns null every { shouldCreatePerson(any(), any(), any()) } returns false viewModel.handleAction(mockk()) @@ -211,7 +211,7 @@ internal class OrchestratorViewModelTest { SubjectQuery(), BiometricDataSource.SIMPRINTS)), ) - every { mapRefusalOrErrorResult(any()) } returns null + coEvery { mapRefusalOrErrorResult(any()) } returns null every { shouldCreatePerson(any(), any(), any()) } returns false viewModel.handleAction(mockk()) diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCaseTest.kt index 2ac48baf65..13558b13ea 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCaseTest.kt @@ -6,24 +6,38 @@ import com.simprints.feature.alert.AlertResult import com.simprints.feature.exitform.ExitFormResult import com.simprints.feature.fetchsubject.FetchSubjectResult import com.simprints.feature.setup.SetupResult +import com.simprints.feature.validatepool.ValidateSubjectPoolResult import com.simprints.fingerprint.connect.FingerprintConnectResult +import com.simprints.infra.events.SessionEventRepository import com.simprints.infra.orchestration.data.responses.AppErrorResponse +import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse import com.simprints.infra.orchestration.data.responses.AppRefusalResponse +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK import io.mockk.mockk +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test class MapRefusalOrErrorResultUseCaseTest { + @MockK + private lateinit var eventRepository: SessionEventRepository + private lateinit var useCase: MapRefusalOrErrorResultUseCase @Before fun setUp() { - useCase = MapRefusalOrErrorResultUseCase() + MockKAnnotations.init(this) + + coEvery { eventRepository.getCurrentSessionScope().id } returns "sessionId" + + useCase = MapRefusalOrErrorResultUseCase(eventRepository) } @Test - fun `Maps terminal step results to appropriate response`() { + fun `Maps terminal step results to appropriate response`() = runTest { mapOf( ExitFormResult(true) to AppRefusalResponse::class.java, FetchSubjectResult(found = false) to AppErrorResponse::class.java, @@ -36,7 +50,13 @@ class MapRefusalOrErrorResultUseCaseTest { } @Test - fun `Maps successful step results to null`() { + fun `Maps id pool validation results`() = runTest { + assertThat(useCase(ValidateSubjectPoolResult(isValid = true))).isNull() + assertThat(useCase(ValidateSubjectPoolResult(isValid = false))).isInstanceOf(AppIdentifyResponse::class.java) + } + + @Test + fun `Maps successful step results to null`() = runTest { listOf( FetchSubjectResult(found = true), SetupResult(isSuccess = true), @@ -45,7 +65,7 @@ class MapRefusalOrErrorResultUseCaseTest { } @Test - fun `Maps non-result serializable to null`() { + fun `Maps non-result serializable to null`() = runTest { assertThat(useCase(mockk())).isNull() } } diff --git a/feature/validate-subject-pool/build.gradle.kts b/feature/validate-subject-pool/build.gradle.kts index d958cfb8e5..f3e5800c8a 100644 --- a/feature/validate-subject-pool/build.gradle.kts +++ b/feature/validate-subject-pool/build.gradle.kts @@ -10,4 +10,7 @@ android { dependencies { implementation(project(":infra:enrolment-records-store")) + implementation(project(":infra:config-store")) + implementation(project(":infra:event-sync")) + implementation(project(":infra:events")) } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt index 34e684ad6a..e76a6d3d5c 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt @@ -2,13 +2,20 @@ package com.simprints.feature.validatepool.screen import android.os.Bundle import android.view.View +import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.validatepool.R +import com.simprints.feature.validatepool.ValidateSubjectPoolResult import com.simprints.feature.validatepool.databinding.FragmentValidateSubjectPoolBinding +import com.simprints.infra.uibase.navigation.finishWithResult import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import com.simprints.infra.resources.R as IDR @AndroidEntryPoint internal class ValidateSubjectPoolFragment : Fragment(R.layout.fragment_validate_subject_pool) { @@ -19,6 +26,70 @@ internal class ValidateSubjectPoolFragment : Fragment(R.layout.fragment_validate override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + viewModel.state.observe(viewLifecycleOwner, LiveDataEventWithContentObserver(::renderState)) + + binding.validationActionsClose.setOnClickListener { finishWithResult(false) } + binding.validationActionsSync.setOnClickListener { + // TODO + } + + viewModel.checkIdentificationPool(args.subjectQuery) } + private fun renderState(state: ValidateSubjectPoolState) = when (state) { + ValidateSubjectPoolState.Success -> finishWithResult(true) + ValidateSubjectPoolState.Validating -> setViews( + titleRes = IDR.string.id_pool_validation_default_message, + ) + + ValidateSubjectPoolState.UserMismatch -> setViews( + titleRes = IDR.string.id_pool_validation_user_mismatch_message, + showTitle = true, + showCloseAction = true, + ) + + ValidateSubjectPoolState.ModuleMismatch -> setViews( + titleRes = IDR.string.id_pool_validation_module_mismatch_message, + showTitle = true, + showCloseAction = true, + ) + + ValidateSubjectPoolState.RequiresSync -> setViews( + titleRes = IDR.string.id_pool_validation_sync_required_message, + showTitle = true, + showCloseAction = true, + showSyncAction = true, + ) + + ValidateSubjectPoolState.SyncInProgress -> setViews( + titleRes = IDR.string.id_pool_validation_syncing_message, + showTitle = true, + showProgress = true, + ) + + ValidateSubjectPoolState.PoolEmpty -> setViews( + titleRes = IDR.string.id_pool_validation_pool_empty_message, + showTitle = true, + showCloseAction = true, + ) + } + + private fun setViews( + @StringRes titleRes: Int, + showTitle: Boolean = false, + showProgress: Boolean = false, + showCloseAction: Boolean = false, + showSyncAction: Boolean = false, + ) = with(binding) { + validationIssueMessage.setText(titleRes) + validationIssueTitle.isVisible = showTitle + validationLoadingIndicator.isVisible = showProgress + validationActions.isVisible = showCloseAction || showSyncAction + validationActionsSync.isVisible = showSyncAction + } + + private fun finishWithResult(isValid: Boolean) { + findNavController().finishWithResult(this, ValidateSubjectPoolResult(isValid)) + } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolState.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolState.kt new file mode 100644 index 0000000000..9698cc50fd --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolState.kt @@ -0,0 +1,16 @@ +package com.simprints.feature.validatepool.screen + +internal sealed class ValidateSubjectPoolState { + + data object Validating : ValidateSubjectPoolState() + data object Success : ValidateSubjectPoolState() + + data object UserMismatch : ValidateSubjectPoolState() + data object ModuleMismatch : ValidateSubjectPoolState() + + data object RequiresSync : ValidateSubjectPoolState() + data object SyncInProgress : ValidateSubjectPoolState() + + data object PoolEmpty : ValidateSubjectPoolState() + +} diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt index a896193d7b..4d6492565e 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -1,10 +1,42 @@ package com.simprints.feature.validatepool.screen +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.core.livedata.LiveDataEventWithContent +import com.simprints.core.livedata.send +import com.simprints.feature.validatepool.usecase.HasRecordsUseCase +import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase +import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase +import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class ValidateSubjectPoolViewModel @Inject constructor( + private val hasRecords: HasRecordsUseCase, + private val isModuleIdNotSynced: IsModuleIdNotSyncedUseCase, + private val shouldSuggestSync: ShouldSuggestSyncUseCase, +) : ViewModel() { -) : ViewModel() + val state: LiveData> + get() = _state + private var _state = MutableLiveData>() + + fun checkIdentificationPool(subjectQuery: SubjectQuery) = viewModelScope.launch { + _state.send(ValidateSubjectPoolState.Validating) + + val validationState = when { + hasRecords(subjectQuery) -> ValidateSubjectPoolState.Success + subjectQuery.attendantId != null && hasRecords(SubjectQuery()) -> ValidateSubjectPoolState.UserMismatch + subjectQuery.moduleId?.let { isModuleIdNotSynced(it) } == true -> ValidateSubjectPoolState.ModuleMismatch + shouldSuggestSync() -> ValidateSubjectPoolState.RequiresSync + else -> ValidateSubjectPoolState.PoolEmpty + } + + _state.send(validationState) + } + +} diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCase.kt new file mode 100644 index 0000000000..daf28da039 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCase.kt @@ -0,0 +1,12 @@ +package com.simprints.feature.validatepool.usecase + +import com.simprints.infra.enrolment.records.store.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery +import javax.inject.Inject + +internal class HasRecordsUseCase @Inject constructor( + private val enrolmentRepo: EnrolmentRecordRepository, +) { + + suspend operator fun invoke(subjectQuery: SubjectQuery) = enrolmentRepo.count(subjectQuery) > 0 +} diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCase.kt new file mode 100644 index 0000000000..730c16cae8 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCase.kt @@ -0,0 +1,14 @@ +package com.simprints.feature.validatepool.usecase + +import com.simprints.infra.config.store.ConfigRepository +import javax.inject.Inject + +internal class IsModuleIdNotSyncedUseCase @Inject constructor( + private val configRepository: ConfigRepository, +) { + + suspend operator fun invoke(moduleId: String): Boolean = + configRepository.getDeviceConfiguration() + .selectedModules + .all { it.value != moduleId } +} 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 new file mode 100644 index 0000000000..5a301c69cf --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -0,0 +1,22 @@ +package com.simprints.feature.validatepool.usecase + +import com.simprints.core.tools.time.TimeHelper +import com.simprints.infra.eventsync.EventSyncManager +import javax.inject.Inject + +internal class ShouldSuggestSyncUseCase @Inject constructor( + private val timeHelper: TimeHelper, + private val syncManager: EventSyncManager, +) { + + suspend operator fun invoke(): Boolean = syncManager + .getLastSyncTime() + ?.let { timeHelper.msBetweenNowAndTime(it.time) > SYNC_THRESHOLD_MS } + ?: true + + companion object { + + // TODO use config instead + private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L + } +} diff --git a/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml b/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml index ef6f3cfc98..55e1dc03d0 100644 --- a/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml +++ b/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml @@ -1,7 +1,102 @@ - + android:layout_height="match_parent" + android:background="@color/simprints_blue"> + + + + + + + + + + + + + + + + + diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt new file mode 100644 index 0000000000..f65b529e98 --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt @@ -0,0 +1,161 @@ +package com.simprints.feature.validatepool.screen + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.common.truth.Truth.assertThat +import com.simprints.feature.validatepool.usecase.HasRecordsUseCase +import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase +import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase +import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ValidateSubjectPoolViewModelTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var hasRecordsUseCase: HasRecordsUseCase + + @MockK + private lateinit var isModuleIdNotSyncedUseCase: IsModuleIdNotSyncedUseCase + + @MockK + private lateinit var shouldSuggestSyncUseCase: ShouldSuggestSyncUseCase + + private lateinit var viewModel: ValidateSubjectPoolViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this) + + viewModel = ValidateSubjectPoolViewModel( + hasRecordsUseCase, + isModuleIdNotSyncedUseCase, + shouldSuggestSyncUseCase, + ) + } + + @Test + fun `when subject pool not empty returns Success `() = runTest { + coEvery { hasRecordsUseCase(any()) } returns true + + viewModel.checkIdentificationPool(SubjectQuery()) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.Success) + } + + @Test + fun `if ID by project when not synced recently returns RequiresSync`() = + runTest { + val subjectQuery = SubjectQuery(projectId = "projectId") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns true + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.RequiresSync) + coVerify(exactly = 1) { hasRecordsUseCase(any()) } + coVerify(exactly = 0) { isModuleIdNotSyncedUseCase(any()) } + } + + @Test + fun `if ID by project when synced recently returns PoolEmpty`() = + runTest { + val subjectQuery = SubjectQuery(projectId = "module1") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns false + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.PoolEmpty) + } + + @Test + fun `if ID by user when subjects enrolled under other attendant ID returns UserMismatch`() = + runTest { + val subjectQuery = SubjectQuery(attendantId = "attendantId") + coEvery { hasRecordsUseCase(any()) } returns true + coEvery { hasRecordsUseCase(subjectQuery) } returns false + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.UserMismatch) + coVerify(exactly = 0) { isModuleIdNotSyncedUseCase(any()) } + coVerify(exactly = 0) { shouldSuggestSyncUseCase() } + } + + @Test + fun `if ID by user when no subjects and should sync returns RequiredSync`() = runTest { + val subjectQuery = SubjectQuery(attendantId = "attendantId") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns true + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.RequiresSync) + coVerify(exactly = 0) { isModuleIdNotSyncedUseCase(any()) } + } + + @Test + fun `if ID by user when no subjects and synced returns PoolEmpty`() = runTest { + val subjectQuery = SubjectQuery(attendantId = "attendantId") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns false + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.PoolEmpty) + coVerify(exactly = 0) { isModuleIdNotSyncedUseCase(any()) } + } + + @Test + fun `if ID by module when module is not synced returns ModuleMismatch`() = + runTest { + val subjectQuery = SubjectQuery(moduleId = "module1") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { isModuleIdNotSyncedUseCase(any()) } returns true + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.ModuleMismatch) + coVerify(exactly = 1) { hasRecordsUseCase(any()) } + coVerify(exactly = 0) { shouldSuggestSyncUseCase() } + } + + @Test + fun `if ID by module when module is synced and not synced recently returns RequiresSync`() = + runTest { + val subjectQuery = SubjectQuery(moduleId = "module1") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { isModuleIdNotSyncedUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns true + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.RequiresSync) + } + + @Test + fun `if ID by module when module is synced and synced recently returns PoolEmpty`() = + runTest { + val subjectQuery = SubjectQuery(moduleId = "module1") + coEvery { hasRecordsUseCase(any()) } returns false + coEvery { isModuleIdNotSyncedUseCase(any()) } returns false + coEvery { shouldSuggestSyncUseCase() } returns false + + viewModel.checkIdentificationPool(subjectQuery) + + assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.PoolEmpty) + } +} diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCaseTest.kt new file mode 100644 index 0000000000..0411bb8195 --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/HasRecordsUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.simprints.feature.validatepool.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.enrolment.records.store.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest + +import org.junit.Before +import org.junit.Test + +class HasRecordsUseCaseTest { + + @MockK + private lateinit var repository: EnrolmentRecordRepository + + private lateinit var usecase: HasRecordsUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this) + + usecase = HasRecordsUseCase(repository) + } + + @Test + fun `Returns false if there are no records`() = runTest { + coEvery { repository.count(any()) }.returns(0) + assertThat(usecase(SubjectQuery())).isFalse() + } + + @Test + fun `Returns true if there are records`() = runTest { + coEvery { repository.count(any()) }.returns(1) + assertThat(usecase(SubjectQuery())).isTrue() + } +} diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCaseTest.kt new file mode 100644 index 0000000000..1b7912f449 --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/IsModuleIdNotSyncedUseCaseTest.kt @@ -0,0 +1,44 @@ +package com.simprints.feature.validatepool.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.infra.config.store.ConfigRepository +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class IsModuleIdNotSyncedUseCaseTest { + + @MockK + lateinit var configRepository: ConfigRepository + + private lateinit var usecase: IsModuleIdNotSyncedUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this) + + coEvery { + configRepository.getDeviceConfiguration().selectedModules + } returns listOf( + "module1".asTokenizableRaw(), + "module2".asTokenizableRaw(), + "module3".asTokenizableRaw(), + ) + + usecase = IsModuleIdNotSyncedUseCase(configRepository) + } + + @Test + fun `returns true if module is not synced`() = runTest { + assertThat(usecase("module2")).isFalse() + } + + @Test + fun `returns false if module is synced`() = runTest { + assertThat(usecase("moduleNone")).isTrue() + } +} 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 new file mode 100644 index 0000000000..e8f13f4e9e --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt @@ -0,0 +1,59 @@ +package com.simprints.feature.validatepool.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.time.TimeHelper +import com.simprints.infra.eventsync.EventSyncManager +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ShouldSuggestSyncUseCaseTest { + + @MockK + lateinit var timeHelper: TimeHelper + + @MockK + lateinit var syncManager: EventSyncManager + + private lateinit var usecase: ShouldSuggestSyncUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this) + + usecase = ShouldSuggestSyncUseCase(timeHelper, syncManager) + } + + @Test + fun `returns true if not synced ever`() = runTest { + coEvery { syncManager.getLastSyncTime() } returns null + + assertThat(usecase()).isTrue() + } + + @Test + fun `returns true if not synced recently`() = runTest { + coEvery { syncManager.getLastSyncTime() } returns Date() + coEvery { timeHelper.msBetweenNowAndTime(any()) } returns WEEK_MS + + assertThat(usecase()).isTrue() + } + + @Test + fun `returns false if synced recently`() = runTest { + coEvery { syncManager.getLastSyncTime() } returns Date() + coEvery { timeHelper.msBetweenNowAndTime(any()) } returns HOUR_MS + + assertThat(usecase()).isFalse() + } + + companion object { + + private const val HOUR_MS = 60 * 60 * 1000L + private const val WEEK_MS = 7 * 24 * HOUR_MS + } +} diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index 45b15c5543..44761c6abc 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -424,4 +424,15 @@ Sync in progress + + Identification issue + Checking identification candidate pool + Provided attendant ID does not match synchronised data + Provided module ID does not match synchronised data + Data has not been synchronised recently + Syncing… + There are no records on the device + Close + Sync and retry + From b7d9a43b8f86e10cd2e8cc96b5ef6712ed89b441 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 29 May 2024 17:10:57 +0300 Subject: [PATCH 4/5] MS-219 Add sync and retry button --- .../dashboard/main/sync/SyncViewModelTest.kt | 28 +- .../syncinfo/SyncInfoViewModelTest.kt | 35 +- .../validate-subject-pool/build.gradle.kts | 1 + .../screen/ValidateSubjectPoolFragment.kt | 4 +- .../screen/ValidateSubjectPoolViewModel.kt | 9 + .../usecase/RunBlockingEventSyncUseCase.kt | 24 ++ .../ValidateSubjectPoolViewModelTest.kt | 28 ++ .../RunBlockingEventSyncUseCaseTest.kt | 114 +++++ .../eventsync/status/models/EventSyncState.kt | 3 + .../eventsync/sync/EventSyncStateProcessor.kt | 28 +- .../status/models/EventSyncStateTest.kt | 402 ++++++++++++------ 11 files changed, 506 insertions(+), 170 deletions(-) create mode 100644 feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt create mode 100644 feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt index d5f037889e..f048e57735 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt @@ -115,7 +115,7 @@ internal class SyncViewModelTest { @Test fun `should initialize the live data syncToBFSIDAllowed correctly`() { - syncState.postValue(EventSyncState("", 0, 0, listOf(), listOf())) + syncState.postValue(EventSyncState("", 0, 0, listOf(), listOf(), listOf())) isConnected.postValue(true) val viewModel = initViewModel() @@ -155,7 +155,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Running ) - ), listOf() + ), listOf(), listOf() ) isConnected.value = true @@ -200,7 +200,7 @@ internal class SyncViewModelTest { fun `should post a SyncDefault card state if there is no sync history`() { coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration isConnected.value = true - syncState.value = EventSyncState("", 0, 0, listOf(), listOf()) + syncState.value = EventSyncState("", 0, 0, listOf(), listOf(), listOf()) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() assertThat(syncCardLiveData).isEqualTo(SyncDefault(DATE)) @@ -217,7 +217,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -236,7 +236,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -254,7 +254,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Running ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -272,7 +272,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Enqueued ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -289,7 +289,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseTooManyRequest = true) ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -306,7 +306,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseCloudIntegration = true) ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -323,7 +323,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseReloginRequired = true) ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -369,7 +369,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseBackendMaintenance = true) ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -389,7 +389,7 @@ internal class SyncViewModelTest { estimatedOutage = 30 ) ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -407,7 +407,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed() ) - ) + ), listOf() ) val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() @@ -426,7 +426,7 @@ internal class SyncViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), listOf() ) val viewModel = initViewModel() viewModel.syncCardLiveData.getOrAwaitValue() 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 94f5fab02e..d3c47b7d48 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 @@ -265,7 +265,8 @@ class SyncInfoViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Running ) - ) + ), + reporterStates = listOf(), ) ) @@ -286,7 +287,8 @@ class SyncInfoViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), + reporterStates = listOf(), ) ) @@ -306,7 +308,8 @@ class SyncInfoViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), + reporterStates = listOf(), ) viewModel.fetchSyncInformationIfNeeded(state) @@ -362,7 +365,7 @@ class SyncInfoViewModelTest { ) } viewModel.refreshInformation() - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList()) + stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) connectionLiveData.value = false assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() @@ -382,7 +385,7 @@ class SyncInfoViewModelTest { viewModel.refreshInformation() connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList()) + stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() stateLiveData.value = EventSyncState( @@ -395,7 +398,8 @@ class SyncInfoViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Running ) - ) + ), + reporterStates = listOf(), ) assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() @@ -409,7 +413,8 @@ class SyncInfoViewModelTest { EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Succeeded ) - ) + ), + reporterStates = listOf(), ) assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() } @@ -423,7 +428,7 @@ class SyncInfoViewModelTest { } viewModel.refreshInformation() connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList()) + stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() } @@ -438,7 +443,7 @@ class SyncInfoViewModelTest { } viewModel.refreshInformation() connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList()) + stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() } @@ -447,12 +452,14 @@ class SyncInfoViewModelTest { fun `emit ReloginRequired = false when lastSyncState updates with different status`() = runTest { stateLiveData.value = EventSyncState( - "", 0, 0, listOf(), listOf( + "", 0, 0, listOf(), + listOf( EventSyncState.SyncWorkerInfo( EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseBackendMaintenance = true) ) - ) + ), + reporterStates = listOf(), ) assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isFalse() @@ -461,12 +468,14 @@ class SyncInfoViewModelTest { @Test fun `emit ReloginRequired = true when lastSyncState updates with such status`() = runTest { stateLiveData.value = EventSyncState( - "", 0, 0, listOf(), listOf( + "", 0, 0, listOf(), + listOf( EventSyncState.SyncWorkerInfo( EventSyncWorkerType.DOWNLOADER, EventSyncWorkerState.Failed(failedBecauseReloginRequired = true) ) - ) + ), + reporterStates = listOf(), ) assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isTrue() diff --git a/feature/validate-subject-pool/build.gradle.kts b/feature/validate-subject-pool/build.gradle.kts index f3e5800c8a..58c306dc9e 100644 --- a/feature/validate-subject-pool/build.gradle.kts +++ b/feature/validate-subject-pool/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(project(":infra:config-store")) implementation(project(":infra:event-sync")) implementation(project(":infra:events")) + implementation(project(":infra:sync")) } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt index e76a6d3d5c..3920351ce1 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt @@ -30,9 +30,7 @@ internal class ValidateSubjectPoolFragment : Fragment(R.layout.fragment_validate viewModel.state.observe(viewLifecycleOwner, LiveDataEventWithContentObserver(::renderState)) binding.validationActionsClose.setOnClickListener { finishWithResult(false) } - binding.validationActionsSync.setOnClickListener { - // TODO - } + binding.validationActionsSync.setOnClickListener { viewModel.syncAndRetry(args.subjectQuery) } viewModel.checkIdentificationPool(args.subjectQuery) } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt index 4d6492565e..5e6f1aaad5 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -8,6 +8,7 @@ import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.feature.validatepool.usecase.HasRecordsUseCase import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase +import com.simprints.feature.validatepool.usecase.RunBlockingEventSyncUseCase import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,6 +20,7 @@ internal class ValidateSubjectPoolViewModel @Inject constructor( private val hasRecords: HasRecordsUseCase, private val isModuleIdNotSynced: IsModuleIdNotSyncedUseCase, private val shouldSuggestSync: ShouldSuggestSyncUseCase, + private val runBlockingSync: RunBlockingEventSyncUseCase, ) : ViewModel() { val state: LiveData> @@ -39,4 +41,11 @@ internal class ValidateSubjectPoolViewModel @Inject constructor( _state.send(validationState) } + fun syncAndRetry(subjectQuery: SubjectQuery) = viewModelScope.launch { + _state.send(ValidateSubjectPoolState.SyncInProgress) + runBlockingSync() + checkIdentificationPool(subjectQuery) + } + } + 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 new file mode 100644 index 0000000000..cf85fc51f2 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -0,0 +1,24 @@ +package com.simprints.feature.validatepool.usecase + +import androidx.lifecycle.asFlow +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.sync.SyncOrchestrator +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +internal class RunBlockingEventSyncUseCase @Inject constructor( + private val syncManager: EventSyncManager, + private val syncOrchestrator: SyncOrchestrator, +) { + + suspend operator fun invoke() { + // First item in the flow is the state of last sync, + // so it can be used to as a filter out old sync states + val lastSyncId = syncManager.getLastSyncState().asFlow().firstOrNull()?.syncId + + syncOrchestrator.startEventSync() + syncManager.getLastSyncState() + .asFlow() + .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } + } +} diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt index f65b529e98..a20399ebf2 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt @@ -2,15 +2,20 @@ package com.simprints.feature.validatepool.screen import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat +import com.jraska.livedata.test +import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.feature.validatepool.usecase.HasRecordsUseCase import com.simprints.feature.validatepool.usecase.IsModuleIdNotSyncedUseCase +import com.simprints.feature.validatepool.usecase.RunBlockingEventSyncUseCase import com.simprints.feature.validatepool.usecase.ShouldSuggestSyncUseCase import com.simprints.infra.enrolment.records.store.domain.models.SubjectQuery import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.impl.annotations.MockK +import io.mockk.justRun import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -33,6 +38,9 @@ class ValidateSubjectPoolViewModelTest { @MockK private lateinit var shouldSuggestSyncUseCase: ShouldSuggestSyncUseCase + @MockK + private lateinit var runBlockingSync: RunBlockingEventSyncUseCase + private lateinit var viewModel: ValidateSubjectPoolViewModel @Before @@ -43,6 +51,7 @@ class ValidateSubjectPoolViewModelTest { hasRecordsUseCase, isModuleIdNotSyncedUseCase, shouldSuggestSyncUseCase, + runBlockingSync, ) } @@ -158,4 +167,23 @@ class ValidateSubjectPoolViewModelTest { assertThat(viewModel.state.value?.peekContent()).isEqualTo(ValidateSubjectPoolState.PoolEmpty) } + + @Test + fun `runs sync and check`() = runTest { + val subjectQuery = SubjectQuery(projectId = "projectId") + + coEvery { hasRecordsUseCase(any()) } returnsMany listOf(true) + coJustRun { runBlockingSync() } + + val result = viewModel.state.test() + + viewModel.syncAndRetry(subjectQuery) + + assertThat(result.valueHistory().map { it.peekContent() }).containsExactly( + ValidateSubjectPoolState.SyncInProgress, + ValidateSubjectPoolState.Validating, + ValidateSubjectPoolState.Success, + ) + coVerify(exactly = 1) { runBlockingSync() } + } } 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 new file mode 100644 index 0000000000..fa1c09400e --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -0,0 +1,114 @@ +package com.simprints.feature.validatepool.usecase + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import com.simprints.infra.eventsync.EventSyncManager +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.SyncOrchestrator +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.justRun +import io.mockk.verify +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class RunBlockingEventSyncUseCaseTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var syncManager: EventSyncManager + + @MockK + private lateinit var syncOrchestrator: SyncOrchestrator + + private lateinit var usecase: RunBlockingEventSyncUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this) + + justRun { syncOrchestrator.startEventSync() } + + usecase = RunBlockingEventSyncUseCase( + syncManager, + syncOrchestrator + ) + } + + @Test + fun `finishes execution when sync reporters are finished`() = runTest { + val liveData = MutableLiveData() + every { syncManager.getLastSyncState() } returns liveData + liveData.postValue(createSyncState("oldSync", EventSyncWorkerState.Succeeded)) + + launch { usecase.invoke() } + testScheduler.advanceUntilIdle() + + liveData.postValue(createSyncState("sync", EventSyncWorkerState.Succeeded)) + testScheduler.advanceUntilIdle() + + verify { syncOrchestrator.startEventSync() } + verify { syncManager.getLastSyncState() } + } + + @Test + fun `finishes execution when sync reporters have failed`() = runTest { + val liveData = MutableLiveData() + every { syncManager.getLastSyncState() } returns liveData + liveData.postValue(createSyncState("oldSync", EventSyncWorkerState.Succeeded)) + + launch { usecase.invoke() } + testScheduler.advanceUntilIdle() + + liveData.postValue(createSyncState("sync", EventSyncWorkerState.Failed())) + testScheduler.advanceUntilIdle() + + verify { syncOrchestrator.startEventSync() } + verify { syncManager.getLastSyncState() } + } + + @Test + fun `finishes execution when sync reporters have been cancelled`() = runTest { + val liveData = MutableLiveData() + every { syncManager.getLastSyncState() } returns liveData + liveData.postValue(createSyncState("oldSync", EventSyncWorkerState.Succeeded)) + + launch { usecase.invoke() } + testScheduler.advanceUntilIdle() + + liveData.postValue(createSyncState("sync", EventSyncWorkerState.Cancelled)) + testScheduler.advanceUntilIdle() + + verify { syncOrchestrator.startEventSync() } + verify { syncManager.getLastSyncState() } + } + + private fun createSyncState( + syncId: String, + endReporterState: EventSyncWorkerState, + ) = EventSyncState( + syncId, + 0, + 0, + emptyList(), + emptyList(), + listOf( + EventSyncState.SyncWorkerInfo( + EventSyncWorkerType.END_SYNC_REPORTER, + endReporterState + ) + ) + ) +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/status/models/EventSyncState.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/status/models/EventSyncState.kt index 0db747158e..1ba007e752 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/status/models/EventSyncState.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/status/models/EventSyncState.kt @@ -9,6 +9,7 @@ data class EventSyncState( val total: Int?, val upSyncWorkersInfo: List, val downSyncWorkersInfo: List, + val reporterStates: List, ) { data class SyncWorkerInfo( @@ -54,4 +55,6 @@ data class EventSyncState( fun isSyncFailed() = syncWorkersInfo .any { it.state is EventSyncWorkerState.Failed || it.state is EventSyncWorkerState.Blocked || it.state is EventSyncWorkerState.Cancelled } + fun isSyncReporterCompleted() = reporterStates + .all { it.state !is EventSyncWorkerState.Running && it.state !is EventSyncWorkerState.Enqueued && it.state !is EventSyncWorkerState.Blocked } } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/EventSyncStateProcessor.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/EventSyncStateProcessor.kt index ae66393c0d..30537db80b 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/EventSyncStateProcessor.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/EventSyncStateProcessor.kt @@ -11,17 +11,10 @@ import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.eventsync.status.models.EventSyncWorkerState.Companion.fromWorkInfo import com.simprints.infra.eventsync.status.models.EventSyncWorkerType.Companion.tagForType import com.simprints.infra.eventsync.status.models.EventSyncWorkerType.DOWNLOADER +import com.simprints.infra.eventsync.status.models.EventSyncWorkerType.END_SYNC_REPORTER +import com.simprints.infra.eventsync.status.models.EventSyncWorkerType.START_SYNC_REPORTER import com.simprints.infra.eventsync.status.models.EventSyncWorkerType.UPLOADER -import com.simprints.infra.eventsync.sync.common.EventSyncCache -import com.simprints.infra.eventsync.sync.common.SYNC_LOG_TAG -import com.simprints.infra.eventsync.sync.common.SyncWorkersLiveDataProvider -import com.simprints.infra.eventsync.sync.common.didFailBecauseBackendMaintenance -import com.simprints.infra.eventsync.sync.common.didFailBecauseCloudIntegration -import com.simprints.infra.eventsync.sync.common.didFailBecauseReloginRequired -import com.simprints.infra.eventsync.sync.common.didFailBecauseTooManyRequests -import com.simprints.infra.eventsync.sync.common.filterByTags -import com.simprints.infra.eventsync.sync.common.getEstimatedOutageTime -import com.simprints.infra.eventsync.sync.common.sortByScheduledTime +import com.simprints.infra.eventsync.sync.common.* import com.simprints.infra.eventsync.sync.down.workers.extractDownSyncMaxCount import com.simprints.infra.eventsync.sync.down.workers.extractDownSyncProgress import com.simprints.infra.eventsync.sync.master.EventStartSyncReporterWorker.Companion.SYNC_ID_STARTED @@ -45,12 +38,15 @@ internal class EventSyncStateProcessor @Inject constructor( val upSyncStates = upSyncUploadersStates(syncWorkers) val downSyncStates = downSyncDownloadersStates(syncWorkers) + val syncReporterStates = syncStartReporterStates(syncWorkers) + syncEndReporterStates(syncWorkers) + val syncState = EventSyncState( lastSyncId, progress, total, upSyncStates, - downSyncStates + downSyncStates, + syncReporterStates, ) emit(syncState) @@ -123,6 +119,16 @@ internal class EventSyncStateProcessor @Inject constructor( SyncWorkerInfo(DOWNLOADER, it.toEventSyncWorkerState()) } + private fun syncStartReporterStates(workInfos: List): List = + workInfos.filterByTags(tagForType(START_SYNC_REPORTER)).map { + SyncWorkerInfo(START_SYNC_REPORTER, it.toEventSyncWorkerState()) + } + + private fun syncEndReporterStates(workInfos: List): List = + workInfos.filterByTags(tagForType(END_SYNC_REPORTER)).map { + SyncWorkerInfo(END_SYNC_REPORTER, it.toEventSyncWorkerState()) + } + private fun WorkInfo.toEventSyncWorkerState(): EventSyncWorkerState = fromWorkInfo( state, diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/status/models/EventSyncStateTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/status/models/EventSyncStateTest.kt index 55bfd06f9e..5f70cbb6e4 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/status/models/EventSyncStateTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/status/models/EventSyncStateTest.kt @@ -14,244 +14,388 @@ class EventSyncStateTest { @Test fun `isThereNotSyncHistory() is true when there are no workers`() { - assertThat(createState( - up = emptyList(), - down = emptyList(), - ).isThereNotSyncHistory()).isTrue() + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = emptyList(), + ).isThereNotSyncHistory() + ).isTrue() } + @Test fun `isThereNotSyncHistory() is false when there are workers`() { - assertThat(createState( - up = listOf(createWorker(Succeeded)), - down = emptyList(), - ).isThereNotSyncHistory()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Succeeded)), + down = emptyList(), + reporters = emptyList(), + ).isThereNotSyncHistory() + ).isFalse() } @Test fun `isSyncRunning() is false when there are no workers`() { - assertThat(createState( - up = emptyList(), - down = emptyList(), - ).isSyncRunning()).isFalse() + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = emptyList(), + ).isSyncRunning() + ).isFalse() } @Test fun `isSyncRunning() is false when when all workers completed`() { - assertThat(createState( - up = listOf(createWorker(Succeeded)), - down = listOf(createWorker(Succeeded)), - ).isSyncRunning()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Succeeded)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncRunning() + ).isFalse() } @Test fun `isSyncRunning() is true when there are running workers`() { - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Succeeded)), - ).isSyncRunning()).isTrue() - assertThat(createState( - up = listOf(createWorker(Succeeded)), - down = listOf(createWorker(Running)), - ).isSyncRunning()).isTrue() - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Running)), - ).isSyncRunning()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() + assertThat( + createState( + up = listOf(createWorker(Succeeded)), + down = listOf(createWorker(Running)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Running)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() } @Test fun `isSyncRunning() is true when there are enqueued workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued)), - down = listOf(createWorker(Succeeded)), - ).isSyncRunning()).isTrue() - assertThat(createState( - up = listOf(createWorker(Succeeded)), - down = listOf(createWorker(Enqueued)), - ).isSyncRunning()).isTrue() - assertThat(createState( - up = listOf(createWorker(Enqueued)), - down = listOf(createWorker(Enqueued)), - ).isSyncRunning()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Enqueued)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() + assertThat( + createState( + up = listOf(createWorker(Succeeded)), + down = listOf(createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() + assertThat( + createState( + up = listOf(createWorker(Enqueued)), + down = listOf(createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncRunning() + ).isTrue() } @Test fun `isSyncCompleted() is true when all workers are completed`() { - assertThat(createState( - up = listOf(createWorker(Succeeded)), - down = listOf(createWorker(Succeeded)), - ).isSyncCompleted()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Succeeded)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncCompleted() + ).isTrue() } @Test fun `isSyncCompleted() is false when there are enqueued workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued)), - down = listOf(createWorker(Succeeded)), - ).isSyncCompleted()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Enqueued)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncCompleted() + ).isFalse() } @Test fun `isSyncCompleted() is false when there are running workers`() { - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Succeeded)), - ).isSyncCompleted()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncCompleted() + ).isFalse() } @Test fun `isSyncInProgress() is false when there are no running workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued)), - down = listOf(createWorker(Succeeded)), - ).isSyncInProgress()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Enqueued)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncInProgress() + ).isFalse() } @Test fun `isSyncInProgress() is true when there are running workers`() { - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncInProgress()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncInProgress() + ).isTrue() } @Test fun `isSyncConnecting() is false when there are no enqueued workers`() { - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Succeeded)), - ).isSyncConnecting()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncConnecting() + ).isFalse() } @Test fun `isSyncConnecting() is true when there are enqueued workers`() { - assertThat(createState( - up = listOf(createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncConnecting()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncConnecting() + ).isTrue() } @Test fun `isSyncFailedBecauseReloginRequired() is false when there are no workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed())), - down = listOf(createWorker(Succeeded)), - ).isSyncFailedBecauseReloginRequired()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Failed())), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncFailedBecauseReloginRequired() + ).isFalse() } @Test fun `isSyncFailedBecauseReloginRequired() is true when there are workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed(failedBecauseReloginRequired = true))), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncFailedBecauseReloginRequired()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Failed(failedBecauseReloginRequired = true))), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncFailedBecauseReloginRequired() + ).isTrue() } @Test fun `isSyncFailedBecauseTooManyRequests() is false when there are no workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed())), - down = listOf(createWorker(Succeeded)), - ).isSyncFailedBecauseTooManyRequests()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Failed())), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncFailedBecauseTooManyRequests() + ).isFalse() } @Test fun `isSyncFailedBecauseTooManyRequests() is true when there are workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed(failedBecauseTooManyRequest = true))), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncFailedBecauseTooManyRequests()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Failed(failedBecauseTooManyRequest = true))), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncFailedBecauseTooManyRequests() + ).isTrue() } @Test fun `isSyncFailedBecauseCloudIntegration() is false when there are no workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed())), - down = listOf(createWorker(Succeeded)), - ).isSyncFailedBecauseCloudIntegration()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Failed())), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncFailedBecauseCloudIntegration() + ).isFalse() } @Test fun `isSyncFailedBecauseBackendMaintenance() is true when there are workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed(failedBecauseBackendMaintenance = true))), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncFailedBecauseBackendMaintenance()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Failed(failedBecauseBackendMaintenance = true))), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncFailedBecauseBackendMaintenance() + ).isTrue() } @Test fun `isSyncFailedBecauseBackendMaintenance() is false when there are no workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed())), - down = listOf(createWorker(Succeeded)), - ).isSyncFailedBecauseBackendMaintenance()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Failed())), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncFailedBecauseBackendMaintenance() + ).isFalse() } @Test fun `isSyncFailedBecauseCloudIntegration() is true when there are workers with that status`() { - assertThat(createState( - up = listOf(createWorker(Failed(failedBecauseCloudIntegration = true))), - down = listOf(createWorker(Succeeded), createWorker(Enqueued)), - ).isSyncFailedBecauseCloudIntegration()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Failed(failedBecauseCloudIntegration = true))), + down = listOf(createWorker(Succeeded), createWorker(Enqueued)), + reporters = emptyList(), + ).isSyncFailedBecauseCloudIntegration() + ).isTrue() } @Test fun `isSyncFailed() is false when there are no Failed, Blocked or Cancelled workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued), createWorker(Running)), - down = listOf(createWorker(Succeeded)), - ).isSyncFailed()).isFalse() + assertThat( + createState( + up = listOf(createWorker(Enqueued), createWorker(Running)), + down = listOf(createWorker(Succeeded)), + reporters = emptyList(), + ).isSyncFailed() + ).isFalse() } @Test fun `isSyncFailed() is true when there are no Failed workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued), createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Failed())), - ).isSyncFailed()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Enqueued), createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Failed())), + reporters = emptyList(), + ).isSyncFailed() + ).isTrue() } @Test fun `isSyncFailed() is true when there are no Blocked workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued), createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Blocked)), - ).isSyncFailed()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Enqueued), createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Blocked)), + reporters = emptyList(), + ).isSyncFailed() + ).isTrue() } @Test fun `isSyncFailed() is true when there are no Cancelled workers`() { - assertThat(createState( - up = listOf(createWorker(Enqueued), createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Cancelled)), - ).isSyncFailed()).isTrue() + assertThat( + createState( + up = listOf(createWorker(Enqueued), createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Cancelled)), + reporters = emptyList(), + ).isSyncFailed() + ).isTrue() } @Test fun `getEstimatedBackendMaintenanceOutage() returns outage value when there is a worker with that status`() { val outage: Long = 666 - assertThat(createState( - up = listOf( - createWorker(Enqueued), - createWorker(Running), - createWorker(Failed(failedBecauseBackendMaintenance = true, estimatedOutage = outage))) - , - down = listOf(createWorker(Succeeded), createWorker(Cancelled)), - ).getEstimatedBackendMaintenanceOutage()).isEqualTo(outage) + assertThat( + createState( + up = listOf( + createWorker(Enqueued), + createWorker(Running), + createWorker( + Failed( + failedBecauseBackendMaintenance = true, + estimatedOutage = outage + ) + ) + ), + down = listOf(createWorker(Succeeded), createWorker(Cancelled)), + reporters = emptyList(), + ).getEstimatedBackendMaintenanceOutage() + ).isEqualTo(outage) } @Test fun `getEstimatedBackendMaintenanceOutage() returns null when there is no worker with that status`() { - assertThat(createState( - up = listOf(createWorker(Enqueued), createWorker(Running)), - down = listOf(createWorker(Succeeded), createWorker(Cancelled)), - ).getEstimatedBackendMaintenanceOutage()).isNull() + assertThat( + createState( + up = listOf(createWorker(Enqueued), createWorker(Running)), + down = listOf(createWorker(Succeeded), createWorker(Cancelled)), + reporters = emptyList(), + ).getEstimatedBackendMaintenanceOutage() + ).isNull() + } + + @Test + fun `isSyncReporterCompleted() is false when there are enqueued workers`() { + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = listOf(createWorker(Enqueued)), + ).isSyncReporterCompleted() + ).isFalse() + } + + @Test + fun `isSyncReporterCompleted() is false when there are blocked workers`() { + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = listOf(createWorker(Blocked)), + ).isSyncReporterCompleted() + ).isFalse() + } + + @Test + fun `isSyncReporterCompleted() is true when there are completed workers`() { + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = listOf(createWorker(Succeeded)), + ).isSyncReporterCompleted() + ).isTrue() + } + + @Test + fun `isSyncReporterCompleted() is true when all workers failed`() { + assertThat( + createState( + up = emptyList(), + down = emptyList(), + reporters = listOf(createWorker(Failed())), + ).isSyncReporterCompleted() + ).isTrue() } private fun createState( up: List, down: List, - ) = EventSyncState("id", 0, 0, up, down) + reporters: List, + ) = EventSyncState("id", 0, 0, up, down, reporters) private fun createWorker(state: EventSyncWorkerState) = SyncWorkerInfo(type = EventSyncWorkerType.DOWNLOADER, state = state) From 943f0669c6888e46406b99ed8346464d6ea3c0ed Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 30 May 2024 15:27:22 +0300 Subject: [PATCH 5/5] MS-219 Add a configuration for "recenly" when suggesting sync --- .../syncinfo/SyncInfoViewModelTest.kt | 1 + .../usecase/ShouldSuggestSyncUseCase.kt | 20 ++++++++++++------- .../usecase/ShouldSuggestSyncUseCaseTest.kt | 12 ++++++++++- .../store/local/ConfigLocalDataSourceImpl.kt | 9 ++++++--- .../migrations/models/OldProjectConfig.kt | 3 ++- .../DownSynchronizationConfiguration.kt | 2 ++ .../DownSynchronizationConfiguration.kt | 9 ++++++++- .../models/ApiSynchronizationConfiguration.kt | 5 ++++- .../src/main/proto/project_config.proto | 1 + .../ProjectConfigSharedPrefsMigrationTest.kt | 8 ++++++++ .../infra/config/store/testtools/Models.kt | 8 ++++++-- .../infra/sync/config/testtools/Models.kt | 3 ++- 12 files changed, 64 insertions(+), 17 deletions(-) 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 d3c47b7d48..519de6e79d 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 @@ -513,6 +513,7 @@ class SyncInfoViewModelTest { partitionType = partitionType, moduleOptions = modules.map(String::asTokenizableRaw), maxNbOfModules = 0, + maxAge = "PT24H", ) ) } 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 5a301c69cf..d90f91befe 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 @@ -1,22 +1,28 @@ package com.simprints.feature.validatepool.usecase import com.simprints.core.tools.time.TimeHelper +import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager import javax.inject.Inject +import kotlin.time.Duration internal class ShouldSuggestSyncUseCase @Inject constructor( private val timeHelper: TimeHelper, private val syncManager: EventSyncManager, + private val configRepository: ConfigRepository, ) { suspend operator fun invoke(): Boolean = syncManager .getLastSyncTime() - ?.let { timeHelper.msBetweenNowAndTime(it.time) > SYNC_THRESHOLD_MS } - ?: true - - companion object { + ?.let { + val thresholdMs = configRepository.getProjectConfiguration() + .synchronization + .down + .maxAge + .let(Duration.Companion::parseIsoString) + .inWholeMilliseconds - // TODO use config instead - private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L - } + timeHelper.msBetweenNowAndTime(it.time) > thresholdMs + } + ?: true } 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 e8f13f4e9e..69168ddc90 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 @@ -2,6 +2,7 @@ package com.simprints.feature.validatepool.usecase import com.google.common.truth.Truth.assertThat import com.simprints.core.tools.time.TimeHelper +import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -19,13 +20,16 @@ class ShouldSuggestSyncUseCaseTest { @MockK lateinit var syncManager: EventSyncManager + @MockK + lateinit var configRepository: ConfigRepository + private lateinit var usecase: ShouldSuggestSyncUseCase @Before fun setUp() { MockKAnnotations.init(this) - usecase = ShouldSuggestSyncUseCase(timeHelper, syncManager) + usecase = ShouldSuggestSyncUseCase(timeHelper, syncManager, configRepository) } @Test @@ -39,6 +43,9 @@ class ShouldSuggestSyncUseCaseTest { fun `returns true if not synced recently`() = runTest { coEvery { syncManager.getLastSyncTime() } returns Date() coEvery { timeHelper.msBetweenNowAndTime(any()) } returns WEEK_MS + coEvery { + configRepository.getProjectConfiguration().synchronization.down.maxAge + } returns "PT24H" assertThat(usecase()).isTrue() } @@ -47,6 +54,9 @@ class ShouldSuggestSyncUseCaseTest { fun `returns false if synced recently`() = runTest { coEvery { syncManager.getLastSyncTime() } returns Date() coEvery { timeHelper.msBetweenNowAndTime(any()) } returns HOUR_MS + coEvery { + configRepository.getProjectConfiguration().synchronization.down.maxAge + } returns "PT24H" assertThat(usecase()).isFalse() } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt index b944504582..cd3ff29918 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt @@ -12,6 +12,7 @@ import com.simprints.infra.config.store.models.ConsentConfiguration import com.simprints.infra.config.store.models.DecisionPolicy import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.DownSynchronizationConfiguration +import com.simprints.infra.config.store.models.DownSynchronizationConfiguration.Companion.DEFAULT_DOWN_SYNC_MAX_AGE import com.simprints.infra.config.store.models.Finger import com.simprints.infra.config.store.models.FingerprintConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration @@ -109,19 +110,20 @@ internal class ConfigLocalDataSourceImpl @Inject constructor( fileForPrivacyNotice(projectId, language).exists() override fun deletePrivacyNotices() { - File("$absolutePath${File.separator}${ConfigLocalDataSourceImpl.Companion.PRIVACY_NOTICE_FOLDER}").deleteRecursively() + File("$absolutePath${File.separator}$PRIVACY_NOTICE_FOLDER").deleteRecursively() } private fun fileForPrivacyNotice(projectId: String, language: String): File = File( filePathForPrivacyNoticeDirectory(projectId), - "$language.${ConfigLocalDataSourceImpl.Companion.FILE_TYPE}" + "$language.$FILE_TYPE" ) private fun filePathForPrivacyNoticeDirectory(projectId: String): String = - "$absolutePath${File.separator}${ConfigLocalDataSourceImpl.Companion.PRIVACY_NOTICE_FOLDER}${File.separator}$projectId" + "$absolutePath${File.separator}$PRIVACY_NOTICE_FOLDER${File.separator}$projectId" companion object { + val defaultProjectConfiguration: ProtoProjectConfiguration = ProjectConfiguration( projectId = "", @@ -191,6 +193,7 @@ internal class ConfigLocalDataSourceImpl @Inject constructor( partitionType = DownSynchronizationConfiguration.PartitionType.USER, maxNbOfModules = 6, moduleOptions = listOf(), + maxAge = DEFAULT_DOWN_SYNC_MAX_AGE, ), ), ).toProto() diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt index 5f0b3dc66a..40b8590f4f 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/migrations/models/OldProjectConfig.kt @@ -224,7 +224,8 @@ internal data class OldProjectConfig( if (syncGroup == "GLOBAL") "PROJECT" else syncGroup ), maxNbOfModules = maxNbOfModules.toInt(), - moduleOptions = moduleIdOptions.split("|").map(String::asTokenizableRaw) + moduleOptions = moduleIdOptions.split("|").map(String::asTokenizableRaw), + maxAge = DownSynchronizationConfiguration.DEFAULT_DOWN_SYNC_MAX_AGE, ), ) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/DownSynchronizationConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/DownSynchronizationConfiguration.kt index 594a420fd7..0c762b7719 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/DownSynchronizationConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/DownSynchronizationConfiguration.kt @@ -14,6 +14,7 @@ internal fun DownSynchronizationConfiguration.toProto(): ProtoDownSynchronizatio .setMaxNbOfModules(maxNbOfModules) .addAllModuleOptions(moduleOptions.values()) .setIsTokenized(isTokenized) + .setMaxAge(maxAge) .build() } @@ -31,6 +32,7 @@ internal fun ProtoDownSynchronizationConfiguration.toDomain(): DownSynchronizati moduleOptionsList.map { if (isTokenized) it.asTokenizableEncrypted() else it.asTokenizableRaw() }, + maxAge, ) internal fun ProtoDownSynchronizationConfiguration.PartitionType.toDomain(): DownSynchronizationConfiguration.PartitionType = diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/DownSynchronizationConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/DownSynchronizationConfiguration.kt index 02d7c96e78..25224d7066 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/DownSynchronizationConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/DownSynchronizationConfiguration.kt @@ -6,7 +6,8 @@ import com.simprints.core.domain.tokenization.TokenizableString data class DownSynchronizationConfiguration( val partitionType: PartitionType, val maxNbOfModules: Int, - val moduleOptions: List + val moduleOptions: List, + val maxAge: String, ) { enum class PartitionType { @@ -20,4 +21,10 @@ data class DownSynchronizationConfiguration( USER -> Partitioning.USER } } + + + companion object { + + const val DEFAULT_DOWN_SYNC_MAX_AGE = "PT24H" + } } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiSynchronizationConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiSynchronizationConfiguration.kt index 709c814be6..c365f2a157 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiSynchronizationConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiSynchronizationConfiguration.kt @@ -3,6 +3,7 @@ package com.simprints.infra.config.store.remote.models import androidx.annotation.Keep import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.models.DownSynchronizationConfiguration +import com.simprints.infra.config.store.models.DownSynchronizationConfiguration.Companion.DEFAULT_DOWN_SYNC_MAX_AGE import com.simprints.infra.config.store.models.SynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration @@ -104,13 +105,15 @@ internal data class ApiSynchronizationConfiguration( val partitionType: PartitionType, val maxNbOfModules: Int, val moduleOptions: List?, + val maxAge: String?, ) { fun toDomain(): DownSynchronizationConfiguration = DownSynchronizationConfiguration( partitionType.toDomain(), maxNbOfModules, - moduleOptions?.map(String::asTokenizableEncrypted) ?: emptyList() + moduleOptions?.map(String::asTokenizableEncrypted) ?: emptyList(), + maxAge ?: DEFAULT_DOWN_SYNC_MAX_AGE, ) @Keep diff --git a/infra/config-store/src/main/proto/project_config.proto b/infra/config-store/src/main/proto/project_config.proto index f2c3bea747..0c7f3f5ddf 100644 --- a/infra/config-store/src/main/proto/project_config.proto +++ b/infra/config-store/src/main/proto/project_config.proto @@ -162,6 +162,7 @@ message ProtoDownSynchronizationConfiguration { int32 max_nb_of_modules = 2; repeated string module_options = 3; bool is_tokenized = 4; + string max_age = 5; enum PartitionType { PROJECT = 0; diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt index 84e5edb8e6..352f05f4be 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/migrations/ProjectConfigSharedPrefsMigrationTest.kt @@ -516,6 +516,8 @@ class ProjectConfigSharedPrefsMigrationTest { .setPartitionType(ProtoDownSynchronizationConfiguration.PartitionType.PROJECT) .setMaxNbOfModules(5) .addAllModuleOptions(listOf("module1", "module2")) + .setMaxAge("PT24H") + .build() ) .build() @@ -542,6 +544,8 @@ class ProjectConfigSharedPrefsMigrationTest { .setPartitionType(ProtoDownSynchronizationConfiguration.PartitionType.PROJECT) .setMaxNbOfModules(5) .addAllModuleOptions(listOf("module1", "module2")) + .setMaxAge("PT24H") + .build() ) .build() @@ -568,6 +572,8 @@ class ProjectConfigSharedPrefsMigrationTest { .setPartitionType(ProtoDownSynchronizationConfiguration.PartitionType.PROJECT) .setMaxNbOfModules(5) .addAllModuleOptions(listOf("module1", "module2")) + .setMaxAge("PT24H") + .build() ) .build() @@ -595,6 +601,8 @@ class ProjectConfigSharedPrefsMigrationTest { .setPartitionType(ProtoDownSynchronizationConfiguration.PartitionType.PROJECT) .setMaxNbOfModules(5) .addAllModuleOptions(listOf("module1", "module2")) + .setMaxAge("PT24H") + .build() ) .build() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt index 6afd3d3cb2..51ece29ca7 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt @@ -212,7 +212,8 @@ internal val apiSynchronizationConfiguration = ApiSynchronizationConfiguration( ApiSynchronizationConfiguration.ApiDownSynchronizationConfiguration( ApiSynchronizationConfiguration.ApiDownSynchronizationConfiguration.PartitionType.PROJECT, 1, - listOf("module1") + listOf("module1"), + "PT24H", ) ) @@ -233,7 +234,8 @@ internal val synchronizationConfiguration = SynchronizationConfiguration( DownSynchronizationConfiguration( DownSynchronizationConfiguration.PartitionType.PROJECT, 1, - listOf("module1".asTokenizableEncrypted()) + listOf("module1".asTokenizableEncrypted()), + "PT24H", ) ) @@ -266,6 +268,8 @@ internal val protoSynchronizationConfiguration = ProtoSynchronizationConfigurati .setMaxNbOfModules(1) .setIsTokenized(true) .addModuleOptions("module1") + .setMaxAge("PT24H") + .build() ) .build() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt index b39dc86746..7be53ed895 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt @@ -76,7 +76,8 @@ internal val synchronizationConfiguration = SynchronizationConfiguration( DownSynchronizationConfiguration( DownSynchronizationConfiguration.PartitionType.PROJECT, 1, - listOf("module1".asTokenizableEncrypted()) + listOf("module1".asTokenizableEncrypted()), + "PT24H", ) )