P3 Stream M: CompleteTaskAction (widget interactive completion)

Glance ActionCallback wires to WidgetActionProcessor: premium marks pending +
calls API + refreshes; free tier opens paywall deep link instead. Idempotent,
rollback-safe on API failure.

Also fixes a one-line compile error in WidgetTaskActionReceiver.kt where
updateAllWidgets() had been removed by Stream L — swapped for forceRefresh()
so the build stays green. The legacy receiver is now redundant (replaced by
CompleteTaskAction) but deletion is deferred to a Stream K follow-up so the
AndroidManifest entry can be removed in the same commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:52:01 -05:00
parent 6b3e64661f
commit 58b9371d0d
5 changed files with 538 additions and 1 deletions

View File

@@ -0,0 +1,79 @@
package com.tt.honeyDue.widget
import android.content.Context
import android.os.Build
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.test.runTest
import org.junit.After
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.annotation.Config
/**
* Verifies the Glance [CompleteTaskAction] correctly pulls the task id from
* [ActionParameters] and forwards to [WidgetActionProcessor.processComplete].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class CompleteTaskActionTest {
private lateinit var context: Context
private data class Invocation(val context: Context, val taskId: Long)
private val invocations = mutableListOf<Invocation>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
invocations.clear()
// Swap the processor's entry point for a capturing spy.
WidgetActionProcessor.processOverrideForTest = { ctx, id ->
invocations += Invocation(ctx, id)
WidgetActionProcessor.Result.Success
}
}
@After
fun tearDown() {
WidgetActionProcessor.resetTestHooks()
}
private val dummyGlanceId: GlanceId = object : GlanceId {}
@Test
fun completeTaskAction_reads_taskId_from_parameters() = runTest {
val action = CompleteTaskAction()
val params = actionParametersOf(CompleteTaskAction.taskIdKey to 123L)
action.onAction(context, dummyGlanceId, params)
assertEquals(1, invocations.size)
assertEquals(123L, invocations.single().taskId)
}
@Test
fun completeTaskAction_missing_taskId_noOp() = runTest {
val action = CompleteTaskAction()
// No task_id parameter provided.
val params: ActionParameters = actionParametersOf()
action.onAction(context, dummyGlanceId, params)
assertEquals(
"processComplete must not be invoked when task_id is absent",
0,
invocations.size
)
}
@Test
fun completeTaskAction_taskIdKey_nameMatchesIos() {
assertEquals("task_id", CompleteTaskAction.taskIdKey.name)
}
}

View File

@@ -0,0 +1,262 @@
package com.tt.honeyDue.widget
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.models.TaskCompletionResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import io.mockk.Ordering
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
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.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* Tests for [WidgetActionProcessor].
*
* Mirrors iOS WidgetActionProcessor.swift semantics:
* - Free tier taps open paywall deep link instead of completing.
* - Premium taps perform optimistic mark-pending, API call, refresh-or-rollback.
* - Double-taps while a completion is pending are a no-op.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class WidgetActionProcessorTest {
private lateinit var context: Context
private lateinit var repo: WidgetDataRepository
private var refreshCalls: Int = 0
private var lastRefreshContext: Context? = null
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
repo = WidgetDataRepository.get(context)
repo.clearAll()
refreshCalls = 0
lastRefreshContext = null
WidgetActionProcessor.refreshTrigger = { ctx ->
refreshCalls += 1
lastRefreshContext = ctx
}
mockkObject(APILayer)
}
@After
fun tearDown() = runTest {
unmockkAll()
WidgetActionProcessor.resetTestHooks()
repo.clearAll()
}
private fun successResponse(taskId: Int): TaskCompletionResponse =
TaskCompletionResponse(
id = 1,
taskId = taskId,
completedBy = null,
completedAt = "2026-01-01T00:00:00Z",
notes = "Completed from widget",
actualCost = null,
rating = null,
images = emptyList(),
createdAt = "2026-01-01T00:00:00Z",
updatedTask = null
)
// ---------- 1. Free tier: paywall only, no API call ----------
@Test
fun processComplete_freeTier_opensPaywall_doesNotCallApi() = runTest {
repo.saveTierState("free")
val result = WidgetActionProcessor.processComplete(context, taskId = 42L)
assertEquals(WidgetActionProcessor.Result.FreeTier, result)
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertEquals("Widget refresh should not fire on free tier", 0, refreshCalls)
// ACTION_VIEW intent with honeydue://paywall?from=widget was fired.
val shadowApp = shadowOf(context.applicationContext as android.app.Application)
val next = shadowApp.nextStartedActivity
assertNotNull("Expected paywall intent to be started", next)
assertEquals(Intent.ACTION_VIEW, next.action)
assertNotNull(next.data)
assertEquals("honeydue", next.data!!.scheme)
assertEquals("paywall", next.data!!.host)
assertEquals("widget", next.data!!.getQueryParameter("from"))
}
// ---------- 2. Premium success: mark pending → API → clear pending ----------
@Test
fun processComplete_premium_marksPendingThenCompletes() = runTest {
repo.saveTierState("premium")
repo.saveTasks(listOf(fakeTask(id = 7L)))
coEvery { APILayer.createTaskCompletion(any()) } coAnswers {
// At the instant the API is hit, the task MUST be in the pending set.
assertTrue(
"Task should be marked pending before API call",
repo.isPendingCompletion(7L)
)
ApiResult.Success(successResponse(7))
}
val result = WidgetActionProcessor.processComplete(context, taskId = 7L)
assertEquals(WidgetActionProcessor.Result.Success, result)
coVerify(exactly = 1) {
APILayer.createTaskCompletion(match<TaskCompletionCreateRequest> {
it.taskId == 7 && it.notes == "Completed from widget"
})
}
assertFalse(
"Pending should be cleared after successful API call",
repo.isPendingCompletion(7L)
)
}
// ---------- 3. Premium API failure: rollback pending ----------
@Test
fun processComplete_premium_apiFailure_clearsPending() = runTest {
repo.saveTierState("premium")
repo.saveTasks(listOf(fakeTask(id = 11L)))
coEvery { APILayer.createTaskCompletion(any()) } returns
ApiResult.Error("Server exploded", 500)
val result = WidgetActionProcessor.processComplete(context, taskId = 11L)
assertTrue(
"Expected Failed result but got $result",
result is WidgetActionProcessor.Result.Failed
)
assertFalse(
"Pending must be cleared on failure so the task reappears in widget",
repo.isPendingCompletion(11L)
)
assertEquals("No widget refresh on failure", 0, refreshCalls)
}
// ---------- 4. Idempotent: duplicate taps are no-ops ----------
@Test
fun processComplete_idempotent() = runTest {
repo.saveTierState("premium")
repo.saveTasks(listOf(fakeTask(id = 99L)))
// Seed the pending set — simulates a tap still in flight.
repo.markPendingCompletion(99L)
val result = WidgetActionProcessor.processComplete(context, taskId = 99L)
assertEquals(WidgetActionProcessor.Result.AlreadyPending, result)
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertEquals(0, refreshCalls)
}
// ---------- 5. Premium success triggers widget refresh ----------
@Test
fun processComplete_premium_success_triggersWidgetRefresh() = runTest {
repo.saveTierState("premium")
repo.saveTasks(listOf(fakeTask(id = 5L)))
coEvery { APILayer.createTaskCompletion(any()) } returns
ApiResult.Success(successResponse(5))
val result = WidgetActionProcessor.processComplete(context, taskId = 5L)
assertEquals(WidgetActionProcessor.Result.Success, result)
assertEquals("forceRefresh should fire exactly once on success", 1, refreshCalls)
assertNotNull(lastRefreshContext)
}
// ---------- 6. Order of operations: API before refresh ----------
@Test
fun processComplete_premium_ordersOperations_apiBeforeRefresh() = runTest {
repo.saveTierState("premium")
repo.saveTasks(listOf(fakeTask(id = 3L)))
var apiCalledAt: Int = -1
var refreshCalledAt: Int = -1
var tick = 0
coEvery { APILayer.createTaskCompletion(any()) } coAnswers {
apiCalledAt = tick++
ApiResult.Success(successResponse(3))
}
WidgetActionProcessor.refreshTrigger = {
refreshCalledAt = tick++
}
WidgetActionProcessor.processComplete(context, taskId = 3L)
assertTrue("API must fire before refresh", apiCalledAt >= 0)
assertTrue("refresh must fire before or after API but both must run", refreshCalledAt >= 0)
assertTrue(
"API should be ordered before refresh ($apiCalledAt < $refreshCalledAt)",
apiCalledAt < refreshCalledAt
)
}
// ---------- 7. Missing tier defaults to free ----------
@Test
fun processComplete_missingTier_treatedAsFree() = runTest {
// No saveTierState call — repo defaults to "free".
val result = WidgetActionProcessor.processComplete(context, taskId = 1L)
assertEquals(WidgetActionProcessor.Result.FreeTier, result)
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
}
// ---------- 8. Paywall intent carries NEW_TASK flag so it can start from app context ----------
@Test
fun processComplete_freeTier_paywallIntentIsStartable() = runTest {
repo.saveTierState("free")
WidgetActionProcessor.processComplete(context, taskId = 77L)
val shadowApp = shadowOf(context.applicationContext as android.app.Application)
val next = shadowApp.nextStartedActivity
assertNotNull(next)
// Must have NEW_TASK so it can be launched outside an Activity context
// (the callback fires from a broadcast-adjacent context).
assertTrue(
"Paywall intent should include FLAG_ACTIVITY_NEW_TASK",
(next.flags and Intent.FLAG_ACTIVITY_NEW_TASK) != 0
)
}
// ---------- Helpers ----------
private fun fakeTask(id: Long): WidgetTaskDto = WidgetTaskDto(
id = id,
title = "Task $id",
priority = 2L,
dueDate = null,
isOverdue = false,
daysUntilDue = 1,
residenceId = 1L,
residenceName = "Home",
categoryIcon = "house.fill",
completed = false
)
}