diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8a24ce3..ce702c33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,13 @@ android { resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + } ksp { diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index df775f54..9bab157e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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") + } } diff --git a/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt b/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt new file mode 100644 index 00000000..57ff2e64 --- /dev/null +++ b/app/src/test/java/app/closer/notifications/PartnerNotificationManagerTest.kt @@ -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(relaxed = true) + private val settingsRepository = mockk() + private val quietHoursManager = mockk() + private val rateLimiter = mockk(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 + } +}