485f70dfa1
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>
366 lines
13 KiB
Kotlin
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()
|
|
}
|
|
}
|