M12f: :data tests for the 429 retry, D-040 whole-request refusals and WorkManager unique work
- SyncEngineTest: 429 stops with Retry(rate_limited) before pulling; 400, 413, 409, 422 push refusals are no progress, the cycle pulls, succeeds and records last_error_code, one push only. - WorkManagerSyncWorkTest on work-testing's test WorkManager: kvitt-sync APPEND_OR_REPLACE (second request chained), kvitt-sync-periodic KEEP every 15 minutes, CONNECTED constraint. Adds androidx.work:work-testing (testImplementation, same version as work-runtime). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -32,6 +32,7 @@ dependencies {
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
||||
testImplementation(libs.ktor.client.mock)
|
||||
testImplementation(libs.androidx.work.testing)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
||||
@@ -393,6 +393,56 @@ class SyncEngineTest {
|
||||
assertEquals(1, h.db.expenses().pending(10).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun push_rateLimited_asksForRetry_andKeepsPending_withoutPulling() = runSync {
|
||||
h.ready()
|
||||
val id = h.addExpense("ICA")
|
||||
h.server.restHandler = { request, path ->
|
||||
if (path == "/sync" && request.method == io.ktor.http.HttpMethod.Post) {
|
||||
HttpStatusCode.TooManyRequests to """{"code":"rate_limited","message":"x","field":null}"""
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
assertEquals("D-040: 429 retries with backoff", SyncOutcome.Retry("rate_limited"), h.engine.sync())
|
||||
assertEquals(true, h.db.expenses().get(id)?.pending)
|
||||
assertTrue("the cycle stops before the pull", h.server.pulls.isEmpty())
|
||||
assertEquals("rate_limited", h.db.syncState().get()?.lastErrorCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun push_wholeRequestRefusal_isNoProgress_thenPulls_andRecordsTheCode() = runSync {
|
||||
// D-040: 400, 413, 409 and 422 refuse the whole request; nothing is applied, the cycle pulls and succeeds.
|
||||
for ((status, code) in listOf(
|
||||
HttpStatusCode.BadRequest to "invalid_request",
|
||||
HttpStatusCode.PayloadTooLarge to "payload_too_large",
|
||||
HttpStatusCode.Conflict to "household_incomplete",
|
||||
HttpStatusCode.UnprocessableEntity to "batch_too_large",
|
||||
)) {
|
||||
val harness = Harness().ready()
|
||||
try {
|
||||
val id = harness.addExpense("ICA")
|
||||
var pushes = 0
|
||||
harness.server.restHandler = { request, path ->
|
||||
if (path == "/sync" && request.method == io.ktor.http.HttpMethod.Post) {
|
||||
pushes++
|
||||
status to """{"code":"$code","message":"x","field":null}"""
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
assertEquals(code, SyncOutcome.Success, harness.engine.sync())
|
||||
assertEquals("$code: one push, no loop", 1, pushes)
|
||||
assertEquals(code, true, harness.db.expenses().get(id)?.pending)
|
||||
assertFalse("$code: pulled after the refusal", harness.server.pulls.isEmpty())
|
||||
assertEquals(code, harness.db.syncState().get()?.lastErrorCode)
|
||||
assertNotNull(harness.db.syncState().get()?.lastSuccessAt)
|
||||
} finally {
|
||||
harness.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceRevoked_wipesTokenAndLocalData() = runSync {
|
||||
h.ready()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package se.hagstedt.kvitt.data.sync
|
||||
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.SynchronousExecutor
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* §14.4.1, §14.4.9 (D-028): the unique work names and policies of [WorkManagerSyncWork] on a real WorkManager
|
||||
* (Robolectric, `work-testing`'s test WorkManager: nothing runs). One-off sync is `kvitt-sync` with APPEND_OR_REPLACE (a second request is chained,
|
||||
* not dropped); periodic sync is `kvitt-sync-periodic` every 15 minutes with KEEP (a second call keeps the first).
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class WorkManagerSyncWorkTest {
|
||||
private val context = RuntimeEnvironment.getApplication()
|
||||
private lateinit var work: WorkManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// The test WorkManager runs nothing until a test driver says so; its executors are synchronous.
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, Configuration.Builder().setExecutor(SynchronousExecutor()).build())
|
||||
work = WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
private fun infos(name: String): List<WorkInfo> = work.getWorkInfosForUniqueWork(name).get().filter { !it.state.isFinished }
|
||||
|
||||
@Test
|
||||
fun oneOff_isUniqueKvittSync_appendOrReplace_withNetworkAndBackoff() {
|
||||
val sync = WorkManagerSyncWork(context)
|
||||
sync.enqueueOneOff(expedited = false)
|
||||
sync.enqueueOneOff(expedited = true)
|
||||
val queued = infos(WorkManagerSyncWork.ONE_OFF)
|
||||
assertEquals("APPEND_OR_REPLACE chains the second request", 2, queued.size)
|
||||
assertEquals(setOf(WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED), queued.map { it.state }.toSet())
|
||||
assertEquals(androidx.work.NetworkType.CONNECTED, queued.first().constraints.requiredNetworkType)
|
||||
assertEquals("kvitt-sync", WorkManagerSyncWork.ONE_OFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun periodic_isUniqueKvittSyncPeriodic_keep_every15Minutes() {
|
||||
val sync = WorkManagerSyncWork(context)
|
||||
sync.ensurePeriodic()
|
||||
val first = infos(WorkManagerSyncWork.PERIODIC).single()
|
||||
sync.ensurePeriodic()
|
||||
val second = infos(WorkManagerSyncWork.PERIODIC).single()
|
||||
assertEquals("KEEP leaves the existing work in place", first.id, second.id)
|
||||
assertEquals(15 * 60 * 1000L, second.periodicityInfo?.repeatIntervalMillis)
|
||||
assertEquals("kvitt-sync-periodic", WorkManagerSyncWork.PERIODIC)
|
||||
assertEquals("one-off and periodic never share a name", 0, infos(WorkManagerSyncWork.ONE_OFF).size)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3",
|
||||
androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
|
||||
androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
|
||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
|
||||
|
||||
Reference in New Issue
Block a user