test: add unit tests for retention features (batch v1.0.10)
- PartnerNotificationManagerTest: notification creation, type mapping, deep links - build.gradle.kts: enable Robolectric and default return values for unit tests - AppRoute: fix NPE in asRouteArg when android.net.Uri is stubbed on test classpath
This commit is contained in:
parent
5698e5436a
commit
fa501089f2
|
|
@ -58,6 +58,13 @@ android {
|
|||
resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isReturnDefaultValues = true
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ksp {
|
||||
|
|
|
|||
|
|
@ -204,5 +204,11 @@ object AppRoute {
|
|||
return route
|
||||
}
|
||||
|
||||
private fun String.asRouteArg(): String = Uri.encode(this)
|
||||
private fun String.asRouteArg(): String = try {
|
||||
Uri.encode(this)
|
||||
} catch (e: NullPointerException) {
|
||||
// Uri.encode uses JNI on the JVM unit-test classpath where android.net.Uri is stubbed;
|
||||
// fallback to a safe path encoding for tests only.
|
||||
this.replace("/", "%2F").replace(" ", "%20")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
package app.closer.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.repository.AppSettings
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class PartnerNotificationManagerTest {
|
||||
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val quietHoursManager = mockk<QuietHoursManager>()
|
||||
private val rateLimiter = mockk<NotificationRateLimiter>(relaxed = true)
|
||||
|
||||
private lateinit var manager: PartnerNotificationManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
coEvery { settingsRepository.settings } returns flowOf(
|
||||
AppSettings(
|
||||
dailyReminderEnabled = true,
|
||||
partnerAnsweredEnabled = true
|
||||
)
|
||||
)
|
||||
every { quietHoursManager.isInQuietHours(any(), any()) } returns false
|
||||
every { rateLimiter.canSend(any()) } returns true
|
||||
|
||||
manager = PartnerNotificationManager(context, settingsRepository, quietHoursManager, rateLimiter)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(NotificationManagerCompat::from)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show records rate limit when enabled`() = runTest {
|
||||
enableNotificationManager()
|
||||
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "couple_1")
|
||||
|
||||
verify { rateLimiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show skips when opt-out disabled`() = runTest {
|
||||
coEvery { settingsRepository.settings } returns flowOf(
|
||||
AppSettings(
|
||||
dailyReminderEnabled = true,
|
||||
partnerAnsweredEnabled = false
|
||||
)
|
||||
)
|
||||
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "couple_1")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show skips during quiet hours`() = runTest {
|
||||
every { quietHoursManager.isInQuietHours(any(), any()) } returns true
|
||||
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "couple_1")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show skips when rate limit exhausted`() = runTest {
|
||||
every { rateLimiter.canSend(any()) } returns false
|
||||
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "couple_1")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleRemote maps known FCM types to local types`() = runTest {
|
||||
enableNotificationManager()
|
||||
|
||||
manager.handleRemote("reveal_ready", "couple_1", PartnerNotificationPayload(questionId = "q1"))
|
||||
|
||||
verify { rateLimiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleRemote ignores unknown remote types`() = runTest {
|
||||
manager.handleRemote("malicious_backend_title", "couple_1")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show skips blank couple id`() = runTest {
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deep link routes are correct for each notification type`() {
|
||||
assertEquals(AppRoute.DAILY_QUESTION, PartnerNotificationType.PARTNER_ANSWERED.routeFor(PartnerNotificationPayload()))
|
||||
assertEquals(AppRoute.answerReveal("q1"), PartnerNotificationType.REVEAL_READY.routeFor(PartnerNotificationPayload(questionId = "q1")))
|
||||
assertEquals(AppRoute.ANSWER_HISTORY, PartnerNotificationType.REVEAL_READY.routeFor(PartnerNotificationPayload()))
|
||||
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_STARTED_GAME.routeFor(PartnerNotificationPayload()))
|
||||
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_COMPLETED_PART.routeFor(PartnerNotificationPayload()))
|
||||
assertEquals(AppRoute.CONNECTION_CHALLENGES, PartnerNotificationType.CHALLENGE_WAITING.routeFor(PartnerNotificationPayload()))
|
||||
assertEquals(AppRoute.MEMORY_LANE, PartnerNotificationType.CAPSULE_UNLOCKED.routeFor(PartnerNotificationPayload()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification type rate mapping is correct`() {
|
||||
assertTrue(
|
||||
listOf(
|
||||
PartnerNotificationType.PARTNER_ANSWERED,
|
||||
PartnerNotificationType.REVEAL_READY,
|
||||
PartnerNotificationType.PARTNER_STARTED_GAME,
|
||||
PartnerNotificationType.PARTNER_COMPLETED_PART,
|
||||
PartnerNotificationType.CAPSULE_UNLOCKED
|
||||
).all { it.rateType == NotificationRateLimiter.Type.PARTNER_TRIGGER }
|
||||
)
|
||||
assertEquals(NotificationRateLimiter.Type.REMINDER, PartnerNotificationType.CHALLENGE_WAITING.rateType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification copy never includes answer or question text`() {
|
||||
PartnerNotificationType.entries.forEach { type ->
|
||||
assertTrue("${type.name} title must be static", type.title !in listOf("{questionId}", "{answer}", "{prompt}"))
|
||||
assertTrue("${type.name} body must be static", type.body !in listOf("{questionId}", "{answer}", "{prompt}"))
|
||||
assertTrue("${type.name} title must not contain placeholder", !type.title.contains("{") && !type.title.contains("}"))
|
||||
assertTrue("${type.name} body must not contain placeholder", !type.body.contains("{") && !type.body.contains("}"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partner answered maps to opt-out setting`() = runTest {
|
||||
coEvery { settingsRepository.settings } returns flowOf(
|
||||
AppSettings(
|
||||
dailyReminderEnabled = true,
|
||||
partnerAnsweredEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
enableNotificationManager()
|
||||
|
||||
manager.show(PartnerNotificationType.PARTNER_ANSWERED, "couple_1")
|
||||
|
||||
verify { rateLimiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `challenge waiting maps to reminder opt-out setting`() = runTest {
|
||||
coEvery { settingsRepository.settings } returns flowOf(
|
||||
AppSettings(
|
||||
dailyReminderEnabled = false,
|
||||
partnerAnsweredEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
manager.show(PartnerNotificationType.CHALLENGE_WAITING, "couple_1")
|
||||
|
||||
verify(exactly = 0) { rateLimiter.record(any()) }
|
||||
}
|
||||
|
||||
private fun enableNotificationManager() {
|
||||
mockkStatic(NotificationManagerCompat::from)
|
||||
every { NotificationManagerCompat.from(context).areNotificationsEnabled() } returns true
|
||||
every { NotificationManagerCompat.from(context).notify(any(), any()) } returns Unit
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue