From 4d90932cdcf9e201ec003d185644b1831e69b406 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 30 Oct 2025 16:55:59 +0200 Subject: [PATCH 1/3] MS-1228 Add repository boilerplate to allow deleting individual events in session --- .../simprints/infra/events/EventRepository.kt | 2 + .../infra/events/EventRepositoryImpl.kt | 2 + .../event/local/EventLocalDataSource.kt | 4 ++ .../infra/events/event/local/EventRoomDao.kt | 3 + .../events/session/SessionEventRepository.kt | 2 + .../session/SessionEventRepositoryImpl.kt | 6 ++ .../infra/events/EventRepositoryImplTest.kt | 7 ++ .../event/local/EventLocalDataSourceTest.kt | 7 ++ .../events/event/local/EventRoomDaoTest.kt | 67 ++++++++++--------- .../session/SessionEventRepositoryImplTest.kt | 12 ++++ 10 files changed, 80 insertions(+), 32 deletions(-) diff --git a/infra/events/src/main/java/com/simprints/infra/events/EventRepository.kt b/infra/events/src/main/java/com/simprints/infra/events/EventRepository.kt index 874a6757ba..fc6d5c614e 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/EventRepository.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/EventRepository.kt @@ -65,6 +65,8 @@ interface EventRepository { scopeEvents: List? = null, ): Event + suspend fun deleteEvents(eventIds: List) + suspend fun deleteAll() /** diff --git a/infra/events/src/main/java/com/simprints/infra/events/EventRepositoryImpl.kt b/infra/events/src/main/java/com/simprints/infra/events/EventRepositoryImpl.kt index e184fdade9..3ee835b29d 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/EventRepositoryImpl.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/EventRepositoryImpl.kt @@ -193,6 +193,8 @@ internal open class EventRepositoryImpl @Inject constructor( return event } + override suspend fun deleteEvents(eventIds: List) = eventLocalDataSource.deleteEvents(eventIds) + override suspend fun deleteAll() = eventLocalDataSource.deleteAll() private suspend fun reportException(block: suspend () -> T): T = try { diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventLocalDataSource.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventLocalDataSource.kt index faf2cf5a5c..37bcc48774 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventLocalDataSource.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventLocalDataSource.kt @@ -170,6 +170,10 @@ internal open class EventLocalDataSource @Inject constructor( } } + suspend fun deleteEvents(eventIds: List) = useRoom(writingContext) { + eventDao.deleteById(eventIds) + } + suspend fun deleteAll() = useRoom(writingContext) { scopeDao.deleteAll() eventDao.deleteAll() diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDao.kt b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDao.kt index 8a38365244..5a5d9f8cbf 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDao.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/local/EventRoomDao.kt @@ -34,6 +34,9 @@ internal interface EventRoomDao { ) fun observeCountInClosedScopes(): Flow + @Query("delete from DbEvent where id in (:eventIds)") + suspend fun deleteById(eventIds: List) + @Query("delete from DbEvent where scopeId = :scopeId") suspend fun deleteAllFromScope(scopeId: String) diff --git a/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepository.kt b/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepository.kt index ede12a09fe..bfe2a921f7 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepository.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepository.kt @@ -23,5 +23,7 @@ interface SessionEventRepository { suspend fun addOrUpdateEvent(event: Event) + suspend fun deleteEvents(events: List) + suspend fun closeCurrentSession(reason: EventScopeEndCause? = null) } diff --git a/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepositoryImpl.kt b/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepositoryImpl.kt index 8ba99ea1ea..681b8a0d37 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepositoryImpl.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/session/SessionEventRepositoryImpl.kt @@ -66,6 +66,12 @@ internal class SessionEventRepositoryImpl @Inject constructor( sessionDataCache.eventCache[savedEvent.id] = savedEvent } + override suspend fun deleteEvents(events: List): Unit = withLockedContext { + val ids = events.map { it.id } + eventRepository.deleteEvents(ids) + ids.forEach { sessionDataCache.eventCache.remove(it) } + } + override suspend fun closeCurrentSession(reason: EventScopeEndCause?) = withLockedContext { eventRepository.closeEventScope(getCurrentScopeInternal(), reason) credentialImageRepository.deleteAllCredentialScans() diff --git a/infra/events/src/test/java/com/simprints/infra/events/EventRepositoryImplTest.kt b/infra/events/src/test/java/com/simprints/infra/events/EventRepositoryImplTest.kt index 75388342bc..925ab24ced 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/EventRepositoryImplTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/EventRepositoryImplTest.kt @@ -399,6 +399,13 @@ internal class EventRepositoryImplTest { verify { eventValidator.validate(listOf(eventInScope), newEvent) } } + @Test + fun `should delegate delete events`() = runTest { + eventRepo.deleteEvents(listOf("eventId")) + + coVerify { eventLocalDataSource.deleteEvents(listOf("eventId")) } + } + @Test fun `should delegate delete all`() = runTest { eventRepo.deleteAll() diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/EventLocalDataSourceTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/EventLocalDataSourceTest.kt index 012644d3e9..bf8bacd278 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/EventLocalDataSourceTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/EventLocalDataSourceTest.kt @@ -441,6 +441,13 @@ internal class EventLocalDataSourceTest { coVerify { eventDao.deleteAllFromScope(GUID1) } } + @Test + fun deleteEvents() = runTest { + eventLocalDataSource.deleteEvents(listOf(GUID1)) + + coVerify { eventDao.deleteById(listOf(GUID1)) } + } + @Test fun deleteAll() = runTest { eventLocalDataSource.deleteAll() diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/EventRoomDaoTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/EventRoomDaoTest.kt index 6687df4707..868f06549c 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/EventRoomDaoTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/EventRoomDaoTest.kt @@ -2,9 +2,9 @@ package com.simprints.infra.events.event.local import android.content.Context import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat +import androidx.test.core.app.* +import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.* import com.simprints.core.tools.utils.randomUUID import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.events.event.local.models.DbEvent @@ -13,7 +13,8 @@ import com.simprints.infra.events.sampledata.SampleDefaults.CREATED_AT import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_PROJECT_ID import com.simprints.infra.events.sampledata.SampleDefaults.GUID1 import com.simprints.infra.events.sampledata.SampleDefaults.GUID2 -import io.mockk.MockKAnnotations +import com.simprints.infra.events.sampledata.SampleDefaults.GUID3 +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -54,43 +55,45 @@ internal class EventRoomDaoTest { } @Test - fun loadBySessionId() { - runTest { - val wrongEvent = event.copy(id = randomUUID(), scopeId = GUID2) - addIntoDb(event, wrongEvent) - verifyEvents(listOf(event), eventDao.loadFromScope(scopeId = GUID1)) - } + fun loadBySessionId() = runTest { + val wrongEvent = event.copy(id = randomUUID(), scopeId = GUID2) + addIntoDb(event, wrongEvent) + verifyEvents(listOf(event), eventDao.loadFromScope(scopeId = GUID1)) } @Test - fun loadEventJsonFormSession() { - runTest { - addIntoDb(event) - val results = eventDao.loadEventJsonFromScope(GUID1) - assertThat(results).containsExactlyElementsIn(listOf(eventJson)) - } + fun loadEventJsonFormSession() = runTest { + addIntoDb(event) + val results = eventDao.loadEventJsonFromScope(GUID1) + assertThat(results).containsExactlyElementsIn(listOf(eventJson)) } @Test - fun loadAll() { - runTest { - val secondEvent = event.copy(id = randomUUID()) - addIntoDb(event, secondEvent) - verifyEvents(listOf(event, secondEvent), eventDao.loadAll()) - } + fun loadAll() = runTest { + val secondEvent = event.copy(id = randomUUID()) + addIntoDb(event, secondEvent) + verifyEvents(listOf(event, secondEvent), eventDao.loadAll()) } @Test - fun deletionBySessionId() { - runTest { - val eventSameSession = - event.copy(id = randomUUID(), scopeId = GUID1) - val eventDifferentSession = - event.copy(id = randomUUID(), scopeId = GUID2) - addIntoDb(event, eventSameSession, eventDifferentSession) - db.eventDao.deleteAllFromScope(scopeId = GUID1) - verifyEvents(listOf(eventDifferentSession), eventDao.loadAll()) - } + fun deletionBySessionId() = runTest { + val eventSameSession = + event.copy(id = randomUUID(), scopeId = GUID1) + val eventDifferentSession = + event.copy(id = randomUUID(), scopeId = GUID2) + addIntoDb(event, eventSameSession, eventDifferentSession) + db.eventDao.deleteAllFromScope(scopeId = GUID1) + verifyEvents(listOf(eventDifferentSession), eventDao.loadAll()) + } + + @Test + fun deletionEventsById() = runTest { + val event1 = event.copy(id = GUID1) + val event2 = event.copy(id = GUID2) + val event3 = event.copy(id = GUID3) + addIntoDb(event1, event2, event3) + db.eventDao.deleteById(listOf(GUID1, GUID2)) + verifyEvents(listOf(event3), eventDao.loadAll()) } private suspend fun addIntoDb(vararg events: DbEvent) { diff --git a/infra/events/src/test/java/com/simprints/infra/events/session/SessionEventRepositoryImplTest.kt b/infra/events/src/test/java/com/simprints/infra/events/session/SessionEventRepositoryImplTest.kt index 9070cbcbe5..873c3c684d 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/session/SessionEventRepositoryImplTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/session/SessionEventRepositoryImplTest.kt @@ -204,6 +204,18 @@ internal class SessionEventRepositoryImplTest { assertThat(sessionDataCache.eventCache["eventId"]?.scopeId).isEqualTo("updatedEventScopeId") } + @Test + fun `deletes provided events from DB and cache`() = runTest { + val event = createEventWithSessionId("eventId", "mockId") + sessionDataCache.eventCache["eventId"] = event + coJustRun { eventRepository.deleteEvents(any()) } + + sessionEventRepository.deleteEvents(listOf(event)) + + assertThat(sessionDataCache.eventCache["eventId"]).isNull() + coVerify { eventRepository.deleteEvents(listOf("eventId")) } + } + @Test fun `clears cache when closing session`() = runTest { sessionDataCache.eventScope = createSessionScope("mockId") From 4d10aec81672ce6bb1437dc106f66e5350582ab6 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 3 Nov 2025 11:52:24 +0200 Subject: [PATCH 2/3] MS-1228 Add a way to remove external credentials from subject by id --- ...entRecordLocalDataSourceIntegrationTest.kt | 19 ++++-- .../repository/domain/models/SubjectAction.kt | 3 +- .../RealmEnrolmentRecordLocalDataSource.kt | 7 +- .../RoomEnrolmentRecordLocalDataSource.kt | 7 +- ...RealmEnrolmentRecordLocalDataSourceTest.kt | 68 +++++++++++++++---- .../RoomEnrolmentRecordLocalDataSourceTest.kt | 56 ++++++++++++--- .../records/room/store/SubjectDao.kt | 3 + .../sync/down/tasks/BaseEventDownSyncTask.kt | 1 + 8 files changed, 132 insertions(+), 32 deletions(-) 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 index 00ac46d6ae..f11482bfff 100644 --- 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 @@ -243,6 +243,14 @@ class RealmEnrolmentRecordLocalDataSourceIntegrationTest { referenceId = "ref1", ), ) + originalSubject.externalCredentials = listOf( + ExternalCredential( + id = "id-1", + value = "value".asTokenizableEncrypted(), + subjectId = "subjectId", + type = ExternalCredentialType.NHISCard, + ), + ) dataSource.performActions(listOf(SubjectAction.Creation(originalSubject)), mockk()) val updateAction = SubjectAction.Update( @@ -265,12 +273,13 @@ class RealmEnrolmentRecordLocalDataSourceIntegrationTest { referenceIdsToRemove = listOf("ref1"), externalCredentialsToAdd = listOf( ExternalCredential( - id = "id", + id = "id-2", value = "value".asTokenizableEncrypted(), subjectId = "subjectId", - type = ExternalCredentialType.NHISCard - ) - ) + type = ExternalCredentialType.NHISCard, + ), + ), + externalCredentialIdsToRemove = listOf("id-1"), ) val project = mockk() // When @@ -290,7 +299,7 @@ class RealmEnrolmentRecordLocalDataSourceIntegrationTest { assertThat(savedSubject?.fingerprintSamples).hasSize(1) assertThat(savedSubject?.fingerprintSamples?.first()?.referenceId).isEqualTo("ref3") savedSubject?.externalCredentials?.first()?.let { - assertThat(it.id).isEqualTo("id") + assertThat(it.id).isEqualTo("id-2") assertThat(it.value).isEqualTo("value") assertThat(it.subjectId).isEqualTo("subjectId") assertThat(it.type).isEqualTo(ExternalCredentialType.NHISCard.toString()) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/SubjectAction.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/SubjectAction.kt index 7953eac800..e222562f1c 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/SubjectAction.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/domain/models/SubjectAction.kt @@ -15,8 +15,9 @@ sealed class SubjectAction { val subjectId: String, val faceSamplesToAdd: List, val fingerprintSamplesToAdd: List, - val externalCredentialsToAdd: List, val referenceIdsToRemove: List, + val externalCredentialsToAdd: List, + val externalCredentialIdsToRemove: List, ) : SubjectAction() data class Deletion( diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt index 37427298d0..bc51a673d5 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSource.kt @@ -236,10 +236,12 @@ internal class RealmEnrolmentRecordLocalDataSource @Inject constructor( val referencesToDelete = action.referenceIdsToRemove.toSet() // to make lookup O(1) val faceSamplesMap = dbSubject.faceSamples.groupBy { it.referenceId in referencesToDelete } val fingerprintSamplesMap = dbSubject.fingerprintSamples.groupBy { it.referenceId in referencesToDelete } + + val credentialsToDelete = action.externalCredentialIdsToRemove.toSet() + val externalCredentialMap = dbSubject.externalCredentials.groupBy { it.id in credentialsToDelete } val allExternalCredentials = - (dbSubject.externalCredentials + action.externalCredentialsToAdd.map { it.toRealmDb() }) + (externalCredentialMap[false].orEmpty() + action.externalCredentialsToAdd.map { it.toRealmDb() }) .distinctBy { it.id } - .toSet() // Append new samples to the list of samples that remain after removing dbSubject.faceSamples = ( @@ -252,6 +254,7 @@ internal class RealmEnrolmentRecordLocalDataSource @Inject constructor( faceSamplesMap[true]?.forEach { realm.delete(it) } fingerprintSamplesMap[true]?.forEach { realm.delete(it) } + externalCredentialMap[true]?.forEach { realm.delete(it) } realm.copyToRealm(dbSubject, updatePolicy = UpdatePolicy.ALL) } else { Simber.i("[SubjectLocalDataSourceImpl] Subject not found for update", tag = REALM_DB) diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt index 5fe58b49e9..a6a056309e 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSource.kt @@ -308,7 +308,8 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( referencesToDelete.size != dbSubject.biometricTemplates.size || action.faceSamplesToAdd.isNotEmpty() || action.fingerprintSamplesToAdd.isNotEmpty() || - action.externalCredentialsToAdd.isNotEmpty(), + action.externalCredentialsToAdd.isNotEmpty() || + action.externalCredentialIdsToRemove.isNotEmpty(), ) { val errorMsg = "Cannot delete all samples for subject ${action.subjectId} without adding new ones" @@ -324,6 +325,10 @@ internal class RoomEnrolmentRecordLocalDataSource @Inject constructor( if (templatesToAdd.isNotEmpty()) { subjectDao.insertBiometricSamples(templatesToAdd) } + + dbSubject.externalCredentials.filter { it.id in action.externalCredentialIdsToRemove }.forEach { + subjectDao.deleteExternalCredentialById(it.id) + } if (action.externalCredentialsToAdd.isNotEmpty()) { subjectDao.insertExternalCredentials(action.externalCredentialsToAdd.map { it.toRoomDb() }) } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt index 97d48d1430..a87c08bd00 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RealmEnrolmentRecordLocalDataSourceTest.kt @@ -12,6 +12,7 @@ import com.simprints.core.tools.time.TimeHelper 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.models.DbExternalCredential import com.simprints.infra.enrolment.records.realm.store.models.DbFaceSample import com.simprints.infra.enrolment.records.realm.store.models.DbFingerprintSample import com.simprints.infra.enrolment.records.realm.store.models.DbSubject @@ -325,6 +326,7 @@ class RealmEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(getRandomFingerprintSample()), referenceIdsToRemove = listOf(faceReferenceId, fingerReferenceId), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ), ), project, @@ -338,9 +340,47 @@ class RealmEnrolmentRecordLocalDataSourceTest { match { // one old + one new it.faceSamples.size == 2 && - it.fingerprintSamples.size == 2 && - it.faceSamples.none { sample -> sample.referenceId == faceReferenceId } && - it.fingerprintSamples.none { sample -> sample.referenceId == fingerReferenceId } + it.fingerprintSamples.size == 2 && + it.faceSamples.none { sample -> sample.referenceId == faceReferenceId } && + it.fingerprintSamples.none { sample -> sample.referenceId == fingerReferenceId } + }, + any(), + ) + } + } + + @Test + fun performSubjectUpdateExternalActions() = runTest { + val subject = getFakePerson() + every { realmSingleQuery.find() } returns getRandomSubject( + faceSamples = listOf(getRandomFaceSample()), + fingerprintSamples = listOf(getRandomFingerprintSample()), + externalCredentials = listOf( + getRandomExternalCredential("id1"), + getRandomExternalCredential("id2"), + ), + ).toRealmDb() + + enrolmentRecordLocalDataSource.performActions( + listOf( + SubjectAction.Update( + subject.subjectId.toString(), + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(), + externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf("id1"), + ), + ), + project, + ) + val peopleCount = enrolmentRecordLocalDataSource.count() + assertThat(peopleCount).isEqualTo(1) + verify { + mutableRealm.delete(match { it.id == "id1" }) + mutableRealm.copyToRealm( + match { subject -> + subject.externalCredentials.map { it.id } == listOf("id2") }, any(), ) @@ -414,15 +454,17 @@ class RealmEnrolmentRecordLocalDataSourceTest { id = "id", value = credentialValue.asTokenizableEncrypted(), subjectId = "subjectId", - type = ExternalCredentialType.NHISCard + type = ExternalCredentialType.NHISCard, ) - saveFakePeople(listOf( - getRandomSubject(externalCredentials = listOf(externalCredential)) - )) + saveFakePeople( + listOf( + getRandomSubject(externalCredentials = listOf(externalCredential)), + ), + ) enrolmentRecordLocalDataSource.load( - SubjectQuery(externalCredential = credentialValue.asTokenizableEncrypted()) + SubjectQuery(externalCredential = credentialValue.asTokenizableEncrypted()), ) verify { @@ -466,7 +508,7 @@ class RealmEnrolmentRecordLocalDataSourceTest { ), fingerprintSamples: List = listOf(), externalCredentials: List = listOf( - getRandomExternalCredential() + getRandomExternalCredential(), ), ): Subject = Subject( subjectId = patientId, @@ -475,7 +517,7 @@ class RealmEnrolmentRecordLocalDataSourceTest { moduleId = moduleId.asTokenizableRaw(), faceSamples = faceSamples, fingerprintSamples = fingerprintSamples, - externalCredentials = externalCredentials + externalCredentials = externalCredentials, ) private fun getRandomFaceSample( @@ -488,10 +530,10 @@ class RealmEnrolmentRecordLocalDataSourceTest { referenceId: String = "referenceId", ) = FingerprintSample(IFingerIdentifier.LEFT_3RD_FINGER, Random.nextBytes(64), "fingerprintTemplateFormat", referenceId, id) - private fun getRandomExternalCredential() = ExternalCredential( - id = "id", + private fun getRandomExternalCredential(id: String = "id") = ExternalCredential( + id = id, value = "value".asTokenizableEncrypted(), subjectId = "subjectId", - type = ExternalCredentialType.NHISCard + type = ExternalCredentialType.NHISCard, ) } diff --git a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt index 7c8c63193b..1b82dd0813 100644 --- a/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt +++ b/infra/enrolment-records/repository/src/test/java/com/simprints/infra/enrolment/records/repository/local/RoomEnrolmentRecordLocalDataSourceTest.kt @@ -1,7 +1,7 @@ package com.simprints.infra.enrolment.records.repository.local -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat +import androidx.test.core.app.* +import com.google.common.truth.Truth.* import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.face.FaceSample @@ -71,14 +71,6 @@ class RoomEnrolmentRecordLocalDataSourceTest { // --- Test Data --- private val date = Date() // Use a fixed date for consistent timestamps in tests - // External credentials - private val externalCredential = ExternalCredential( - id = "id", - value = "value".asTokenizableEncrypted(), - subjectId = "subjectId", - type = ExternalCredentialType.NHISCard, - ) - // Samples defined first private val faceSample1 = FaceSample( template = byteArrayOf(1, 2, 3), @@ -366,6 +358,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(fingerprintSample1), referenceIdsToRemove = listOf(), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -398,6 +391,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), // Explicitly empty as in original referenceIdsToRemove = listOf(faceSample2.referenceId), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -428,6 +422,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), referenceIdsToRemove = listOf(fingerprintSample2.referenceId), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -457,6 +452,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), referenceIdsToRemove = listOf(faceSample1.referenceId), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -481,6 +477,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), referenceIdsToRemove = listOf(fingerprintSample1.referenceId), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -501,6 +498,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(fingerprintSample1), referenceIdsToRemove = listOf(), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -531,6 +529,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), referenceIdsToRemove = listOf(), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) // When @@ -640,6 +639,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(fingerprintSample1), referenceIdsToRemove = listOf(), externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf(), ) dataSource.performActions(listOf(updateAction), project) loadedSubject = @@ -1662,6 +1662,7 @@ class RoomEnrolmentRecordLocalDataSourceTest { fingerprintSamplesToAdd = listOf(), referenceIdsToRemove = listOf(faceSample2.referenceId, fingerprintSample2.referenceId), // Remove all samples externalCredentialsToAdd = listOf(newExternalCredential), + externalCredentialIdsToRemove = listOf(), ) dataSource.performActions(listOf(updateAction), project) @@ -1671,4 +1672,39 @@ class RoomEnrolmentRecordLocalDataSourceTest { assertThat(loaded.externalCredentials).hasSize(2) assertThat(loaded.externalCredentials).contains(newExternalCredential) } + + @Test + fun `performActions - Update - should succeed when removing external credentials`() = runTest { + val subject = subject3P1WithBoth.copy( + externalCredentials = listOf( + ExternalCredential( + id = "credential-id-1", + value = "value-1".asTokenizableEncrypted(), + subjectId = subject3P1WithBoth.subjectId, + type = ExternalCredentialType.NHISCard, + ), + ExternalCredential( + id = "credential-id-2", + value = "value-2".asTokenizableEncrypted(), + subjectId = subject3P1WithBoth.subjectId, + type = ExternalCredentialType.NHISCard, + ), + ), + ) + dataSource.performActions(listOf(SubjectAction.Creation(subject)), project) + + val updateAction = SubjectAction.Update( + subjectId = subject3P1WithBoth.subjectId, + faceSamplesToAdd = listOf(), + fingerprintSamplesToAdd = listOf(), + referenceIdsToRemove = listOf(), + externalCredentialsToAdd = listOf(), + externalCredentialIdsToRemove = listOf("credential-id-1"), + ) + + dataSource.performActions(listOf(updateAction), project) + val loaded = dataSource.load(SubjectQuery(subjectId = subject3P1WithBoth.subjectId)).first() + assertThat(loaded.externalCredentials).hasSize(1) + assertThat(loaded.externalCredentials.map { it.id }).containsExactly("credential-id-2") + } } diff --git a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt index ec12c72960..b5eb4c2e9b 100644 --- a/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt +++ b/infra/enrolment-records/room-store/src/main/java/com/simprints/infra/enrolment/records/room/store/SubjectDao.kt @@ -32,6 +32,9 @@ interface SubjectDao { @Query("DELETE FROM DbExternalCredential WHERE value = :value") suspend fun deleteExternalCredential(value: String) + @Query("DELETE FROM DbExternalCredential WHERE id = :id") + suspend fun deleteExternalCredentialById(id: String) + @RawQuery suspend fun deleteSubjects(query: SupportSQLiteQuery): Int diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/BaseEventDownSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/BaseEventDownSyncTask.kt index 33e7e19fa3..20427b199a 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/BaseEventDownSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/BaseEventDownSyncTask.kt @@ -277,6 +277,7 @@ internal abstract class BaseEventDownSyncTask( fingerprintSamplesToAdd = subjectFactory.extractFingerprintSamplesFromBiometricReferences(biometricReferencesAdded), referenceIdsToRemove = biometricReferencesRemoved, externalCredentialsToAdd = externalCredentialsAdded, + externalCredentialIdsToRemove = emptyList(), // Only used locally to ensure a single credential is linked per session ), ) } From c0f73aa588d696ede16f2b6354a19a2b3fc8a701 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 3 Nov 2025 14:57:13 +0200 Subject: [PATCH 3/3] MS-1228 Ensure that only the latest EnrolmentUpdateEvent is present in session --- .../screen/EnrolLastBiometricViewModel.kt | 15 +- .../screen/EnrolLastBiometricViewModelTest.kt | 7 + .../AddExternalCredentialToSubjectUseCase.kt | 31 --- ...esetExternalCredentialsInSessionUseCase.kt | 66 +++++++ ...dExternalCredentialToSubjectUseCaseTest.kt | 96 --------- ...ExternalCredentialsInSessionUseCaseTest.kt | 183 ++++++++++++++++++ .../screen/SelectSubjectViewModel.kt | 36 +++- .../screen/SelectSubjectViewModelTest.kt | 148 +++++++++++--- .../core/tools/extentions/String.ext.kt | 10 + .../core/tools/extentions/StringExtTest.kt | 15 ++ 10 files changed, 442 insertions(+), 165 deletions(-) delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCase.kt create mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt delete mode 100644 feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCaseTest.kt create mode 100644 feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt create mode 100644 infra/core/src/main/java/com/simprints/core/tools/extentions/String.ext.kt create mode 100644 infra/core/src/test/java/com/simprints/core/tools/extentions/StringExtTest.kt diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt index fdb974f15d..42d25137d0 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt @@ -16,6 +16,7 @@ import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager @@ -42,6 +43,7 @@ internal class EnrolLastBiometricViewModel @Inject constructor( private val checkForDuplicateEnrolments: CheckForDuplicateEnrolmentsUseCase, private val tokenizationProcessor: TokenizationProcessor, private val buildSubject: BuildSubjectUseCase, + private val resetEnrolmentUpdateEventsFromSession: ResetExternalCredentialsInSessionUseCase, ) : ViewModel() { val finish: LiveData> get() = _finish @@ -96,7 +98,7 @@ internal class EnrolLastBiometricViewModel @Inject constructor( try { val subject = buildSubject(params, isAddingCredential = isAddingCredential) - registerEvent(subject) + registerEvent(params, subject) enrolmentRecordRepository.performActions(listOf(SubjectAction.Creation(subject)), project) _finish.send(EnrolLastState.Success(subject.subjectId, scannedCredential?.toExternalCredential(subject.subjectId))) } catch (t: Throwable) { @@ -137,10 +139,15 @@ internal class EnrolLastBiometricViewModel @Inject constructor( private fun getPreviousEnrolmentResult(steps: List) = steps.filterIsInstance().firstOrNull() - private suspend fun registerEvent(subject: Subject) { + private suspend fun registerEvent( + params: EnrolLastBiometricParams, + subject: Subject, + ) { Simber.d("Register events for enrolments", tag = ENROLMENT) - val events = eventRepository - .getEventsInCurrentSession() + val events = eventRepository.getEventsInCurrentSession() + + // Ensures that any previous confirmations are removed from session + resetEnrolmentUpdateEventsFromSession(params.projectId) val biometricReferenceIds = events .filterIsInstance() diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt index 7d65a4c229..94070cf4e8 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt @@ -11,6 +11,7 @@ import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.TokenKeyType @@ -71,6 +72,9 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var tokenizationProcessor: TokenizationProcessor + @MockK + lateinit var resetEnrolmentUpdateEventsFromSession: ResetExternalCredentialsInSessionUseCase + private lateinit var viewModel: EnrolLastBiometricViewModel private val guidToEnrol = "guidToEnrol" @@ -87,6 +91,7 @@ internal class EnrolLastBiometricViewModelTest { coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( mockk { every { id } returns SESSION_ID }, ) + coJustRun { resetEnrolmentUpdateEventsFromSession.invoke(any()) } every { subject.subjectId } returns guidToEnrol @@ -98,6 +103,7 @@ internal class EnrolLastBiometricViewModelTest { checkForDuplicateEnrolments = checkForDuplicateEnrolments, tokenizationProcessor = tokenizationProcessor, buildSubject = buildSubject, + resetEnrolmentUpdateEventsFromSession = resetEnrolmentUpdateEventsFromSession, ) } @@ -254,6 +260,7 @@ internal class EnrolLastBiometricViewModelTest { viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) + coVerify { resetEnrolmentUpdateEventsFromSession.invoke(any(), any()) } coVerify { eventRepository.addOrUpdateEvent( withArg { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCase.kt deleted file mode 100644 index 5d7f5ac006..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.simprints.feature.externalcredential.usecase - -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential -import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository -import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction -import javax.inject.Inject - -class AddExternalCredentialToSubjectUseCase @Inject() constructor( - private val enrolmentRecordRepository: EnrolmentRecordRepository, - private val configManager: ConfigManager, -) { - suspend operator fun invoke( - scannedCredential: ScannedCredential, - subjectId: String, - projectId: String, - ) { - val project = configManager.getProject(projectId) - val updateActions = listOf( - SubjectAction.Update( - subjectId = subjectId, - externalCredentialsToAdd = listOf(scannedCredential.toExternalCredential(subjectId)), - faceSamplesToAdd = emptyList(), - fingerprintSamplesToAdd = emptyList(), - referenceIdsToRemove = emptyList(), - ), - ) - enrolmentRecordRepository.performActions(updateActions, project) - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt new file mode 100644 index 0000000000..1c90b0a55f --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt @@ -0,0 +1,66 @@ +package com.simprints.feature.externalcredential.usecase + +import com.simprints.core.SessionCoroutineScope +import com.simprints.core.tools.extentions.isValidGuid +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent +import com.simprints.infra.events.session.SessionEventRepository +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +class ResetExternalCredentialsInSessionUseCase @Inject() constructor( + private val enrolmentRecordRepository: EnrolmentRecordRepository, + private val configManager: ConfigManager, + private val eventRepository: SessionEventRepository, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) { + suspend operator fun invoke( + projectId: String, + scannedCredential: ScannedCredential? = null, + subjectId: String = "", + ) { + val enrolmentUpdateEvents = eventRepository + .getEventsInCurrentSession() + .filterIsInstance() + + // Within a session the external credentials can be linked to a single subject only, + // therefore we must ensure that on consecutive confirmation the previous links are reverted. + val credentialsToRemove = enrolmentUpdateEvents.map { + SubjectAction.Update( + subjectId = it.payload.subjectId, + faceSamplesToAdd = emptyList(), + fingerprintSamplesToAdd = emptyList(), + referenceIdsToRemove = emptyList(), + externalCredentialsToAdd = emptyList(), + externalCredentialIdsToRemove = it.payload.externalCredentialIdsToAdd, + ) + } + + val validSubjectId = subjectId.takeIf { it.isValidGuid() } + val credentialsToAdd = if (validSubjectId != null && scannedCredential != null) { + listOf( + SubjectAction.Update( + subjectId = subjectId, + faceSamplesToAdd = emptyList(), + fingerprintSamplesToAdd = emptyList(), + referenceIdsToRemove = emptyList(), + externalCredentialsToAdd = listOf(scannedCredential.toExternalCredential(validSubjectId)), + externalCredentialIdsToRemove = emptyList(), + ), + ) + } else { + emptyList() + } + + val project = configManager.getProject(projectId) + val updateActions = credentialsToRemove + credentialsToAdd + enrolmentRecordRepository.performActions(updateActions, project) + + // Since we are potentially linking the credentials to a new subject, previous updates must be deleted + with(sessionCoroutineScope) { eventRepository.deleteEvents(enrolmentUpdateEvents) } + } +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCaseTest.kt deleted file mode 100644 index 62850f52c5..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/AddExternalCredentialToSubjectUseCaseTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.simprints.feature.externalcredential.usecase - -import com.google.common.truth.Truth.* -import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository -import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -internal class AddExternalCredentialToSubjectUseCaseTest { - @MockK - lateinit var enrolmentRecordRepository: EnrolmentRecordRepository - - @MockK - lateinit var configManager: ConfigManager - - @MockK - lateinit var project: Project - - @MockK - lateinit var scannedCredential: ScannedCredential - - private lateinit var useCase: AddExternalCredentialToSubjectUseCase - - private val projectId = "projectId" - private val subjectId = "subjectId" - private val encryptedCredential = "credential".asTokenizableEncrypted() - private val mockCredentialType = ExternalCredentialType.NHISCard - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - coEvery { configManager.getProject(projectId) } returns project - - useCase = AddExternalCredentialToSubjectUseCase( - enrolmentRecordRepository = enrolmentRecordRepository, - configManager = configManager, - ) - every { scannedCredential.credential } returns encryptedCredential - every { scannedCredential.credentialType } returns mockCredentialType - } - - @Test - fun `invokes enrolment repository with correct update action`() = runTest { - val actionsSlot = slot>() - useCase(scannedCredential, subjectId, projectId) - coVerify { - enrolmentRecordRepository.performActions( - capture(actionsSlot), - project, - ) - } - - val actions = actionsSlot.captured - assertThat(actions).hasSize(1) - val updateAction = actions.first() as SubjectAction.Update - assertThat(updateAction.subjectId).isEqualTo(subjectId) - assertThat(updateAction.externalCredentialsToAdd).hasSize(1) - assertThat(updateAction.faceSamplesToAdd).isEmpty() - assertThat(updateAction.fingerprintSamplesToAdd).isEmpty() - assertThat(updateAction.referenceIdsToRemove).isEmpty() - } - - @Test - fun `adds correct external credential to subject`() = runTest { - val actionsSlot = slot>() - - useCase(scannedCredential, subjectId, projectId) - - coVerify { - enrolmentRecordRepository.performActions( - capture(actionsSlot), - project, - ) - } - - val updateAction = actionsSlot.captured.first() as SubjectAction.Update - val addedCredential = updateAction.externalCredentialsToAdd.first() - assertThat(addedCredential.value).isEqualTo(encryptedCredential) - assertThat(addedCredential.type).isEqualTo(mockCredentialType) - } - - @Test - fun `retrieves project using correct project id`() = runTest { - useCase(scannedCredential, subjectId, projectId) - coVerify { configManager.getProject(projectId) } - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt new file mode 100644 index 0000000000..f3abb776c6 --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt @@ -0,0 +1,183 @@ +package com.simprints.feature.externalcredential.usecase + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.common.truth.Truth.* +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent +import com.simprints.infra.events.session.SessionEventRepository +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +internal class ResetExternalCredentialsInSessionUseCaseTest { + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + lateinit var enrolmentRecordRepository: EnrolmentRecordRepository + + @MockK + lateinit var configManager: ConfigManager + + @MockK + lateinit var project: Project + + @MockK + lateinit var scannedCredential: ScannedCredential + + @MockK + lateinit var eventRepository: SessionEventRepository + + private lateinit var useCase: ResetExternalCredentialsInSessionUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + coEvery { configManager.getProject(PROJECT_ID) } returns project + + useCase = ResetExternalCredentialsInSessionUseCase( + enrolmentRecordRepository = enrolmentRecordRepository, + configManager = configManager, + eventRepository = eventRepository, + sessionCoroutineScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + ) + every { scannedCredential.credential } returns CREDENTIAL + every { scannedCredential.credentialType } returns CREDENTIAL_TYE + } + + @Test + fun `invokes enrolment repository with correct update action`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() + + useCase(PROJECT_ID, scannedCredential, SUBJECT_ID) + + val actionsSlot = slot>() + coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + + val actions = actionsSlot.captured + assertThat(actions).hasSize(1) + val updateAction = actions.first() as SubjectAction.Update + assertThat(updateAction.subjectId).isEqualTo(SUBJECT_ID) + assertThat(updateAction.externalCredentialsToAdd).hasSize(1) + assertThat(updateAction.faceSamplesToAdd).isEmpty() + assertThat(updateAction.fingerprintSamplesToAdd).isEmpty() + assertThat(updateAction.referenceIdsToRemove).isEmpty() + } + + @Test + fun `adds correct external credential to subject`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf() + + useCase(PROJECT_ID, scannedCredential, SUBJECT_ID) + + val actionsSlot = slot>() + coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + + val updateAction = actionsSlot.captured.first() as SubjectAction.Update + val addedCredential = updateAction.externalCredentialsToAdd.first() + assertThat(addedCredential.value).isEqualTo(CREDENTIAL) + assertThat(addedCredential.type).isEqualTo(CREDENTIAL_TYE) + } + + @Test + fun `removes correct external credential to subject`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( + enrolmentUpdateEvent("subject-1", listOf("credentia-1")), + ) + + useCase( + scannedCredential = scannedCredential, + subjectId = SUBJECT_ID, + projectId = PROJECT_ID, + ) + + val actionsSlot = slot>() + coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + + // Remove actions come first + val removeAction = actionsSlot.captured.first() as SubjectAction.Update + assertThat(removeAction.subjectId).isEqualTo("subject-1") + assertThat(removeAction.externalCredentialsToAdd).isEmpty() + assertThat(removeAction.externalCredentialIdsToRemove).containsExactly("credentia-1") + // Additions come after + val addAction = actionsSlot.captured.last() as SubjectAction.Update + assertThat(addAction.externalCredentialsToAdd).isNotEmpty() + assertThat(addAction.externalCredentialIdsToRemove).isEmpty() + } + + @Test + fun `remove existing update events in the session`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( + otherEvent(), + enrolmentUpdateEvent("subject-1", listOf("credentia-1")), + otherEvent(), + ) + + useCase( + scannedCredential = scannedCredential, + subjectId = SUBJECT_ID, + projectId = PROJECT_ID, + ) + + coEvery { eventRepository.deleteEvents(match { it.size == 1 }) } + } + + @Test + fun `does not add credentials to any subject if no subjectID`() = runTest { + useCase( + scannedCredential = scannedCredential, + subjectId = "none_selected", + projectId = PROJECT_ID, + ) + + val actionsSlot = slot>() + coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + + assertThat(actionsSlot.captured).isEmpty() + } + + @Test + fun `retrieves project using correct project id`() = runTest { + useCase(PROJECT_ID, scannedCredential, SUBJECT_ID) + coVerify { configManager.getProject(PROJECT_ID) } + } + + private fun enrolmentUpdateEvent( + subjectId: String, + credentialIds: List, + ) = EnrolmentUpdateEvent( + createdAt = Timestamp(0L), + subjectId = subjectId, + externalCredentialIdsToAdd = credentialIds, + ) + + private fun otherEvent() = ExternalCredentialSelectionEvent( + Timestamp(0L), + Timestamp(1L), + CREDENTIAL_TYE, + ) + + companion object { + private const val PROJECT_ID = "projectId" + private const val SUBJECT_ID = "bbaa8ff3-34f7-41d3-a6c9-ff3b952d832e" + private val CREDENTIAL = "credential".asTokenizableEncrypted() + private val CREDENTIAL_TYE = ExternalCredentialType.NHISCard + } +} diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt index 46b70e2f7f..0a17de0cd8 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt @@ -8,10 +8,11 @@ import com.simprints.core.SessionCoroutineScope import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send +import com.simprints.core.tools.extentions.isValidGuid import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential -import com.simprints.feature.externalcredential.usecase.AddExternalCredentialToSubjectUseCase +import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.feature.selectsubject.SelectSubjectParams import com.simprints.feature.selectsubject.SelectSubjectResult import com.simprints.feature.selectsubject.model.SelectSubjectState @@ -39,7 +40,7 @@ internal class SelectSubjectViewModel @AssistedInject constructor( private val authStore: AuthStore, private val eventRepository: SessionEventRepository, private val configManager: ConfigManager, - private val addExternalCredentialToSubjectUseCase: AddExternalCredentialToSubjectUseCase, + private val resetExternalCredentialsUseCase: ResetExternalCredentialsInSessionUseCase, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val tokenizationProcessor: TokenizationProcessor, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @@ -98,16 +99,28 @@ internal class SelectSubjectViewModel @AssistedInject constructor( if (scannedCredential == null) return null val credential = scannedCredential.credential val project = configManager.getProject(authStore.signedInProjectId) - val isCredentialAlreadyLinkedToSubject = enrolmentRecordRepository + val alreadyLinkedSubject = enrolmentRecordRepository .load( SubjectQuery( projectId = project.id, subjectId = subjectId, externalCredential = credential, ), - ).isNotEmpty() + ).firstOrNull() + + if (!subjectId.isValidGuid()) { + // Confirmation of "none_selected" (or any non UUID value) should not display the dialog, + // but still remove update event from session and reset previously linked external credentials + resetExternalCredentialsUseCase( + projectId = params.projectId, + scannedCredential = scannedCredential, + subjectId = params.subjectId, + ) + return null + } - if (isCredentialAlreadyLinkedToSubject) return null + // Credentials already linked to the correct subject, so no need to re-link + if (alreadyLinkedSubject != null && alreadyLinkedSubject.subjectId == subjectId) return null val decrypted = tokenizationProcessor.decrypt( encrypted = credential, @@ -121,8 +134,16 @@ internal class SelectSubjectViewModel @AssistedInject constructor( updateState { SelectSubjectState.SavingExternalCredential } viewModelScope.launch { val addedCredential = try { - addExternalCredentialToSubjectUseCase(scannedCredential, subjectId = params.subjectId, projectId = params.projectId) - saveCredentialSelectionEvent(params.subjectId) + resetExternalCredentialsUseCase( + scannedCredential = scannedCredential, + subjectId = params.subjectId, + projectId = params.projectId, + ) + + // Confirmation of "none_selected" (or any non UUID value) should not produce an EnrolmentUpdateEvent + if (params.subjectId.isValidGuid()) { + saveCredentialSelectionEvent(params.subjectId) + } scannedCredential } catch (e: Exception) { Simber.e("Failed to attach scanned credential", e, tag = SESSION) @@ -149,6 +170,7 @@ internal class SelectSubjectViewModel @AssistedInject constructor( .map { it.payload.id } Simber.d("Adding credentials $externalCredentialIdsToAdd to subject $subjectId", tag = SESSION) + eventRepository.addOrUpdateEvent( EnrolmentUpdateEvent( timeHelper.now(), diff --git a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt index d18a4d25cf..73abef8e6b 100644 --- a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt +++ b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt @@ -9,7 +9,7 @@ import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.usecase.AddExternalCredentialToSubjectUseCase +import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.feature.selectsubject.SelectSubjectParams import com.simprints.feature.selectsubject.model.SelectSubjectState import com.simprints.infra.authstore.AuthStore @@ -19,6 +19,9 @@ import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import com.simprints.infra.events.session.SessionEventRepository import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* @@ -49,7 +52,7 @@ internal class SelectSubjectViewModelTest { lateinit var configManager: ConfigManager @MockK - lateinit var addExternalCredentialToSubjectUseCase: AddExternalCredentialToSubjectUseCase + lateinit var resetScannedCredentialsInSession: ResetExternalCredentialsInSessionUseCase @MockK lateinit var enrolmentRecordRepository: EnrolmentRecordRepository @@ -57,7 +60,6 @@ internal class SelectSubjectViewModelTest { @MockK lateinit var tokenizationProcessor: TokenizationProcessor - @MockK lateinit var selectSubjectParams: SelectSubjectParams private lateinit var viewModel: SelectSubjectViewModel @@ -67,8 +69,7 @@ internal class SelectSubjectViewModelTest { MockKAnnotations.init(this, relaxed = true) every { timeHelper.now() } returns TIMESTAMP - every { selectSubjectParams.projectId } returns PROJECT_ID - every { selectSubjectParams.subjectId } returns SUBJECT_ID + selectSubjectParams = SelectSubjectParams(PROJECT_ID, SUBJECT_ID, null) viewModel = SelectSubjectViewModel( params = selectSubjectParams, @@ -76,20 +77,20 @@ internal class SelectSubjectViewModelTest { authStore = authStore, eventRepository = eventRepository, configManager = configManager, - addExternalCredentialToSubjectUseCase = addExternalCredentialToSubjectUseCase, + resetExternalCredentialsUseCase = resetScannedCredentialsInSession, enrolmentRecordRepository = enrolmentRecordRepository, tokenizationProcessor = tokenizationProcessor, sessionCoroutineScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ) } - private fun createViewModel() = SelectSubjectViewModel( - params = selectSubjectParams, + private fun createViewModel(params: SelectSubjectParams = selectSubjectParams) = SelectSubjectViewModel( + params = params, timeHelper = timeHelper, authStore = authStore, eventRepository = eventRepository, configManager = configManager, - addExternalCredentialToSubjectUseCase = addExternalCredentialToSubjectUseCase, + resetExternalCredentialsUseCase = resetScannedCredentialsInSession, enrolmentRecordRepository = enrolmentRecordRepository, tokenizationProcessor = tokenizationProcessor, sessionCoroutineScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), @@ -97,7 +98,6 @@ internal class SelectSubjectViewModelTest { @Test fun `saves selection if signed in`() = runTest { - every { selectSubjectParams.scannedCredential } returns null coEvery { authStore.isProjectIdSignedIn(PROJECT_ID) } returns true val viewModel = createViewModel() @@ -127,7 +127,6 @@ internal class SelectSubjectViewModelTest { @Test fun `correctly handles exception with saving`() = runTest { - every { selectSubjectParams.scannedCredential } returns null coEvery { authStore.isProjectIdSignedIn(PROJECT_ID) } returns true coEvery { eventRepository.addOrUpdateEvent(any()) } throws RuntimeException("RuntimeException") @@ -146,9 +145,25 @@ internal class SelectSubjectViewModelTest { fun `displays credential dialog when credential is scanned and not already linked`() = runTest { val scannedCredential = mockk(relaxed = true) val displayedCredential = mockk(relaxed = true) - setupCredentialState(scannedCredential, displayedCredential, repositoryResponse = emptyList()) + setupCredentialState(displayedCredential, repositoryResponse = emptyList()) - val viewModel = createViewModel() + val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) + + val state = viewModel.stateLiveData.test().value() + assertThat(state).isInstanceOf(SelectSubjectState.CredentialDialogDisplayed::class.java) + val dialogState = state as SelectSubjectState.CredentialDialogDisplayed + assertThat(dialogState.scannedCredential).isEqualTo(scannedCredential) + assertThat(dialogState.displayedCredential).isEqualTo(displayedCredential) + } + + @Test + fun `displays credential dialog when credential is scanned and linked to different subject`() = runTest { + val scannedCredential = mockk(relaxed = true) + val displayedCredential = mockk(relaxed = true) + val repositoryResponse = listOf(mockk { every { subjectId } returns "not_this_subject_id" }) + setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) + + val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) val state = viewModel.stateLiveData.test().value() assertThat(state).isInstanceOf(SelectSubjectState.CredentialDialogDisplayed::class.java) @@ -158,7 +173,7 @@ internal class SelectSubjectViewModelTest { } @Test - fun `does not display credential dialog when credential is already linked`() = runTest { + fun `does not display credential dialog when credential is already linked to same subject`() = runTest { val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() val type = ExternalCredentialType.NHISCard val scannedCredential = mockk { @@ -167,10 +182,10 @@ internal class SelectSubjectViewModelTest { every { credentialType } returns type } val displayedCredential = mockk(relaxed = true) - val repositoryResponse = listOf(mockk()) - setupCredentialState(scannedCredential, displayedCredential, repositoryResponse = repositoryResponse) + val repositoryResponse = listOf(mockk { every { subjectId } returns SUBJECT_ID }) + setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) - val viewModel = createViewModel() + val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) val result = viewModel.finish .test() @@ -181,10 +196,36 @@ internal class SelectSubjectViewModelTest { assertThat(result?.savedCredential?.type).isEqualTo(type) } + @Test + fun `does not display credential dialog when subject ID is none_selected`() = runTest { + val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() + val type = ExternalCredentialType.NHISCard + val scannedCredential = mockk { + every { credentialScanId } returns "credentialId" + every { credential } returns tokenizedValue + every { credentialType } returns type + } + val displayedCredential = mockk(relaxed = true) + val repositoryResponse = listOf(mockk { every { subjectId } returns SUBJECT_ID }) + setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) + + val viewModel = createViewModel( + params = selectSubjectParams.copy( + subjectId = "none_selected", + scannedCredential = scannedCredential, + ), + ) + + val result = viewModel.finish + .test() + .value() + .getContentIfNotHandled() + assertThat(result?.isSubjectIdSaved).isTrue() + } + @Test fun `finishes without credential when no credential is scanned`() = runTest { coEvery { authStore.isProjectIdSignedIn(PROJECT_ID) } returns true - every { selectSubjectParams.scannedCredential } returns null val viewModel = createViewModel() @@ -197,13 +238,11 @@ internal class SelectSubjectViewModelTest { } private fun setupCredentialState( - scannedCredential: ScannedCredential, displayedCredential: TokenizableString.Raw, repositoryResponse: List, ) { val project = mockk(relaxed = true) - every { selectSubjectParams.scannedCredential } returns scannedCredential coEvery { authStore.isProjectIdSignedIn(PROJECT_ID) } returns true coEvery { authStore.signedInProjectId } returns PROJECT_ID coEvery { configManager.getProject(PROJECT_ID) } returns project @@ -228,13 +267,58 @@ internal class SelectSubjectViewModelTest { every { credentialType } returns type } coEvery { - addExternalCredentialToSubjectUseCase( - scannedCredential, - subjectId = SUBJECT_ID, + eventRepository.getEventsInCurrentSession() + } returns listOf( + mockk(), + mockk(), + mockk(), + ) + + coJustRun { + resetScannedCredentialsInSession(any(), any(), any()) + } + + val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) + viewModel.saveCredential(scannedCredential) + + val state = viewModel.stateLiveData.test().value() + assertThat(state).isEqualTo(SelectSubjectState.SavingExternalCredential) + + val result = viewModel.finish + .test() + .value() + .getContentIfNotHandled() + assertThat(result?.isSubjectIdSaved).isTrue() + assertThat(result?.savedCredential?.value).isEqualTo(tokenizedValue) + assertThat(result?.savedCredential?.type).isEqualTo(type) + + coVerify { + resetScannedCredentialsInSession( projectId = PROJECT_ID, + scannedCredential = scannedCredential, + subjectId = SUBJECT_ID, ) - } returns Unit + } + coVerify { eventRepository.addOrUpdateEvent(match { it is EnrolmentUpdateEvent }) } + } + @Test + fun `saveCredential does not saves update event if invalid subject id`() = runTest { + val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() + val type = ExternalCredentialType.NHISCard + val scannedCredential = mockk { + every { credentialScanId } returns "credentialId" + every { credential } returns tokenizedValue + every { credentialType } returns type + } + + coJustRun { + resetScannedCredentialsInSession(any(), any(), any()) + } + + val viewModel = createViewModel( + params = selectSubjectParams.copy(subjectId = "none_selected", scannedCredential = scannedCredential), + ) viewModel.saveCredential(scannedCredential) val state = viewModel.stateLiveData.test().value() @@ -247,14 +331,24 @@ internal class SelectSubjectViewModelTest { assertThat(result?.isSubjectIdSaved).isTrue() assertThat(result?.savedCredential?.value).isEqualTo(tokenizedValue) assertThat(result?.savedCredential?.type).isEqualTo(type) + + coVerify { + // Still needs to remove previous links + resetScannedCredentialsInSession( + projectId = PROJECT_ID, + scannedCredential = scannedCredential, + subjectId = "none_selected", + ) + } + coVerify(exactly = 0) { eventRepository.addOrUpdateEvent(any()) } } @Test fun `saveCredential handles exception when saving fails`() = runTest { val scannedCredential = mockk(relaxed = true) coEvery { - addExternalCredentialToSubjectUseCase( - scannedCredential, + resetScannedCredentialsInSession( + scannedCredential = scannedCredential, subjectId = SUBJECT_ID, projectId = PROJECT_ID, ) @@ -288,6 +382,6 @@ internal class SelectSubjectViewModelTest { companion object { private val TIMESTAMP = Timestamp(1L) private const val PROJECT_ID = "projectId" - private const val SUBJECT_ID = "subjectId" + private const val SUBJECT_ID = "302cc3c8-72cb-4525-95c5-ac5abfab43a9" } } diff --git a/infra/core/src/main/java/com/simprints/core/tools/extentions/String.ext.kt b/infra/core/src/main/java/com/simprints/core/tools/extentions/String.ext.kt new file mode 100644 index 0000000000..0581bfff27 --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/extentions/String.ext.kt @@ -0,0 +1,10 @@ +package com.simprints.core.tools.extentions + +import java.util.UUID + +fun String.isValidGuid() = try { + UUID.fromString(this) + true +} catch (_: IllegalArgumentException) { + false +} diff --git a/infra/core/src/test/java/com/simprints/core/tools/extentions/StringExtTest.kt b/infra/core/src/test/java/com/simprints/core/tools/extentions/StringExtTest.kt new file mode 100644 index 0000000000..2fca5541c8 --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/tools/extentions/StringExtTest.kt @@ -0,0 +1,15 @@ +package com.simprints.core.tools.extentions + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class StringExtTest { + @Test + fun `Is valid UUID`() { + assertThat("".isValidGuid()).isFalse() + assertThat("test".isValidGuid()).isFalse() + + assertThat("63d26965-e68c-447c-9ee9-5aba2ebf589c".isValidGuid()).isTrue() + assertThat("85500708-52D9-4A06-855F-391469E5C220".isValidGuid()).isTrue() + } +}