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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Tests for [NotificationChannels] — verify that the four iOS-parity channels
|
||||
* are created with the correct importance levels and that the helper is
|
||||
* idempotent across repeated invocations.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
class NotificationChannelsTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var manager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
// Clean slate — remove any channels left over from previous tests.
|
||||
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureChannels_creates_four_channels() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
|
||||
val ids = manager.notificationChannels.map { it.id }.toSet()
|
||||
assertTrue("task_reminder missing", NotificationChannels.TASK_REMINDER in ids)
|
||||
assertTrue("task_overdue missing", NotificationChannels.TASK_OVERDUE in ids)
|
||||
assertTrue("residence_invite missing", NotificationChannels.RESIDENCE_INVITE in ids)
|
||||
assertTrue("subscription missing", NotificationChannels.SUBSCRIPTION in ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureChannels_idempotent() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val firstCount = manager.notificationChannels.size
|
||||
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val secondCount = manager.notificationChannels.size
|
||||
|
||||
assertEquals(firstCount, secondCount)
|
||||
assertEquals(4, secondCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun taskReminder_has_default_importance() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val channel = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
|
||||
assertNotNull(channel)
|
||||
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, channel!!.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun taskOverdue_has_high_importance() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val channel = manager.getNotificationChannel(NotificationChannels.TASK_OVERDUE)
|
||||
assertNotNull(channel)
|
||||
assertEquals(NotificationManager.IMPORTANCE_HIGH, channel!!.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun residenceInvite_has_default_importance() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val channel = manager.getNotificationChannel(NotificationChannels.RESIDENCE_INVITE)
|
||||
assertNotNull(channel)
|
||||
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, channel!!.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscription_has_low_importance() {
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val channel = manager.getNotificationChannel(NotificationChannels.SUBSCRIPTION)
|
||||
assertNotNull(channel)
|
||||
assertEquals(NotificationManager.IMPORTANCE_LOW, channel!!.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelIdForType_mapsAllKnownTypes() {
|
||||
assertEquals(NotificationChannels.TASK_REMINDER, NotificationChannels.channelIdForType("task_reminder"))
|
||||
assertEquals(NotificationChannels.TASK_OVERDUE, NotificationChannels.channelIdForType("task_overdue"))
|
||||
assertEquals(NotificationChannels.RESIDENCE_INVITE, NotificationChannels.channelIdForType("residence_invite"))
|
||||
assertEquals(NotificationChannels.SUBSCRIPTION, NotificationChannels.channelIdForType("subscription"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelIdForType_returnsTaskReminder_forUnknownType() {
|
||||
// Unknown types fall back to task_reminder (safe default).
|
||||
assertEquals(
|
||||
NotificationChannels.TASK_REMINDER,
|
||||
NotificationChannels.channelIdForType("mystery_type")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [NotificationPayload.parse] covering the FCM data-map shapes
|
||||
* produced by the backend for the four iOS parity notification types:
|
||||
* task_reminder, task_overdue, residence_invite, subscription.
|
||||
*/
|
||||
class NotificationPayloadTest {
|
||||
|
||||
@Test
|
||||
fun parse_taskReminder_payload() {
|
||||
val data = mapOf(
|
||||
"type" to "task_reminder",
|
||||
"task_id" to "123",
|
||||
"title" to "Mow the lawn",
|
||||
"body" to "Don't forget to mow today",
|
||||
"deep_link" to "honeydue://task/123"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.parse(data)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertEquals("task_reminder", payload!!.type)
|
||||
assertEquals(123L, payload.taskId)
|
||||
assertNull(payload.residenceId)
|
||||
assertEquals("Mow the lawn", payload.title)
|
||||
assertEquals("Don't forget to mow today", payload.body)
|
||||
assertEquals("honeydue://task/123", payload.deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_taskOverdue_payload() {
|
||||
val data = mapOf(
|
||||
"type" to "task_overdue",
|
||||
"task_id" to "456",
|
||||
"title" to "Overdue: Clean gutters",
|
||||
"body" to "This task is past due"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.parse(data)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertEquals("task_overdue", payload!!.type)
|
||||
assertEquals(456L, payload.taskId)
|
||||
assertEquals("Overdue: Clean gutters", payload.title)
|
||||
assertNull(payload.deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_residenceInvite_payload() {
|
||||
val data = mapOf(
|
||||
"type" to "residence_invite",
|
||||
"residence_id" to "42",
|
||||
"title" to "You've been invited",
|
||||
"body" to "Join the home",
|
||||
"deep_link" to "honeydue://residence/42"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.parse(data)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertEquals("residence_invite", payload!!.type)
|
||||
assertNull(payload.taskId)
|
||||
assertEquals(42L, payload.residenceId)
|
||||
assertEquals("honeydue://residence/42", payload.deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_subscription_payload() {
|
||||
val data = mapOf(
|
||||
"type" to "subscription",
|
||||
"title" to "Subscription updated",
|
||||
"body" to "Your plan changed"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.parse(data)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertEquals("subscription", payload!!.type)
|
||||
assertNull(payload.taskId)
|
||||
assertNull(payload.residenceId)
|
||||
assertEquals("Subscription updated", payload.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_malformed_returns_null_whenTypeMissing() {
|
||||
val data = mapOf(
|
||||
"task_id" to "1",
|
||||
"title" to "x",
|
||||
"body" to "y"
|
||||
)
|
||||
|
||||
assertNull(NotificationPayload.parse(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_malformed_returns_null_whenTitleAndBodyMissing() {
|
||||
val data = mapOf("type" to "task_reminder")
|
||||
|
||||
assertNull(NotificationPayload.parse(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_emptyMap_returns_null() {
|
||||
assertNull(NotificationPayload.parse(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_ignoresInvalidNumericIds() {
|
||||
val data = mapOf(
|
||||
"type" to "task_reminder",
|
||||
"task_id" to "not-a-number",
|
||||
"title" to "x",
|
||||
"body" to "y"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.parse(data)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertNull(payload!!.taskId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user