diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index 21ac5711cf..3ceffe7748 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -11,6 +11,7 @@ import com.simprints.infra.events.EventRepository import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.events.event.domain.models.scope.EventScopeEndCause import com.simprints.infra.events.event.domain.models.scope.EventScopeType +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncCache import com.simprints.infra.eventsync.event.remote.EventRemoteDataSource import com.simprints.infra.eventsync.status.down.EventDownSyncScopeRepository import com.simprints.infra.eventsync.status.down.domain.EventDownSyncOperation @@ -40,6 +41,7 @@ internal class EventSyncManagerImpl @Inject constructor( private val eventRepository: EventRepository, private val upSyncScopeRepo: EventUpSyncScopeRepository, private val eventSyncCache: EventSyncCache, + private val commCareSyncCache: CommCareSyncCache, private val simprintsDownSyncTask: SimprintsEventDownSyncTask, private val eventRemoteDataSource: EventRemoteDataSource, private val configRepository: ConfigRepository, @@ -127,6 +129,7 @@ internal class EventSyncManagerImpl @Inject constructor( override suspend fun deleteSyncInfo() { downSyncScopeRepository.deleteAll() + commCareSyncCache.clearAllSyncedCases() upSyncScopeRepo.deleteAll() eventSyncCache.clearProgresses() eventSyncCache.storeLastSuccessfulSyncTime(null) @@ -134,5 +137,6 @@ internal class EventSyncManagerImpl @Inject constructor( override suspend fun resetDownSyncInfo() { downSyncScopeRepository.deleteAll() + commCareSyncCache.clearAllSyncedCases() } } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncModule.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncModule.kt index 59097355fd..f1a9dcd8db 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncModule.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncModule.kt @@ -1,6 +1,11 @@ package com.simprints.infra.eventsync import android.content.Context +import androidx.room.Room +import com.simprints.core.DispatcherIO +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncCache +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncDao +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncDatabase import com.simprints.infra.eventsync.status.EventSyncStatusDatabase import com.simprints.infra.eventsync.status.down.local.DbEventDownSyncOperationStateDao import com.simprints.infra.eventsync.status.up.local.DbEventUpSyncOperationStateDao @@ -10,6 +15,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Singleton @Module( @@ -41,4 +47,28 @@ internal class EventSyncProvider { fun provideEventsSyncStatusDatabase( @ApplicationContext ctx: Context, ): EventSyncStatusDatabase = EventSyncStatusDatabase.getDatabase(ctx) + + @Provides + @Singleton + fun provideCommCareSyncDatabase(@ApplicationContext context: Context): CommCareSyncDatabase { + return Room.databaseBuilder( + context.applicationContext, + CommCareSyncDatabase::class.java, + CommCareSyncDatabase.DATABASE_NAME + ).build() + } + + @Provides + @Singleton + fun provideCommCareSyncDao(database: CommCareSyncDatabase): CommCareSyncDao { + return database.commCareSyncDao() + } + + @Provides + @Singleton + fun provideCommCareSyncCache( + commCareSyncDao: CommCareSyncDao, + ): CommCareSyncCache { + return CommCareSyncCache(commCareSyncDao) + } } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt index bc3922e434..645d7eb01c 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSource.kt @@ -13,6 +13,10 @@ import com.simprints.infra.config.store.LastCallingPackageStore import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordCreationEventDeserializer import com.simprints.infra.events.event.cosync.CoSyncEnrolmentRecordEvents import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordDeletionEvent +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvent +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncCache +import com.simprints.infra.eventsync.event.commcare.cache.SyncedCaseEntity import com.simprints.infra.eventsync.status.down.domain.CommCareEventSyncResult import com.simprints.infra.logging.LoggingConstants.CrashReportTag.COMMCARE_SYNC import com.simprints.infra.logging.Simber @@ -20,16 +24,29 @@ import com.simprints.libsimprints.Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject internal class CommCareEventDataSource @Inject constructor( private val jsonHelper: JsonHelper, + private val commCareSyncCache: CommCareSyncCache, private val lastCallingPackageStore: LastCallingPackageStore, @ApplicationContext private val context: Context, ) { + + private val pendingSyncedCases = CopyOnWriteArrayList() + + // Pre-created date formatters to avoid repeated instantiation during sync + private val commCareDateFormats = listOf( + SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US), // Standard Date.toString() format + SimpleDateFormat("EEE MMM dd HH:mm:ss Z yyyy", Locale.US) // Numeric timezone fallback + ) fun getEvents(): CommCareEventSyncResult { + pendingSyncedCases.clear() // Clear any leftover state from previous syncs val totalCount = count() - val eventFlow = loadEnrolmentRecordCreationEvents() + val eventFlow = loadDataFromCommCare() return CommCareEventSyncResult( totalCount = totalCount, @@ -45,58 +62,122 @@ internal class CommCareEventDataSource @Inject constructor( return count } - private fun loadEnrolmentRecordCreationEvents(): Flow = flow { + private fun loadDataFromCommCare(): Flow = flow { try { - // First collect all case IDs in a list - Simber.i("Start listing caseIds", tag = COMMCARE_SYNC) - val caseIds = mutableListOf() + Simber.i("Start listing caseIds for CommCare sync", tag = COMMCARE_SYNC) + + val casesToParse = mutableListOf() + val caseIdsPresentInCommCare = mutableSetOf() + // Fetch all previously synced cases with their details (including lastSyncedTimestamp) + val previouslySyncedCasesMap = commCareSyncCache.getAllSyncedCases() + .associateBy { it.caseId } + context.contentResolver - .query(getCaseMetadataUri(), arrayOf(COLUMN_CASE_ID), null, null, null) + .query(getCaseMetadataUri(), arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), null, null, null) ?.use { cursor -> while (cursor.moveToNext()) { - cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_CASE_ID))?.let { caseId -> - caseIds.add(caseId) + val caseId = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) + if (caseId.isNullOrEmpty()) { + continue // Skip empty case IDs } + caseIdsPresentInCommCare.add(caseId) + + val commCareLastModifiedString = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_LAST_MODIFIED)) + val commCareLastModifiedTime = parseCommCareDateToMillis(commCareLastModifiedString) + + val cachedCase = previouslySyncedCasesMap[caseId] + if (cachedCase != null) { + // Case was synced before, check its specific lastSyncedTimestamp + if (commCareLastModifiedTime > 0L && commCareLastModifiedTime <= cachedCase.lastSyncedTimestamp) { + Simber.d( + "Skipping caseId $caseId: CommCare lastModified ($commCareLastModifiedTime) is not newer than lastSyncedTimestamp (${cachedCase.lastSyncedTimestamp})", + tag = COMMCARE_SYNC + ) + continue // Skip cases not modified since last sync + } + } + + casesToParse.add(SyncedCaseEntity(caseId, "", commCareLastModifiedTime)) } } - Simber.i("Finished listing caseIds", tag = COMMCARE_SYNC) + Simber.i("Finished listing caseIds. ${casesToParse.size} cases to parse.", tag = COMMCARE_SYNC) // Process case IDs in batches to avoid large pauses val batchSize = BATCH_SIZE // Adjust based on performance testing - caseIds.chunked(batchSize).forEach { batch -> - batch.forEach { caseId -> - loadEnrolmentRecordCreationEvents(caseId).collect { emit(it) } + casesToParse.chunked(batchSize).forEach { batch -> + batch.forEach { case -> + loadEnrolmentRecordCreationEvents(case).collect { emit(it) } + } + } + + // If no cases were found in CommCare, it's most likely that CommCare is logged out. + if (caseIdsPresentInCommCare.isNotEmpty()) { + val casesToRemove = previouslySyncedCasesMap.values.filterNot { (it.caseId in caseIdsPresentInCommCare) } + Simber.i("Generating deletion events for ${casesToRemove.size} cases no longer in CommCare.", tag = COMMCARE_SYNC) + casesToRemove.forEach { case -> + generateEnrolmentRecordDeletionEvent(case).collect { emit(it) } } } } catch (e: Exception) { - Simber.e("Error while querying CommCare", e) - throw e + Simber.e("Error during CommCare data loading", e, tag = COMMCARE_SYNC) + throw e // Rethrow to let the sync worker handle the failure + } + } + + /* Generates deletion events for enrolment records that were previously synced but are no longer present in CommCare. + * This is called when a case is not found in the latest sync. + */ + private fun generateEnrolmentRecordDeletionEvent(case: SyncedCaseEntity): Flow = flow { + if (case.simprintsId.isEmpty()) { + Simber.d("Skipping deletion event for caseId ${case.caseId} with empty simprintsId", tag = COMMCARE_SYNC) + // Directly remove the case from the cache if it has no simprintsId + commCareSyncCache.removeSyncedCase(case.caseId) + return@flow } + + Simber.d("Generating deletion event for caseId ${case.caseId} with simprintsId ${case.simprintsId}", tag = COMMCARE_SYNC) + pendingSyncedCases.add(case) + emit(EnrolmentRecordDeletionEvent( + subjectId = case.simprintsId, + projectId = "", // Only subjectId is required for deletion events + moduleId = "", + attendantId = "", + )) } - private fun loadEnrolmentRecordCreationEvents(caseId: String): Flow = flow { + private fun loadEnrolmentRecordCreationEvents(case: SyncedCaseEntity): Flow = flow { // Access Case Data Listing for the caseId - val caseDataUri = getCaseDataUri().buildUpon().appendPath(caseId).build() + val caseDataUri = getCaseDataUri().buildUpon().appendPath(case.caseId).build() val cursor = context.contentResolver .query(caseDataUri, null, null, null, null) - Simber.d("Cursor for caseId $caseId: $cursor", tag = COMMCARE_SYNC) if (cursor != null) { cursor.use { caseDataCursor -> val subjectActions = getSubjectActionsValue(caseDataCursor) Simber.d(subjectActions) val coSyncEnrolmentRecordEvents = parseRecordEvents(subjectActions) + if (coSyncEnrolmentRecordEvents == null) { + Simber.d("No valid enrolment records found for caseId ${case.caseId}.", tag = COMMCARE_SYNC) + // Add the case to the cache with an empty simprintsId so that we don't try to sync it again until updated + commCareSyncCache.addSyncedCase(case) + Simber.d("Added case ${case.caseId} with empty simprintsId to CommCareSyncCache", tag = COMMCARE_SYNC) + return@flow + } + coSyncEnrolmentRecordEvents - ?.events - ?.filterIsInstance() - ?.forEach { emit(it) } + .events + .filterIsInstance() + .forEach { event -> + pendingSyncedCases.add(case.copy(simprintsId = event.payload.subjectId)) + emit(event) + } } } else { // If listing returned the caseId but the cursor is null, most likely CommCare // logged out in the middle of sync. Throw an exception to retry the worker // instead of thinking sync is complete (and possibly deleting unsynced subjects). - throw IllegalStateException("Cursor for caseId $caseId is null") + throw IllegalStateException("Cursor for caseId ${case.caseId} is null") } } @@ -130,6 +211,57 @@ internal class CommCareEventDataSource @Inject constructor( private fun getCaseDataUri() = "content://${lastCallingPackageStore.lastCallingPackageName}.case/casedb/data".toUri() + private fun parseCommCareDateToMillis(dateString: String): Long { + for (format in commCareDateFormats) { + try { + return format.parse(dateString)?.time ?: 0L + } catch (e: Exception) { + Simber.e("Error parsing date: $dateString", e, tag = COMMCARE_SYNC) + continue + } + } + + Simber.w("All date parsing attempts failed for: $dateString", tag = COMMCARE_SYNC) + return 0L + } + + /** + * This function is called after all events have been processed. + * It updates the CommCareSyncCache with the latest case IDs and their corresponding Simprints IDs. + */ + suspend fun onEventsProcessed(events: List) { + val creationSubjectIds = mutableSetOf() + val deletionSubjectIds = mutableSetOf() + + events.forEach { event -> + when (event) { + is EnrolmentRecordCreationEvent -> creationSubjectIds.add(event.payload.subjectId) + is EnrolmentRecordDeletionEvent -> deletionSubjectIds.add(event.payload.subjectId) + else -> { /* Ignore other event types */ } + } + } + + val pendingCasesToRemove = mutableListOf() + + pendingSyncedCases.forEach { case -> + when (case.simprintsId) { + in creationSubjectIds -> { + commCareSyncCache.addSyncedCase(case) + Simber.d("Added case ${case.caseId} with simprintsId ${case.simprintsId} to CommCareSyncCache", tag = COMMCARE_SYNC) + pendingCasesToRemove.add(case) + } + in deletionSubjectIds -> { + commCareSyncCache.removeSyncedCase(case.caseId) + Simber.d("Removed case ${case.caseId} with simprintsId ${case.simprintsId} from CommCareSyncCache", tag = COMMCARE_SYNC) + pendingCasesToRemove.add(case) + } + } + } + + // Remove processed cases from pendingSyncedCases + pendingSyncedCases.removeAll(pendingCasesToRemove) + } + private val coSyncSerializationModule = SimpleModule().apply { addSerializer( TokenizableString::class.java, @@ -147,8 +279,9 @@ internal class CommCareEventDataSource @Inject constructor( companion object { internal const val COLUMN_CASE_ID = "case_id" + internal const val COLUMN_LAST_MODIFIED = "last_modified" internal const val COLUMN_DATUM_ID = "datum_id" internal const val COLUMN_VALUE = "value" - private const val BATCH_SIZE = 20 + internal const val BATCH_SIZE = 20 } } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCache.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCache.kt new file mode 100644 index 0000000000..6054973aef --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCache.kt @@ -0,0 +1,31 @@ +package com.simprints.infra.eventsync.event.commcare.cache + +import com.simprints.infra.logging.Simber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CommCareSyncCache @Inject constructor( + private val commCareSyncDao: CommCareSyncDao, +) { + + suspend fun addSyncedCase(case: SyncedCaseEntity) = commCareSyncDao.insert(case).also { + Simber.d("Added/Updated case: ${case.caseId} -> ${case.simprintsId} with timestamp ${case.lastSyncedTimestamp} in CommCareSyncCache (DB)") + } + + suspend fun getSimprintsId(caseId: String): String? = commCareSyncDao.getByCaseId(caseId).also { entity -> + Simber.d("Retrieved simprintsId for case: $caseId -> ${entity?.simprintsId} from CommCareSyncCache (DB)") + }?.simprintsId + + suspend fun removeSyncedCase(caseId: String) = commCareSyncDao.deleteByCaseId(caseId).also { + Simber.d("Removed case: $caseId from CommCareSyncCache (DB)") + } + + suspend fun getAllSyncedCases(): List = commCareSyncDao.getAll().also { allEntries -> + Simber.d("Retrieved all ${allEntries.size} case entities from CommCareSyncCache (DB)") + } + + suspend fun clearAllSyncedCases() = commCareSyncDao.clearAll().also { + Simber.d("Cleared all cases from CommCareSyncCache (DB)") + } +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDao.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDao.kt new file mode 100644 index 0000000000..55b1935bff --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDao.kt @@ -0,0 +1,25 @@ +package com.simprints.infra.eventsync.event.commcare.cache + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface CommCareSyncDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(syncedCase: SyncedCaseEntity) + + @Query("SELECT * FROM synced_commcare_cases WHERE caseId = :caseId") + suspend fun getByCaseId(caseId: String): SyncedCaseEntity? + + @Query("SELECT * FROM synced_commcare_cases") + suspend fun getAll(): List + + @Query("DELETE FROM synced_commcare_cases WHERE caseId = :caseId") + suspend fun deleteByCaseId(caseId: String) + + @Query("DELETE FROM synced_commcare_cases") + suspend fun clearAll() +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDatabase.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDatabase.kt new file mode 100644 index 0000000000..9268abfe15 --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncDatabase.kt @@ -0,0 +1,13 @@ +package com.simprints.infra.eventsync.event.commcare.cache + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [SyncedCaseEntity::class], version = 1, exportSchema = false) +abstract class CommCareSyncDatabase : RoomDatabase() { + abstract fun commCareSyncDao(): CommCareSyncDao + + companion object { + const val DATABASE_NAME = "commcare_sync_cache_db" + } +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/SyncedCaseEntity.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/SyncedCaseEntity.kt new file mode 100644 index 0000000000..553aa17307 --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/commcare/cache/SyncedCaseEntity.kt @@ -0,0 +1,12 @@ +package com.simprints.infra.eventsync.event.commcare.cache + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "synced_commcare_cases") +data class SyncedCaseEntity( + @PrimaryKey + val caseId: String, + val simprintsId: String, + val lastSyncedTimestamp: Long, +) 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 62f774f103..624b575778 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 @@ -51,8 +51,6 @@ internal abstract class BaseEventDownSyncTask( abstract fun shouldRethrowError(throwable: Throwable): Boolean - abstract suspend fun performPostSyncCleanup(project: Project, count: Int) - data class EventFetchResult( val eventFlow: Flow, val totalCount: Int?, @@ -98,8 +96,6 @@ internal abstract class BaseEventDownSyncTask( lastOperation = processBatchedEvents(operation, batchOfEventsToProcess, lastOperation, project) emitProgress(lastOperation, count, result.totalCount) - performPostSyncCleanup(project, count) - lastOperation = lastOperation.copy(state = COMPLETE, lastSyncTime = timeHelper.now().ms) emitProgress(lastOperation, count, result.totalCount) } catch (t: Throwable) { @@ -182,7 +178,9 @@ internal abstract class BaseEventDownSyncTask( enrolmentRecordRepository.performActions(actions, project) // Hook for subclasses to perform additional processing after actions are executed - onActionsProcessed(actions) + // Convert the mutable list to an immutable one to let subclasses safely use it + // while processing continues + onEventsProcessed(batchOfEventsToProcess.toList()) return if (batchOfEventsToProcess.isNotEmpty()) { lastOperation.copy( @@ -199,7 +197,7 @@ internal abstract class BaseEventDownSyncTask( * Hook method called after actions have been processed and executed. * Subclasses can override this to perform additional processing. */ - protected open suspend fun onActionsProcessed(actions: List) { + protected open suspend fun onEventsProcessed(events: List) { // Default implementation does nothing } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTask.kt index 60e0cd075f..c5d3e3c844 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTask.kt @@ -1,13 +1,10 @@ package com.simprints.infra.eventsync.sync.down.tasks import com.simprints.core.tools.time.TimeHelper -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.enrolment.records.repository.domain.models.SubjectAction.Creation -import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction.Deletion import com.simprints.infra.events.EventRepository +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordEvent import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource import com.simprints.infra.eventsync.status.down.EventDownSyncScopeRepository import com.simprints.infra.eventsync.status.down.domain.EventDownSyncOperation @@ -33,8 +30,6 @@ internal class CommCareEventSyncTask @Inject constructor( timeHelper, eventRepository, ) { - private var subjectIdsPresentInCommCare = mutableSetOf() - override suspend fun fetchEvents( operation: EventDownSyncOperation, scope: CoroutineScope, @@ -54,31 +49,7 @@ internal class CommCareEventSyncTask @Inject constructor( return throwable is SecurityException || throwable is IllegalStateException } - // Override to track subject IDs present in CommCare - override suspend fun onActionsProcessed(actions: List) { - actions.forEach { action -> - if (action is Creation) { - subjectIdsPresentInCommCare.add(action.subject.subjectId) - } - } - } - - override suspend fun performPostSyncCleanup(project: Project, count: Int) { - //Don't trigger if count is 0 because it might be due to CommCare logout - if (count > 0) { - deleteSubjectsNotInCommCare(project) - } - - Simber.i("CommCareEventSyncTask finished", tag = COMMCARE_SYNC) - } - - private suspend fun deleteSubjectsNotInCommCare(project: Project) { - val deleteActions = enrolmentRecordRepository.getAllSubjectIds() - .filterNot { subjectIdsPresentInCommCare.contains(it) } - .map { Deletion(it) } - if (deleteActions.isNotEmpty()) { - enrolmentRecordRepository.performActions(deleteActions, project) - Simber.i("Deleted ${deleteActions.size} subjects not present in CommCare", tag = COMMCARE_SYNC) - } - } + // Override to track subject IDs present in CommCare and update CommCareSyncCache + override suspend fun onEventsProcessed(events: List) = + commCareEventDataSource.onEventsProcessed(events) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/SimprintsEventDownSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/SimprintsEventDownSyncTask.kt index cc55f1c1f2..0973e0ba60 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/SimprintsEventDownSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/tasks/SimprintsEventDownSyncTask.kt @@ -2,7 +2,6 @@ package com.simprints.infra.eventsync.sync.down.tasks import com.simprints.core.tools.time.TimeHelper import com.simprints.infra.authstore.exceptions.RemoteDbNotSignedInException -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.events.EventRepository @@ -63,8 +62,4 @@ internal class SimprintsEventDownSyncTask @Inject constructor( // Return true to re-throw specific exceptions that should not be handled by the base class return throwable is RemoteDbNotSignedInException } - - override suspend fun performPostSyncCleanup(project: Project, count: Int) { - // No additional cleanup needed for Simprints sync - } } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index 693eb34dd2..a5d21e222f 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -14,6 +14,7 @@ import com.simprints.infra.events.event.domain.models.scope.EventScope import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_MODULE_ID import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_MODULE_ID_2 import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_PROJECT_ID +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncCache import com.simprints.infra.eventsync.event.remote.EventRemoteDataSource import com.simprints.infra.eventsync.status.down.EventDownSyncScopeRepository import com.simprints.infra.eventsync.status.models.DownSyncCounts @@ -57,6 +58,9 @@ internal class EventSyncManagerTest { @MockK lateinit var eventSyncCache: EventSyncCache + @MockK + lateinit var commCareSyncCache: CommCareSyncCache + @MockK lateinit var eventRepository: EventRepository @@ -96,6 +100,7 @@ internal class EventSyncManagerTest { eventRepository = eventRepository, upSyncScopeRepo = eventUpSyncScopeRepository, eventSyncCache = eventSyncCache, + commCareSyncCache = commCareSyncCache, simprintsDownSyncTask = downSyncTask, eventRemoteDataSource = eventRemoteDataSource, configRepository = configRepository, @@ -174,12 +179,14 @@ internal class EventSyncManagerTest { coVerify(exactly = 1) { eventDownSyncScopeRepository.deleteAll() } coVerify(exactly = 1) { eventSyncCache.clearProgresses() } coVerify(exactly = 1) { eventSyncCache.storeLastSuccessfulSyncTime(null) } + coVerify(exactly = 1) { commCareSyncCache.clearAllSyncedCases() } } @Test fun `resetDownSyncInfo should call sync scope repo`() = runTest { eventSyncManagerImpl.resetDownSyncInfo() - coVerify { eventDownSyncScopeRepository.deleteAll() } + coVerify(exactly = 1) { eventDownSyncScopeRepository.deleteAll() } + coVerify(exactly = 1) { commCareSyncCache.clearAllSyncedCases() } } } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSourceTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSourceTest.kt index 669bba17d6..cb5984fb04 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSourceTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/CommCareEventDataSourceTest.kt @@ -7,14 +7,22 @@ import android.net.Uri import com.simprints.core.tools.json.JsonHelper import com.simprints.infra.config.store.LastCallingPackageStore import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordCreationEvent +import com.simprints.infra.events.event.domain.models.subject.EnrolmentRecordDeletionEvent +import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource.Companion.BATCH_SIZE import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource.Companion.COLUMN_CASE_ID import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource.Companion.COLUMN_DATUM_ID +import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource.Companion.COLUMN_LAST_MODIFIED import com.simprints.infra.eventsync.event.commcare.CommCareEventDataSource.Companion.COLUMN_VALUE +import com.simprints.infra.eventsync.event.commcare.cache.CommCareSyncCache +import com.simprints.infra.eventsync.event.commcare.cache.SyncedCaseEntity +import com.simprints.infra.logging.LoggingConstants import com.simprints.infra.logging.Simber +import com.simprints.libsimprints.Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.AfterClass @@ -22,18 +30,29 @@ import org.junit.Before import org.junit.BeforeClass import org.junit.Rule import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class CommCareEventDataSourceTest { companion object { private const val TEST_PACKAGE_NAME = "org.commcare.dalvik" + private const val SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID = "b26c91bc-b307-4131-80c3-55090ba5dbf2" private const val SUBJECT_ACTIONS_EVENT_1 = - """{"events":[{"id":"0dafcd03-96c4-4ca5-b802-292da6d4f799","payload":{"subjectId":"b26c91bc-b307-4131-80c3-55090ba5dbf2","projectId":"nXcj9neYhXP9rFp56uWk","moduleId":{"value":"AWuA3H0WGtHI2uod+ePZ3yiWTt9etQ=="},"attendantId":{"value":"AdySMrjuy7uq0Dcxov3rUFIw66uXTFrKd0BnzSr9MYXl5maWEpyKQT8AUdcPuVHUWpOkO88="},"biometricReferences":[{"id":"2b9b4991-29d7-3eee-ac02-191afaa0c1a2","templates":[{"quality":99,"template":"123","finger":"LEFT_THUMB"}],"format":"ISO_19794_2","type":"FINGERPRINT_REFERENCE"}]},"type":"EnrolmentRecordCreation"}]}""" + """{"events":[{"id":"0dafcd03-96c4-4ca5-b802-292da6d4f799","payload":{"subjectId":"$SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID","projectId":"nXcj9neYhXP9rFp56uWk","moduleId":{"value":"AWuA3H0WGtHI2uod+ePZ3yiWTt9etQ=="},"attendantId":{"value":"AdySMrjuy7uq0Dcxov3rUFIw66uXTFrKd0BnzSr9MYXl5maWEpyKQT8AUdcPuVHUWpOkO88="},"biometricReferences":[{"id":"2b9b4991-29d7-3eee-ac02-191afaa0c1a2","templates":[{"quality":99,"template":"123","finger":"LEFT_THUMB"}],"format":"ISO_19794_2","type":"FINGERPRINT_REFERENCE"}]},"type":"EnrolmentRecordCreation"}]}""" + private const val SUBJECT_ACTIONS_EVENT_2_SUBJECT_ID = "a961fcb4-8573-4270-a1b2-088e88275b00" private const val SUBJECT_ACTIONS_EVENT_2 = - """{"events":[{"id":"1eafcd03-96c4-4ca5-b802-292da6d4f799","payload":{"subjectId":"a961fcb4-8573-4270-a1b2-088e88275b00","projectId":"nXcj9neYhXP9rFp56uWk","moduleId":{"value":"AWuA3H0WGtHI2uod+ePZ3yiWTt9etQ=="},"attendantId":{"value":"AdySMrjuy7uq0Dcxov3rUFIw66uXTFrKd0BnzSr9MYXl5maWEpyKQT8AUdcPuVHUWpOkO88="},"biometricReferences":[{"id":"2b9b4991-29d7-3eee-ac02-191afaa0c1a2","templates":[{"quality":88,"template":"456","finger":"LEFT_INDEX_FINGER"}],"format":"ISO_19794_2","type":"FINGERPRINT_REFERENCE"}]},"type":"EnrolmentRecordCreation"}]}""" + """{"events":[{"id":"1eafcd03-96c4-4ca5-b802-292da6d4f799","payload":{"subjectId":"$SUBJECT_ACTIONS_EVENT_2_SUBJECT_ID","projectId":"nXcj9neYhXP9rFp56uWk","moduleId":{"value":"AWuA3H0WGtHI2uod+ePZ3yiWTt9etQ=="},"attendantId":{"value":"AdySMrjuy7uq0Dcxov3rUFIw66uXTFrKd0BnzSr9MYXl5maWEpyKQT8AUdcPuVHUWpOkO88="},"biometricReferences":[{"id":"2b9b4991-29d7-3eee-ac02-191afaa0c1a2","templates":[{"quality":88,"template":"456","finger":"LEFT_INDEX_FINGER"}],"format":"ISO_19794_2","type":"FINGERPRINT_REFERENCE"}]},"type":"EnrolmentRecordCreation"}]}""" private const val INVALID_JSON = """{"invalid": json""" - @get:Rule - val testCoroutineRule = TestCoroutineRule() + private const val COLUMN_INDEX_DATUM_ID = 0 + private const val COLUMN_INDEX_VALUE = 1 + private const val COLUMN_INDEX_CASE_ID = 2 + private const val COLUMN_INDEX_LAST_MODIFIED = 3 + + // Helper to format date strings as CommCare does (using US locale to match Java's Date.toString()) + private val commCareDateFormat = SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US) + private fun formatCommCareDate(millis: Long): String = commCareDateFormat.format(Date(millis)) @JvmStatic lateinit var mockMetadataUri: Uri @@ -66,6 +85,9 @@ class CommCareEventDataSourceTest { } } + @get:Rule + val testCoroutineRule = TestCoroutineRule() + @MockK private lateinit var context: Context @@ -75,10 +97,11 @@ class CommCareEventDataSourceTest { @MockK private lateinit var mockLastCallingPackageStore: LastCallingPackageStore - private lateinit var mockMetadataCursor: Cursor + @MockK(relaxUnitFun = true) + private lateinit var mockCommCareSyncCache: CommCareSyncCache + private lateinit var mockMetadataCursor: Cursor private lateinit var mockDataCursor: Cursor - private lateinit var dataSource: CommCareEventDataSource @Before @@ -100,6 +123,18 @@ class CommCareEventDataSourceTest { every { mockMetadataCursor.close() } just Runs every { mockDataCursor.close() } just Runs + // Default behavior for column indices + every { mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID) } returns COLUMN_INDEX_CASE_ID + every { mockMetadataCursor.getColumnIndexOrThrow(COLUMN_LAST_MODIFIED) } returns COLUMN_INDEX_LAST_MODIFIED + every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns COLUMN_INDEX_DATUM_ID + every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns COLUMN_INDEX_VALUE + + // Default behavior for last modified time + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns formatCommCareDate(10000L) + + // Default behaviour for previously synced cases: none + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns emptyList() + every { mockContentResolver.query( mockMetadataUri, @@ -122,6 +157,7 @@ class CommCareEventDataSourceTest { dataSource = CommCareEventDataSource( JsonHelper, + mockCommCareSyncCache, mockLastCallingPackageStore, context, ) @@ -129,27 +165,143 @@ class CommCareEventDataSourceTest { @Test fun `getEvents returns correct count and event flow`() = runTest { + val caseId1 = "case1" + val caseId2 = "case2" val expectedCount = 2 every { mockMetadataCursor.count } returns expectedCount every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returnsMany listOf("case1", "case2") + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returnsMany listOf(caseId1, caseId2) - every { mockDataCursor.moveToNext() } returnsMany listOf(true, true, false) - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns 0 - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns 1 - every { mockDataCursor.getString(0) } returnsMany listOf("subjectActions", "subjectActions") - every { mockDataCursor.getString(1) } returnsMany listOf(SUBJECT_ACTIONS_EVENT_1, SUBJECT_ACTIONS_EVENT_2) + every { mockDataCursor.moveToNext() } returnsMany listOf(true, true, false) //datum_id, value for each case + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returnsMany listOf(SUBJECT_ACTIONS_EVENT_1, SUBJECT_ACTIONS_EVENT_2) val result = dataSource.getEvents() assertEquals(expectedCount, result.totalCount) val events = result.eventFlow.toList() assertEquals(2, events.size) - assertEquals("b26c91bc-b307-4131-80c3-55090ba5dbf2", (events[0] as? EnrolmentRecordCreationEvent)?.payload?.subjectId) - assertEquals("a961fcb4-8573-4270-a1b2-088e88275b00", (events[1] as? EnrolmentRecordCreationEvent)?.payload?.subjectId) + assertTrue(events[0] is EnrolmentRecordCreationEvent) + val event1 = events[0] as EnrolmentRecordCreationEvent + assertEquals(SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID, event1.payload.subjectId) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } - verify { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } + assertTrue(events[1] is EnrolmentRecordCreationEvent) + val event2 = events[1] as EnrolmentRecordCreationEvent + assertEquals(SUBJECT_ACTIONS_EVENT_2_SUBJECT_ID, event2.payload.subjectId) + + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } + verify(exactly = 2) { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } + } + + @Test + fun `getEvents generates deletion event for case not in CommCare`() = runTest { + val caseIdPresent = "case_present" + val caseIdMissing = "case_missing_to_delete" + val simprintsIdForMissingCase = "simprints_id_for_missing_case" + + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseIdPresent + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + // Setup previously synced cases + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseIdPresent, "some_sid", 5000L), + SyncedCaseEntity(caseIdMissing, simprintsIdForMissingCase, 5000L) + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(2, events.size) // One creation for present, one deletion for missing + + val creationEvent = events.find { it is EnrolmentRecordCreationEvent } as? EnrolmentRecordCreationEvent + assertEquals(SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID, creationEvent?.payload?.subjectId) + + val deletionEvent = events.find { it is EnrolmentRecordDeletionEvent } as? EnrolmentRecordDeletionEvent + assertEquals(simprintsIdForMissingCase, deletionEvent?.payload?.subjectId) + } + + @Test + fun `getEvents does not generate deletion events when CommCare response is empty`() = runTest { + val caseIdMissing = "case_missing_but_commcare_empty" + val simprintsIdForMissingCase = "simprints_id_for_missing_empty" + + every { mockMetadataCursor.count } returns 0 + every { mockMetadataCursor.moveToNext() } returns false + + // Setup previously synced cases + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseIdMissing, simprintsIdForMissingCase, 5000L) + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(0, result.totalCount) + assertEquals(0, events.size) // No deletion events because CommCare was empty + } + + @Test + fun `getEvents skips case when lastModified is not newer than lastSyncedTimestamp`() = runTest { + val caseId = "case1" + val lastSyncedTimestamp = 15000L + val commCareLastModified = 10000L // Older than lastSyncedTimestamp + + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns formatCommCareDate(commCareLastModified) + + // Setup previously synced case + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseId, "some_sid", lastSyncedTimestamp) + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(0, events.size) // Case skipped because not modified since last sync + + verify(exactly = 0) { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } + } + + @Test + fun `getEvents processes case when lastModified is newer than lastSyncedTimestamp`() = runTest { + val caseId = "case1" + val lastSyncedTimestamp = 5000L + val commCareLastModified = 15000L // Newer than lastSyncedTimestamp + + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns formatCommCareDate(commCareLastModified) + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + // Setup previously synced case + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseId, "some_sid", lastSyncedTimestamp) + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(1, events.size) // Case processed because modified since last sync + + verify(exactly = 1) { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @Test @@ -157,10 +309,10 @@ class CommCareEventDataSourceTest { val expectedCount = 5 every { mockMetadataCursor.count } returns expectedCount - val result = dataSource.getEvents() + val result = dataSource.getEvents() // count is called internally assertEquals(expectedCount, result.totalCount) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, null, null, null, null) } } @Test @@ -168,21 +320,21 @@ class CommCareEventDataSourceTest { every { mockContentResolver.query( mockMetadataUri, - any(), - any(), - any(), - any(), + null, // For count() specifically + null, + null, + null, ) } returns null val result = dataSource.getEvents() assertEquals(0, result.totalCount) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, null, null, null, null) } } @Test - fun `loadEnrolmentRecordCreationEvents handles empty case list`() = runTest { + fun `loadDataFromCommCare handles empty case list`() = runTest { every { mockMetadataCursor.count } returns 0 every { mockMetadataCursor.moveToNext() } returns false @@ -191,28 +343,42 @@ class CommCareEventDataSourceTest { assertEquals(0, result.totalCount) assertEquals(0, events.size) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } } @Test - fun `loadEnrolmentRecordCreationEvents handles null case id`() = runTest { + fun `loadDataFromCommCare handles null case id`() = runTest { every { mockMetadataCursor.count } returns 1 every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returns null + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns null val result = dataSource.getEvents() val events = result.eventFlow.toList() assertEquals(1, result.totalCount) - assertEquals(0, events.size) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + assertEquals(0, events.size) // No caseId, so no events processed + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } } @Test - fun `loadEnrolmentRecordCreationEvents throws exception when data cursor is null`() = runTest { + fun `loadDataFromCommCare handles empty case id`() = runTest { every { mockMetadataCursor.count } returns 1 every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "" + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(0, events.size) // Empty caseId, so no events processed + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } + } + + @Test + fun `loadDataFromCommCare throws exception when data cursor is null`() = runTest { + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "case1" every { mockContentResolver.query( mockDataCaseIdUri, @@ -232,28 +398,27 @@ class CommCareEventDataSourceTest { assertEquals("Cursor for caseId case1 is null", e.message) } - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } verify { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @Test fun `getSubjectActionsValue returns empty string when subjectActions not found`() = runTest { + val caseId = "case1" every { mockMetadataCursor.count } returns 1 every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId every { mockDataCursor.moveToNext() } returnsMany listOf(true, true, false) - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns 0 - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns 1 - every { mockDataCursor.getString(0) } returnsMany listOf("someOtherKey", "anotherKey") - every { mockDataCursor.getString(1) } returnsMany listOf("someValue", "anotherValue") + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returnsMany listOf("someOtherKey", "anotherKey") + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returnsMany listOf("someValue", "anotherValue") val result = dataSource.getEvents() val events = result.eventFlow.toList() assertEquals(1, result.totalCount) assertEquals(0, events.size) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } verify { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @@ -261,123 +426,286 @@ class CommCareEventDataSourceTest { fun `parseRecordEvents handles invalid JSON gracefully`() = runTest { every { mockMetadataCursor.count } returns 1 every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "case1" every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns 0 - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns 1 - every { mockDataCursor.getString(0) } returns "subjectActions" - every { mockDataCursor.getString(1) } returns INVALID_JSON + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns INVALID_JSON val result = dataSource.getEvents() val events = result.eventFlow.toList() assertEquals(1, result.totalCount) - assertEquals(0, events.size) + assertEquals(0, events.size) // Event parsing fails, so no event emitted verify { Simber.e(any(), ofType()) } - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } verify { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @Test fun `parseRecordEvents handles empty subjectActions`() = runTest { + val caseId = "case1" every { mockMetadataCursor.count } returns 1 every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns 0 - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns 1 - every { mockDataCursor.getString(0) } returns "subjectActions" - every { mockDataCursor.getString(1) } returns "" + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns "" val result = dataSource.getEvents() val events = result.eventFlow.toList() assertEquals(1, result.totalCount) assertEquals(0, events.size) - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } verify { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @Test - fun `loadEnrolmentRecordCreationEvents processes events in batches`() = runTest { - val caseCount = 25 // More than batch size of 20 + fun `loadDataFromCommCare processes events in batches`() = runTest { + val caseCount = BATCH_SIZE + 5 every { mockMetadataCursor.count } returns caseCount - // Mock cursor to return case IDs - val moveNextResults = (1..caseCount).map { true } + false - every { mockMetadataCursor.moveToNext() } returnsMany moveNextResults - + val moveNextResultsMetadata = (1..caseCount).map { true } + false + every { mockMetadataCursor.moveToNext() } returnsMany moveNextResultsMetadata val caseIds = (1..caseCount).map { "case$it" } - every { mockMetadataCursor.getString(mockMetadataCursor.getColumnIndexOrThrow(COLUMN_CASE_ID)) } returnsMany caseIds + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returnsMany caseIds - // Mock data cursor for each case - every { mockDataCursor.moveToNext() } returnsMany moveNextResults - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_DATUM_ID) } returns 0 - every { mockDataCursor.getColumnIndexOrThrow(COLUMN_VALUE) } returns 1 - every { mockDataCursor.getString(0) } returns "subjectActions" - every { mockDataCursor.getString(1) } returns SUBJECT_ACTIONS_EVENT_1 + // Mock data cursor for each case, assuming one subjectActions per case + val moveNextResultsData = (1..caseCount).map { true } + List(caseCount) {false} // true then false for each caseId + every { mockDataCursor.moveToNext() } returnsMany moveNextResultsData + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + val subjectActionsData = (1..caseCount).map { SUBJECT_ACTIONS_EVENT_1 } + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returnsMany subjectActionsData val result = dataSource.getEvents() val events = result.eventFlow.toList() assertEquals(caseCount, result.totalCount) - assertEquals(caseCount, events.size) // One event per case - verify { mockContentResolver.query(mockMetadataUri, any(), any(), any(), any()) } + assertEquals(caseCount, events.size) + assertTrue(events.all { it is EnrolmentRecordCreationEvent }) + + verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID, COLUMN_LAST_MODIFIED), any(), any(), any()) } verify(exactly = caseCount) { mockContentResolver.query(mockDataCaseIdUri, any(), any(), any(), any()) } } @Test - fun `exception during metadata cursor query is propagated`() = runTest { - every { - mockContentResolver.query( - mockMetadataUri, - arrayOf(COLUMN_CASE_ID), - any(), - any(), - any(), - ) - } throws RuntimeException("Database error") + fun `parseCommCareDateToMillis handles valid date format`() = runTest { + val validDate = formatCommCareDate(12345L) + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns validDate + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 val result = dataSource.getEvents() + val events = result.eventFlow.toList() - try { - result.eventFlow.toList() - assert(false) { "Expected RuntimeException" } - } catch (e: RuntimeException) { - assertEquals("Database error", e.message) + assertEquals(1, result.totalCount) + assertEquals(1, events.size) + } + + @Test + fun `parseCommCareDateToMillis handles numeric timezone format as fallback`() = runTest { + // Test fallback format with numeric timezone (Z pattern) + val numericTimezoneDate = "Mon Oct 05 16:17:01 -0400 2015" + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns numericTimezoneDate + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(1, events.size) // Successfully parsed with fallback format + // Should log error for first format attempt but succeed with second + verify { Simber.e(any(), ofType(), tag = LoggingConstants.CrashReportTag.COMMCARE_SYNC) } + } + + @Test + fun `parseCommCareDateToMillis handles invalid date format`() = runTest { + val invalidDate = "invalid date format" + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns "case1" + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns invalidDate + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + assertEquals(1, result.totalCount) + assertEquals(1, events.size) // Still processes because invalid date defaults to 0L + // Should log error for each format attempt plus final failure warning + verify(atLeast = 2) { Simber.e(any(), ofType(), tag = LoggingConstants.CrashReportTag.COMMCARE_SYNC) } + verify { Simber.w(any(), tag = LoggingConstants.CrashReportTag.COMMCARE_SYNC) } + } + + @Test + fun `onEventsProcessed updates cache for creation events`() = runTest { + val caseId = "case1" + val subjectId = SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID + val lastModifiedTime = 15000L + + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns formatCommCareDate(lastModifiedTime) + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + // Call onEventsProcessed with the collected events + dataSource.onEventsProcessed(events) + + // Verify that the cache was updated with the correct SyncedCaseEntity + coVerify { + mockCommCareSyncCache.addSyncedCase( + match { + it.caseId == caseId && + it.simprintsId == subjectId && + it.lastSyncedTimestamp == lastModifiedTime + } + ) } + } + + @Test + fun `onEventsProcessed removes cache for deletion events`() = runTest { + val caseIdPresent = "case_present" + val caseIdMissing = "case_missing" + val simprintsIdForMissingCase = "simprints_id_for_missing_case" + + // Setup scenario where CommCare has some cases (not empty) + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseIdPresent + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + // Setup previously synced cases - one present in CommCare, one missing + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseIdPresent, "some_sid", 5000L), + SyncedCaseEntity(caseIdMissing, simprintsIdForMissingCase, 5000L) + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases - verify { Simber.e(any(), ofType()) } - verify { mockContentResolver.query(mockMetadataUri, arrayOf(COLUMN_CASE_ID), any(), any(), any()) } + val result = dataSource.getEvents() + val events = result.eventFlow.toList() + + // Should have one creation event for present case and one deletion event for missing case + assertEquals(1, result.totalCount) + assertEquals(2, events.size) + + val creationEvent = events.find { it is EnrolmentRecordCreationEvent } as? EnrolmentRecordCreationEvent + assertEquals(SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID, creationEvent?.payload?.subjectId) + + val deletionEvent = events.find { it is EnrolmentRecordDeletionEvent } as? EnrolmentRecordDeletionEvent + assertEquals(simprintsIdForMissingCase, deletionEvent?.payload?.subjectId) + + // Call onEventsProcessed with the collected events + dataSource.onEventsProcessed(events) + + // Verify that the case was removed from cache for the deletion event + coVerify { mockCommCareSyncCache.removeSyncedCase(caseIdMissing) } + // Verify that the present case was added/updated in cache for the creation event + coVerify { + mockCommCareSyncCache.addSyncedCase( + match { + it.caseId == caseIdPresent && + it.simprintsId == SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID + } + ) + } } @Test - fun `uses custom package name when available`() { - val customPackageName = "custom.commcare.package" - every { mockLastCallingPackageStore.lastCallingPackageName } returns customPackageName + fun `generateEnrolmentRecordDeletionEvent skips deletion event and removes cache for empty simprintsId`() = runTest { + val caseIdPresent = "case_present" + val caseIdMissingWithEmptySimprints = "case_missing_empty_simprints" - // We can't directly test private methods, but we can verify the URI creation behavior - every { Uri.parse("content://$customPackageName.case/casedb/case") } returns mockMetadataUri - every { mockMetadataCursor.count } returns 0 + // Setup scenario where CommCare has some cases (not empty) + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseIdPresent + + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns SUBJECT_ACTIONS_EVENT_1 + + // Setup previously synced cases - one present in CommCare, one missing with empty simprintsId + val previouslySyncedCases = listOf( + SyncedCaseEntity(caseIdPresent, "some_sid", 5000L), + SyncedCaseEntity(caseIdMissingWithEmptySimprints, "", 5000L) // Empty simprintsId + ) + coEvery { mockCommCareSyncCache.getAllSyncedCases() } returns previouslySyncedCases val result = dataSource.getEvents() + val events = result.eventFlow.toList() - assertEquals(0, result.totalCount) - verify { mockLastCallingPackageStore.lastCallingPackageName } + // Should have one creation event for present case, no deletion event for missing case with empty simprintsId + assertEquals(1, result.totalCount) + assertEquals(1, events.size) // Only creation event, no deletion event + + val creationEvent = events.find { it is EnrolmentRecordCreationEvent } as? EnrolmentRecordCreationEvent + assertEquals(SUBJECT_ACTIONS_EVENT_1_SUBJECT_ID, creationEvent?.payload?.subjectId) + + // Verify no deletion events were generated + val deletionEvents = events.filterIsInstance() + assertEquals(0, deletionEvents.size) + + // Verify that the case with empty simprintsId was removed from cache directly + coVerify { mockCommCareSyncCache.removeSyncedCase(caseIdMissingWithEmptySimprints) } } @Test - fun `falls back to default when package name is null`() { - every { mockLastCallingPackageStore.lastCallingPackageName } returns null + fun `loadEnrolmentRecordCreationEvents adds case to cache with empty simprintsId when no valid enrolment records found`() = runTest { + val caseId = "case1" + val lastModifiedTime = 15000L - // The fallback should use the default value from the store - every { mockMetadataCursor.count } returns 0 + every { mockMetadataCursor.count } returns 1 + every { mockMetadataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockMetadataCursor.getString(COLUMN_INDEX_CASE_ID) } returns caseId + every { mockMetadataCursor.getString(COLUMN_INDEX_LAST_MODIFIED) } returns formatCommCareDate(lastModifiedTime) + + // Setup cursor to return invalid/null JSON that results in null coSyncEnrolmentRecordEvents + every { mockDataCursor.moveToNext() } returnsMany listOf(true, false) + every { mockDataCursor.getString(COLUMN_INDEX_DATUM_ID) } returns SIMPRINTS_COSYNC_SUBJECT_ACTIONS + every { mockDataCursor.getString(COLUMN_INDEX_VALUE) } returns "" // This will cause null parsing val result = dataSource.getEvents() + val events = result.eventFlow.toList() - assertEquals(0, result.totalCount) - verify { mockLastCallingPackageStore.lastCallingPackageName } + assertEquals(1, result.totalCount) + assertEquals(0, events.size) // No events because invalid JSON resulted in null parsing + + // Verify that case was added to cache with empty simprintsId + coVerify { + mockCommCareSyncCache.addSyncedCase( + match { + it.caseId == caseId && + it.simprintsId == "" && + it.lastSyncedTimestamp == lastModifiedTime + } + ) + } } } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCacheTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCacheTest.kt new file mode 100644 index 0000000000..c4b1839c53 --- /dev/null +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/commcare/cache/CommCareSyncCacheTest.kt @@ -0,0 +1,125 @@ +package com.simprints.infra.eventsync.event.commcare.cache + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull + +@ExperimentalCoroutinesApi +class CommCareSyncCacheTest { + + private lateinit var commCareSyncDao: CommCareSyncDao + private lateinit var commCareSyncCache: CommCareSyncCache + + @Before + fun setUp() { + commCareSyncDao = mockk() + commCareSyncCache = CommCareSyncCache(commCareSyncDao) + } + + @Test + fun `addSyncedCase should call dao insert with timestamp`() = runTest { + // Arrange + val caseId = "case-123" + val simprintsId = "simprints-456" + val timestamp = System.currentTimeMillis() + val expectedEntity = SyncedCaseEntity(caseId, simprintsId, timestamp) + coEvery { commCareSyncDao.insert(any()) } returns Unit + + // Act + commCareSyncCache.addSyncedCase(SyncedCaseEntity(caseId, simprintsId, timestamp)) + + // Assert + coVerify { commCareSyncDao.insert(expectedEntity) } + } + + @Test + fun `getSimprintsId should return id when entity exists`() = runTest { + // Arrange + val caseId = "case-123" + val expectedSimprintsId = "simprints-456" + val entity = SyncedCaseEntity(caseId, expectedSimprintsId, 12345L) // timestamp is arbitrary here + coEvery { commCareSyncDao.getByCaseId(caseId) } returns entity + + // Act + val actualSimprintsId = commCareSyncCache.getSimprintsId(caseId) + + // Assert + coVerify { commCareSyncDao.getByCaseId(caseId) } + assertEquals(expectedSimprintsId, actualSimprintsId) + } + + @Test + fun `getSimprintsId should return null when entity does not exist`() = runTest { + // Arrange + val caseId = "case-unknown" + coEvery { commCareSyncDao.getByCaseId(caseId) } returns null + + // Act + val actualSimprintsId = commCareSyncCache.getSimprintsId(caseId) + + // Assert + coVerify { commCareSyncDao.getByCaseId(caseId) } + assertNull(actualSimprintsId) + } + + @Test + fun `removeSyncedCase should call dao deleteByCaseId`() = runTest { + // Arrange + val caseId = "case-to-delete" + coEvery { commCareSyncDao.deleteByCaseId(caseId) } returns Unit + + // Act + commCareSyncCache.removeSyncedCase(caseId) + + // Assert + coVerify { commCareSyncDao.deleteByCaseId(caseId) } + } + + @Test + fun `getAllSyncedCases should return list of case entities`() = runTest { + // Arrange + val expectedEntities = listOf( + SyncedCaseEntity("case1", "simId1", 1000L), + SyncedCaseEntity("case2", "simId2", 2000L) + ) + coEvery { commCareSyncDao.getAll() } returns expectedEntities + + // Act + val actualEntities = commCareSyncCache.getAllSyncedCases() + + // Assert + coVerify { commCareSyncDao.getAll() } + assertEquals(expectedEntities, actualEntities) + } + + @Test + fun `getAllSyncedCases should return empty list when no entities`() = runTest { + // Arrange + coEvery { commCareSyncDao.getAll() } returns emptyList() + + // Act + val actualEntities = commCareSyncCache.getAllSyncedCases() + + // Assert + coVerify { commCareSyncDao.getAll() } + assertEquals(emptyList(), actualEntities) + } + + @Test + fun `clearAllSyncedCases should call dao clearAll`() = runTest { + // Arrange + coEvery { commCareSyncDao.clearAll() } returns Unit + + // Act + commCareSyncCache.clearAllSyncedCases() + + // Assert + coVerify { commCareSyncDao.clearAll() } + } +} diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTaskTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTaskTest.kt index fdb1ec5261..29a710700a 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTaskTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/down/tasks/CommCareEventSyncTaskTest.kt @@ -322,42 +322,14 @@ class CommCareEventSyncTaskTest { } @Test - fun downSync_shouldTrackSubjectIdsAndDeleteSubjectsNotInCommCare() = runTest { - val creationEvent = ENROLMENT_RECORD_CREATION - mockCommCareDataSource(listOf(creationEvent)) - - // Mock existing subjects in the repository - coEvery { enrolmentRecordRepository.getAllSubjectIds() } returns listOf("subjectId", "subjectNotInCommCare") - - commCareEventSyncTask.downSync(this, projectOp, eventScope, project).toList() - - // Verify that subjects not in CommCare are deleted - coVerify { - enrolmentRecordRepository.performActions( - match> { actions -> - actions.any { it is Deletion && it.subjectId == "subjectNotInCommCare" } - }, - project, - ) - } - } - - @Test - fun downSync_shouldNotDeleteSubjectsIfNoEventsProcessed() = runTest { - mockCommCareDataSource(emptyList()) - coEvery { enrolmentRecordRepository.getAllSubjectIds() } returns listOf("existingSubject") + fun downSync_shouldCallOnEventsProcessedOnDataSource() = runTest { + val eventsToDownload = listOf(ENROLMENT_RECORD_CREATION, ENROLMENT_RECORD_DELETION) + mockCommCareDataSource(eventsToDownload) commCareEventSyncTask.downSync(this, projectOp, eventScope, project).toList() - // Verify that no deletion actions are performed when no events are processed - coVerify(exactly = 0) { - enrolmentRecordRepository.performActions( - match> { actions -> - actions.any { it is Deletion } - }, - project, - ) - } + coVerify { commCareEventDataSource.onEventsProcessed(listOf(ENROLMENT_RECORD_CREATION)) } + coVerify { commCareEventDataSource.onEventsProcessed(listOf(ENROLMENT_RECORD_DELETION)) } } @Test