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/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..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 @@ -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() @@ -504,6 +513,7 @@ class SyncInfoViewModelTest { partitionType = partitionType, moduleOptions = modules.map(String::asTokenizableRaw), maxNbOfModules = 0, + maxAge = "PT24H", ) ) } 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..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,6 +79,7 @@ internal class OrchestratorViewModel @Inject constructor( fun handleResult(result: Serializable) = viewModelScope.launch { Simber.d(result.toString()) + 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/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/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/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/.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..58c306dc9e --- /dev/null +++ b/feature/validate-subject-pool/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("simprints.feature") + id("kotlin-parcelize") +} + +android { + namespace = "com.simprints.feature.validatepool" +} + +dependencies { + + implementation(project(":infra:enrolment-records-store")) + 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/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/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..3920351ce1 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolFragment.kt @@ -0,0 +1,93 @@ +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) { + + 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) + + viewModel.state.observe(viewLifecycleOwner, LiveDataEventWithContentObserver(::renderState)) + + binding.validationActionsClose.setOnClickListener { finishWithResult(false) } + binding.validationActionsSync.setOnClickListener { viewModel.syncAndRetry(args.subjectQuery) } + + 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 new file mode 100644 index 0000000000..5e6f1aaad5 --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModel.kt @@ -0,0 +1,51 @@ +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.RunBlockingEventSyncUseCase +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, + private val runBlockingSync: RunBlockingEventSyncUseCase, +) : 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) + } + + 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/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/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/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..d90f91befe --- /dev/null +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -0,0 +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 { + val thresholdMs = configRepository.getProjectConfiguration() + .synchronization + .down + .maxAge + .let(Duration.Companion::parseIsoString) + .inWholeMilliseconds + + timeHelper.msBetweenNowAndTime(it.time) > thresholdMs + } + ?: true +} 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..55e1dc03d0 --- /dev/null +++ b/feature/validate-subject-pool/src/main/res/layout/fragment_validate_subject_pool.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + 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..a20399ebf2 --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/screen/ValidateSubjectPoolViewModelTest.kt @@ -0,0 +1,189 @@ +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 +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 + + @MockK + private lateinit var runBlockingSync: RunBlockingEventSyncUseCase + + private lateinit var viewModel: ValidateSubjectPoolViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this) + + viewModel = ValidateSubjectPoolViewModel( + hasRecordsUseCase, + isModuleIdNotSyncedUseCase, + shouldSuggestSyncUseCase, + runBlockingSync, + ) + } + + @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) + } + + @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/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/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/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..69168ddc90 --- /dev/null +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt @@ -0,0 +1,69 @@ +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 +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 + + @MockK + lateinit var configRepository: ConfigRepository + + private lateinit var usecase: ShouldSuggestSyncUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this) + + usecase = ShouldSuggestSyncUseCase(timeHelper, syncManager, configRepository) + } + + @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 + coEvery { + configRepository.getProjectConfiguration().synchronization.down.maxAge + } returns "PT24H" + + assertThat(usecase()).isTrue() + } + + @Test + 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() + } + + companion object { + + private const val HOUR_MS = 60 * 60 * 1000L + private const val WEEK_MS = 7 * 24 * HOUR_MS + } +} 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/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) 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 + 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", ) ) 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