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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user