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:
2026-10-18 22:32:57 +02:00
parent 2bd6b584f8
commit c366fbd9a3
4 changed files with 112 additions and 0 deletions
+1
View File
@@ -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)
}
}