diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa8fcac..d4a6aee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.mockk) + testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.core) diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/FlexRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/FlexRepositoryImpl.kt index 81a7a69..27e0d79 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/repository/FlexRepositoryImpl.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/FlexRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.jdluu.flexinsight.core.errors.Result import com.jdluu.flexinsight.data.cache.CacheKeys import com.jdluu.flexinsight.data.model.* import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.data.sync.HevySyncSource import kotlinx.coroutines.flow.Flow /** @@ -19,7 +20,7 @@ class FlexRepositoryImpl( private val workoutRepository: WorkoutRepository, private val routineRepository: RoutineRepository, private val statsRepository: StatsRepository -) : FlexRepository { +) : FlexRepository, HevySyncSource { private val cacheManager = cacheManager /** @@ -202,6 +203,8 @@ class FlexRepositoryImpl( * Sync all data from API (workouts, routines, exercise templates) * Exercise templates are synced FIRST to ensure muscle group data is available */ + override suspend fun syncAll(): Result = syncAllData() + override suspend fun syncAllData(): Result { val errors = mutableListOf() diff --git a/app/src/main/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorker.kt b/app/src/main/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorker.kt index 3f976fa..b130ad7 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorker.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorker.kt @@ -7,7 +7,6 @@ import androidx.work.CoroutineWorker import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.jdluu.flexinsight.core.errors.ApiError -import com.jdluu.flexinsight.data.repository.FlexRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -15,12 +14,12 @@ import dagger.assisted.AssistedInject class BackgroundSyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, - private val repository: FlexRepository, + private val syncSource: HevySyncSource, private val syncCoordinator: SyncCoordinator ) : CoroutineWorker(context, params) { override suspend fun doWork(): ListenableWorker.Result { - val syncResult = repository.syncAllData() + val syncResult = syncSource.syncAll() return if (syncResult.isSuccess) { syncCoordinator.onSyncComplete() Log.d(TAG, "Periodic sync worker success") diff --git a/app/src/main/java/com/jdluu/flexinsight/data/sync/HevySyncSource.kt b/app/src/main/java/com/jdluu/flexinsight/data/sync/HevySyncSource.kt new file mode 100644 index 0000000..25e199d --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/sync/HevySyncSource.kt @@ -0,0 +1,12 @@ +package com.jdluu.flexinsight.data.sync + +import com.jdluu.flexinsight.core.errors.Result + +/** + * Narrow abstraction over the remote (Hevy) sync pipeline. + * Lets sync orchestration run against any source that can pull all remote data, + * without coupling to the full FlexRepository surface. + */ +interface HevySyncSource { + suspend fun syncAll(): Result +} diff --git a/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt b/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt index ca2bc68..31a88e3 100644 --- a/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt +++ b/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt @@ -173,7 +173,7 @@ object RepositoryModule { @Provides @Singleton - fun provideFlexRepository( + fun provideFlexRepositoryImpl( apiKeyManager: ApiKeyManager, networkMonitor: NetworkMonitor, cacheManager: CacheManager, @@ -181,7 +181,7 @@ object RepositoryModule { workoutRepository: WorkoutRepository, routineRepository: RoutineRepository, statsRepository: StatsRepository - ): FlexRepository { + ): FlexRepositoryImpl { return FlexRepositoryImpl( apiKeyManager = apiKeyManager, networkMonitor = networkMonitor, @@ -193,6 +193,18 @@ object RepositoryModule { ) } + @Provides + @Singleton + fun provideFlexRepository(impl: FlexRepositoryImpl): FlexRepository { + return impl + } + + @Provides + @Singleton + fun provideHevySyncSource(impl: FlexRepositoryImpl): com.jdluu.flexinsight.data.sync.HevySyncSource { + return impl + } + @Provides @Singleton fun provideSyncManager( diff --git a/app/src/test/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorkerTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorkerTest.kt new file mode 100644 index 0000000..0a9b205 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/sync/BackgroundSyncWorkerTest.kt @@ -0,0 +1,122 @@ +package com.jdluu.flexinsight.data.sync + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import com.jdluu.flexinsight.TestApplication +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.health.HealthConnectRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = TestApplication::class) +class BackgroundSyncWorkerTest { + + private lateinit var syncPreferences: RecordingSyncPreferences + private lateinit var healthConnectRepository: HealthConnectRepository + + @Before + fun setUp() { + syncPreferences = RecordingSyncPreferences() + healthConnectRepository = mockk(relaxed = true) + } + + private fun buildWorker(syncSource: HevySyncSource): BackgroundSyncWorker { + val context = ApplicationProvider.getApplicationContext() + val workoutRepository = RecordingWorkoutRepository( + workoutCount = 3, + recentWorkouts = listOf(TestDefaults.workout("w1"), TestDefaults.workout("w2")) + ) + val coordinator = SyncCoordinator(workoutRepository, syncPreferences.manager, healthConnectRepository) + return TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker { + return BackgroundSyncWorker(appContext, workerParameters, syncSource, coordinator) + } + }) + .build() + } + + @Test + fun `success path runs post-sync follow-up and records success`() = runTest { + val source = FakeHevySyncSource(Result.Success(Unit)) + val worker = buildWorker(source) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Success) + assertEquals(1, source.callCount) + assertEquals(listOf(3), syncPreferences.recordedCounts) + coVerify(exactly = 1) { healthConnectRepository.writeWorkoutsToHealthConnect(any()) } + } + + @Test + fun `auth error fails without recording sync`() = runTest { + val source = FakeHevySyncSource(Result.Error(ApiError.AuthError.InvalidApiKey)) + val worker = buildWorker(source) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Failure) + assertEquals(1, source.callCount) + assertTrue(syncPreferences.recordedCounts.isEmpty()) + coVerify(exactly = 0) { healthConnectRepository.writeWorkoutsToHealthConnect(any()) } + } + + @Test + fun `offline error requests retry without crashing or recording`() = runTest { + val source = FakeHevySyncSource(Result.Error(ApiError.NetworkError.NoConnection)) + val worker = buildWorker(source) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Retry) + assertEquals(1, source.callCount) + assertTrue(syncPreferences.recordedCounts.isEmpty()) + coVerify(exactly = 0) { healthConnectRepository.writeWorkoutsToHealthConnect(any()) } + } + + @Test + fun `unknown non-retryable error fails without crashing or recording`() = runTest { + val source = FakeHevySyncSource(Result.Error(ApiError.Unknown("boom"))) + val worker = buildWorker(source) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Failure) + assertEquals(1, source.callCount) + assertTrue(syncPreferences.recordedCounts.isEmpty()) + coVerify(exactly = 0) { healthConnectRepository.writeWorkoutsToHealthConnect(any()) } + } + + @Test + fun `server error requests retry`() = runTest { + val source = FakeHevySyncSource(Result.Error(ApiError.ServerError.InternalServerError)) + val worker = buildWorker(source) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Retry) + assertTrue(syncPreferences.recordedCounts.isEmpty()) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/data/sync/FlexRepositoryImplSyncSourceTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/sync/FlexRepositoryImplSyncSourceTest.kt new file mode 100644 index 0000000..27665c2 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/sync/FlexRepositoryImplSyncSourceTest.kt @@ -0,0 +1,85 @@ +package com.jdluu.flexinsight.data.sync + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.cache.CacheManager +import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.core.network.NetworkMonitor +import com.jdluu.flexinsight.data.repository.ExerciseRepository +import com.jdluu.flexinsight.data.repository.FlexRepositoryImpl +import com.jdluu.flexinsight.data.repository.RoutineRepository +import com.jdluu.flexinsight.data.repository.StatsRepository +import com.jdluu.flexinsight.data.repository.WorkoutRepository +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FlexRepositoryImplSyncSourceTest { + + private fun buildSyncSource( + templates: Result> = Result.Success(emptyMap()), + workouts: Result = Result.Success(Unit), + routines: Result = Result.Success(Unit) + ): Pair { + val exerciseRepository = mockk() + coEvery { exerciseRepository.getExerciseTemplateMapping() } returns templates + val workoutRepository = mockk() + coEvery { workoutRepository.syncWorkouts() } returns workouts + val routineRepository = mockk() + coEvery { routineRepository.syncRoutines() } returns routines + val statsRepository = mockk(relaxed = true) + + val impl = FlexRepositoryImpl( + apiKeyManager = mockk(), + networkMonitor = mockk(), + cacheManager = CacheManager(), + exerciseRepository = exerciseRepository, + workoutRepository = workoutRepository, + routineRepository = routineRepository, + statsRepository = statsRepository + ) + return impl to statsRepository + } + + @Test + fun `syncAll succeeds and invalidates stats cache when all sub-syncs succeed`() = runTest { + val (source, stats) = buildSyncSource() + + val result = source.syncAll() + + assertTrue(result is Result.Success) + verify(exactly = 1) { stats.invalidateStatsCache() } + } + + @Test + fun `syncAll propagates single sub-sync error unchanged`() = runTest { + val (source, stats) = buildSyncSource(workouts = Result.Error(ApiError.NetworkError.NoConnection)) + + val result = source.syncAll() + + assertTrue(result is Result.Error) + assertEquals("No internet connection available", (result as Result.Error).error.message) + verify(exactly = 0) { stats.invalidateStatsCache() } + } + + @Test + fun `syncAll aggregates multiple sub-sync errors`() = runTest { + val (source, stats) = buildSyncSource( + templates = Result.Error(ApiError.NetworkError.ConnectionError()), + routines = Result.Error(ApiError.ServerError.InternalServerError) + ) + + val result = source.syncAll() + + assertTrue(result is Result.Error) + val message = (result as Result.Error).error.message!! + assertTrue(message.startsWith("Sync failed:")) + assertTrue(message.contains("Unable to connect to server")) + assertTrue(message.contains("Internal server error")) + verify(exactly = 0) { stats.invalidateStatsCache() } + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncCoordinatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncCoordinatorTest.kt new file mode 100644 index 0000000..b5ebacb --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncCoordinatorTest.kt @@ -0,0 +1,50 @@ +package com.jdluu.flexinsight.data.sync + +import com.jdluu.flexinsight.data.health.HealthConnectRepository +import com.jdluu.flexinsight.fakes.TestDefaults +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncCoordinatorTest { + + @Test + fun `onSyncComplete records workout count and writes recent workouts to Health Connect`() = runTest { + val syncPreferences = RecordingSyncPreferences() + val healthConnectRepository = mockk(relaxed = true) + val recent = listOf( + TestDefaults.workout("w1", startTime = 100L), + TestDefaults.workout("w2", startTime = 200L) + ) + val coordinator = SyncCoordinator( + workoutRepository = RecordingWorkoutRepository(workoutCount = 7, recentWorkouts = recent), + syncPreferencesManager = syncPreferences.manager, + healthConnectRepository = healthConnectRepository + ) + + coordinator.onSyncComplete() + + assertEquals(listOf(7), syncPreferences.recordedCounts) + coVerify(exactly = 1) { healthConnectRepository.writeWorkoutsToHealthConnect(recent) } + } + + @Test + fun `onSyncComplete with no data records zero count`() = runTest { + val syncPreferences = RecordingSyncPreferences() + val healthConnectRepository = mockk(relaxed = true) + val coordinator = SyncCoordinator( + workoutRepository = RecordingWorkoutRepository(), + syncPreferencesManager = syncPreferences.manager, + healthConnectRepository = healthConnectRepository + ) + + coordinator.onSyncComplete() + + assertEquals(listOf(0), syncPreferences.recordedCounts) + coVerify(exactly = 1) { healthConnectRepository.writeWorkoutsToHealthConnect(emptyList()) } + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncTestFakes.kt b/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncTestFakes.kt new file mode 100644 index 0000000..6eb1988 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/sync/SyncTestFakes.kt @@ -0,0 +1,72 @@ +package com.jdluu.flexinsight.data.sync + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutResponse +import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager +import com.jdluu.flexinsight.data.repository.WorkoutRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeHevySyncSource( + private var result: Result = Result.Success(Unit) +) : HevySyncSource { + var callCount = 0 + private set + + fun enqueue(value: Result) { + result = value + } + + override suspend fun syncAll(): Result { + callCount++ + return result + } +} + +class RecordingWorkoutRepository( + private val workoutCount: Int = 0, + private val recentWorkouts: List = emptyList() +) : WorkoutRepository { + override fun invalidateApiService() {} + override fun getWorkouts(): Flow> = flowOf(emptyList()) + override fun getRecentWorkouts(limit: Int): Flow> = flowOf(recentWorkouts) + override suspend fun getWorkoutById(workoutId: String): Result = + Result.Error(ApiError.Unknown("not used")) + + override fun getWorkoutByIdFlow(workoutId: String): Flow = flowOf(null) + override fun getWorkoutCount(): Flow = flowOf(workoutCount) + override suspend fun getExercisesByWorkoutId(workoutId: String): List = emptyList() + override suspend fun getSetsByExerciseId(exerciseId: String): List = emptyList() + override suspend fun getRemoteWorkoutCount(): Result = + Result.Error(ApiError.Unknown("not used")) + + override fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> = + flowOf(emptyList()) + + override suspend fun syncWorkouts(): Result = Result.Success(Unit) + override suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: WorkoutResponse) {} + override suspend fun getMostRecentSyncedTimestamp(): Long? = null + override suspend fun updateWorkoutStatus( + workoutId: String, + isCompleted: Boolean, + endTime: Long? + ): Result = Result.Success(Unit) + + override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result = + Result.Success(Unit) +} + +class RecordingSyncPreferences { + val recordedCounts = mutableListOf() + val manager: SyncPreferencesManager = mockk(relaxed = true) + + init { + coEvery { manager.recordSyncSuccess(any()) } answers { recordedCounts.add(firstArg()) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b79751..4c9f9ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,7 +62,8 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" } -androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } + androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } + androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workmanager" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }