From c366fbd9a37b7a8bdc86ecc28ba68ccbd0d0e7bf Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:32:57 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/data/build.gradle.kts | 1 + .../kvitt/data/sync/SyncEngineTest.kt | 50 ++++++++++++++++ .../data/sync/WorkManagerSyncWorkTest.kt | 60 +++++++++++++++++++ android/gradle/libs.versions.toml | 1 + 4 files changed, 112 insertions(+) create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/WorkManagerSyncWorkTest.kt diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts index 69829ca..13e0a9c 100644 --- a/android/data/build.gradle.kts +++ b/android/data/build.gradle.kts @@ -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) } diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt index 63add05..627686e 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt @@ -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() diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/WorkManagerSyncWorkTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/WorkManagerSyncWorkTest.kt new file mode 100644 index 0000000..fa56f7b --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/WorkManagerSyncWorkTest.kt @@ -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 = 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) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index ebc1b78..f6ba390 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" }