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"
|
resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ksp {
|
ksp {
|
||||||
|
|
|
||||||
|
|
@ -204,5 +204,11 @@ object AppRoute {
|
||||||
return route
|
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