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:
null 2026-06-19 22:51:08 -05:00
parent 5698e5436a
commit fa501089f2
3 changed files with 198 additions and 1 deletions

View File

@ -58,6 +58,13 @@ android {
resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
testOptions {
unitTests {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}
}
ksp {

View File

@ -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")
}
}

View File

@ -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
}
}