P4 Stream N: FCM service + NotificationChannels matching iOS categories

FcmService + NotificationPayload + 4 NotificationChannels (task_reminder,
task_overdue, residence_invite, subscription) parity with iOS
NotificationCategories.swift. Deep-link routing from payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:45:37 -05:00
parent 6d7b5ee990
commit 0d50726490
8 changed files with 699 additions and 2 deletions

View File

@@ -0,0 +1,183 @@
package com.tt.honeyDue.notifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.google.firebase.messaging.RemoteMessage
import org.junit.Assert.assertEquals
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.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Tests for [FcmService] — verify channel routing and deep-link handling
* when data messages arrive.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class FcmServiceTest {
private lateinit var context: Context
private lateinit var manager: NotificationManager
private lateinit var service: FcmService
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
manager.cancelAll()
service = Robolectric.setupService(FcmService::class.java)
}
private fun remoteMessage(id: String, data: Map<String, String>): RemoteMessage {
val b = RemoteMessage.Builder("test@fcm")
b.setMessageId(id)
data.forEach { (k, v) -> b.addData(k, v) }
return b.build()
}
@Test
fun onMessageReceived_routesTaskReminder_toCorrectChannel() {
val msg = remoteMessage(
id = "m-1",
data = mapOf(
"type" to "task_reminder",
"task_id" to "7",
"title" to "Reminder",
"body" to "Do it"
)
)
service.onMessageReceived(msg)
// Channel must have been created and a notification posted on it.
val channel = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
assertNotNull("task_reminder channel should be created", channel)
val posted = manager.activeNotifications
assertTrue("one notification should be posted", posted.isNotEmpty())
val n = posted.first()
assertEquals(NotificationChannels.TASK_REMINDER, n.notification.channelId)
}
@Test
fun onMessageReceived_routesTaskOverdue_toHighChannel() {
val msg = remoteMessage(
id = "m-2",
data = mapOf(
"type" to "task_overdue",
"task_id" to "8",
"title" to "Overdue",
"body" to "Late"
)
)
service.onMessageReceived(msg)
val posted = manager.activeNotifications
assertTrue(posted.isNotEmpty())
assertEquals(NotificationChannels.TASK_OVERDUE, posted.first().notification.channelId)
}
@Test
fun onMessageReceived_routesResidenceInvite() {
val msg = remoteMessage(
id = "m-3",
data = mapOf(
"type" to "residence_invite",
"residence_id" to "42",
"title" to "Invite",
"body" to "Join us"
)
)
service.onMessageReceived(msg)
val posted = manager.activeNotifications
assertTrue(posted.isNotEmpty())
assertEquals(NotificationChannels.RESIDENCE_INVITE, posted.first().notification.channelId)
}
@Test
fun onMessageReceived_routesSubscription_toLowChannel() {
val msg = remoteMessage(
id = "m-4",
data = mapOf(
"type" to "subscription",
"title" to "Sub",
"body" to "Changed"
)
)
service.onMessageReceived(msg)
val posted = manager.activeNotifications
assertTrue(posted.isNotEmpty())
assertEquals(NotificationChannels.SUBSCRIPTION, posted.first().notification.channelId)
}
@Test
fun onMessageReceived_withTaskId_sets_deep_link() {
val msg = remoteMessage(
id = "m-5",
data = mapOf(
"type" to "task_reminder",
"task_id" to "123",
"title" to "T",
"body" to "B",
"deep_link" to "honeydue://task/123"
)
)
service.onMessageReceived(msg)
val posted = manager.activeNotifications
assertTrue(posted.isNotEmpty())
val contentIntent = posted.first().notification.contentIntent
assertNotNull("content intent should be attached for deep-link tap", contentIntent)
}
@Test
fun onMessageReceived_malformedPayload_postsNothing() {
// Missing type → payload parse returns null → nothing posted.
val msg = remoteMessage(
id = "m-6",
data = mapOf(
"title" to "x",
"body" to "y"
)
)
service.onMessageReceived(msg)
assertTrue(
"no notification should be posted for malformed payload",
manager.activeNotifications.isEmpty()
)
}
@Test
fun onMessageReceived_distinctMessageIds_produceDistinctNotifications() {
service.onMessageReceived(
remoteMessage(
"id-A",
mapOf("type" to "task_reminder", "task_id" to "1", "title" to "A", "body" to "a")
)
)
service.onMessageReceived(
remoteMessage(
"id-B",
mapOf("type" to "task_reminder", "task_id" to "2", "title" to "B", "body" to "b")
)
)
assertEquals(2, manager.activeNotifications.size)
}
}