Files
honeyDueKMP/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt
T
Trey T 485f70dfa1 Integration: residence invite accept/decline APIs + wire notification actions
Adds acceptResidenceInvite / declineResidenceInvite to ResidenceApi
(POST /api/residences/{id}/invite/{accept|decline}) and exposes them via
APILayer. On accept success, myResidences is force-refreshed so the
newly-joined residence appears without a manual pull.

Wires NotificationActionReceiver's ACCEPT_INVITE / DECLINE_INVITE
handlers to the new APILayer calls, replacing the log-only TODOs left
behind by P4 Stream O. Notifications are now cleared only on API
success so a failed accept stays actionable.

Tests:
 - ResidenceApiInviteTest covers correct HTTP method/path + error surfacing.
 - NotificationActionReceiverTest invite cases updated to assert the new
   APILayer calls (were previously asserting the log-only path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:36:59 -05:00

366 lines
13 KiB
Kotlin

package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.tt.honeyDue.MainActivity
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 com.tt.honeyDue.widget.WidgetUpdateManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
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.assertNull
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
/**
* Unit tests for the iOS-parity [NotificationActionReceiver] (P4 Stream O).
*
* Covers the action dispatch table: Complete, Snooze, Open, Accept, Decline,
* plus defensive handling of missing extras.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class NotificationActionReceiverTest {
private lateinit var context: Context
private lateinit var app: Application
private lateinit var notificationManager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
app = context.applicationContext as Application
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
mockkObject(APILayer)
mockkObject(WidgetUpdateManager)
every { WidgetUpdateManager.forceRefresh(any()) } just runs
}
@After
fun tearDown() {
unmockkAll()
notificationManager.cancelAll()
}
// Build a receiver whose async work runs synchronously on the test scheduler.
private fun receiverFor(scope: CoroutineScope): NotificationActionReceiver =
NotificationActionReceiver().apply { coroutineScopeOverride = scope }
private fun successCompletion(taskId: Int) = TaskCompletionResponse(
id = 1,
taskId = taskId,
completedBy = null,
completedAt = "2026-04-16T00:00:00Z",
notes = "Completed from notification",
actualCost = null,
rating = null,
images = emptyList(),
createdAt = "2026-04-16T00:00:00Z",
updatedTask = null
)
private fun postDummyNotification(id: Int) {
// Create channels so the notify() call below actually posts on O+.
NotificationChannels.ensureChannels(context)
val n = androidx.core.app.NotificationCompat.Builder(context, NotificationChannels.TASK_REMINDER)
.setSmallIcon(com.tt.honeyDue.R.mipmap.ic_launcher)
.setContentTitle("t")
.setContentText("b")
.build()
notificationManager.notify(id, n)
assertTrue(
"precondition: dummy notification should be posted",
notificationManager.activeNotifications.any { it.id == id }
)
}
// ---------- 1. COMPLETE dispatches to APILayer + cancels notification ----------
@Test
fun complete_callsCreateTaskCompletion_and_cancelsNotification() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Success(successCompletion(42))
val notifId = 9001
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 42L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) {
APILayer.createTaskCompletion(match<TaskCompletionCreateRequest> {
it.taskId == 42 && it.notes == "Completed from notification"
})
}
verify(exactly = 1) { WidgetUpdateManager.forceRefresh(any()) }
assertFalse(
"notification should be canceled on success",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 2. COMPLETE failure: notification survives for retry ----------
@Test
fun complete_apiFailure_keepsNotification_forRetry() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Error("nope", 500)
val notifId = 9002
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 7L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
assertTrue(
"notification should remain posted so the user can retry",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 3. SNOOZE: schedules AlarmManager +30 min ----------
@Test
fun snooze_schedulesAlarm_thirtyMinutesOut() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9003
postDummyNotification(notifId)
val beforeMs = System.currentTimeMillis()
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.SNOOZE
putExtra(NotificationActions.EXTRA_TASK_ID, 55L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
putExtra(NotificationActions.EXTRA_TITLE, "Title")
putExtra(NotificationActions.EXTRA_BODY, "Body")
putExtra(NotificationActions.EXTRA_TYPE, NotificationChannels.TASK_REMINDER)
}
receiverFor(scope).onReceive(context, intent)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val scheduled = shadowOf(am).scheduledAlarms
assertEquals("exactly one snooze alarm scheduled", 1, scheduled.size)
val alarm = scheduled.first()
val delta = alarm.triggerAtTime - beforeMs
val expected = NotificationActions.SNOOZE_DELAY_MS
// Allow ±2s jitter around the expected 30 minutes.
assertTrue(
"snooze alarm should fire ~30 min out (delta=$delta)",
delta in (expected - 2_000)..(expected + 2_000)
)
assertFalse(
"original notification should be cleared after snooze",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 4. OPEN: launches MainActivity with deep-link ----------
@Test
fun open_launchesMainActivity_withDeepLinkAndExtras() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9004
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.OPEN
putExtra(NotificationActions.EXTRA_TASK_ID, 77L)
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 3L)
putExtra(NotificationActions.EXTRA_DEEP_LINK, "honeydue://task/77")
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
val started = shadowOf(app).nextStartedActivity
assertNotNull("MainActivity should be launched", started)
assertEquals(MainActivity::class.java.name, started.component?.className)
assertEquals("honeydue", started.data?.scheme)
assertEquals("77", started.data?.pathSegments?.last())
assertEquals(77L, started.getLongExtra(FcmService.EXTRA_TASK_ID, -1))
assertEquals(3L, started.getLongExtra(FcmService.EXTRA_RESIDENCE_ID, -1))
assertFalse(
"notification should be canceled after open",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 5. ACCEPT_INVITE: calls APILayer + clears notification ----------
@Test
fun acceptInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
coEvery { APILayer.acceptResidenceInvite(any()) } returns ApiResult.Success(Unit)
val notifId = 9005
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.ACCEPT_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 101L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
coVerify(exactly = 1) { APILayer.acceptResidenceInvite(101) }
assertFalse(
"invite notification should be cleared on accept",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 6. Missing extras: no crash, no-op ----------
@Test
fun complete_withoutTaskId_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val notifId = 9006
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
// no task_id
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertTrue(
"notification must survive a malformed COMPLETE",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 7. Unknown action: no-op ----------
@Test
fun unknownAction_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = "com.tt.honeyDue.action.NONSENSE"
putExtra(NotificationActions.EXTRA_TASK_ID, 1L)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
// No started activity either.
assertNull(shadowOf(app).nextStartedActivity)
scope.cancel()
}
// ---------- 8. Null action: no crash ----------
@Test
fun nullAction_doesNotCrash() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent() // action is null
// Should not throw.
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
scope.cancel()
}
// ---------- 9. Decline invite: clears notification ----------
@Test
fun declineInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
coEvery { APILayer.declineResidenceInvite(any()) } returns ApiResult.Success(Unit)
val notifId = 9009
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.DECLINE_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 77L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
coVerify(exactly = 1) { APILayer.declineResidenceInvite(77) }
assertFalse(
"invite notification should be cleared on decline",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
}