diff --git a/infra/enrolment-records/repository/build.gradle.kts b/infra/enrolment-records/repository/build.gradle.kts index e87c549e8a..fd6d45e7e6 100644 --- a/infra/enrolment-records/repository/build.gradle.kts +++ b/infra/enrolment-records/repository/build.gradle.kts @@ -1,10 +1,14 @@ plugins { id("simprints.infra") id("kotlin-parcelize") + id("simprints.testing.android") } android { namespace = "com.simprints.infra.enrolment.records.repository" + defaultConfig { + testInstrumentationRunner = "com.simprints.infra.enrolment.records.repository.local.HiltTestRunner" + } } dependencies { diff --git a/infra/enrolment-records/repository/src/androidTest/AndroidManifest.xml b/infra/enrolment-records/repository/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..f758f8f81f --- /dev/null +++ b/infra/enrolment-records/repository/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/HiltTestRunner.kt b/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/HiltTestRunner.kt new file mode 100644 index 0000000000..80d53ffbc6 --- /dev/null +++ b/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/HiltTestRunner.kt @@ -0,0 +1,14 @@ +package com.simprints.infra.enrolment.records.repository.local + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader, + name: String, + context: Context, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceIntegrationTest.kt b/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceIntegrationTest.kt new file mode 100644 index 0000000000..e54632e48c --- /dev/null +++ b/infra/enrolment-records/repository/src/androidTest/kotlin/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceIntegrationTest.kt @@ -0,0 +1,405 @@ +package com.simprints.infra.enrolment.records.repository.local + +import androidx.test.core.app.* +import com.google.common.truth.Truth.* +import com.simprints.core.domain.face.FaceSample +import com.simprints.core.domain.fingerprint.FingerprintSample +import com.simprints.core.domain.fingerprint.IFingerIdentifier +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.enrolment.records.realm.store.RealmWrapper +import com.simprints.infra.enrolment.records.realm.store.RealmWrapperImpl +import com.simprints.infra.enrolment.records.realm.store.config.RealmConfig +import com.simprints.infra.enrolment.records.realm.store.models.DbSubject +import com.simprints.infra.enrolment.records.repository.domain.models.FaceIdentity +import com.simprints.infra.enrolment.records.repository.domain.models.FingerprintIdentity +import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import com.simprints.infra.security.SecurityManager +import com.simprints.infra.security.keyprovider.LocalDbKey +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.* +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import java.util.UUID +import kotlin.test.Test + +@HiltAndroidTest +class RealmEnrolmentRecordLocalDataSourceIntegrationTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + private lateinit var realm: Realm + private lateinit var realmWrapper: RealmWrapper + + private lateinit var dataSource: RealmEnrolmentRecordLocalDataSource + + private val mockSecurityManager: SecurityManager = mockk { + justRun { recreateLocalDatabaseKey(any()) } + every { getLocalDbKeyOrThrow(any()) } returns LocalDbKey("test-project", ByteArray(64) { 1 }) + } + private val mockAuthStore: AuthStore = mockk { + every { signedInProjectId } returns "test-project" + every { signedInUserId } returns "test-user".asTokenizableRaw() + } + private val mockTokenizationProcessor: TokenizationProcessor = mockk { + coEvery { + tokenizeIfNecessary(any(), any(), any()) + } answers { firstArg() } + } + + @Before + fun setUp() { + hiltRule.inject() + + realmWrapper = RealmWrapperImpl( + appContext = ApplicationProvider.getApplicationContext(), + configFactory = RealmConfig(), + dispatcher = Dispatchers.Unconfined, + securityManager = mockSecurityManager, + authStore = mockAuthStore, + ) + + dataSource = RealmEnrolmentRecordLocalDataSource( + realmWrapper, + mockTokenizationProcessor, + Dispatchers.Unconfined, + ) + // capture the realm instance used by the data source to verify results + runBlocking { + realmWrapper.readRealm { + realm = it + } + } + } + + @After + fun tearDown() { + realm.close() + } + + @Test + fun givenASubjectCreationActionWhenPerformActionsIsCalledThenTheSubjectIsSavedInRealm() = runTest { + // Given + val subjectId = UUID.randomUUID().toString() + val subject = createTestSubject(subjectId) + val creationAction = SubjectAction.Creation(subject) + val project = mockk() + + // When + dataSource.performActions(listOf(creationAction), project) + + // Then + val savedSubject = realm + .query( + "subjectId == $0", + io.realm.kotlin.types.RealmUUID + .from(subjectId), + ).first() + .find() + assertThat(savedSubject).isNotNull() + assertThat(savedSubject?.subjectId?.toString()).isEqualTo(subjectId) + assertThat(savedSubject?.projectId).isEqualTo(subject.projectId) + } + + @Test + fun givenASubjectExistsWhenLoadIsCalledWithAMatchingQueryThenTheSubjectIsReturned() = runTest { + // Given + val subjectId = UUID.randomUUID().toString() + val subject = createTestSubject(subjectId) + dataSource.performActions(listOf(SubjectAction.Creation(subject)), mockk()) + + val query = SubjectQuery(subjectId = subjectId) + + // When + val result = dataSource.load(query) + + // Then + assertThat(result).hasSize(1) + assertThat(result.first().subjectId).isEqualTo(subjectId) + } + + @Test + fun givenASubjectExistsWhenCountIsCalledThenTheCorrectCountIsReturned() = runTest { + // Given + val subject1 = createTestSubject() + val subject2 = createTestSubject(projectId = "other-project") + dataSource.performActions( + listOf( + SubjectAction.Creation(subject1), + SubjectAction.Creation(subject2), + ), + mockk(), + ) + // Query to match only subject1 + val query = SubjectQuery(projectId = subject1.projectId) + + // When + val count = dataSource.count(query, mockk()) + + // Then + assertThat(count).isEqualTo(1) + } + + @Test + fun givenASubjectDeletionActionWhenPerformActionsIsCalledThenTheSubjectIsRemovedFromRealm() = runTest { + // Given + val subjectId = UUID.randomUUID().toString() + val subject = createTestSubject(subjectId) + dataSource.performActions(listOf(SubjectAction.Creation(subject)), mockk()) + + // Verify it was created + var count = dataSource.count(SubjectQuery(subjectId = subjectId), mockk()) + assertThat(count).isEqualTo(1) + + val deletionAction = SubjectAction.Deletion(subjectId) + + // When + dataSource.performActions(listOf(deletionAction), mockk()) + + // Then + count = dataSource.count(SubjectQuery(subjectId = subjectId), mockk()) + assertThat(count).isEqualTo(0) + } + + @Test + fun givenMultipleSubjectsExistWhenDeleteIsCalledWithQueriesThenOnlyMatchingSubjectsAreDeleted() = runTest { + // Given + val subject1 = createTestSubject(projectId = "proj1") + val subject2 = createTestSubject(projectId = "proj1") + val subject3 = createTestSubject(projectId = "proj2") + dataSource.performActions( + listOf( + SubjectAction.Creation(subject1), + SubjectAction.Creation(subject2), + SubjectAction.Creation(subject3), + ), + mockk(), + ) + + val queryToDelete = SubjectQuery(projectId = "proj1") + + // When + dataSource.delete(listOf(queryToDelete)) + + // Then + val remainingCount = dataSource.count(SubjectQuery(), mockk()) + assertThat(remainingCount).isEqualTo(1) + + val allSubjects = dataSource.load(SubjectQuery()) + assertThat(allSubjects.first().projectId).isEqualTo("proj2") + } + + @Test + fun givenMultipleSubjectsExistWhenDeleteAllIsCalledThenAllSubjectsAreRemoved() = runTest { + // Given + val subject1 = createTestSubject() + val subject2 = createTestSubject() + dataSource.performActions( + listOf( + SubjectAction.Creation(subject1), + SubjectAction.Creation(subject2), + ), + mockk(), + ) + + // When + dataSource.deleteAll() + + // Then + val count = dataSource.count(SubjectQuery(), mockk()) + assertThat(count).isEqualTo(0) + } + + // test subject updates + @Test + fun givenASubjectUpdateActionWhenPerformActionsIsCalledThenTheSubjectIsUpdatedInRealm() = runTest { + // Given + val subjectId = UUID.randomUUID().toString() + val originalSubject = createTestSubject(subjectId) + originalSubject.faceSamples = listOf( + FaceSample( + template = byteArrayOf(), + format = "ISO", + referenceId = "ref1", + ), + ) + dataSource.performActions(listOf(SubjectAction.Creation(originalSubject)), mockk()) + + val updateAction = SubjectAction.Update( + subjectId, + faceSamplesToAdd = listOf( + FaceSample( + template = byteArrayOf(1, 2, 3), + format = "ISO", + referenceId = "ref2", + ), + ), + fingerprintSamplesToAdd = listOf( + FingerprintSample( + template = byteArrayOf(4, 5, 6), + format = "ISO", + referenceId = "ref3", + fingerIdentifier = IFingerIdentifier.LEFT_THUMB, + ), + ), + referenceIdsToRemove = listOf("ref1"), + ) + val project = mockk() + // When + dataSource.performActions(listOf(updateAction), project) + // Then + val savedSubject = realm + .query( + "subjectId == $0", + io.realm.kotlin.types.RealmUUID + .from(subjectId), + ).first() + .find() + + assertThat(savedSubject).isNotNull() + assertThat(savedSubject?.faceSamples).hasSize(1) + assertThat(savedSubject?.faceSamples?.first()?.referenceId).isEqualTo("ref2") + assertThat(savedSubject?.fingerprintSamples).hasSize(1) + assertThat(savedSubject?.fingerprintSamples?.first()?.referenceId).isEqualTo("ref3") + } + + @Test + fun givenManySubjectsWithFaceSamples_whenLoadFaceIdentitiesIsCalledWithRanges_thenReturnsBatchedFaceIdentities() = runTest { + // Given + val subjects = (1..10).map { i -> + createTestSubject(subjectId = UUID.randomUUID().toString()).apply { + faceSamples = listOf( + FaceSample(template = byteArrayOf(i.toByte()), format = "ISO", referenceId = "ref$i"), + ) + } + } + dataSource.performActions( + subjects.map { SubjectAction.Creation(it) }, + mockk(), + ) + + val query = SubjectQuery(faceSampleFormat = "ISO") + val ranges = listOf(0..2, 3..5, 6..9) // 3 batches + val loadedCandidates = mutableListOf() + + // When + val channel = dataSource.loadFaceIdentities( + query = query, + ranges = ranges, + dataSource = mockk(), + project = mockk(), + scope = this, + onCandidateLoaded = { loadedCandidates.add(Unit) }, + ) + + val results = mutableListOf>() + for (batch in channel) { + results.add(batch) + } + + // Then + assertThat(results).hasSize(3) + assertThat(results[0]).hasSize(3) + assertThat(results[1]).hasSize(3) + assertThat(results[2]).hasSize(4) + assertThat(loadedCandidates).hasSize(10) + } + + @Test + fun givenManySubjectsWithFingerprintSamples_whenLoadFingerprintIdentitiesIsCalledWithRanges_thenReturnsBatchedFingerprintIdentities() = + runTest { + // Given + val subjects = (1..10).map { i -> + createTestSubject(subjectId = UUID.randomUUID().toString()).apply { + fingerprintSamples = listOf( + FingerprintSample( + template = byteArrayOf(i.toByte()), + format = "ISO", + referenceId = "ref$i", + fingerIdentifier = IFingerIdentifier.LEFT_THUMB, + ), + ) + } + } + dataSource.performActions( + subjects.map { SubjectAction.Creation(it) }, + mockk(), + ) + + val query = SubjectQuery(fingerprintSampleFormat = "ISO") + val ranges = listOf(0..2, 3..5, 6..9) // 3 batches + val loadedCandidates = mutableListOf() + + // When + val channel = dataSource.loadFingerprintIdentities( + query = query, + ranges = ranges, + dataSource = mockk(), + project = mockk(), + scope = this, + onCandidateLoaded = { loadedCandidates.add(Unit) }, + ) + + val results = mutableListOf>() + for (batch in channel) { + results.add(batch) + } + + // Then + assertThat(results).hasSize(3) + assertThat(results[0]).hasSize(3) + assertThat(results[1]).hasSize(3) + assertThat(results[2]).hasSize(4) + assertThat(loadedCandidates).hasSize(10) + } + + @Test + fun givenManySubjects_whenLoadAllSubjectsInBatchesIsCalled_thenReturnsSubjectsInBatches() = runTest { + // Given + val subjects = (1..10).map { i -> + createTestSubject(subjectId = UUID.randomUUID().toString()) + } + dataSource.performActions( + subjects.map { SubjectAction.Creation(it) }, + mockk(), + ) + + val batchSize = 4 + val batches = mutableListOf>() + + // When + val flow = dataSource.loadAllSubjectsInBatches(batchSize) + flow.collect { batch -> + batches.add(batch) + } + + // Then + assertThat(batches).hasSize(3) + assertThat(batches[0]).hasSize(4) + assertThat(batches[1]).hasSize(4) + assertThat(batches[2]).hasSize(2) + assertThat(batches.flatten()).hasSize(10) + } + + private fun createTestSubject( + subjectId: String = UUID.randomUUID().toString(), + projectId: String = "test-project", + attendantId: String = "test-attendant", + moduleId: String = "test-module", + ): Subject = Subject( + subjectId = subjectId, + projectId = projectId, + attendantId = attendantId.asTokenizableRaw(), + moduleId = moduleId.asTokenizableRaw(), + ) +}