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 { 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() } }