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,39 @@
|
|||||||
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance [ActionCallback] wired to the "complete task" button on
|
||||||
|
* [HoneyDueLargeWidget] (and wherever else Stream K surfaces the control).
|
||||||
|
*
|
||||||
|
* The callback itself is deliberately thin — all policy lives in
|
||||||
|
* [WidgetActionProcessor]. This keeps the Glance action registration simple
|
||||||
|
* (required for Glance's reflective `actionRunCallback<CompleteTaskAction>`
|
||||||
|
* pattern) and lets the processor be unit-tested without needing a Glance
|
||||||
|
* runtime.
|
||||||
|
*
|
||||||
|
* iOS parity: mirrors `iosApp/HoneyDue/AppIntent.swift` `CompleteTaskIntent`.
|
||||||
|
*/
|
||||||
|
class CompleteTaskAction : ActionCallback {
|
||||||
|
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
val taskId = parameters[taskIdKey] ?: return
|
||||||
|
WidgetActionProcessor.processComplete(context, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Parameter key used by widget task rows to pass the clicked task's
|
||||||
|
* id. Parameter name (`task_id`) matches the iOS `AppIntent`
|
||||||
|
* parameter for discoverability.
|
||||||
|
*/
|
||||||
|
val taskIdKey: ActionParameters.Key<Long> = ActionParameters.Key("task_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||||
|
import com.tt.honeyDue.network.APILayer
|
||||||
|
import com.tt.honeyDue.network.ApiResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates the server-side effect of a "complete task" tap on a widget.
|
||||||
|
*
|
||||||
|
* Mirrors `iosApp/iosApp/Helpers/WidgetActionProcessor.swift` semantics:
|
||||||
|
*
|
||||||
|
* - **Free tier:** do not hit the API. Fire an `ACTION_VIEW` intent against
|
||||||
|
* the `honeydue://paywall?from=widget` deep link so the hosting app can
|
||||||
|
* land on the upgrade flow. Return [Result.FreeTier].
|
||||||
|
* - **Premium:** record optimistic pending-completion state in
|
||||||
|
* [WidgetDataRepository] (which hides the task from subsequent renders),
|
||||||
|
* call [APILayer.createTaskCompletion], then either
|
||||||
|
* (a) **success** — clear pending and ask [WidgetUpdateManager] to
|
||||||
|
* refresh so the now-completed task is confirmed gone, or
|
||||||
|
* (b) **failure** — roll back pending so the task reappears in the
|
||||||
|
* widget and the user can retry. Return [Result.Failed] carrying
|
||||||
|
* the error so the caller can surface a Toast / log / retry.
|
||||||
|
* - **Idempotent:** if the task is already in the pending set, return
|
||||||
|
* [Result.AlreadyPending] without hitting the API. A double-tap while
|
||||||
|
* the first completion is in flight must not double-complete.
|
||||||
|
*
|
||||||
|
* Test hooks ([refreshTrigger], [processOverrideForTest]) are intentionally
|
||||||
|
* public-internal so Robolectric unit tests can observe side effects and
|
||||||
|
* swap the entry point without reflection. Call [resetTestHooks] in test
|
||||||
|
* teardown.
|
||||||
|
*/
|
||||||
|
object WidgetActionProcessor {
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
/** API completion succeeded. Task is gone from the widget's view. */
|
||||||
|
object Success : Result()
|
||||||
|
|
||||||
|
/** User is on the free tier. Paywall deep-link was fired instead. */
|
||||||
|
object FreeTier : Result()
|
||||||
|
|
||||||
|
/** Task is already in the pending set — duplicate tap, no-op. */
|
||||||
|
object AlreadyPending : Result()
|
||||||
|
|
||||||
|
/** API call failed. Pending state has been rolled back. */
|
||||||
|
data class Failed(val error: Throwable) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point. Usually invoked from [CompleteTaskAction.onAction], which
|
||||||
|
* runs on Glance's callback dispatcher. Safe to invoke off the main
|
||||||
|
* thread.
|
||||||
|
*/
|
||||||
|
suspend fun processComplete(context: Context, taskId: Long): Result {
|
||||||
|
processOverrideForTest?.let { return it(context, taskId) }
|
||||||
|
|
||||||
|
val repo = WidgetDataRepository.get(context)
|
||||||
|
val store = WidgetDataStore(context)
|
||||||
|
|
||||||
|
// Idempotent short-circuit: another tap is already in flight.
|
||||||
|
if (store.readPendingCompletionIds().contains(taskId)) {
|
||||||
|
return Result.AlreadyPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier gate — free users get the paywall, not the API.
|
||||||
|
val tier = repo.loadTierState()
|
||||||
|
if (tier != TIER_PREMIUM) {
|
||||||
|
launchPaywall(context)
|
||||||
|
return Result.FreeTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic UI: hide the task from the widget before hitting the API.
|
||||||
|
repo.markPendingCompletion(taskId)
|
||||||
|
|
||||||
|
val request = TaskCompletionCreateRequest(
|
||||||
|
taskId = taskId.toInt(),
|
||||||
|
notes = WIDGET_COMPLETION_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
|
val apiResult: ApiResult<*> = try {
|
||||||
|
APILayer.createTaskCompletion(request)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
repo.clearPendingCompletion(taskId)
|
||||||
|
return Result.Failed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (apiResult) {
|
||||||
|
is ApiResult.Success<*> -> {
|
||||||
|
// Completion synced. Clear the pending marker and force a
|
||||||
|
// refresh so the worker re-fetches the (now shorter) task
|
||||||
|
// list from the API.
|
||||||
|
repo.clearPendingCompletion(taskId)
|
||||||
|
refreshTrigger(context)
|
||||||
|
Result.Success
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
// Server rejected the completion. Roll back the optimistic
|
||||||
|
// state so the task re-appears in the widget.
|
||||||
|
repo.clearPendingCompletion(taskId)
|
||||||
|
val message = apiResult.message.ifBlank { "widget-complete failed" }
|
||||||
|
Result.Failed(RuntimeException(message))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// ApiResult.Idle / ApiResult.Loading are not valid terminal
|
||||||
|
// states here — treat as failure, roll back.
|
||||||
|
repo.clearPendingCompletion(taskId)
|
||||||
|
Result.Failed(
|
||||||
|
IllegalStateException("Unexpected terminal ApiResult: $apiResult")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the `honeydue://paywall?from=widget` deep link. */
|
||||||
|
private fun launchPaywall(context: Context) {
|
||||||
|
val uri = Uri.parse(PAYWALL_URI)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||||
|
// Widget callbacks don't run inside an Activity — must ask the
|
||||||
|
// OS for a fresh task.
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
setPackage(context.packageName)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Test hooks — see class kdoc. Do NOT reference from production code.
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called after a successful API completion to nudge the widget
|
||||||
|
* host into re-rendering. Defaults to [WidgetUpdateManager.forceRefresh];
|
||||||
|
* tests swap this to observe the call without mocking WorkManager.
|
||||||
|
*/
|
||||||
|
@JvmField
|
||||||
|
internal var refreshTrigger: (Context) -> Unit = { WidgetUpdateManager.forceRefresh(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, [processComplete] short-circuits to this lambda instead of
|
||||||
|
* running the real pipeline. Used by [CompleteTaskActionTest] to isolate
|
||||||
|
* parameter-parsing behaviour from the processor's side effects.
|
||||||
|
*/
|
||||||
|
@JvmField
|
||||||
|
internal var processOverrideForTest: (suspend (Context, Long) -> Result)? = null
|
||||||
|
|
||||||
|
/** Restore test hooks to production defaults. Call from `@After`. */
|
||||||
|
internal fun resetTestHooks() {
|
||||||
|
refreshTrigger = { WidgetUpdateManager.forceRefresh(it) }
|
||||||
|
processOverrideForTest = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TIER_PREMIUM = "premium"
|
||||||
|
private const val PAYWALL_URI = "honeydue://paywall?from=widget"
|
||||||
|
private const val WIDGET_COMPLETION_NOTE = "Completed from widget"
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ class WidgetTaskActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
// Update widgets after completion
|
// Update widgets after completion
|
||||||
if (result is com.tt.honeyDue.network.ApiResult.Success) {
|
if (result is com.tt.honeyDue.network.ApiResult.Success) {
|
||||||
WidgetUpdateManager.updateAllWidgets(context)
|
WidgetUpdateManager.forceRefresh(context)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -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