Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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

/**
Expand Down Expand Up @@ -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<Unit> = syncAllData()

override suspend fun syncAllData(): Result<Unit> {
val errors = mutableListOf<com.jdluu.flexinsight.core.errors.ApiError>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,19 @@ 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

@HiltWorker
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Unit>
}
16 changes: 14 additions & 2 deletions app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,15 @@ object RepositoryModule {

@Provides
@Singleton
fun provideFlexRepository(
fun provideFlexRepositoryImpl(
apiKeyManager: ApiKeyManager,
networkMonitor: NetworkMonitor,
cacheManager: CacheManager,
exerciseRepository: ExerciseRepository,
workoutRepository: WorkoutRepository,
routineRepository: RoutineRepository,
statsRepository: StatsRepository
): FlexRepository {
): FlexRepositoryImpl {
return FlexRepositoryImpl(
apiKeyManager = apiKeyManager,
networkMonitor = networkMonitor,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()
val workoutRepository = RecordingWorkoutRepository(
workoutCount = 3,
recentWorkouts = listOf(TestDefaults.workout("w1"), TestDefaults.workout("w2"))
)
val coordinator = SyncCoordinator(workoutRepository, syncPreferences.manager, healthConnectRepository)
return TestListenableWorkerBuilder<BackgroundSyncWorker>(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())
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<String, String>> = Result.Success(emptyMap()),
workouts: Result<Unit> = Result.Success(Unit),
routines: Result<Unit> = Result.Success(Unit)
): Pair<HevySyncSource, StatsRepository> {
val exerciseRepository = mockk<ExerciseRepository>()
coEvery { exerciseRepository.getExerciseTemplateMapping() } returns templates
val workoutRepository = mockk<WorkoutRepository>()
coEvery { workoutRepository.syncWorkouts() } returns workouts
val routineRepository = mockk<RoutineRepository>()
coEvery { routineRepository.syncRoutines() } returns routines
val statsRepository = mockk<StatsRepository>(relaxed = true)

val impl = FlexRepositoryImpl(
apiKeyManager = mockk<ApiKeyManager>(),
networkMonitor = mockk<NetworkMonitor>(),
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() }
}
}
Original file line number Diff line number Diff line change
@@ -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<HealthConnectRepository>(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<HealthConnectRepository>(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()) }
}
}
Loading
Loading