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
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class SessionSyncServiceTest {
}

@Test
fun sync_withPendingSessions_uploadsAndMarksSent() = runBlocking {
fun sync_withPendingSessions_uploadsAndMarksAcceptedSessionsSent() = runBlocking {
val sessions = listOf(
createSession("com.app.one", 1000L, 2000L),
createSession("com.app.two", 2000L, 3000L)
Expand All @@ -65,16 +65,15 @@ class SessionSyncServiceTest {
assertEquals(1, fakeApi.uploadedBatches.size)
assertEquals(2, fakeApi.uploadedBatches[0].size)
assertEquals("Bearer fake-jwt-token", fakeApi.lastAuthHeader)
assertEquals(sessions.map { it.id }, fakeApi.uploadedBatches[0].map { it.clientSessionId })

// Verify database states
val pending = repository.getPendingSessions(10)
assertTrue(pending.isEmpty())

val dbSessions = database.sessionDao().getSessionsBetween(0L, 10000L)
assertEquals(2, dbSessions.size)
assertTrue(dbSessions.all { it.syncStatus == "SENT" })

// Verify last successful sync metadata
val lastSync = repository.getLastSuccessfulSync()
assertNotNull(lastSync)
}
Expand All @@ -94,62 +93,42 @@ class SessionSyncServiceTest {
assertEquals(50, fakeApi.uploadedBatches[0].size)
assertEquals(50, fakeApi.uploadedBatches[1].size)
assertEquals(20, fakeApi.uploadedBatches[2].size)

val pending = repository.getPendingSessions(200)
assertTrue(pending.isEmpty())
assertTrue(repository.getPendingSessions(200).isEmpty())
}

@Test
fun sync_authError_stopsSyncAndReturnsAuthError() = runBlocking {
val sessions = listOf(
createSession("com.app.one", 1000L, 2000L)
)
repository.saveSessions(sessions, 2000L)
repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L)
fakeApi.shouldFailWithCode = 401

val result = syncService.syncPendingSessions()

assertEquals(SessionSyncResult.AUTH_ERROR, result)
// Sessions remain PENDING
val pending = repository.getPendingSessions(10)
assertEquals(1, pending.size)
assertEquals(SyncStatus.PENDING, pending[0].syncStatus)
assertEquals(1, repository.getPendingSessions(10).size)
}

@Test
fun sync_serverError_marksFailedAndReturnsServerError() = runBlocking {
val sessions = listOf(
createSession("com.app.one", 1000L, 2000L)
)
repository.saveSessions(sessions, 2000L)
repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L)
fakeApi.shouldFailWithCode = 500

val result = syncService.syncPendingSessions()

assertEquals(SessionSyncResult.SERVER_ERROR, result)

// Session should be marked FAILED with error message
val dbSessions = database.sessionDao().getSessionsBetween(0L, 10000L)
assertEquals(1, dbSessions.size)
assertEquals("FAILED", dbSessions[0].syncStatus)
assertEquals("Server error: 500", dbSessions[0].errorMessage)
}

@Test
fun sync_networkError_keepsPendingAndReturnsNetworkError() = runBlocking {
val sessions = listOf(
createSession("com.app.one", 1000L, 2000L)
)
repository.saveSessions(sessions, 2000L)
repository.saveSessions(listOf(createSession("com.app.one", 1000L, 2000L)), 2000L)
fakeApi.shouldThrowNetworkError = true

val result = syncService.syncPendingSessions()

assertEquals(SessionSyncResult.NETWORK_ERROR, result)

// Session should remain PENDING
val dbSessions = database.sessionDao().getSessionsBetween(0L, 10000L)
assertEquals(1, dbSessions.size)
assertEquals("PENDING", dbSessions[0].syncStatus)
}

Expand All @@ -167,12 +146,9 @@ class SessionSyncServiceTest {
val result = syncService.syncPendingSessions()

assertEquals(SessionSyncResult.PARTIAL_SUCCESS, result)

val dbSessions = database.sessionDao().getSessionsBetween(0L, 200000L)
val sentCount = dbSessions.count { it.syncStatus == "SENT" }
val failedCount = dbSessions.count { it.syncStatus == "FAILED" }
assertEquals(50, sentCount)
assertEquals(25, failedCount)
assertEquals(50, dbSessions.count { it.syncStatus == "SENT" })
assertEquals(25, dbSessions.count { it.syncStatus == "FAILED" })
}

private fun createSession(packageName: String, startTime: Long, endTime: Long): Session {
Expand Down Expand Up @@ -201,7 +177,7 @@ class SessionSyncServiceTest {
override suspend fun uploadSessions(
authToken: String,
sessions: List<SessionUploadDto>
): Response<Unit> {
): Response<AcceptedTimeLogsResponse> {
lastAuthHeader = authToken
val currentCall = callCount
callCount++
Expand All @@ -216,7 +192,9 @@ class SessionSyncServiceTest {
}

uploadedBatches.add(sessions)
return Response.success(Unit)
return Response.success(
AcceptedTimeLogsResponse(sessions.map { it.clientSessionId })
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.harshit.crossdevicetracker

data class AcceptedTimeLogsResponse(
val acceptedClientSessionIds: List<String>
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ interface SessionApi {
suspend fun uploadSessions(
@Header("Authorization") authToken: String,
@Body sessions: List<SessionUploadDto>
): Response<Unit>
): Response<AcceptedTimeLogsResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ class SessionSyncService(
) {
suspend fun syncPendingSessions(): SessionSyncResult {
val deviceToken = deviceTokenStore.getDeviceToken()
if (deviceToken.isNullOrBlank()) {
return SessionSyncResult.AUTH_ERROR
}
if (deviceToken.isNullOrBlank()) return SessionSyncResult.AUTH_ERROR

val authHeader = "Bearer $deviceToken"
var hasSuccessfulBatch = false
Expand All @@ -23,40 +21,41 @@ class SessionSyncService(
while (true) {
val pendingSessions = sessionRepository.getPendingSessions(limit = 50)
Log.d("SessionSync", "Pending sessions: ${pendingSessions.size}")
if (pendingSessions.isEmpty()) {
break
}
if (pendingSessions.isEmpty()) break

val dtos = pendingSessions.map(SessionUploadMapper::toDto)
Log.d("SessionSync", "Uploading batch of ${pendingSessions.size} sessions")
val result = try {
val response = sessionApi.uploadSessions(authHeader, dtos)
Log.d("SessionSync", "Upload API returned")
Log.d("SessionSync", "HTTP: ${response.code()} success=${response.isSuccessful}")
if (!response.isSuccessful) {
Log.d("SessionSync", "Body: ${response.errorBody()?.string()}")
}
Log.d("API_RESPONSE", response.toString())

if (response.isSuccessful) {
for (session in pendingSessions) {
sessionRepository.markSessionSent(session.id)
}
sessionRepository.setLastSuccessfulSync(Instant.now().toEpochMilli())
hasSuccessfulBatch = true
null
} else {
val code = response.code()
if (code == 401) {
SessionSyncResult.AUTH_ERROR
} else if (code >= 500) {
val errMsg = "Server error: $code"
val acceptedIds = response.body()?.acceptedClientSessionIds
val acceptedIdSet = acceptedIds?.toHashSet()
val acceptedPendingCount = acceptedIdSet?.count { id -> pendingSessions.any { it.id == id } } ?: 0

if (acceptedIdSet == null || acceptedPendingCount == 0) {
Comment on lines +32 to +36
val errMsg = "Successful sync response did not accept any pending session ids"
for (session in pendingSessions) {
sessionRepository.markSessionFailed(session.id, errMsg)
}
hasFailedBatch = true
SessionSyncResult.SERVER_ERROR
} else {
val errMsg = "API error: $code"
for (session in pendingSessions) {
if (session.id in acceptedIdSet) {
sessionRepository.markSessionSent(session.id)
}
}
sessionRepository.setLastSuccessfulSync(Instant.now().toEpochMilli())
hasSuccessfulBatch = true
null
}
} else {
val code = response.code()
if (code == 401) {
SessionSyncResult.AUTH_ERROR
} else {
val errMsg = if (code >= 500) "Server error: $code" else "API error: $code"
for (session in pendingSessions) {
sessionRepository.markSessionFailed(session.id, errMsg)
}
Expand All @@ -77,9 +76,7 @@ class SessionSyncService(

if (result != null) {
lastErrorResult = result
if (result == SessionSyncResult.AUTH_ERROR || result == SessionSyncResult.NETWORK_ERROR) {
break
}
if (result == SessionSyncResult.AUTH_ERROR || result == SessionSyncResult.NETWORK_ERROR) break
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.harshit.crossdevicetracker

data class SessionUploadDto(
val id: String,
val clientSessionId: String,
val packageName: String,
val appName: String?,
val startTimeUtc: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.harshit.crossdevicetracker
object SessionUploadMapper {
fun toDto(session: Session): SessionUploadDto {
return SessionUploadDto(
id = session.id,
clientSessionId = session.id,
packageName = session.packageName,
appName = session.appName,
startTimeUtc = session.startTimeUtc.toString(),
Expand Down