From 70c8a1489191f7bd4263dbbabf6d4922ace4b489 Mon Sep 17 00:00:00 2001 From: Trevor Gowing Date: Sat, 9 Oct 2021 17:36:39 +0200 Subject: [PATCH] Quest questionnaire sync (#590) * Filter Questionnaires by publisher g6pd (#571) * SharedPreferencesHelper.read default value null * Make SharedPreferencesHelper.read default value nullable in line with Android API SharedPrefereneces.getString. (#571) * Add UserResponse to engine * Expose keycloak GET /userInfo response as data class to simplify property access. (#571) * Store User Questionnaire Publisher as Preference * Store User Questionnaire Publisher in SharedPreferences (#571) * Filter synchronized Questionnaires by cached publisher. (#571) * Make SharedPreferencesHelper.write value nullable * Make SharedPreferencesHelper.write value nullable in line with Android API SharedPrefereneces.Editor.putString. (#607) * Add UserResponse.questionnairePublisher * Replace UserResponse.realmAccess.roles with UserResponse.questionnairePublisher. * Enable key/value rather than array access therefore enabling a more generic solution. (#607) * Store User Questionnaire Publisher as Preference * Store User Questionnaire Publisher in SharedPreferences (#607) * Log Quest Questionnaire filtering Change (#571) (#607) * Setup engine module test application class Signed-off-by: Elly Kitoto * Add more tests for LoginViewModel Signed-off-by: Elly Kitoto * Run eir:spotlessApply Signed-off-by: Elly Kitoto * add refresh token * Fix failing LoginViewModel tests Signed-off-by: Elly Kitoto Co-authored-by: Elly Kitoto Co-authored-by: Peter Lubell-Doughtie --- CHANGELOG.md | 8 +- .../fhircore/eir/EirApplication.kt | 2 - .../fhircore/eir/EirApplicationTest.kt | 2 +- .../remote/model/response/UserResponse.kt | 25 ++ .../engine/ui/login/LoginViewModel.kt | 26 ++- .../engine/util/SharedPrefConstants.kt | 2 + .../engine/util/SharedPreferencesHelper.kt | 6 +- .../fhircore/engine/impl/FhirApplication.kt | 150 ++++++++++++ .../engine/robolectric/RobolectricTest.kt | 18 +- .../fhircore/engine/rule/CoroutineTestRule.kt | 52 +++++ .../fhircore/engine/shadow/FakeKeystore.kt | 94 ++++++++ .../engine/shadow/ShadowNpmPackageProvider.kt | 42 ++++ .../shadow/activity/ShadowLoginActivity.kt | 23 ++ .../ui/components/LoginViewModelTest.kt | 100 -------- .../engine/ui/login/LoginViewModelTest.kt | 213 ++++++++++++++++++ .../fhircore/quest/QuestApplication.kt | 20 +- 16 files changed, 664 insertions(+), 119 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/model/response/UserResponse.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/impl/FhirApplication.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/FakeKeystore.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/ShadowNpmPackageProvider.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/activity/ShadowLoginActivity.kt delete mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginViewModelTest.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index be56495a91..fbf32842c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added class for Measure report evaluation which will be used in ANC application - ANC | Added Condition resource to sync params list - Moved Token to secure storage from AccountManager -- QUEST | Patient List, Load Config from server -- QUEST | Added Patient Profile View -- QUEST | Patient Registration Questionnaire +- Expose [custom user attribute](https://www.keycloak.org/docs/latest/server_admin/index.html#_user-attributes) `questionnaire_publisher` available in SharedPreferences with key `USER_QUESTIONNAIRE_PUBLISHER` (#607) +- Quest | Filter [Questionnaires](http://hl7.org/fhir/questionnaire.html) by [publisher](http://hl7.org/fhir/questionnaire-definitions.html#Questionnaire.publisher) using user attribute as per above. (#571) +- Quest | Patient List, Load Config from server +- Quest | Added Patient Profile View +- Quest | Patient Registration Questionnaire ### Fixed diff --git a/android/eir/src/main/java/org/smartregister/fhircore/eir/EirApplication.kt b/android/eir/src/main/java/org/smartregister/fhircore/eir/EirApplication.kt index d334447059..23e8f73d00 100644 --- a/android/eir/src/main/java/org/smartregister/fhircore/eir/EirApplication.kt +++ b/android/eir/src/main/java/org/smartregister/fhircore/eir/EirApplication.kt @@ -93,8 +93,6 @@ class EirApplication : Application(), ConfigurableApplication { private lateinit var eirApplication: EirApplication fun getContext() = eirApplication - - fun getSyncJob() = Sync.basicSyncJob(eirApplication) } override val syncJob: SyncJob diff --git a/android/eir/src/test/java/org/smartregister/fhircore/eir/EirApplicationTest.kt b/android/eir/src/test/java/org/smartregister/fhircore/eir/EirApplicationTest.kt index 52689fcb84..ec23f9760f 100644 --- a/android/eir/src/test/java/org/smartregister/fhircore/eir/EirApplicationTest.kt +++ b/android/eir/src/test/java/org/smartregister/fhircore/eir/EirApplicationTest.kt @@ -56,7 +56,7 @@ class EirApplicationTest : RobolectricTest() { @Test fun testSyncJobShouldReturnNonNull() { - Assert.assertNotNull(EirApplication.getSyncJob()) + Assert.assertNotNull(EirApplication.getContext().syncJob) } @Test diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/model/response/UserResponse.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/model/response/UserResponse.kt new file mode 100644 index 0000000000..9668c404a8 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/model/response/UserResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.data.remote.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserResponse( + @SerialName("questionnaire_publisher") val questionnairePublisher: String? = null +) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt index beea2c67dd..2c3ab85b4c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt @@ -31,11 +31,13 @@ import okhttp3.ResponseBody import org.smartregister.fhircore.engine.auth.AuthenticationService import org.smartregister.fhircore.engine.configuration.view.LoginViewConfiguration import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse +import org.smartregister.fhircore.engine.data.remote.model.response.UserResponse import org.smartregister.fhircore.engine.data.remote.shared.ResponseCallback import org.smartregister.fhircore.engine.data.remote.shared.ResponseHandler import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY import org.smartregister.fhircore.engine.util.extension.decodeJson import retrofit2.Call import retrofit2.Response @@ -48,12 +50,14 @@ class LoginViewModel( private val dispatcher: DispatcherProvider = DefaultDispatcherProvider ) : AndroidViewModel(application), AccountManagerCallback { + val sharedPreferences = + SharedPreferencesHelper.init(getApplication().applicationContext) + val responseBodyHandler = object : ResponseHandler { override fun handleResponse(call: Call, response: Response) { response.body()?.run { - Timber.i(this.string()) - SharedPreferencesHelper.init(application.applicationContext).write("USER", this.string()) + storeUserPreferences(this) _showProgressBar.postValue(false) } } @@ -65,6 +69,16 @@ class LoginViewModel( } } + private fun storeUserPreferences(responseBody: ResponseBody) { + val responseBodyString = responseBody.string() + Timber.d(responseBodyString) + val userResponse = responseBodyString.decodeJson() + sharedPreferences.write( + USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY, + userResponse.questionnairePublisher + ) + } + private val userInfoResponseCallback: ResponseCallback by lazy { object : ResponseCallback(responseBodyHandler) {} } @@ -150,13 +164,13 @@ class LoginViewModel( } fun onUsernameUpdated(username: String) { - _loginError.value = "" - _username.value = username + _loginError.postValue("") + _username.postValue(username) } fun onPasswordUpdated(password: String) { - _loginError.value = "" - _password.value = password + _loginError.postValue("") + _password.postValue(password) } override fun run(future: AccountManagerFuture?) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt index b606aa9949..3f41f28df0 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt @@ -17,3 +17,5 @@ package org.smartregister.fhircore.engine.util const val LAST_SYNC_TIMESTAMP = "last_sync_timestamp" +const val USER_SHARED_PREFERENCE_KEY = "USER" +const val USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY = "USER_QUESTIONNAIRE_PUBLISHER" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt index b6d7c58fe0..c7c61ef9d6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt @@ -32,9 +32,11 @@ object SharedPreferencesHelper { return this } - fun read(key: String, value: String) = prefs.getString(key, value) + /** @see [SharedPreferences.getString] */ + fun read(key: String, defaultValue: String?) = prefs.getString(key, defaultValue) - fun write(key: String, value: String) { + /** @see [SharedPreferences.Editor.putString] */ + fun write(key: String, value: String?) { with(prefs.edit()) { putString(key, value) commit() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/impl/FhirApplication.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/impl/FhirApplication.kt new file mode 100644 index 0000000000..0ec9ef8fab --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/impl/FhirApplication.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.impl + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.rest.gclient.TokenClientParam +import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SyncDownloadContext +import com.google.android.fhir.db.impl.dao.LocalChangeToken +import com.google.android.fhir.db.impl.dao.SquashedLocalChange +import com.google.android.fhir.search.Search +import com.google.android.fhir.sync.Sync +import com.google.android.fhir.sync.SyncJob +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import java.time.OffsetDateTime +import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.robolectric.annotation.Config +import org.smartregister.fhircore.engine.auth.AuthCredentials +import org.smartregister.fhircore.engine.auth.AuthenticationService +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration +import org.smartregister.fhircore.engine.configuration.app.ConfigurableApplication +import org.smartregister.fhircore.engine.configuration.app.applicationConfigurationOf +import org.smartregister.fhircore.engine.shadow.ShadowNpmPackageProvider +import org.smartregister.fhircore.engine.shadow.activity.ShadowLoginActivity +import org.smartregister.fhircore.engine.util.SecureSharedPreference + +@Config(shadows = [ShadowNpmPackageProvider::class]) +class FhirApplication : Application(), ConfigurableApplication { + + override val syncJob: SyncJob + get() = spyk(Sync.basicSyncJob(ApplicationProvider.getApplicationContext())) + + override var applicationConfiguration: ApplicationConfiguration = applicationConfigurationOf() + + override val authenticationService: AuthenticationService + get() = spyk(FhirAuthenticationService()) + + override val fhirEngine: FhirEngine + get() = spyk(FhirEngineImpl()) + + override val secureSharedPreference: SecureSharedPreference by lazy { + val secureSharedPreferenceSpy = + spyk(SecureSharedPreference(ApplicationProvider.getApplicationContext())) + every { secureSharedPreferenceSpy.retrieveCredentials() } returns + AuthCredentials( + username = "demo", + password = "Amani123", + refreshToken = "", + sessionToken = "same-gibberish-string-as-token" + ) + secureSharedPreferenceSpy + } + + override val resourceSyncParams: Map> + get() = mapOf() + + override val workerContextProvider: SimpleWorkerContext + get() = mockk(relaxed = true) { SimpleWorkerContext() } + + override fun configureApplication(applicationConfiguration: ApplicationConfiguration) { + this.applicationConfiguration = applicationConfiguration + } + + override fun schedulePeriodicSync() { + // Do nothing + } + + inner class FhirEngineImpl : FhirEngine { + + val mockedResourcesStore = mutableListOf() + + override suspend fun count(search: Search): Long = 1 + + override suspend fun getLastSyncTimeStamp(): OffsetDateTime? = OffsetDateTime.now() + + override suspend fun load(clazz: Class, id: String): R { + val existingResource = + mockedResourcesStore.find { it.hasId() && it.id == id } + ?: throw ResourceNotFoundException(id) + return existingResource as R + } + + override suspend fun remove(clazz: Class, id: String) { + mockedResourcesStore.removeIf { it.id == id } + } + + override suspend fun save(vararg resource: R) { + mockedResourcesStore.addAll(resource) + } + + @Suppress("UNCHECKED_CAST") + override suspend fun search(search: Search): List = + mockedResourcesStore.filter { search.filter(TokenClientParam(it.id), Identifier()) } as + List + + override suspend fun syncDownload(download: suspend (SyncDownloadContext) -> List) { + // Do nothing + } + + override suspend fun syncUpload( + upload: suspend (List) -> List + ) { + // Do nothing + } + + override suspend fun update(resource: R) { + // Replace old resource + mockedResourcesStore.removeIf { it.hasId() && it.id == resource.id } + mockedResourcesStore.add(resource) + } + } + + inner class FhirAuthenticationService : + AuthenticationService(ApplicationProvider.getApplicationContext()) { + override fun skipLogin(): Boolean = false + + override fun getLoginActivityClass(): Class<*> = ShadowLoginActivity::class.java + + override fun getAccountType(): String = "test.account.type" + + override fun clientSecret(): String = "test.client.secret" + + override fun clientId(): String = "test.client.id" + + override fun providerScope(): String = "openid" + + override fun getApplicationConfigurations(): ApplicationConfiguration = applicationConfiguration + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt index 27400f8e3f..2da70ae0d0 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt @@ -19,13 +19,21 @@ package org.smartregister.fhircore.engine.robolectric import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import io.mockk.clearAllMocks import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import org.junit.AfterClass import org.junit.runner.RunWith import org.robolectric.annotation.Config +import org.smartregister.fhircore.engine.impl.FhirApplication +import org.smartregister.fhircore.engine.shadow.ShadowNpmPackageProvider @RunWith(FhircoreTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) +@Config( + sdk = [Build.VERSION_CODES.O_MR1], + application = FhirApplication::class, + shadows = [ShadowNpmPackageProvider::class] +) abstract class RobolectricTest { /** Get the liveData value by observing but wait for 3 seconds if not ready then stop observing */ @Throws(InterruptedException::class) @@ -44,4 +52,12 @@ abstract class RobolectricTest { latch.await(3, TimeUnit.SECONDS) return data[0] as T? } + + companion object { + @JvmStatic + @AfterClass + fun resetMocks() { + clearAllMocks() + } + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt new file mode 100644 index 0000000000..70318f7e45 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.rule + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.smartregister.fhircore.engine.util.DispatcherProvider + +@ExperimentalCoroutinesApi +class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : + TestRule, TestCoroutineScope by TestCoroutineScope(testDispatcher) { + + val testDispatcherProvider = + object : DispatcherProvider { + override fun default() = testDispatcher + override fun io() = testDispatcher + override fun main() = testDispatcher + override fun unconfined() = testDispatcher + } + + override fun apply(base: Statement?, description: Description?) = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + Dispatchers.setMain(testDispatcher) + base?.evaluate() + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/FakeKeystore.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/FakeKeystore.kt new file mode 100644 index 0000000000..e5ccbc0626 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/FakeKeystore.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.shadow + +import java.io.InputStream +import java.io.OutputStream +import java.security.Key +import java.security.KeyStore +import java.security.KeyStoreSpi +import java.security.Provider +import java.security.SecureRandom +import java.security.Security +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.Date +import java.util.Enumeration +import javax.crypto.KeyGenerator +import javax.crypto.KeyGeneratorSpi +import javax.crypto.SecretKey + +object FakeKeyStore { + + val setup by lazy { + Security.addProvider( + object : Provider("AndroidKeyStore", 1.0, "") { + init { + put("KeyGenerator.AES", FakeAesKeyGenerator::class.java.name) + put("KeyStore.AndroidKeyStore", FakeKeyStore::class.java.name) + } + } + ) + } + + class FakeKeyStore : KeyStoreSpi() { + private val wrapped = KeyStore.getInstance(KeyStore.getDefaultType()) + + override fun engineIsKeyEntry(alias: String?): Boolean = wrapped.isKeyEntry(alias) + override fun engineIsCertificateEntry(alias: String?): Boolean = + wrapped.isCertificateEntry(alias) + override fun engineGetCertificate(alias: String?): Certificate = wrapped.getCertificate(alias) + override fun engineGetCreationDate(alias: String?): Date = wrapped.getCreationDate(alias) + override fun engineDeleteEntry(alias: String?) = wrapped.deleteEntry(alias) + override fun engineSetKeyEntry( + alias: String?, + key: Key?, + password: CharArray?, + chain: Array? + ) = wrapped.setKeyEntry(alias, key, password, chain) + + override fun engineSetKeyEntry( + alias: String?, + key: ByteArray?, + chain: Array? + ) = wrapped.setKeyEntry(alias, key, chain) + override fun engineStore(stream: OutputStream?, password: CharArray?) = + wrapped.store(stream, password) + override fun engineSize(): Int = wrapped.size() + override fun engineAliases(): Enumeration = wrapped.aliases() + override fun engineContainsAlias(alias: String?): Boolean = wrapped.containsAlias(alias) + override fun engineLoad(stream: InputStream?, password: CharArray?) = + wrapped.load(stream, password) + override fun engineGetCertificateChain(alias: String?): Array = + wrapped.getCertificateChain(alias) + override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) = + wrapped.setCertificateEntry(alias, cert) + override fun engineGetCertificateAlias(cert: Certificate?): String = + wrapped.getCertificateAlias(cert) + override fun engineGetKey(alias: String?, password: CharArray?): Key? = + wrapped.getKey(alias, password) + } + + class FakeAesKeyGenerator : KeyGeneratorSpi() { + private val wrapped = KeyGenerator.getInstance("AES") + + override fun engineInit(random: SecureRandom?) = Unit + override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = Unit + override fun engineInit(keysize: Int, random: SecureRandom?) = Unit + override fun engineGenerateKey(): SecretKey = wrapped.generateKey() + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/ShadowNpmPackageProvider.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/ShadowNpmPackageProvider.kt new file mode 100644 index 0000000000..26549c41d9 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/ShadowNpmPackageProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.shadow + +import android.content.Context +import com.google.android.fhir.datacapture.utilities.NpmPackageProvider +import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager +import org.hl7.fhir.utilities.npm.NpmPackage +import org.hl7.fhir.utilities.npm.ToolsVersion +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.util.ReflectionHelpers + +@Implements(NpmPackageProvider::class) +class ShadowNpmPackageProvider { + + @Implementation + suspend fun loadNpmPackage(context: Context): NpmPackage { + // Package name manually checked from + // https://simplifier.net/packages/hl7.fhir.r4.core/4.0.1 + val npmPackage = + FilesystemPackageCacheManager(true, ToolsVersion.TOOLS_VERSION) + .loadPackage("hl7.fhir.r4.core", "4.0.1") + + ReflectionHelpers.setField(NpmPackageProvider, "npmPackage", npmPackage) + return npmPackage + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/activity/ShadowLoginActivity.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/activity/ShadowLoginActivity.kt new file mode 100644 index 0000000000..114325d6c7 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/shadow/activity/ShadowLoginActivity.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.shadow.activity + +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowActivity +import org.smartregister.fhircore.engine.ui.login.BaseLoginActivity + +@Implements(BaseLoginActivity::class) class ShadowLoginActivity : ShadowActivity() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginViewModelTest.kt deleted file mode 100644 index d2fbf5209e..0000000000 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginViewModelTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2021 Ona Systems, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.smartregister.fhircore.engine.ui.components - -import androidx.test.core.app.ApplicationProvider -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.robolectric.util.ReflectionHelpers -import org.smartregister.fhircore.engine.auth.AuthenticationService -import org.smartregister.fhircore.engine.configuration.view.loginViewConfigurationOf -import org.smartregister.fhircore.engine.robolectric.RobolectricTest -import org.smartregister.fhircore.engine.ui.login.LoginViewModel - -class LoginViewModelTest : RobolectricTest() { - lateinit var loginViewModel: LoginViewModel - lateinit var authenticationService: AuthenticationService - - @Before - fun setup() { - authenticationService = mockk() - loginViewModel = - LoginViewModel( - ApplicationProvider.getApplicationContext(), - authenticationService, - loginViewConfigurationOf() - ) - } - - @Test - fun testAttemptLocalLoginShouldValidateLocalCredentials() { - every { - authenticationService.validLocalCredentials("testuser", "testpw".toCharArray()) - } returns true - - loginViewModel.onUsernameUpdated("testuser") - loginViewModel.onPasswordUpdated("testpw") - - val result = ReflectionHelpers.callInstanceMethod(loginViewModel, "attemptLocalLogin") - - Assert.assertTrue(result) - - verify { authenticationService.validLocalCredentials(any(), any()) } - } - - @Test - fun testAttemptLocalLoginShouldReturnFalseForInvalidLocalCredentials() { - every { - authenticationService.validLocalCredentials("testuser", "invalid".toCharArray()) - } returns false - - loginViewModel.onUsernameUpdated("testuser") - loginViewModel.onPasswordUpdated("invalid") - - val result = ReflectionHelpers.callInstanceMethod(loginViewModel, "attemptLocalLogin") - - Assert.assertFalse(result) - - verify { authenticationService.validLocalCredentials(any(), any()) } - } - - @Test - fun testLoginUserNavigateToHomeWithActiveSession() { - every { authenticationService.hasActiveSession() } returns true - every { authenticationService.skipLogin() } returns false - - loginViewModel.loginUser() - - verify(timeout = 2000) { authenticationService.hasActiveSession() } - verify(inverse = true, timeout = 2000) { authenticationService.loadActiveAccount(any(), any()) } - } - - @Test - fun testLoginUserShouldTryLoadActiveWithNonActiveSession() { - every { authenticationService.hasActiveSession() } returns false - every { authenticationService.skipLogin() } returns false - - loginViewModel.loginUser() - - verify(timeout = 2000) { authenticationService.hasActiveSession() } - verify(timeout = 2000) { authenticationService.loadActiveAccount(any(), any()) } - } -} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt new file mode 100644 index 0000000000..901cb23591 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.login + +import android.app.Application +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.ResponseBody +import okhttp3.internal.http.RealResponseBody +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.auth.AuthenticationService +import org.smartregister.fhircore.engine.configuration.app.ConfigurableApplication +import org.smartregister.fhircore.engine.configuration.view.loginViewConfigurationOf +import org.smartregister.fhircore.engine.data.remote.model.response.UserResponse +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.rule.CoroutineTestRule +import org.smartregister.fhircore.engine.shadow.FakeKeyStore +import org.smartregister.fhircore.engine.util.USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY +import org.smartregister.fhircore.engine.util.extension.encodeJson +import retrofit2.Response + +@ExperimentalCoroutinesApi +internal class LoginViewModelTest : RobolectricTest() { + + @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val application: Application = ApplicationProvider.getApplicationContext() + + private lateinit var loginViewModel: LoginViewModel + + private lateinit var configurableApplication: ConfigurableApplication + + private lateinit var authenticationService: AuthenticationService + + companion object { + @JvmStatic + @BeforeClass + fun resetMocks() { + FakeKeyStore.setup + } + } + + @Before + fun setUp() { + configurableApplication = application as ConfigurableApplication + authenticationService = spyk(configurableApplication.authenticationService) + loginViewModel = + spyk( + objToCopy = + LoginViewModel( + application = ApplicationProvider.getApplicationContext(), + authenticationService = authenticationService, + loginViewConfiguration = loginViewConfigurationOf(), + dispatcher = coroutineTestRule.testDispatcherProvider + ), + recordPrivateCalls = true + ) + } + + @After + fun tearDown() { + // Reset defaults after every test + loginViewModel.run { + updateViewConfigurations(loginViewConfigurationOf()) + onUsernameUpdated("") + onPasswordUpdated("") + } + } + + @Test + fun testLoginUserNavigateToHomeWithActiveSession() = + coroutineTestRule.runBlockingTest { + every { authenticationService.hasActiveSession() } returns true + every { authenticationService.skipLogin() } returns true + + loginViewModel.loginUser() + + // Navigate home is set to true + Assert.assertNotNull(loginViewModel.navigateToHome.value) + Assert.assertTrue(loginViewModel.navigateToHome.value!!) + } + + @Test + fun testLoginUserShouldTryLoadActiveWithNonActiveSession() = + coroutineTestRule.runBlockingTest { + every { authenticationService.hasActiveSession() } returns false + every { authenticationService.skipLogin() } returns false + + loginViewModel.loginUser() + + verify { authenticationService.loadActiveAccount(any(), any()) } + } + + @Test + fun testThatViewModelIsInitialized() { + Assert.assertNotNull(loginViewModel) + } + + @Test + fun testOnPasswordChanged() { + val newPassword = "NewP455W0rd" + loginViewModel.onPasswordUpdated(newPassword) + Assert.assertNotNull(loginViewModel.password.value) + Assert.assertEquals(newPassword, loginViewModel.password.value) + } + + @Test + fun testOnUsernameChanged() { + val username = "username" + loginViewModel.onUsernameUpdated(username) + Assert.assertNotNull(loginViewModel.username.value) + Assert.assertEquals(username, loginViewModel.username.value) + } + + @Test + fun testApplicationConfiguration() { + val coolAppName = "Cool App" + loginViewModel.updateViewConfigurations(loginViewConfigurationOf(applicationName = coolAppName)) + Assert.assertNotNull(loginViewModel.loginViewConfiguration.value) + Assert.assertEquals(coolAppName, loginViewModel.loginViewConfiguration.value?.applicationName) + } + + @Test + fun testResponseBodyHandlerWithSuccessfulResponse() { + val realResponseBody = spyk(RealResponseBody("", 10, spyk())) + val userResponse = UserResponse("G6PD") + every { realResponseBody.string() } returns userResponse.encodeJson() + val response: Response = spyk(Response.success(realResponseBody)) + loginViewModel.responseBodyHandler.handleResponse(spyk(), response) + + // Shared preference saved G6PD + Assert.assertEquals( + userResponse.questionnairePublisher, + loginViewModel.sharedPreferences.read( + USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY, + null + ) + ) + Assert.assertNotNull(loginViewModel.showProgressBar.value) + Assert.assertFalse(loginViewModel.showProgressBar.value!!) + } + + @Test + fun testResponseBodyHandlerWithFailedResponse() { + val errorMessage = "We have a problem" + loginViewModel.responseBodyHandler.handleFailure(spyk(), IllegalStateException(errorMessage)) + // Login error shared + Assert.assertNotNull(loginViewModel.loginError.value) + Assert.assertTrue(loginViewModel.loginError.value!!.isNotEmpty()) + Assert.assertEquals(errorMessage, loginViewModel.loginError.value) + Assert.assertNotNull(loginViewModel.showProgressBar.value) + Assert.assertFalse(loginViewModel.showProgressBar.value!!) + } + + @Test + fun testOauthResponseHandlerWithFailureWithSuccessfulPreviousLogin() { + // set username = 'demo' and password = 'Amani123' for local login + loginViewModel.onUsernameUpdated("demo") + loginViewModel.onPasswordUpdated("Amani123") + + every { authenticationService.validLocalCredentials(any(), any()) } returns true + + val errorMessage = "We have a problem login you in" + loginViewModel.oauthResponseHandler.handleFailure(spyk(), IllegalStateException(errorMessage)) + + // Direct user to register screen instead + Assert.assertNotNull(loginViewModel.navigateToHome.value) + Assert.assertTrue(loginViewModel.navigateToHome.value!!) + } + + @Test + fun testOauthResponseHandlerWithFailureWithFailedPreviousLogin() { + // set username = 'demo' and password = 'Amani123' for local login + loginViewModel.onUsernameUpdated("demo") + loginViewModel.onPasswordUpdated("Amani123") + + val errorMessage = "We have a problem login you in" + loginViewModel.oauthResponseHandler.handleFailure(spyk(), IllegalStateException(errorMessage)) + + // Show error message + Assert.assertNotNull(loginViewModel.loginError.value) + Assert.assertTrue(loginViewModel.loginError.value!!.isNotEmpty()) + Assert.assertEquals(errorMessage, loginViewModel.loginError.value) + Assert.assertNotNull(loginViewModel.showProgressBar.value) + Assert.assertFalse(loginViewModel.showProgressBar.value!!) + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index ca9beaafe8..75f51e65e2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -24,6 +24,7 @@ import com.google.android.fhir.sync.SyncJob import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.auth.AuthenticationService import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration @@ -32,6 +33,7 @@ import org.smartregister.fhircore.engine.configuration.app.loadApplicationConfig import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY import org.smartregister.fhircore.engine.util.extension.initializeWorkerContext import org.smartregister.fhircore.engine.util.extension.runPeriodicSync import timber.log.Timber @@ -56,12 +58,22 @@ class QuestApplication : Application(), ConfigurableApplication { get() = SecureSharedPreference(applicationContext) override val resourceSyncParams: Map> - get() = - mapOf( + get() { + return mapOf( + ResourceType.Binary to mapOf("_id" to CONFIG_RESOURCE_IDS), + ResourceType.CarePlan to mapOf(), ResourceType.Patient to mapOf(), - ResourceType.Questionnaire to mapOf(), - ResourceType.Binary to mapOf("_id" to CONFIG_RESOURCE_IDS) + ResourceType.Questionnaire to buildQuestionnaireFilterMap() ) + } + + private fun buildQuestionnaireFilterMap(): MutableMap { + val questionnaireFilterMap: MutableMap = HashMap() + val publisher = + SharedPreferencesHelper.read(USER_QUESTIONNAIRE_PUBLISHER_SHARED_PREFERENCE_KEY, null) + if (publisher != null) questionnaireFilterMap[Questionnaire.SP_PUBLISHER] = publisher + return questionnaireFilterMap + } private fun constructFhirEngine(): FhirEngine { return FhirEngineProvider.getInstance(this)